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.
- TypeScript
- React
- Vite
- Go
- Fiber
- PostgreSQL
- WebAuthn
- WebSockets
- OpenRouter
- Tiptap
- Python
- Tailwind CSS
- nginx
- systemd
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.ukfor 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.
/joinis 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 /applicationsstores 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.viewandapplications.managepermissions, with a five-step workflow (new, reading, trial review, on the team, declined) and notes per applicant /joinin the sitemap, and Write for Us in the main navigation
Notes
- The credit ledger itself is not built. Until
docs/plans/10-reviewer-credits.mdships, 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
=w1920returns 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-gameandsnakebit-exe, three of which had one as their hero, and advert banners fromfeed-itandbitiverse. 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.tsxwas 1553 lines against a 200-line house rule. It is now a 189-line page over 18 modules incomponents/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/oemsinstead of a 34-entry map hardcoded in the page, so the list has one home.handheld_brands.goseeds 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 draftedfive 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,cwebpthen 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/oemsserved 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-gameandweather-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:imagefor a freshly submitted listing as HTTP 200 withContent-Type: image/jpegand an empty body.downloadGameImagetrusted that header and skipped the byte sniff,io.Copytreated an empty source as a successful write, andcwebpthen failed on the empty file so the.jpgpath 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 saveToDiskrefuses 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.exeandChaos Core, by re-fetching eachog: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/appdetailsgeolocates 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 at2,--€. The call now pinscc=gb&l=en, so prices arrive as GBP in the form a UK reader expects (£1.76). Both other callers ofFetchSteamAppDetails, 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.99form and expands the,--round-amount idiom. Anything it does not recognise is left alone, so$9.99,FreeandUS$19.99, 15% launch discountpass 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]withobject-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.tsxintocomponents/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/naturalHeightthat jsdom never reports: landscape keeps 16/9 andobject-coverwith a singleimgon the page, portrait switches to the taller box andobject-containand mounts anaria-hiddenblurred 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-cleangoes through the sameSetStatusas 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.IndexNowServicenow tracks in-flight submissions and exposesWait(); the CLI wires the service in, flushes before it exits, and reports what it sent instead of printing a reminder to go and rungw-seosubmitby hand.-drydisables 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-clisbuilds and installs the fivegw-*Go tools into/usr/local/bin.deploy.shnever 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-abpreviewson the box was three days older than the code that had supposedly fixed it. Run it after any change underinternal/that a CLI path reads- A test for the delivery guarantee itself: five
SubmitAsynccalls against anhttptestserver, thenWait, then assert all five landed.indexNowEndpointbecame avarso 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 ofabpreviewsandhuntimageshas been broken since a value with a space enteredapi.env— it fails withenv: 'World': No such file or directory. Both now document sourcing the file as root and passing--preserve-envfor the three vars the tools actually need, which is also the only form that works given the file is0600 root:root
[0.1.31] - 2026-08-31
Fixed
- Accented letters in a title collapsed into hyphens in its slug.
generateSluglowercased and then replaced every run of non-[a-z0-9]with a hyphen, so "Hotel Paraíso" becamehotel-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" staysshoot-em-upand "don't" no longer becomesdon-t. The helper moved out ofaward_template_service.gointo its ownslug.gowith 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-relintre-runs the linter over previews as they are stored and reports where the storedlint_reportno 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.
previewsandgenerated-imageswere 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.localOnlyDirsis gone:ManagedUploadDirs()is now every directory inallowedDirs, andMANAGED_ROOTSin the mirror script matches. One-time cost to seed, about 1,189objects.insertcalls, is under a penny; storage adds roughly $0.004/month - Comments across
storage.go, the mirror script,gcssync,infra/nginx/gwanddocs/gcs-uploads.mdstill 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
flockfor 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.-ndoes not take the lock infra/cron/gw-uploads-mirroris tracked and installed byjust 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.goimplements 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 fromapps/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.-dryprints the list,-only previewsnarrows 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.htmlships 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.usePageMetanow owns the canonical, setsog:site_namealongside it, and restores the default on unmount robots.txtno longer invites crawlers into/admin- Publishing a preview never worked.
PreviewRepository.SetStatusused$2twice — 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.Listbuilt its count query from the samewherestring as the list query, so a status filter sentWHERE p.status = $3with one argument. The two queries number their own placeholders now gw-abpreviews -publish-cleanpublishes 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.gois 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 (migration029, 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.bioon the public preview DTO scripts/gcs-bucket-access.shshows 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:
presskitlinks were skipped entirely (a press kit is the one page a studio builds specifically to hand out screenshots), and onlyog:imagewas read, so any site that ships its screenshots as plain<img>tags read as "no images". The hunt now reads inline images,srcsetand 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/typographywas never installed, so theprose*classes in the preview, review, news and editor views styled nothing. Installed and bound to the theme variables inindex.css, so articles pick up real typography in every themegamingworld-uploadsis no longer world-readable. It was made public when nginx proxied/uploads/to it; nothing has served from the bucket since 0.1.26, soallUserswas exposure with no upside. This also closes thesubmissions/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.goflags 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.timerstopped nginx at 06:35; the restart rannginx -t, which had to resolvestorage.googleapis.combecause/uploads/was aproxy_passto the bucket; the lookup failed for a moment ([emerg] host not found in upstream); andnginx.servicehas noRestart=, so the failed start was final and nothing retried./uploads/is now plainroot /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.goenforces 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 thenginx -terror rather than thrashing. Alerts[email protected](override withALERT_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-failuredrop-in for nginx (infra/systemd/nginx-restart.conf), five attempts over five minutesdocs/monitoring.md
Changed
- Previews carry twice as many pictures.
huntMaxGallery8 to 16 (45 of the 90 queued games were pinned at exactly 8, so the cap was discarding art Steam had already provided) andhuntMaxCands40 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.godistinguishes 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 intogamingworld-uploadsusing the existing root-only/var/keys/gcs-key, the same shape asgw-backup.shandbackup-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 withcurl -Tand 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 withoutFORCE=1 infra/logrotate/gw-logsrotates all three gw cron logs, none of which rotated before;just uploads-mirror-checkdry-runs the mirror,just uploads-mirror-installinstalls itstorage_mirror_test.gofails ifMANAGED_ROOTSin the script drifts fromservices.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 aswww-dataand 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 /healthno longer reports astoragefield, anddeploy.shno longer carries the GCS credential pre-flight
Changed
ConvertLocalFileToWebPandDeleteLocalUploadare gone, folded back intoConvertToWebPandDeleteFilenow that there is only one place a file can live. The split existed only to stop bucket-backed code from looking in the wrong placedocs/gcs-uploads.md,docs/plans/README.mdand 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/OpenUploadnow dispatch to a storage backend chosen bySTORAGE_BACKEND(diskdefault, orgcs). Objects are named exactly like the public path minus/uploads/, stored with their content type and a 30-day immutableCache-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 tostorage.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 isSTORAGE_BACKEND=diskwith no nginx change gcssyncoperator 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 thegsutil iam ch/rsyncsteps the plan assumedinternal/gcs: a ~350-line GCS JSON API client (signed-JWT auth, multipart upload, download, delete, list, IAM) instead ofcloud.google.com/go/storage, which would have pulled gRPC and ~40 modules into a ten-dependency go.mod/healthreports the live storage backend;deploy.shrefuses to deploy withSTORAGE_BACKEND=gcsif www-data cannot read the credentials filedocs/gcs-uploads.mdcovers 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-localConvertLocalFileToWebP/DeleteLocalUploadhelpers 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-reviewseeds a draft with title, links, synopsis and the submitter's media copied into review storage;review_submissions.review_idlinks 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_byrecords which staffer sent an admin reply - Email-sourced submissions: mail to
[email protected]is filed as areview_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_sizeraised 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 (
&#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-huntimagesCLI - 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
PublicPreviewDTO - 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.managepermissions 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.shbuilds 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 buildno 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
.gzserved viagzip_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/getCache-Control: immutable(1y),index.htmlisno-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/healthnow 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/--dangertokens 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.productionby deploy.sh), runs aswww-datainstead of root, withProtectSystem/NoNewPrivilegeshardening. 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 -Fcwithpg_restore --listverification, 14-day local retention in/srv/backups/pg/, off-box copy togs://gamingworld-uploads/backups/, plus a weekly tar of/srv/uploadson Sundays - Tracked migrations (
scripts/migrate.sh):schema_migrationstable, files applied exactly once in sorted order — no more "already exists" spam from re-running every file;015_sync_log.sqlrenamed to015b_sync_log.sqlto 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_SECRETorDATABASE_URLis missing (previously fell back to a hardcoded public JWT secret) - Rotated the DB password leaked by the old world-readable systemd unit;
.env.productionand 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;
/trackuses 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 negativeoffset—?limit=99999999no 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
totaland 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_idstamps 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
staffsystem 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.sendreplace the borrowedadmin.analytics.view/admin.settings.editgates on/admin/mail/*; granted tostaff,admin, andsuper_admin - Working user management:
GET /admin/users(search, role/status filters, pagination) andPUT /admin/users/:id/statusimplemented 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 theadminrole
[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_activeon 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 fromPOST /api/v1/ws/ticket; raw JWTs in the query string are no longer accepted./wsand 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:8080so nginx is the only ingress (blocks direct-connectX-Real-IPspoofing 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-streamis no longer accepted - LOW: 5xx responses return a generic message instead of raw internal error strings;
/healthno 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/beginnow 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/externalsplash-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
GetSessionByRefreshTokenactually finds rows. Existing sessions are invalidated; users re-login once - LOW: Fail closed when
POSTMARK_WEBHOOK_SECRET/ mail / news-external API keys are empty (previoussubtle.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/sendendpoint - 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/externalendpoint 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.titleand OG meta tag updates in NewsArticlePage for in-app navigation - source_project tracking: News articles record which external project posted them
Infrastructure
NEWS_API_KEYenvironment variable for external news API authentication- Migration 019:
source_projectcolumn onnews_articlestable - Nginx
mapdirective 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-imagesandsync-emulator-images
Fixed
- CORS blocking all requests via www: Added nginx redirect from
www.gamingworld.ukto apexgamingworld.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
·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/systemsendpoint returning distinct emulated systems with counts;systemquery param added toGET /emulatorsfor 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 migratenow 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, orALERT_EMAILenv vars to the API process — webhook auth always failed and sync was silently disabled - Added missing Postmark env vars to
.env.productionand.env.example - Analytics page views always 0:
page_viewsINSERT failed silently becausewww-datauser lackedUSAGEpermission onpage_views_id_seq; also fixed empty IP causing::inetcast error - Dark mode email body unreadable: HTML email preview used hardcoded
bg-white, now uses theme-awarebg-secondarywith proper text color just migratenow auto-grants table and sequence permissions towww-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
/newswith search, individual article pages at/news/:slug - Review submission funnel: Comprehensive public form at
/submitfor 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
uploadEndpointprop 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_articlestable,review_submissionstable, permissions, GWUK user - Upload directories:
news/,news/inline/,submissions/
Changed
- ReviewRequestModal now redirects to
/submitpage 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½/5instead 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 includedsearch_vectorgenerated 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
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.