# Run migrations and seeders
php artisan migrate --seed
# Create the admin user id 1, required for the system lists
php artisan user:create-admin --email=admin@example.com --password=secure_password --force
# Create monthly game lists for the current year
php artisan games:lists:create-monthly --year=2026
# Populate db with games
php artisan igdb:upcoming:update --start-date=2026-01-06 --days=10 # dump prod db
mysqldump -h xxx.db.laravel.cloud -P 3306 -u <user> -p --single-transaction main > dump-go-main.sql
# import DB
mysql -u root -p games_outbreak < /usr/dumps/dump-go-main.sql
After initial setup, create an admin user to access admin features:
Interactive Mode (Recommended):
php artisan user:create-adminThe command will prompt for:
- Email address
- Password (hidden input)
- Password confirmation (hidden input)
Non-Interactive Mode:
php artisan user:create-admin --email=admin@example.com --password=secure_password --forceSecurity Notes:
- Use strong passwords (minimum 8 characters enforced)
- Never commit admin credentials to git
- Run command directly on server via SSH for production
- Password is automatically hashed using Laravel's bcrypt
The News admin (/admin/news-imports) turns a pasted article URL into localised news articles (EN / pt-PT / pt-BR)
via an async pipeline. Requires:
- Jina Reader — extracts article title, body, summary and image from the pasted URL. Sign up at jina.ai for a
free key and add:
JINA_API_KEY=your_key_here - AI provider for localisation — pick one and set:
NEWS_AI_PROVIDER=anthropic # or: openai ANTHROPIC_API_KEY=your_key_here # (if anthropic) ANTHROPIC_MODEL=claude-haiku-4-5-20251001 # OR OPENAI_API_KEY=your_key_here # (if openai) OPENAI_MODEL=gpt-4o-mini - Feature flags — the news feature defaults to admin-only preview mode:
FEATURE_NEWS=admin # true = public, admin = admin-only, false = disabled FEATURE_NEWS_URL_IMPORT=true # show Import URL button in admin FEATURE_NEWS_IMPORT_PIPELINE=false # enable the full queued pipeline (false = store imports only) php artisan config:clear, then runphp artisan queue:workso jobs process.
The Videos admin (/admin/videos) uses YouTube Data API v3 to fetch title, channel, duration, thumbnails and
published date from a pasted URL.
- Create a Google Cloud project and enable YouTube Data API v3.
- Generate an API key (no OAuth needed — public data only).
- Add it to
.env:YOUTUBE_API_KEY=your_key_here - Clear config:
php artisan config:clear. - Make sure the queue worker is running so imports actually process:
php artisan queue:work
Without the key, imports fail with YOUTUBE_API_KEY is not configured. visible in the admin detail page. Tests fake
the HTTP client and never hit Google.
This project is a web application for managing game lists and tracking game statuses.
- Track your game collection and statuses (Playing, Beaten, Completed, etc.)
- Organize games into custom lists
- Backlog: a dedicated list for games you plan to play
- Wishlist: a dedicated list for games you want to buy
- Quickly add/remove games to/from Backlog and Wishlist with one-click icons on each game card
- News imports — admins paste an article URL; the pipeline extracts and localises it (EN / pt-PT / pt-BR)
- Videos — admins paste a YouTube URL; a queued job fetches metadata via YouTube Data API v3 and stores a
curated video record. One toggleable "featured" video plus the latest imports render as a homepage section and
on a public
/videospage with an in-page lightbox - Admin panel for managing users, games, and lists
Each user can have:
- Multiple
regularlists (custom named lists) - One
backloglist (automatically created if missing) - One
wishlistlist (automatically created if missing)
| Type | Description | Unique per user |
|---|---|---|
| regular | User-created custom lists | No |
| backlog | Games the user plans to play | Yes |
| wishlist | Games the user wants to buy | Yes |
- The backlog is a special list with
type = 'backlog'. - It is created automatically when the user visits My Games.
- Users can add/remove games to/from their backlog.
- Displayed under the Backlog tab on
/my-games.
- The wishlist is a special list with
type = 'wishlist'. - It is created automatically when the user visits My Games.
- Users can add/remove games to/from their wishlist.
- Displayed under the Wishlist tab on
/my-games. - Useful for tracking games you want to buy or try in the future.
Monthly game lists are system lists that showcase games releasing in a specific month. These lists are automatically created and managed by administrators.
- Type: System lists (
is_system = true) - Visibility: Public (
is_public = true) - Active Status: Controlled by
is_activeflag and date ranges (start_at,end_at) - Slug: Auto-generated from month name (e.g.,
january-2026) - Access: Viewable by all users via
/list/{slug}route
Create monthly lists for a specific year using the Artisan command:
php artisan games:lists:create-monthly --year=2026This creates 12 lists (one for each month) with:
- Start date: First day of the month
- End date: Last day of the month
- Auto-generated unique slugs
- Public and active flags set
- Homepage: The active monthly list (within current date range) is displayed as "Featured Games"
- Monthly Releases Page: Full list view at
/monthly-releases - Public Slug View: Accessible at
/list/{slug}for any active/public list
- Lists are created by admin user (user_id = 1)
- Games are added manually by admin or via seeders
- Lists can be activated/deactivated via
is_activeflag - Date ranges control when lists appear on the homepage
The homepage features seasonal event banners displayed at the top of the page, above the Featured Games section.
Location: Place banner images in public/images/ directory
Recommended Sizes:
- Aspect Ratio: 16:9 (rectangular)
- Two Banners Side-by-Side:
- Minimum: 1200px × 675px
- Optimal: 1600px × 900px
- Maximum: 1920px × 1080px (Full HD)
- Single Banner (Full Width):
- Optimal: 1920px × 1080px (Full HD)
File Format: JPG or WebP (optimized for web, < 500KB per image recommended)
Usage: Update banner data in resources/views/homepage/index.blade.php:
<x-seasonal-banners :banners="[
[
'image' => '/images/seasonal-event-1.jpg',
'link' => route('monthly-releases'),
'title' => 'January Releases',
'description' => 'Discover the best games releasing this month',
'alt' => 'January Releases Banner'
],
[
'image' => '/images/seasonal-event-2.jpg',
'link' => route('upcoming'),
'title' => 'Upcoming Games',
'description' => 'See what\'s coming soon',
'alt' => 'Upcoming Games Banner'
]
]" />Layout Behavior:
- 2 Banners: Displayed side-by-side on desktop, stacked on mobile
- 1 Banner: Spans full width on all screen sizes
- Responsive: Automatically adapts to screen size
Note: For retina/high-DPI displays, use 2x resolution (e.g., 1920px × 1080px for standard, 3840px × 2160px for retina).
Games can have multiple release dates per platform, each with a different status (e.g., Early Access, Full Release, Advanced Access).
- Detailed Release Information: See all release dates for each platform, not just the earliest
- Status Badges: Color-coded status indicators show the type of release:
- 🟢 Full Release - The official 1.0 release
- 🟣 Advanced Access - Early access for pre-orders or special editions
- 🔵 Early Access - Public testing/beta release
- 🟡 Alpha / 🟠 Beta - Development builds
- 🔴 Cancelled - Cancelled releases
- 📱 Digital Comp. - Backward compatible digital releases
- ⚡ Next-Gen Patch - Performance optimization updates
- Platform Colors: Each platform group has its own color (PlayStation = Blue, Xbox = Green, Nintendo = Red, PC = Gray)
- Expandable View: Click a platform to see all its release dates when there are multiple
Release date statuses are fetched from IGDB and stored locally for better performance:
php artisan igdb:sync-release-date-statuses
This command:
- Fetches all release date status types from IGDB
- Stores them in the
release_date_statusestable with abbreviations - Caches the data for fast lookups
- Should be run once during setup (statuses rarely change)
On the game details page, release dates are grouped by platform and show:
- Platform name with colored border
- Earliest release date prominently displayed
- Badge showing count of additional releases (if any)
- Expandable list showing all releases with dates and status badges
Example:
PC 15/10/2025 [+2]
• 15/10/2025 [Adv. Access]
• 30/10/2025 [Full Release]
• 15/11/2025 [Next-Gen Patch]Multi-locale news (EN / pt-PT / pt-BR) with a URL-import pipeline that extracts article content and generates a
localised article per supported locale. Feature-flag gated via config/features.php.
NewsLocaleEnum (app/Enums/NewsLocaleEnum.php) is the single source of truth:
| Case | BCP-47 value | URL prefix | Path segment |
|---|---|---|---|
En |
en |
en |
news |
PtPt |
pt-PT |
pt-pt |
noticias |
PtBr |
pt-BR |
pt-br |
noticias |
Always use enum cases or their methods — never raw locale strings.
/en/news EN index
/en/news/{slug} EN article
/pt-pt/noticias PT-PT index
/pt-pt/noticias/{slug}
/pt-br/noticias PT-BR index
/pt-br/noticias/{slug}
/news redirect to the best locale
- EN routes use a fixed
en/newsprefix (no route param). - PT routes use
{localePrefix}/noticiaswherelocalePrefixis constrained topt-pt|pt-br. /newsredirects using:session('news_locale')→Accept-Languageheader →app.localeconfig.
Applied to both public news route groups. On each news page request it:
- Resolves the current
NewsLocaleEnumfrom the URL. - Calls
app()->setLocale($newsLocale->value)— sets Laravel runtime locale for__()translations. - Persists
session(['news_locale' => $newsLocale->slugPrefix()])— used for the/newsredirect and the header switcher. - Shares
$currentNewsLocalewith all views rendered for that request.
Do not apply this middleware globally or to non-news routes.
The switcher is visible on all pages (not just news) and uses a 3-tier fallback (since $currentNewsLocale is
only shared on news routes):
$headerNewsLocale = $currentNewsLocale // middleware (news pages)
?? NewsLocaleEnum::fromPrefix(session('news_locale')) // session (previous visit)
?? NewsLocaleEnum::fromAppLocale(); // config defaultClicking a locale navigates to $l->indexUrl(), which triggers the middleware and updates the session.
Each NewsArticle has separate slug columns per locale: slug_en, slug_pt_pt, slug_pt_br. A locale's article
URL is only valid when that slug column is non-null — check $article->{$l->slugColumn()} before linking.
Admin pastes an article URL on /admin/news-imports/create →
StoreNewsImportRequest (auth + URL + private-IP guards) →
ImportNewsUrlJob (queued) → chain:
CreateNewsImportaction →NewsImportrow in Pending with the source domain extracted.ExtractNewsArticleJob→ExtractNewsArticleaction callsContentExtractorInterface::extract($url). Default implementation isJinaReaderService(usesJINA_API_KEY). Populatesraw_title,raw_body,raw_excerpt,raw_image_url. On failure marks Failed with reason.GenerateNewsContentJob→GenerateLocalizedNewsContentaction calls the configured AI provider (NEWS_AI_PROVIDER:anthropic→AnthropicNewsGenerationService,openai→OpenAiNewsGenerationService) to produce per-locale title / summary / body. Writesnews_article_localizationsrows keyed by(news_article_id, locale).- Admin reviews the generated article on
/admin/news-articles, edits via Tiptap editor, then publishes or schedules it. PublishScheduledNewsJob(scheduled) — flips any duescheduled_atarticles topublished.
FEATURE_NEWS— master toggle:true(public),admin(admin-only preview, default),false(disabled / 404).FEATURE_NEWS_URL_IMPORT— shows the Import URL button in the admin (defaulttrue).FEATURE_NEWS_IMPORT_PIPELINE— enables the queued extract + AI-generate pipeline (defaultfalse). When off,NewsImportrows are stored but never extracted.
EnsureNewsFeatureEnabled::isVisibleTo($user) gates visibility on both sides (404 for anonymous users when the
feature is in admin mode).
- Jina Reader — content extractor. Key:
JINA_API_KEY. Bound inAppServiceProviderasContentExtractorInterface → JinaReaderService. - Anthropic or OpenAI — localised content generator. Driver selected by
config('services.news_ai_provider'). Keys:ANTHROPIC_API_KEY/OPENAI_API_KEY. Models configurable viaANTHROPIC_MODEL/OPENAI_MODEL.
Each NewsArticle carries should_broadcast (default true) and broadcasted_at. When the admin clicks Publish
Now on /admin/news-articles/{article}/edit, the Publish form carries the should_broadcast checkbox (default ON)
to NewsArticleController::publish, which persists the flag and calls PublishNewsArticle.
PublishNewsArticle dispatches BroadcastNewsArticleJob when should_broadcast = true and broadcasted_at = null.
The job:
- Picks the first available locale via
NewsArticleTelegramFormatter::resolveLocale($article)in the order pt-PT → pt-BR → EN (a locale needs both a non-null slug and aNewsArticleLocalizationwith a title). - Formats a MarkdownV2 caption (
📰 *title*+ summary + "Ler mais →" link to the localized article URL). - If
featured_image_urlis set →TelegramClient::sendPhoto()with the caption. Otherwise →TelegramClient::sendMessage()with link preview enabled. - On success, sets
broadcasted_at = now()— idempotent; a second dispatch is a no-op unlessforce = true.
All gates re-check inside the job (services.telegram.enabled, should_broadcast, broadcasted_at, published status),
so the DB state wins against races.
- Feature:
tests/Feature/News/SetNewsLocaleMiddlewareTest.php— middleware, session persistence,/newsredirect, header switcher labels - Feature:
tests/Feature/News/NewsImportPipelineIntegrationTest.php— full pipeline end-to-end with fakes - Feature:
tests/Feature/News/ExtractNewsArticleTest.php,GenerateLocalizedNewsContentTest.php,PublishNewsArticleTest.php,NewsJobsTest.php,NewsModelsTest.php - Feature:
tests/Feature/News/NewsArticlePublicRoutesTest.php,NewsArticleSeoTest.php— public routes + canonical / hreflang / OG / JSON-LD - Feature:
tests/Feature/Admin/NewsImportControllerTest.php,NewsArticleControllerTest.php - Unit:
tests/Unit/NewsLocaleEnumTest.php—fromBrowserLocale()parsing
Curated YouTube videos surfaced in a homepage section and on a public /videos index. Videos are
language-neutral — one record serves all locales; no per-locale slugs or localizations table.
Video model / videos table — see app/Models/Video.php and the migration.
youtube_id(nullable, unique) — extracted from the pasted URL via regextitle,channel_name,channel_id,duration_seconds,thumbnail_url,description,published_at— fetched from YouTube Data API v3is_featured(bool) — admin-toggled;VideoImportController::toggleFeatured()enforces that only one video is featured at a time inside a DB transactionis_active(bool) — staging toggle; hides the row from public listings without deletingstatus(VideoImportStatusEnum: Pending, Fetching, Ready, Failed) +failure_reasonraw_api_response(JSON) — full Data API payload for debugging/re-extractuser_id— the admin who triggered the importvideo_category_id(nullable FK →video_categories,nullOnDelete) — optional grouping (see Categories below)
Key scopes on Video:
ready()— status = Readyactive()— is_active = truepublicVisible()— ready + active, used on homepage and/videos
Helpers: embedUrl(bool $autoplay), watchUrl(), thumbnailMaxRes(), thumbnailHq(),
durationFormatted() (returns M:SS or H:MM:SS), markAs(VideoImportStatusEnum, ?string $reason).
Admin pastes a YouTube URL on /admin/videos/create →
StoreVideoImportRequest (auth via isAdmin(), URL + regex + private-IP guards) →
ImportYoutubeVideoJob (queued, 3 tries, backoff [10, 30, 90]) → inside handle():
YoutubeDataService::extractYoutubeId($url)— regex against/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/shorts\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/. No match →CreateVideoaction writes a Failed row with a reason. The row is visible in the admin list so the failure is not silent.- Dedupe — if a
Videowith the sameyoutube_idexists, skip. CreateVideoaction → creates the row in Pending.FetchYoutubeVideoMetadataaction → marks Fetching, callsYoutubeDataService::fetchVideo(), populates fields, marks Ready. OnThrowable: logs + marks Failed with the exception message.
YoutubeDataService::fetchVideo() calls https://www.googleapis.com/youtube/v3/videos with
part=snippet,contentDetails + the API key, throws RuntimeException on HTTP failure or empty items[]. ISO 8601
durations (PT4M46S, PT1H2M3S) are parsed by parseIsoDuration().
config/services.php → 'youtube' => ['api_key' => env('YOUTUBE_API_KEY')]. The key is required; the service throws
a RuntimeException when it is missing. Set YOUTUBE_API_KEY in .env before running the queue worker against real
imports (tests always fake Http).
/videos— public, no locale prefix (videos.index)/admin/videos,/admin/videos/create,/admin/videos/{video}— admin index / create / showPATCH /admin/videos/{video}/toggle-featured— enforces single-featured in one transactionPATCH /admin/videos/{video}/toggle-active— flipsis_activePATCH /admin/videos/{video}/update-category— assigns or clears the video categoryDELETE /admin/videos/{video}/admin/video-categories— categories CRUD (index / store / update / destroy)
Admin routes sit inside the existing auth + EnsureAdminUser + prevent-caching group. The Videos + Video Categories
links in the admin dropdown (resources/views/components/header.blade.php, desktop + mobile) are not gated by the
news feature flag.
VideoCategory (video_categories table) groups videos for display. Admin-managed via /admin/video-categories with a
modal-based CRUD (mirrors the Genre admin pattern). Columns:
name,slug(unique),is_activecolor(hex string, e.g.#ff8a2a) — drives the badge color via a CSS variableicon(heroicon slug, e.g.film, optional)
Seeded rows from VideoCategorySeeder (idempotent via firstOrCreate): trailers, gameplay, reviews, tech.
Deleting a category is safe — the FK is nullOnDelete, so videos keep existing but lose their category tag.
Each video has an optional video_category_id. Admin assigns it via a dropdown on /admin/videos/{video} which posts
to the update-category endpoint (single-field PATCH, same shape as toggleFeatured). When a video has a category, a
<x-videos.category-badge> component renders a neon pill on:
- The homepage hero tile (top-right of the thumbnail)
- The homepage list rows (inline above the title)
- The public
/videoscards (top-right of the thumbnail) - The admin videos index (Category column) + admin show page (header row)
The pill (.neon-category-pill in resources/css/homepage.css) uses color-mix(in srgb, var(--c) ...) with a
solid-color fallback for Safari ≤15. The color comes from the category's color column via an inline style="--c: ...".
<x-homepage.latest-videos :featured="$featuredVideo" :videos="$latestVideos"> sits between This Week's Choices and
Events in resources/views/homepage/index.blade.php.
HomepageController::getLatestVideos() pulls the top 6 public-visible videos by published_at desc, picks the
featured one (or falls back to the newest), and returns the rest (up to 5) as the list. The section renders nothing
when the pool is empty — no empty-state placeholder on the homepage.
Both sub-components render a news-style meta line above the title: orange channel name · cyan
$video->published_at->diffForHumans() (e.g. Rockstar · 3 days ago).
Sub-components:
resources/views/components/videos/hero-tile.blade.php— big hero with featured badge, play overlay, duration, category badge (top-right), channel + days-ago metaresources/views/components/videos/list-row.blade.php— 118px thumbnail + inline category badge + channel + days-ago meta + 2-line title
Both apply the theme-neon palette (--neon-cyan, --neon-orange, --neon-purple) and Inter font — no bespoke
go-* tokens or Space Grotesk / JetBrains Mono fonts.
Shared modal root <div id="go-video-lightbox"> lives in resources/views/layouts/app.blade.php (before
@stack('scripts')). resources/js/video-lightbox.js delegates clicks on [data-video-id], builds
https://www.youtube.com/embed/{id}?autoplay=1&rel=0&modestbranding=1, locks body scroll while open, closes on
backdrop click / ESC / Close button. Imported from resources/js/app.js.
resources/views/videos/index.blade.php mirrors the News index structure: neon-section-frame, neon-card rows,
pagination at 20/page, breadcrumb JSON-LD, canonical + OG tags. Single locale → no hreflang. Empty state uses
neon-panel + x-heroicon-o-video-camera.
Each Video carries should_broadcast (default true) and broadcasted_at. The import form (/admin/videos/create)
has a "Broadcast to Telegram when Ready" checkbox (default ON) that threads through ImportYoutubeVideoJob → CreateVideo onto the row. The admin show page offers a separate PATCH toggle
(admin.videos.toggle-should-broadcast) to flip the flag later.
MaybeBroadcastVideo action dispatches BroadcastVideoJob when all of the following are true:
status = Readyis_active = trueshould_broadcast = truebroadcasted_at = null
It is called from two places:
FetchYoutubeVideoMetadata— right after a successful transition toReady(covers the default happy path sinceis_activedefaults totrue).VideoImportController::toggleActive— after flippingis_activefromfalsetotrue(covers videos that were staged hidden).
The job formats 🎬 *title* + channel · duration + [Ver no YouTube →], sends the thumbnail via sendPhoto (falls back
to sendMessage if no thumbnail), then stamps broadcasted_at. Second dispatches are no-ops unless force = true.
php artisan broadcast:resend <type> <id> [--channel=telegram] re-sends a previously-broadcast (or never-broadcast) news
article or video. Intended as a fallback / testing tool — it passes force: true to the job so the broadcasted_at
gate is bypassed; should_broadcast and the global enabled flag are still respected.
php artisan broadcast:resend news 42 # re-broadcast news article id 42
php artisan broadcast:resend video 7 # re-broadcast video id 7
php artisan broadcast:resend news 42 --channel=x # aborts: X channel is not implemented yet
Only telegram is implemented today; x is accepted as an argument so the CLI shape stays stable when X is added,
but it exits with a "not implemented" error.
- Unit:
tests/Unit/VideoImportStatusEnumTest.php— labels / color class /isFinal() - Feature:
tests/Feature/Videos/YoutubeDataServiceTest.php— ID extraction, ISO 8601 parsing, Http::fake for success / empty / HTTP failure. Lives underFeature/(notUnit/) because Pest's container is only wired there. - Feature:
tests/Feature/Videos/FetchYoutubeVideoMetadataTest.php— Ready / Failed transitions - Feature:
tests/Feature/Videos/ImportYoutubeVideoJobTest.php— full job withHttp::fake, dedupe, non-YouTube URL - Feature:
tests/Feature/Admin/VideoImportControllerTest.php— auth/forbidden/happy paths, toggle-featured single-row invariant, toggle-active, destroy - Feature:
tests/Feature/VideosIndexPageTest.php— public index visibility (Ready + active only), SEO tags, pagination - Feature:
tests/Feature/HomepageLatestVideosTest.php— section renders when videos exist, hidden when empty, positioned between This Week's Choices and Events; category badge + days-ago meta rendering - Unit:
tests/Unit/VideoCategoryTest.php— fillable + cast - Feature:
tests/Feature/Admin/VideoCategoryControllerTest.php— CRUD, validation, slug uniqueness,nullOnDeletebehaviour - Feature:
tests/Feature/Admin/VideoCategoryAssignmentTest.php—update-categoryPATCH assigns / clears / rejects bogus ids - Feature:
tests/Feature/Broadcasts/BroadcastNewsArticleJobTest.php— sendPhoto/sendMessage branches, locale fallback (pt-PT → pt-BR → EN), idempotency gates,forcepath - Feature:
tests/Feature/Broadcasts/BroadcastVideoJobTest.php— ditto for videos - Feature:
tests/Feature/Broadcasts/NewsPublishBroadcastTest.php— Publish action dispatches viaBus::fakewhenshould_broadcastis on, skips otherwise - Feature:
tests/Feature/Broadcasts/VideoActivationBroadcastTest.php—MaybeBroadcastVideogates, toggle-active dispatch, toggle-should-broadcast PATCH - Feature:
tests/Feature/Commands/ResendBroadcastCommandTest.php— happy paths, unknown type/id/channel, x aborts