Work gamingworld
service

Gaming World

An indie-only game platform — reviews, AI-drafted previews, hosting, emulator guides, an AI-triaged submissions desk, no AAA noise.

Gaming World UK is a community-driven platform that promotes independent game developers — honest human-written reviews, AI-drafted previews for games nobody on staff has played yet, hosting on `*.gamingworld.uk` subdomains, emulator and handheld guides, and a small in-house studio shipping original projects. Submissions arrive by mail and an AI inbox scanner classifies them for staff triage; a roster of AI writer personas drafts previews from trailers, store pages, and hunted screenshots — openly speculative, never claiming hands-on time. A public volunteer-reviewer funnel pays in site credits rather than money. Built as a TypeScript SPA on top of a Go (Fiber) API + PostgreSQL, with a Python toolchain feeding the emulator database.

Last updateSep 6, 2026 PrimaryTypeScript
  • TypeScript
  • React
  • Vite
  • Go
  • Fiber
  • PostgreSQL
  • WebAuthn
  • WebSockets
  • OpenRouter
  • Tiptap
  • Python
  • Tailwind CSS
  • nginx
  • systemd
Gaming World — An indie-only game platform — reviews, AI-drafted previews, hosting, emulator guides, an AI-triaged submissions desk, no AAA noise.
Gaming World media
Gaming World media
Gaming World media
Gaming World media

Gaming World is built on a single, opinionated thesis: independent games deserve a platform that only covers independent games. No AAA bleed-over, no franchise news, no engagement-bait listicles — just a curated feed of what actually-small studios are shipping right now, the people behind them, and a free runway to host their work.

What the platform does

  • Honest indie reviews — every game that ships on the platform gets a real write-up, not a syndicated press release. Submissions arrive by mail, and an AI inbox scanner sweeps the queue every fifteen minutes — classifying each message, pulling cover art from the Steam API where it can, and landing the result in a two-pane staff mail client where an editor claims it over a live WebSocket. The machine does the sorting; the human does the words.
  • AI-drafted previews — for games nobody on staff has played, a roster of six AI writer personas (each with their own beat, voice, and byline) drafts a preview from the trailer, store page, and hunted screenshots alone. The voice guide is explicit that nobody has played the game, and a lint pass catches drafts that drift into hands-on language. Over a hundred are published, with artwork interleaved through the prose instead of dumped in a grid at the end.
  • Subdomain hosting on *.gamingworld.uk for a submitted game's marketing site — templated vhosts, auto-issued certificates, and the dev keeps full ownership of the content.
  • Emulator & handheld guides — a curated database of emulators and review coverage of the handhelds that run them, fed by a Python toolchain and vendor spreadsheet syncs, because "how do I actually play this" is half of indie preservation.
  • An in-house studio shipping original work (Tulpa, Revamp, and friends) under the same brand, to keep the platform honest about what "indie" actually means.
  • Volunteer reviewer recruiting at /join — the pay is Gaming World credits, not money: 100 per published review plus bonuses, spent on claiming a game out of the queue, making the desk chase a studio for a key, or a week on the front page. The credit ledger itself isn't built yet; credits are tracked and honored by hand.

How it ships

The frontend is a React + Vite + Tailwind SPA — fast, static-deployable, and small enough that the entire bundle warms in a few hundred kilobytes. Behind it is a Go (Fiber) HTTP API backed by PostgreSQL (with a small library of PL/pgSQL functions for the heavier queue queries), passkey login via WebAuthn, and role-based staff permissions. A Python sidecar handles emulator ingestion, image normalization, and OG-tag scraping. Everything runs under systemd on a single VPS behind nginx (HTTP/2, gzip, CSP); deploys stage the new binary, poll its health, and roll back automatically if it doesn't come up — with nightly database dumps shipped to cloud storage.

Why we built it

The big games-coverage sites stopped covering independent work seriously a long time ago — small studios get one slot a quarter if they're lucky, buried under whatever publisher-funded preview is trending. Gaming World takes the opposite vow: AAA studios are explicitly out of scope. If a team of more than ten people is making it, it doesn't belong here. The platform is the editorial line.

Straight from the source

The project's own README.

Rendered in place — every link, image, and code block carried over from the repo. The page below is what a contributor would see opening the project for the first time.

Changelog

All notable changes to Gaming World are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[0.1.40] - 2026-09-03

Fixed
  • Admin → Previews crashed on the Published filter. A clean lint report is stored with problems: null (a nil Go slice marshals to null), and the row component read its length without checking. Every one of the 101 published previews carried that shape, so the whole list fell over into the error boundary. The client now treats a missing list as empty, and the linter returns an empty slice instead of nil. Existing rows keep their null, which the client now tolerates

[0.1.39] - 2026-09-03

Added
  • We are hiring humans to review games. /join is a public page that says what the job is, what it pays and how to apply. It leads with the honest part, that there is no salary, because burying that under the word opportunity wastes everybody's afternoon
  • Gaming World credits. A reviewer earns credits for published work (100 a review, plus bonuses for covering a game first, being picked for the front page, and keeping a pace) and spends them on things nobody can buy with money here: claiming a game out of the queue, making us chase a studio for a key, a week on the homepage, an author page. Ranks run on lifetime credits rather than the balance, so spending never costs standing. The numbers live in components/join/creditsData.ts, which the page renders, so the economy has one home
  • Applications land twice. POST /applications stores the row and mails the staff inbox, so an application can be answered from the mail client without opening the admin panel. The applicant gets a note back saying a person reads these and it takes days rather than minutes. Mail is best effort in a goroutine: a Postmark outage must not fail somebody's submit
  • Admin → Applications, gated on new applications.view and applications.manage permissions, with a five-step workflow (new, reading, trial review, on the team, declined) and notes per applicant
  • /join in the sitemap, and Write for Us in the main navigation
Notes
  • The credit ledger itself is not built. Until docs/plans/10-reviewer-credits.md ships, credits are tracked and honoured by hand, which is fine at nought writers and not at ten

[0.1.38] - 2026-09-03

Fixed
  • Preview galleries showed the same screenshot more than once. The image hunt deduplicated on the bytes it downloaded, so one shot arriving as Steam's PNG and the studio's JPEG, or at two resolutions, was kept as two pictures: Wish Impossible had three such pairs and Fish Them All! carried its own hero again further down the gallery. A download is now compared three ways: the bytes fetched, the bytes stored after webp conversion (two different downloads can encode to an identical file, which is how a hero was repeated), and an average hash of the picture itself, which survives re-encoding and rescaling. The two byte keys are namespaced, because a source that is already webp passes through unconverted and would otherwise be read as a copy of itself
  • A 260x95 site logo was the hero image of a preview. The size floor only checked width, so a wordmark cleared it. Nothing under 150px tall is game art. LIFE LIKE A DJ, whose own site and press kit both 404 and whose only usable image was a publisher's logo, is now correctly held back as having no artwork instead of being published behind it
  • App icons padded onto white were beating real screenshots to the hero slot. A store's social card is 1200x630 of flat field around an icon, so it wins on width and then renders as a logo floating in a white box. An image whose whole border ring is one pale colour and whose content covers less than 60% of the frame is now rejected. The check is deliberately narrow: judging on content area alone flagged one image in ten, because a dark screenshot has dark corners, and it would have thrown away terminal roguelikes and horror scenes to tidy up a hero
  • Rating badges, app icons and advert banners were reaching galleries. A PEGI 3 badge and an app icon sat in Zomdie's, because the Play collector skips squares by the dimensions Google declares in page markup, and asking for =w1920 returns a small source at its native size; square images from Play are now judged on the pixels actually received. Wide short strips go too: a Steam wordmark, a publisher sale advert, a Discord invite. Wide alone was not safe, since King Me's own hero art is 2000x500, so the rule needs both, the adverts all being under 220px tall and the artwork over 500
Changed
  • Repaired the back catalogue. Padded icon cards removed from 2048-trader, chaos-core, deadly-prompt, nitelore, parcel-game and snakebit-exe, three of which had one as their hero, and advert banners from feed-it and bitiverse. Every one of the 1141 pictures the previews point at now decodes, exists on disk and passes the new checks
Added
  • Four previews published: Arcane Heroes 2: Divided Fate, Wish Impossible, Fish Them All! and Zomdie

[0.1.37] - 2026-09-01

Changed
  • HandheldsPage.tsx was 1553 lines against a 200-line house rule. It is now a 189-line page over 18 modules in components/handhelds/: the data types, the sheet constants, the grade and tier maths, the formatting helpers, and one file each for the card, the detail modal, its specification tables, its pros and cons, its shop and review links, the filter bar, the badges, the device images and the spec rows. Behaviour is unchanged; the split is mechanical, and the brand list is the one thing that moved rather than being copied
  • The handhelds page reads its brand storefronts from GET /handhelds/oems instead of a 34-entry map hardcoded in the page, so the list has one home. handheld_brands.go seeds it, and the sync writes it
Fixed
  • An unchanged sheet no longer preserves an empty table. The sync skipped all its work on a hash match, which meant the OEM table, empty since the day it was created, could never be filled by code added after the sheet last changed. The skip now also requires the tables the sheet feeds to actually have rows

[0.1.36] - 2026-09-01

Fixed
  • A pitch that arrived just after the hour waited the best part of an hour for a preview that was already due. The scan that queues a game ran on a 15-minute ticker while the image hunt and the auto-draft batch ran on their own hourly one, so on 31 August the hourly pass reported 0/0 drafted five seconds before the scan queued Dominion Tap, and nothing looked at it again until the next hour. The three stages feed each other in one direction, so they now run in order on a single 15-minute ticker: scan, then hunt, then draft. An idle queue costs nothing per pass, since the hunt revisits an item at most weekly and the batch only drafts items that have artwork and no preview yet
  • Three corrupt screenshots and nine blank white banners were live in published preview galleries. The image hunt validated a download with image.DecodeConfig, which reads the header and stops. Mancalero's gallery held three 1920x1080 PNGs whose second half was garbage: the header parsed, the bytes were stored, cwebp then failed on the corrupt tail and the fallback kept the unreadable .png. Separately, a 744x171 image of pure white had been hunted into nine different galleries. Hunted images are now decoded all the way to pixels, and one that decodes to a single flat colour is rejected as blank
  • The games queue applies the same check: a header can be right and the pixels still unusable, so the scanner decodes a download in full before keeping it
  • All 481 handheld pictures were on a 30-day fuse. The handheld sync read its name-to-image map from /tmp/handheld_name_to_image.json, which systemd clears at 30 days, and the sync replaces every row wholesale. The day that file aged out, every handheld image would have disappeared from the site behind a clean, successful sync. The map is tracked repo data now, and a handheld with no entry keeps the picture it already had
  • OpenXBOX's logo in the emulator grid was a 256x256 square of pure black. The emulator sync now ignores feed art that is missing from disk or decodes to one flat colour, so the card falls back to its generated placeholder
Added
  • /handhelds/oems served an empty list from the day the table was created, because nothing ever wrote to it. The sync fills it from the manufacturer named on every sheet row: 121 makers, each with its device count and, for the 34 we know, its own storefront
Changed
  • Repaired the affected galleries: the three unreadable PNGs and nine blank banners were removed from mancalero, 1-min-aim-neo-seoul, arcade-architect, deep-blue-sushi, feed-it, hotel-paraiso, lawless-streets, terra-7, the-world-s-shortest-horror-game and weather-the-swarm, and the files deleted

[0.1.35] - 2026-09-01

Fixed
  • Three games in the queue showed a broken image and no console error. Apple's App Store serves the og:image for a freshly submitted listing as HTTP 200 with Content-Type: image/jpeg and an empty body. downloadGameImage trusted that header and skipped the byte sniff, io.Copy treated an empty source as a successful write, and cwebp then failed on the empty file so the .jpg path was the one stored. The result was a 0-byte file that nginx serves as a 200, which is why nothing 404'd. The download now rejects a non-2xx status, a body under 512 bytes, and anything that sniffs as text however the header describes it
  • saveToDisk refuses to store a 0-byte file at all, so no upload path can leave one behind: the scanner, the preview image hunt, generated art and manual uploads all go through it
  • Repaired the three affected queue items, Nitelore, Snakebit.exe and Chaos Core, by re-fetching each og:image (Apple has since milled all three) and deleting the empty files

[0.1.34] - 2026-09-01

Fixed
  • Steam prices came back in euros, in continental notation. store.steampowered.com/api/appdetails geolocates a request with no country code, and the box is in Germany, so every price we pulled off Steam was EUR formatted the German way. For a round amount Steam writes the cents as a dash pair, which is why the games queue showed Slaymeji at 2,--€. The call now pins cc=gb&l=en, so prices arrive as GBP in the form a UK reader expects (£1.76). Both other callers of FetchSteamAppDetails, the preview fact sheet and the image hunt, get the same treatment
  • Prices lifted out of a pitch email by the scanner go through NormalisePriceInfo, which rewrites continental euro amounts into €19.99 form and expands the ,-- round-amount idiom. Anything it does not recognise is left alone, so $9.99, Free and US$19.99, 15% launch discount pass through untouched
  • Backfilled the eight queue items and eight previews that had stored a euro price, re-fetching each from Steam in GBP

[0.1.33] - 2026-08-31

Fixed
  • Portrait hero art was shown as a band from the middle of the image. The preview hero rendered every image in a hard aspect-[16/9] with object-cover, which is right for the 88 previews whose art is landscape and wrong for the seven whose art is phone screenshots: a 1440x2560 Android capture was cropped to a strip somewhere around its centre, usually missing both the title screen at the top and the UI at the bottom. Nothing stores image dimensions, so the hero now measures the image on load and, when it is taller than it is wide, gives it a taller box, contains the whole image inside it and fills the space either side with a blurred copy of the same file. Landscape art takes exactly the path it took before. Affected today: dominion-tap, snappals, bounce-trix, color-collapse, protocol-73-the-zenith, sprin-world, orbit-switch
Changed
  • The hero moved out of PreviewPage.tsx into components/previews/PreviewHero.tsx, which is where the orientation state belongs and keeps the page inside the file-length convention
Added
  • Component tests for the hero, standing in for the naturalWidth/naturalHeight that jsdom never reports: landscape keeps 16/9 and object-cover with a single img on the page, portrait switches to the taller box and object-contain and mounts an aria-hidden blurred fill, square counts as landscape

[0.1.32] - 2026-08-31

Fixed
  • Publishing from the command line never reached the search engines. gw-abpreviews -publish-clean goes through the same SetStatus as the admin panel, so it queued an IndexNow ping for every URL it published — and then the process exited, killing the goroutine before it had sent anything. Publishing is deliberately fire-and-forget so a search engine having a bad afternoon cannot fail a publish, which is free for an API that outlives its own goroutines and silently wrong for a CLI that does not. IndexNowService now tracks in-flight submissions and exposes Wait(); the CLI wires the service in, flushes before it exits, and reports what it sent instead of printing a reminder to go and run gw-seosubmit by hand. -dry disables submission outright. Every batch published since the CLI existed had to be pushed manually, and any batch where that was forgotten was invisible to Bing, Yandex, Seznam, Naver and Yep until the next backfill
Added
  • just install-clis builds and installs the five gw-* Go tools into /usr/local/bin. deploy.sh never touched them, so a fix in a package the CLIs share with the API shipped to the API on the next deploy and to the tools not at all — gw-abpreviews on the box was three days older than the code that had supposedly fixed it. Run it after any change under internal/ that a CLI path reads
  • A test for the delivery guarantee itself: five SubmitAsync calls against an httptest server, then Wait, then assert all five landed. indexNowEndpoint became a var so it can be pointed somewhere harmless. A ping that is queued and then dropped on exit is indistinguishable from a delivered one at the call site, which is why the bug survived
Changed
  • The sudo -u www-data env $(grep ... | xargs) invocation documented at the top of abpreviews and huntimages has been broken since a value with a space entered api.env — it fails with env: 'World': No such file or directory. Both now document sourcing the file as root and passing --preserve-env for the three vars the tools actually need, which is also the only form that works given the file is 0600 root:root

[0.1.31] - 2026-08-31

Fixed
  • Accented letters in a title collapsed into hyphens in its slug. generateSlug lowercased and then replaced every run of non-[a-z0-9] with a hyphen, so "Hotel Paraíso" became hotel-para-so — a permanent, unreadable URL, and one that no longer contains the word anyone would search for. Titles are now folded to ASCII first ("í" to "i", "ß" to "ss", "æ" to "ae"), and apostrophes are dropped rather than hyphenated, so "Shoot 'em up" stays shoot-em-up and "don't" no longer becomes don-t. The helper moved out of award_template_service.go into its own slug.go with tests, since previews, news, reviews and awards all slug through it. Stored slugs are untouched; this only affects titles slugged from here on

[0.1.30] - 2026-08-30

Fixed
  • Eleven published preview headlines were Title Case where the house style is sentence case, which read like a press release next to the other seventy-nine. Normalised in place: capital on the first word and proper nouns only. The writer prompt gains a hard rule (9) and the linter gains a check, so the next draft that drifts is caught before an editor has to notice it. The check exempts words from the game title, because studios do name things "FEED IT" and "The Legend Of Joke", and the short function words Title Case leaves lowercase anyway; four or more capitalised ordinary words left over is the giveaway. Across all ninety previews it separates the two styles with nothing in between
  • Two published previews carried lint flags for problems they did not have. "That's the part I want to get my hands on" was read as a hands-on claim, when it says the opposite; the future-tense idiom is now blanked before the hands-on patterns see it, and "got my hands on" still fails. "The pitch for Desktop Pet: Idle Cats is almost insultingly simple:" was read as list-style label padding, when the colon belongs to the game's name; a title containing a colon is now removed before the label check runs
Added
  • gw-relint re-runs the linter over previews as they are stored and reports where the stored lint_report no longer matches the content. Reports are written once, at generation time, so editing a published preview leaves its report describing text that is no longer there, and the next publish is blocked for a problem the piece does not have. It lints only what the row stores: the hook, the SEO fields and the Steam blurb are not persisted, so checks that read them are dropped rather than guessed at

[0.1.29] - 2026-08-28

Fixed
  • The artwork behind every published preview existed on one disk. previews and generated-images were held out of the uploads mirror (localOnlyDirs) because nginx read the bucket before the disk, and a stale object could shadow a live local file. That read path was removed in 0.1.26, so all the exclusion still bought was 1,189 files and 150MB with no offsite copy — including the art on all 87 previews that were published and submitted to the search engines the same day. localOnlyDirs is gone: ManagedUploadDirs() is now every directory in allowedDirs, and MANAGED_ROOTS in the mirror script matches. One-time cost to seed, about 1,189 objects.insert calls, is under a penny; storage adds roughly $0.004/month
  • Comments across storage.go, the mirror script, gcssync, infra/nginx/gw and docs/gcs-uploads.md still explained themselves in terms of nginx checking the bucket first, which stopped being true in 0.1.26. Delete reconciliation in particular is not about shadowing a deleted image any more; it is about the backup not becoming a paid archive of everything the site has ever deleted
Added
  • The mirror takes a non-blocking flock for its apply phase, so a run with hundreds of files still to upload is skipped by the next five-minute tick rather than doubled by it. -n does not take the lock
  • infra/cron/gw-uploads-mirror is tracked and installed by just uploads-mirror-install, which previously installed the script and left the cron entry to be written by hand

[0.1.28] - 2026-08-28

Added
  • The site tells the search engines when something goes up, instead of waiting to be found. services/indexnow.go implements IndexNow, which Bing, Yandex, Seznam, Naver and Yep all accept: publishing a preview, a news article or a review submits its URL immediately, in the background, and a failure there can never fail the publish. The key is public by design and served from apps/web/public/<key>.txt, so every build ships it. Google is deliberately not wired up — it dropped sitemap ping in 2023 and only accepts Search Console, which needs a human to verify the property once
  • gw-seosubmit (cmd/seosubmit) submits the whole live sitemap in one go: the backfill for everything published before IndexNow existed, and the thing to run after a bulk rewrite. -dry prints the list, -only previews narrows it
  • Published previews now carry an <image:image> entry in the sitemap with the hero and the game's name. The artwork is half of what a preview is, and Google Images is a real way into a page like that
Fixed
  • Every SPA route was telling crawlers it was a duplicate of the homepage. index.html ships a hardcoded <link rel="canonical"> pointing at the apex, and nothing moved it on navigation, so any crawler that renders JS rather than hitting the bot prerender saw 80-odd previews all claiming to be the front page. usePageMeta now owns the canonical, sets og:site_name alongside it, and restores the default on unmount
  • robots.txt no longer invites crawlers into /admin
  • Publishing a preview never worked. PreviewRepository.SetStatus used $2 twice — assigned to a varchar column and compared against a bare literal — so Postgres deduced two different types for one parameter and refused the statement outright. Every publish, unpublish and reject has failed since plan 09 phase C; the previews that were live had been published some other way. Both uses are cast explicitly now
  • Filtering previews by status never worked either. PreviewRepository.List built its count query from the same where string as the list query, so a status filter sent WHERE p.status = $3 with one argument. The two queries number their own placeholders now
  • gw-abpreviews -publish-clean publishes every draft the lint has no problem with and leaves the rest for a human, which is the point of the gate. -regen <preview id> re-runs the writer for one preview that came out badly

[0.1.27] - 2026-08-28

Added
  • The Previews desk is six writers instead of one. Wren Ashby wrote all 82 previews, which is why the back catalogue read like one long shrug. services/preview_personas.go is the registry: Priya Raman (strategy, sims, anything with a spreadsheet under the art), Kai Okonkwo (action, platformers, animation), Nell Brannigan (horror, atmosphere, art direction), Rhodri Vaughan (retro, pixel art, arcade) and Sunny Patel (cosy, mobile, twenty minutes on a sofa) join Wren on the desk. Each has a user row (migration 029, staff role, no passphrase so none can sign in), a generated portrait and a voice block that replaces the generic one in the writer prompt. The stance and every hard rule are shared, so nobody claims to have played anything
  • Assignment is by beat, with a stable per-game hash breaking ties and spreading the games nobody has a claim on. Deterministic on the queue item id, so the byline never shuffles under a published piece
  • The whole back catalogue was rewritten and re-bylined (gw-abpreviews -reassign [-with-images]), keeping each preview's slug, links and status
  • An "About the writer" card under each preview, and author.bio on the public preview DTO
  • scripts/gcs-bucket-access.sh shows or strips public IAM on a bucket, using the existing backup-agent key
Fixed
  • Five games that could not be previewed now can. The image hunt was throwing away artwork: presskit links were skipped entirely (a press kit is the one page a studio builds specifically to hand out screenshots), and only og:image was read, so any site that ships its screenshots as plain <img> tags read as "no images". The hunt now reads inline images, srcset and CSS backgrounds, follows Google Drive file and folder links through the thumbnail endpoint, and accepts base64 images inlined into the page. Dice After Dark went from nothing to seven images with a 3840px hero
  • Small artwork is used rather than discarded when it is all there is. huntMinWidth (600) is now the preferred size, not the floor: images down to 256px wide are kept, but only surface when a game has fewer than three full-size images, so no well-supplied game gets a small hero. Three games were blocked on this alone
  • The scanner's own saved image is now a last-resort candidate on every hunt instead of only when nothing else was collected
  • @tailwindcss/typography was never installed, so the prose* classes in the preview, review, news and editor views styled nothing. Installed and bound to the theme variables in index.css, so articles pick up real typography in every theme
  • gamingworld-uploads is no longer world-readable. It was made public when nginx proxied /uploads/ to it; nothing has served from the bucket since 0.1.26, so allUsers was exposure with no upside. This also closes the submissions/ listability noted on 2026-08-25. The mirror and backup jobs authenticate as the service account and are unaffected
Changed
  • Previews may no longer open by announcing that nobody has played the game. It is honest and it was in one draft in three, which across a whole desk is a tic rather than a disclosure; preview_lint.go flags it in the first paragraph only, so saying it later in passing is still fine

[0.1.26] - 2026-08-28

Fixed
  • gamingworld.uk was down for thirteen hours on 2026-08-28. certbot.timer stopped nginx at 06:35; the restart ran nginx -t, which had to resolve storage.googleapis.com because /uploads/ was a proxy_pass to the bucket; the lookup failed for a moment ([emerg] host not found in upstream); and nginx.service has no Restart=, so the failed start was final and nothing retried. /uploads/ is now plain root /srv;. Bucket serving was never worth a hard startup dependency: the bytes still transited this box, so it added a paid GCS egress leg in front of a local file, and Cloudflare fronts the site either way. The five-minute mirror is unchanged; the bucket copy is now purely an offsite backup
  • Previews no longer claim to have played the game. The voice guide asked for a personal aside and offered "I lost twenty minutes to the fishing minigame" as the model, so drafts drifted into hands-on language for games nobody here has touched. The guide now states the stance up front (everything comes from the trailer, the screenshots and the store page) and the aside must be about watching or reading. preview_lint.go enforces it, so the writer's retry loop corrects it rather than trusting the prompt. Two published previews (Copero, Bird Tone) that had slipped were corrected in place
Added
  • Health watchdog (scripts/gw-watchdog.sh, root cron every two minutes, just watchdog-*): checks nginx, gw-api, the site over TLS on the loopback and from the public name, the API health endpoint, disk usage and certificate expiry. Restarts what is down and reports it; the one exception is nginx with a config that does not parse, where it mails the nginx -t error rather than thrashing. Alerts [email protected] (override with ALERT_EMAIL) through Postmark directly, never through the API, because "the API is down" is one of the things it must be able to report. Alerts on the second consecutive failure so a deploy never mails, reminds every six hours, and sends one recovery mail only if it actually alerted
  • Restart=on-failure drop-in for nginx (infra/systemd/nginx-restart.conf), five attempts over five minutes
  • docs/monitoring.md
Changed
  • Previews carry twice as many pictures. huntMaxGallery 8 to 16 (45 of the 90 queued games were pinned at exactly 8, so the cap was discarding art Steam had already provided) and huntMaxCands 40 to 60
  • Screenshots are interleaved through the prose rather than stacked in a grid below it (utils/interleaveFigures.ts): a preview is a "does this look interesting" page, so making the reader finish the text before seeing the game was backwards. The hook and the sign-off keep their breathing room, and whatever does not fit still fills the grid at the bottom
  • Wren Ashby's bio no longer claims to play every demo (migration 028)
  • preview_lint.go distinguishes watching from playing rather than banning both. The first draft of the rule flagged 15 of the 81 published previews, of which 13 were the correct voice ("I spent ten minutes on the store page watching the clip loop"), so the time-spent rule is now sentence-aware and "a hands-on version of the sport" is allowed as a description of a game

[0.1.25] - 2026-08-25

Added
  • Uploads mirror (scripts/gw-uploads-mirror.sh, root cron every five minutes): mirrors the managed upload directories into gamingworld-uploads using the existing root-only /var/keys/gcs-key, the same shape as gw-backup.sh and backup-keys.sh. /var/lib/gw-uploads-mirror/state (md5 size mtime object) is the index instead of the bucket, so an idle run makes zero GCS calls and never lists. Uploads stream with curl -T and are verified against the returned ETag; deletes are reconciled, because nginx checks the bucket first and a leftover object would keep serving an image the site had deleted. Guards: a missing managed directory suspends delete reconciliation entirely, and more than 50 deletions in one run refuses without FORCE=1
  • infra/logrotate/gw-logs rotates all three gw cron logs, none of which rotated before; just uploads-mirror-check dry-runs the mirror, just uploads-mirror-install installs it
  • storage_mirror_test.go fails if MANAGED_ROOTS in the script drifts from services.ManagedUploadDirs(), including if a local-only tree ever appears in it
Removed
  • The in-process GCS storage backend (services/storage_gcs.go, STORAGE_BACKEND, GCS_BUCKET, GCS_CREDENTIALS_FILE) and the one-implementation backend interface behind it. The API runs as www-data and the only GCS key on this box also reaches the DB backups and the secrets vault, so the backend was unreachable without minting a second service account for the web process to hold. The five-minute mirror gets the same bucket contents with no credential in reach of the API. Serving is unchanged: nginx still goes bucket first, disk second
  • /health no longer reports a storage field, and deploy.sh no longer carries the GCS credential pre-flight
Changed
  • ConvertLocalFileToWebP and DeleteLocalUpload are gone, folded back into ConvertToWebP and DeleteFile now that there is only one place a file can live. The split existed only to stop bucket-backed code from looking in the wrong place
  • docs/gcs-uploads.md, docs/plans/README.md and the nginx /uploads/ comments describe the mirror rather than a cutover that is not going to happen

[0.1.24] - 2026-08-25

Added
  • Google Cloud Storage backend for uploads (plan 04): services.SaveFile / ConvertToWebP / DeleteFile / OpenUpload now dispatch to a storage backend chosen by STORAGE_BACKEND (disk default, or gcs). Objects are named exactly like the public path minus /uploads/, stored with their content type and a 30-day immutable Cache-Control, so nothing downstream (DB rows, TipTap JSON, the frontend) changes
  • nginx serves /uploads/ from the bucket with a disk fallback: managed media is proxied to storage.googleapis.com, anything the bucket answers 403/404 for falls back to /srv/uploads, and the trees that bypass the storage backend (mail attachments, emoji assets, preview hunter art, generated images, the offline emulator/handheld art) are served from disk directly. Rollback is STORAGE_BACKEND=disk with no nginx change
  • gcssync operator CLI (apps/api/cmd/gcssync, just gcs-check / gcs-sync / gcs-verify): credential round-trip probe, public-read IAM grant, idempotent upload of local files, and a per-directory local-vs-bucket verify. There is no gsutil on the box, so this replaces the gsutil iam ch / rsync steps the plan assumed
  • internal/gcs: a ~350-line GCS JSON API client (signed-JWT auth, multipart upload, download, delete, list, IAM) instead of cloud.google.com/go/storage, which would have pulled gRPC and ~40 modules into a ten-dependency go.mod
  • /health reports the live storage backend; deploy.sh refuses to deploy with STORAGE_BACKEND=gcs if www-data cannot read the credentials file
  • docs/gcs-uploads.md covers the request path, the local-only directories, the rollout and the rollback
Fixed
  • Deleting an image under the GCS backend also removes any leftover local copy. Without that, nginx's disk fallback would keep serving a file the user had just deleted
  • Code that writes straight to /srv/uploads (the preview image hunter) now uses the explicitly-local ConvertLocalFileToWebP / DeleteLocalUpload helpers instead of the dispatching ones, which would have gone looking in the bucket for a file it had just written to disk

[0.1.23] - 2026-08-25

Added
  • Staff review tooling (plan 03): draft preview (/admin/reviews/:id/preview, renders the public layout with a draft banner), video in reviews (POST /admin/uploads/review-video, mp4/webm up to 100MB, TipTap video block + YouTube embeds via youtube-nocookie; the public sanitizer now keeps <video> and YouTube-only iframes), submission to review conversion (POST /admin/submissions/:id/create-review seeds a draft with title, links, synopsis and the submitter's media copied into review storage; review_submissions.review_id links them), reply-by-email from the Submissions tab with Accepted / Declined / Need more info templates, all five submission statuses in the UI, mail_log.sent_by records which staffer sent an admin reply
  • Email-sourced submissions: mail to [email protected] is filed as a review_submission (source=email, attachments as media) by the Postmark inbound webhook
  • Image hunter: Google Play listings now yield full-size screenshots (page cap raised to 3MB, the og:image tag sat just past the old 1MB cap), and trailer links contribute YouTube maxresdefault / Vimeo 1920x1080 posters. 5 of the 10 art-less queue items got artwork on the first run
Fixed
  • Review genre cap (3) was enforced in the service but the handler discarded the error; a 4-genre save now returns 400. Genres can also be cleared to none
  • API body limit and nginx client_max_body_size raised to 100MB for video uploads

[0.1.22] - 2026-08-25

Fixed
  • SEO prerender crawl loop: the bot prerender for news, previews and the fallback page carried a <meta http-equiv="refresh"> back to the same URL. Googlebot treats that as a redirect, so the Rich Results test looped six times and reported "crawl failed". The refresh is gone (bots already get a plain link to the page)
  • Prerender body excerpt no longer double-escapes entities (&amp;#39;), keeps a space between paragraphs, and truncates on rune boundaries

[0.1.21] - 2026-08-25

Added
  • Previews (/previews, /previews/:slug): promotional mini-previews of indie games from the pitch queue, written by staff persona Wren Ashby with AI assistance (Claude Sonnet 5 via OpenRouter, ~$0.02 per piece, cost-logged to AI Usage). Each preview has a hero image, screenshot gallery, trailer embed, store links and a "Pitch your game" CTA. Previews lead the homepage showcase and sit in the top-level nav
  • Image hunter: Steam storefront screenshots/movie thumbs, itch.io art, og/twitter images, submission media and mail attachments are collected per queue item (min 600px, webp, gallery cap 8), with an hourly re-hunt ticker and a gw-huntimages CLI
  • Content lint + scrubber: generated copy is checked for em dashes, AI-tell phrases, review keys, emails, phones and private links before it can be published; public API only ever exposes a PublicPreview DTO
  • Admin Previews tab (/admin?tab=previews): draft batches with a cost estimate and model picker, edit modal with lint panel and hero/gallery picker, rewrite, publish/unpublish/reject/pin/delete, hourly auto-draft toggle. Games tab gained "Make preview", a Previewed filter and a "needs artwork" badge
  • SEO: bot prerender for previews with the site's first JSON-LD (Article + VideoGame), sitemap now lists previews and every published review

[0.1.20] - 2026-08-09

Added
  • Games queue display modes (/admin?tab=games): a Comfy / Compact / List switcher next to the Alerts button, remembered per browser. Compact shows small artwork cards in up to 6 columns (12–18 per laptop screen); List shows dense single-line rows with inline Claim/Release/Done/Dismiss actions (24+ per screen) — ideal for demoing the whole inventory
  • Games queue pages now load 24 at a time (was 12) so the dense views fill the screen on first fetch

[0.1.16] - 2026-08-04

Added
  • "Games" admin area (/admin?tab=games): a games-to-review queue between Analytics and Mail for staff/moderator/admin roles. Card grid with artwork, genres/platforms, AI summary, store/trailer/press-kit links, one-click copy for included review keys, and a detail view with screenshots, the original pitch, and a deep link back to the source email/submission
  • AI inbox scanner: every 15 minutes (plus a "Scan now" button) an LLM (DeepSeek v4 Flash via OpenRouter, ~$0.0002/item, cost-logged to AI Usage) classifies unprocessed inbound mail + review submissions as game pitch vs. noise, extracts title/studio/links/keys/release/price, and enriches with artwork from the Steam storefront API or the site's og:image — DMARC/Postmark noise is filtered for free before the LLM ever runs
  • Claim workflow with live presence: staff claim a game to put their avatar + hold on it; claims, releases, and status changes (new → in-review → reviewed/dismissed) broadcast over WebSocket to every staff viewer in real time
  • Email alerts: staff-tier users can self-subscribe (or be added by managers) to a branded digest email whenever the scanner queues new games, deep-linking each game
  • New games.view / games.manage permissions granted to staff, moderator, admin, super_admin
Fixed
  • WebSocket connections now carry the user's real roles (previously hardcoded to user, so role-targeted messages reached nobody)

[0.1.15] - 2026-07-22

Added
  • Branded maintenance page: nginx now serves a styled auto-refreshing "back in a moment" page on 502/503/504 (both apex and API vhosts) instead of the stock white nginx error — visible only during deploys/restarts
  • Route-level error boundary + chunk-load retry: a failed page render no longer takes down the whole app shell; dynamic chunks that 404 after a deploy retry once, then auto-reload the tab to pick up fresh hashes (loop-guarded)
  • Shared ErrorState + skeleton loaders: public pages now show an honest "couldn't load, retry" block when the API is unreachable instead of fake empty states, and skeleton placeholders instead of bare spinners
  • nginx configs are now tracked in the repo under infra/nginx/
Changed
  • Deploys are atomic-ish: deploy.sh builds frontend + API and runs migrations before anything goes live; the API binary is staged and swapped with automatic rollback to the previous binary if the health check fails; the frontend swap keeps the 2 newest generations of hashed chunks so already-open tabs survive a deploy (npm run build no longer touches production at all)
  • Delivery is much lighter: gzip enabled for JS/CSS/JSON/SVG (assets were shipping uncompressed), HTTP/2 on all vhosts, build precompresses .gz served via gzip_static, react/react-dom/react-router split into a stable long-cached vendor chunk, admin panel tabs are per-tab lazy chunks
  • Caching is correct: hashed /assets/ get Cache-Control: immutable (1y), index.html is no-store — the stale-tab-after-deploy class of bugs is closed
  • Tulpa and Revamp card splashes converted from 2.9MB/2.3MB PNGs to sized WebP (274KB/84KB, ~4.8MB saved on first homepage load); card images lazy-load with explicit dimensions; homepage LCP splash is preloaded with fetchpriority=high
  • https://gamingworld.uk/health now proxies to the API health endpoint (previously 404)
Security
  • nginx outer-belt rate limit on the API vhost (20r/s per IP, burst 40 → 429), in front of the app's per-endpoint limiters
  • Content-Security-Policy (report-only) on the apex to iterate toward enforcement; uploads now serve non-media types with Content-Disposition: attachment
  • systemd sandbox tightened: PrivateDevices, ProtectKernel*, RestrictNamespaces, RestrictAddressFamilies, empty capability bounding set, and more
  • Frontend dependency audit: 17 vulnerabilities fixed (incl. react-router XSS advisory → 7.18.1); WebSocket proxying moved to a dedicated location with sane timeouts (30s API / 1h WS)
UI
  • Fixed Tailwind v4 opacity utilities sitewide (modal backdrops rendered solid black, nav blur was opaque)
  • Removed the universal button-lift hover and global 0.3s transition on every element; theme transition stays on body only
  • Semantic --success/--warning/--danger tokens replace scattered status hexes; dark: classes now follow the site's [data-theme] switcher
  • Loading and error states polished across Home, Games, News, Reviews, Emulators, Handhelds

[0.1.14] - 2026-07-22

Added
  • systemd service (gw-api.service): the API is managed by systemd — auto-restart on crash (3s), starts on boot, secrets in a root-only /etc/gamingworld/api.env (generated from .env.production by deploy.sh), runs as www-data instead of root, with ProtectSystem/NoNewPrivileges hardening. Replaces the nohup process that nothing restarted and a stale, broken unit that pointed at a September binary with a plaintext DB password
  • Nightly database backups (scripts/gw-backup.sh, cron 03:30): pg_dump -Fc with pg_restore --list verification, 14-day local retention in /srv/backups/pg/, off-box copy to gs://gamingworld-uploads/backups/, plus a weekly tar of /srv/uploads on Sundays
  • Tracked migrations (scripts/migrate.sh): schema_migrations table, files applied exactly once in sorted order — no more "already exists" spam from re-running every file; 015_sync_log.sql renamed to 015b_sync_log.sql to remove ordering ambiguity
  • Daily background purge of page-analytics data older than 90 days (previously only ran when an admin opened the dashboard)
Security
  • Inbound email attachments are now content-sniffed against an allowlist (images, pdf, zip, gz, txt) with a 15MB cap and the extension forced from the sniffed type — previously attacker-controlled bytes/filenames were written verbatim into the publicly-served /uploads/mail/ (stored-XSS vector). Blocked attachments show a "blocked" note in the mail UI
  • Config fails closed: the API refuses to start when JWT_SECRET or DATABASE_URL is missing (previously fell back to a hardcoded public JWT secret)
  • Rotated the DB password leaked by the old world-readable systemd unit; .env.production and the generated env file are now mode 600
  • Full-text search endpoints (emulators, handhelds, game cards, reviews) sanitize tsquery input — & | ! ( ) : characters no longer cause 500s; ILIKE filters escape %/_ wildcards
  • Mail handlers log detailed errors server-side and return generic messages to clients; /track uses the proxy-resolved client IP instead of trusting spoofable headers
Fixed
  • Background sync goroutines (Postmark, models, handhelds, emulators, analytics) recover from panics instead of killing the API
  • All outbound HTTP calls carry timeouts (Postmark 15s; image downloads 30s via the SSRF-filtered client); Fiber got read/write/idle timeouts
  • All list endpoints clamp limit (max 200) and negative offset?limit=99999999 no longer works as a free DoS

[0.1.13] - 2026-07-22

Added
  • Two-pane mail client: the admin Mail tab is now a proper mail client — message list on the left, reading pane on the right, each scrolling independently; collapses to list→detail navigation on mobile
  • Read/unread tracking (migration 021): unread inbound mail shows bold with a dot, opening a message marks it read (DB-backed), and the Inbox toggle shows a live unread count badge
  • Server-side mail search (q=) across sender, recipient, subject, and body, plus an All/Inbound/Outbound direction filter and an unread-only toggle in the UI
  • Archive: archive/restore messages (archived_at); the inbox excludes archived mail by default and an Archived filter shows it
  • Real pagination: the inbox API returns total and the UI has a Load More button with "x of total" (previously hardcoded to the latest 50)
  • Compose-new mail (previously reply-only) with a From dropdown of known @gamingworld.uk addresses (+ custom), and replies quote the original message behind a "Show quoted text" toggle
  • Replied indicator: sending with reply_to_id stamps the original inbound message's metadata, showing a ↩ marker in the list
  • New endpoints (all mail.view): PUT /admin/mail/inbox/:id/read, PUT /admin/mail/inbox/:id/archive, GET /admin/mail/unread-count
Changed
  • MailTab split from one 491-line file into focused components under components/admin/mail/ (all < 200 LOC)
  • Relative timestamps in the message list (full date on hover), sender avatars with deterministic colors, skeleton loading states, and proper empty states
  • Subscribers panel: filter inputs are debounced (no more refetch per keystroke), result count shown, sticky table header, striped rows

[0.1.12] - 2026-07-22

Added
  • Staff role: new staff system role (migration 020) with exactly the permissions needed to work reviews, submissions, and mail — no role/user management, settings, news admin, or API key access
  • Granular mail permissions: mail.view / mail.send replace the borrowed admin.analytics.view / admin.settings.edit gates on /admin/mail/*; granted to staff, admin, and super_admin
  • Working user management: GET /admin/users (search, role/status filters, pagination) and PUT /admin/users/:id/status implemented for real (previously mock/disabled); Users tab in the admin panel now shows the live user table with activate/deactivate and a Manage Roles modal
  • Permission-based admin access: the admin panel gate and sidebar now check permissions instead of role names — staff (or any custom role) see exactly the tabs their permissions unlock; landing on a forbidden tab redirects to the first permitted one
Fixed
  • Self-deactivation is refused (an admin can no longer lock themselves out from the UI)
  • Backfilled missing gamecards.* permissions on the admin role

[0.1.11] - 2026-07-22

Security
  • HIGH: Fix JWT expiry env vars being silently ignored — JWT_ACCESS_EXPIRY="15m" / JWT_REFRESH_EXPIRY="7d" failed integer parsing and fell back to 8-hour access / 90-day refresh defaults. Config now accepts both bare integers and duration strings (15m, 8h, 7d), defaults tightened to 15m/7d, and the resolved expiries are logged at startup
  • MEDIUM: Enforce is_active on every authenticated request and on token refresh — deactivating a user now locks them out immediately instead of at token expiry
  • MEDIUM: WebSocket auth switched from ?token=<JWT> (leaked into nginx access logs) to a single-use 30-second ticket from POST /api/v1/ws/ticket; raw JWTs in the query string are no longer accepted. /ws and the ticket endpoint are rate-limited (10 req/min/IP)
  • LOW: Rate-limit /auth/refresh (5 req/min/IP, same as login)
  • LOW: Bind API to 127.0.0.1:8080 so nginx is the only ingress (blocks direct-connect X-Real-IP spoofing that bypassed per-IP rate limits)
  • LOW: Submission uploads now require the sniffed content type to match the file extension's category (image/video/pdf); application/octet-stream is no longer accepted
  • LOW: 5xx responses return a generic message instead of raw internal error strings; /health no longer echoes DB error details (both are logged server-side)
  • LOW: Sanitize changelog HTML with DOMPurify for consistency with all other rich-text sinks

[0.1.10] - 2026-05-20

Security
  • CRITICAL: Fix unauthenticated WebAuthn account takeover — /auth/webauthn/register/begin now refuses any handle or email already in the database, forcing existing users through the authenticated /webauthn/add/* flow to attach new passkeys
  • HIGH: Synchronize in-memory WebAuthn ceremony sessions with a mutex and add 5-minute TTL + background sweeper (concurrent map writes were crashing the API; unbounded growth could exhaust memory)
  • MEDIUM: Rate-limit /auth/login/passphrase, /auth/webauthn/login/begin, and /auth/webauthn/register/begin (5 req/min/IP) to deter brute force and enumeration
  • LOW: Equalize bcrypt timing for unknown handles in passphrase login (handle-enumeration oracle)
  • LOW: SSRF defense in /news/external splash-image fetch — refuse to dial loopback / link-local / RFC1918 / cloud-metadata addresses and require http(s) scheme
  • LOW: Fix refresh-token rotation — switch DB-stored hash from bcrypt to HMAC-SHA256(jwt_secret, token) so GetSessionByRefreshToken actually finds rows. Existing sessions are invalidated; users re-login once
  • LOW: Fail closed when POSTMARK_WEBHOOK_SECRET / mail / news-external API keys are empty (previous subtle.ConstantTimeCompare("","") would have returned 1)
Added
  • Admin reply composer: One-click "Reply" button on any inbound email at /admin?tab=mail, sends via authenticated /admin/mail/send endpoint
  • Inbound mail notifications: Branded Gaming World email alert fires to ALERT_EMAIL (default [email protected]) whenever an inbound message arrives, with who/what/where/when + admin-panel deep link

[0.1.9] - 2026-04-02

Added
  • External News API: POST /api/v1/news/external endpoint for external projects to publish news articles via API key auth (X-API-Key header)
  • SEO prerender: Server-side rendered meta tags (Open Graph, Twitter Card) for news articles, served to bots via nginx user-agent detection
  • Dynamic sitemap.xml: Auto-generated from published news articles with 15-minute cache
  • Client-side SEO: document.title and OG meta tag updates in NewsArticlePage for in-app navigation
  • source_project tracking: News articles record which external project posted them
Infrastructure
  • NEWS_API_KEY environment variable for external news API authentication
  • Migration 019: source_project column on news_articles table
  • Nginx map directive for bot detection with prerender proxy routing

[0.1.8] - 2026-03-21

Added
  • Emulator logo sync: 465 emulators now show real logos synced from disk into the database
  • AI fallback images: Category-specific AI-generated fallback images (via FLUX) for 420 emulators with no logo
  • Polymorf game card: New project card for the AI-powered dual-camera photo booth activation
Improved
  • Ghost watermarks on all emulator cards including fallback images
  • Emulator detail modals show fallback images instead of emoji placeholders
  • New justfile recipes: gen-fallback-images and sync-emulator-images
Fixed
  • CORS blocking all requests via www: Added nginx redirect from www.gamingworld.uk to apex gamingworld.uk — previously both were served without redirect, causing CORS origin mismatch with the API

[0.1.7] - 2026-03-21

Added
  • Emulator system images: Real hardware photos for 40+ gaming systems across category, landing, and system browse cards
  • Process monitor: Admin analytics showing top 20 processes by memory usage
  • Image preview on submit: Thumbnail previews for uploaded images in the submission form
Fixed
  • HTML entities in submissions tab rendering as literal &middot; text
  • SNES system image mismatch between API and frontend map
Improved
  • Category browse cards show real hardware images instead of emoji placeholders
  • Landing page browse cards use category/system images with ghost watermarks
  • System image map expanded from 32 to 90+ entries

[0.1.6] - 2026-03-20

Added
  • Emulators 3-tier browsing: Landing page with 3 browse options (By Category, By System, All Emulators)
  • Category browse view: Grid of 8 category cards (Home Console, Handheld, Computer, Arcade, Multi-system, Mobile, Calculator, Other) with AI-generated watermark images and emulator counts
  • System browse view: Grid of 60+ emulated system cards (NES, SNES, N64, PlayStation, etc.) with console images, search filter, and manufacturer-based colors
  • System filter API: New GET /emulators/systems endpoint returning distinct emulated systems with counts; system query param added to GET /emulators for JSONB array filtering
  • Category images: 8 generated images at /uploads/emulators/categories/
  • System images: 28 console/system images at /uploads/emulators/systems/
  • All emulator views use URL search params (?view=category&category=Home+Console) for proper back/forward/bookmark support
  • Breadcrumb navigation across all emulator views
  • Shared emulator constants/types extracted to emulatorConstants.ts
Fixed
  • Analytics page views always 0 (sequence permissions + empty IP inet cast)
  • Dark mode email body unreadable (hardcoded bg-white)
  • just migrate now auto-grants sequence permissions

[0.1.5] - 2026-03-20

Fixed
  • Postmark inbound emails broken: deploy.sh was not passing POSTMARK_TOKEN, POSTMARK_WEBHOOK_SECRET, MAIL_API_KEY, SUBSCRIBE_API_KEY, or ALERT_EMAIL env vars to the API process — webhook auth always failed and sync was silently disabled
  • Added missing Postmark env vars to .env.production and .env.example
  • Analytics page views always 0: page_views INSERT failed silently because www-data user lacked USAGE permission on page_views_id_seq; also fixed empty IP causing ::inet cast error
  • Dark mode email body unreadable: HTML email preview used hardcoded bg-white, now uses theme-aware bg-secondary with proper text color
  • just migrate now auto-grants table and sequence permissions to www-data
Added
  • Inbound email attachments saved to disk: Webhook decodes base64 attachment content and saves to /srv/uploads/mail/{id}/, downloadable via nginx
  • Attachment display in admin mail inbox: Inbound emails with attachments show paperclip icon in list; expanded view has clickable download links
  • Postmark sync fetches attachment metadata: Background sync calls Postmark message details API to capture attachment info for each new inbound message
  • Admin panel URL routing: Active tab is now reflected in the URL as ?tab=mail, ?tab=news, etc. — browser back/forward buttons work correctly, pages are bookmarkable and shareable
Removed
  • Admin mail tab 30-second auto-refresh: Removed wasteful polling interval; manual Refresh button remains

[0.1.4] - 2026-03-18

Added
  • News system: Full CRUD for news articles with TipTap rich text editor, inline image drag-drop with live WebP conversion, splash images, publish/unpublish workflow
  • News banner on homepage: Most recent published news article displayed as a prominent banner at the top of the home page
  • News public pages: Browse all news at /news with search, individual article pages at /news/:slug
  • Review submission funnel: Comprehensive public form at /submit for indie devs to submit games for review — includes multi-file upload (screenshots, trailers, press kits) with 50MB cap, 10 file max
  • Submissions admin inbox: Unified admin tab showing all submissions (form + email) with expandable detail cards, media preview, notes, status filters (pending/reviewing/accepted/declined/completed)
  • GWUK system user: handle=GWUK, [email protected] — system/AI robot user with admin role for automated and editorial news posts
  • News full-text search with weighted tsvector (title A, synopsis B, content C)
  • ReviewEditor component now accepts uploadEndpoint prop for reuse across news and reviews
  • Navigation updated with "News" in Content dropdown and "Submit Game" link
  • Permissions: news.create, news.edit.any, news.delete.any, news.publish, submissions.view, submissions.manage
  • Migration 018: news_articles table, review_submissions table, permissions, GWUK user
  • Upload directories: news/, news/inline/, submissions/
Changed
  • ReviewRequestModal now redirects to /submit page instead of using mailto
  • Storage service extended to support video (mp4, webm) and PDF uploads for submissions

[0.1.3] - 2026-03-16

Added
  • Console system icons displayed throughout handhelds UI — filter pills, grade badges, and performance bar all use official console logos instead of text labels
  • 18 supplemental console hardware photos (Wikimedia Commons CC) stored in /uploads/handhelds/systems/supplemental/ — survive sheet re-syncs
  • Grade badges show console hardware image as subtle background with hover reveal
  • System icons auto-rotated from vertical spreadsheet orientation to horizontal and converted to WebP
  • Colored side tabs on handheld cards showing top emulation systems (blue=A, green=B, yellow=C)
  • 3D corner ribbons for Upcoming (purple), All A (gold), Budget (green), Premium (blue)
  • "Coming Soon" disclaimer in detail modal for upcoming/unreleased devices with expected release date
  • Performance rating parsed into clean badge (e.g. 🔥 4½/5 instead of raw emoji strings)
  • Accent-secondary color spread across hover effects, toggles, input focus, card shadows project-wide
Changed
  • Sync script now auto-rotates extracted system icons 90° and converts to WebP
  • System names only appear in hidden title/alt attributes for accessibility
  • Filter pill selection uses grayscale-to-color + checkmark instead of invert (fixes NES/GBA/Genesis readability)
  • Clear/Clear-all filter buttons are now compact X icons with badge counter
  • Max emulation box uses solid background instead of transparent accent tint

[0.1.2] - 2026-03-15

Fixed
  • Handhelds section showing 0 results: Google Sheets sync was using wrong tab gid (1→0), headers had multi-line names that didn't match column lookups
  • Avatar upload showing broken image: URL was pointing to api.gamingworld.uk but uploads are served from gamingworld.uk
  • Game cards not respecting admin sort order: frontend was grouping all hero/large/normal cards separately, now groups consecutive same-size cards to preserve sort_order
  • Handheld images broken: image URLs were using API domain instead of frontend domain
  • Price overflow causing horizontal scrollbar: prices now show first line only with full details in tooltip
  • Name overflow on long handheld names: dynamic font sizing based on name length
Added
  • 565 retro gaming handhelds syncing from Retro Handhelds community spreadsheet
  • 473 device images and 18 system console icons extracted from XLSX
  • 35 new handheld data columns: CPU/GPU details, screen specs, controls, audio, materials, performance data
  • Performance grades for 18 emulation systems (GB/GBC through PS3) with letter grades (A-F)
  • Performance-tier card borders: gold (S-tier), blue (A-tier), green (B-tier)
  • Ghost image watermark behind card content at 6% opacity
  • Corner ribbons: Upcoming (purple), All A (gold star), Budget ($), Premium (diamond)
  • Form factor emoji badges on each card
  • "Supports" multi-select filter: filter by system emulation capability
  • Enhanced performance heatmap bar with hover tooltips
  • Sync log capture for model and handheld sync services, displayed in admin Models tab
  • Weekly cron job for handheld image extraction from XLSX
  • Migration 015 (sync_log), Migration 016 (handhelds overhaul with 35 new columns)

[0.1.1] - 2026-03-13

Fixed
  • Review editing 404 error: SELECT * on reviews table included search_vector generated column, causing sqlx scan failure
  • TipTap duplicate extension warning: StarterKit v3 includes Link and Underline by default, now disabled in StarterKit config
  • Logo in navigation now links to home page
  • Empty reviews page now shows "Reviews coming soon!" instead of generic message
Added
  • Review unpublish support: admin can unpublish reviews from both editor and admin panel
  • Splash image removal button in review editor
  • Publish/unpublish toggle in admin reviews tab
  • Game cards "Import All" button always visible (not just when empty)
  • Hardcoded cards comparison view on Games page for verifying database card imports
  • Orphaned image cleanup script (scripts/cleanup-orphaned-images.sh)
Removed
  • Test review deleted from database

[0.1.0] - 2026-03-12

Added
  • Full game reviews system with TipTap rich text editor
  • Award templates with CSS-rendered shapes and AI image generation (ImageRouter)
  • AI sentiment analysis via OpenRouter + Steam/Itch.io scraping
  • Encrypted API key management (AES-256-GCM) with admin GUI
  • File upload infrastructure with nginx static serving (/srv/uploads/)
  • Review editor with drag & drop images, genre tags (max 3), rating slider, splash images
  • Public review pages with full-text search, genre filters, 3D card hover effects
  • Homepage "Latest Reviews" carousel
  • Award system with templates, AI-generated images, review attachment
  • Sentiment analysis with circular gauge visualization
  • 4 new admin tabs: API Keys, Award Templates, Awards, Reviews
  • Reviews CRUD, publish, search, genres, sentiment analysis API endpoints
  • Awards & templates CRUD, AI image generation API endpoints
  • Encrypted API key management with test endpoints
  • File upload endpoints with rate limiting
  • 4 database migrations (006-009): api_keys, award_templates, awards, reviews/genres
  • 10 new backend services, 5 handlers, 4 repositories, 4 models
  • Nginx configured to serve uploads with 30-day cache headers

[0.0.2] - 2025-09-23

Changed
  • 86% LOC reduction in main App.tsx through component extraction
  • Complete frontend refactoring with SOLID principles
Added
  • ESLint integration with TypeScript support
  • Glassy UI enhancements for light mode teaser images
  • Professional build pipeline with quality gates
  • Component-based architecture (7 focused components extracted)
  • Reusable UI components (Modal, Navigation, etc.)
  • Automated linting in build process

[0.0.1] - 2025-09-22

Added
  • React + TypeScript + Tailwind CSS SPA
  • WebAuthn/Passkey authentication system with passphrase fallback
  • User account management (add/remove passkeys)
  • Theme system (dark/light mode with customizable accent colors)
  • Settings persistence
  • Home and About pages with content
  • Go API backend with Fiber framework and JWT tokens
  • PostgreSQL database integration
  • Responsive design and mobile navigation
  • Hetzner server deployment with SSL certificates (Let's Encrypt)
  • Nginx reverse proxy and production build process

Roadmap

[0.2.0] - Community & Polish (Target: Q2 2026)
  • Comments system on reviews
  • Image WebP conversion and EXIF stripping
  • Backend HTML sanitization (bluemonday)
  • Review image gallery and auto-save drafts
[1.0.0] - Production Ready (Target: Q2 2026)
  • Comprehensive testing
  • Performance optimization
  • API documentation and stability

Gallery

The full set.

Build something like this

Want a tool like this for your shop?

We've shipped this kind of thing before. Twenty-minute intro call, no slides.