Hackdex redesign, private drafts, AI disclosure, and more (#101)

* Restyle site chrome for v2: tokens, class-based theme toggle, Archivo, bottom tabs

Move the color system to the v2 tokens in globals.css (cool neutrals, rose only
for actions, green only for on-device ROM state) and alias the old variable
names so existing components keep working. Dark mode is now a class on <html>
set before first paint, with a sun/moon toggle in the header. Archivo replaces
Geist and the Arial body fallback; headings use its width axis via
font-display. Header drops the guest login and Beta pill, adds search and a
quiet Submit link; phones get a three-tab bottom bar instead of a hamburger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Rebuild HackCard for v2 and add a list row

The card keeps the embla screenshot carousel, drag guard, and dots from the
old card and reshuffles the text to the v2 layout: title, author with an
outline completion pill, two-line summary, first two tags, then base ROM (or
Ready), last update, and downloads. Screenshots sit in a well at native width
on desktop and stretch on phones when the grid asks for it; pixelated
rendering is now opt-in and only applied at whole-number scales. HackRow is
the Discover list view. formatRelativeDate feeds the new updated field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Rebuild Discover for v2: filter rail, phone filter sheet, list view, pager above the grid

Filtering, sorting, and URL state are unchanged in substance; the browser
component now renders the v2 layout around them. Desktop gets a sticky filter
rail with in-facet search, platform disclosures that collapse ROM revisions
into one row, and catalog-wide counts. Phones get one Filters button that
opens a 90% draft sheet with a live "Show N hacks" footer; close, scrim,
Escape, and Back discard. Tags now OR within a category and AND across
categories. Pages are 24 long with a small pager beside the result range and
a full one below; grid and list views are remembered per device.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Rebuild Home for v2: promise hero, shelves from the discover cache, How it works last

The page now reads the cached discover catalog instead of running its own
queries, and slices it into Trending, New, and Recently updated shelves with
hover-revealed paging arrows. A ready shelf leads once a base ROM is on the
device, and the hero flips to "N hacks are one click away". The hero patch
diagram and guest login link are gone; How it works keeps its copy and moves
to the bottom for cold visitors, where the hero link jumps to it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Rebuild the hack page for v2: patch module in the rail, compact header bar, tabs, lightbox filmstrip

The sticky bottom bar is gone. The patch flow (HackActions and its state,
onboarding, ROM checks, error modal) now renders as a patch module at the top
of the rail: status line, version picker, one action button, facts, terms.
When it scrolls behind the site header the title, picker, and the same
button reappear in a compact bar portaled into the header, wrapping onto a
second row on phones. The body is About / Gallery / Versions tabs; About
keeps the screenshot stage, thumbnails, description, and latest changelog,
Versions lists selectable patches and links to the full history. The
lightbox keeps pixel-perfect scaling and scroll locking and gains a title bar
and filmstrip. Tags collapse to one row with a peek and expand in place.
Compatibility, box art, details, links, and more-from-author fill the rail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Start hacks as private server-side drafts with a checklist and Submit for review

Submit now opens with a start card (title, base ROM, summary, own or on
behalf) that creates a hack row with submitted_at null and sends the creator
to the edit page. The edit page shows a status strip (Draft / In review /
Listed) with a Preview-as-a-player link, a publishing checklist computed from
the real row (patch, screenshots, description, tags, completion status), and
Submit for review, which stamps submitted_at, opens the review thread, and
tells the admins. Patch uploads on a draft no longer ping reviewers, the
admin queue hides drafts, and the dashboard list shows Draft / In review /
Approved. The old wizard stays for archive entries. Migration backfills
submitted_at from created_at for everything that already exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Restyle My ROMs and the creator dashboard to match v2

Light touch only: page widths, display headings, the ready count as a green
dot pill, ROM cards on the new tokens (green for on-device, amber for a
permission prompt, orange-red for errors), tokenized buttons and stat cards.
No layout changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep base ROM revisions visible in labels and Discover filters

A hack is built against one dump, and revisions and regions hash differently,
so the player picking a ROM needs the revision in front of them. The label
helper now only drops the "Pokémon" prefix, and the Discover rail lists every
base ROM on its own row instead of collapsing revisions into one game.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Nest ROM revisions under one game row in the Discover rail

Games with several dumps (FireRed Rev 0 / Rev 1, regional releases) get a
parent checkbox that selects or clears them all, an indeterminate dash when
only some are on, and a chevron that reveals each dump with its own count.
Single-dump games stay a plain row. Applied chips name the exact dump.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Put the revision chevron after the game name and open the list from the row

The checkbox still selects every dump under a game; the rest of the row
(name, chevron, count) is a button that expands or collapses the revisions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Give the Discover rail room between the counts and its scrollbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Drop the updated date from hack cards

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Move the onboarding gate into the patch module and follow the action when scrolled

The gate was styled for the old sticky bar and hung off the module as a stray
tab. It is now a row under the primary action, a help icon beside the compact
action on desktop, and a floating pill above the phone tab bar once the module
scrolls away. The compact homes scroll back to the module before opening the
tour. A rose beacon marks the icon while the player still needs a ROM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Switch to the compact bar when the action button hides and settle scroll before the tour opens

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Scroll the module into place from every help entry and hold user scrolling on the way

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Place the phone coach card under the patch module instead of above it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Edit drafts in place on the session page

The session page becomes the hack page in edit mode for unlisted hacks:
title, summary and tags in the header, a Write / Preview description in
About, a screenshot manager in Gallery, and an Edit details sheet in the
rail for language, completion, box art and links. Base ROM locks once a
patch exists. Fields autosave; the status strip shows save state and a
player preview via ?preview=1. The edit page redirects unlisted hacks
here, and listed hacks keep the existing form.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Edit listed hacks in place too, behind ?edit=1

The middleware now carries the query string through the session rewrite
so ?edit=1 and ?preview=1 survive. Listed hacks open as players see them
and switch to the editor from Edit in the options menu or the dashboard;
the strip ends with Done editing. Only archive hacks still use the form.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Hide the patch card and header actions while editing; checklist takes the patch slot

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Stage edits on listed hacks until Save; only drafts save live

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Open the details sheet from rail group headings, give the About editor its section, wrap the strip on phones

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Show the current version in the hack page header

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Fix the avatar-in-paragraph hydration error and re-apply the theme class after mount

The byline held a block avatar inside a <p>, so the browser restructured the
server HTML, hydration failed, and the client re-render reset <html> and
dropped the dark class the head script had set. The byline is a <div> now,
and ThemeToggle re-applies the stored or system theme on mount and follows
system changes while no choice is stored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Polish pass: handle styling, card-wide ready outline, version on cards, rail height, sort width, How to play, footer login

- Author handles render the at-sign in mono and muted so it reads as a handle.
- Ready hacks outline the whole card (grid and list) instead of the screenshot.
- Grid cards show the version next to the completion pill.
- Desktop Discover rail fits the viewport from wherever its top sits, so it
  never needs both panes scrolled to reach the bottom.
- Sort select fills the row on phones and has a stable id so the listbox
  button never hits a useId hydration mismatch.
- Results area keeps a viewport of height so narrowing filters doesn't yank
  the page.
- Version label centers against the picker pill in the patch module.
- Tab strip overhang no longer creates a 1px vertical scroller; screenshot
  thumbnails keep room for the rose ring.
- "Plays on" becomes a "How to play" rail group listing the FAQ's
  recommended emulators per platform.
- Footer gains Log in (Dashboard once signed in, swapped after mount) and
  extra phone bottom padding for the floating help pill.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Open pending hacks in edit mode only for their creator; keep the options menu for admins editing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let creators edit the creator name, permission source, and verification contact from the details sheet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restyle Select, Share, Report, and the auth forms to v2; share one dialog hook between Sheet and Modal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cards: mark archives, show when a linked ROM needs permission again, fall back to the description

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Desktop compact bar takes the search box's place so the nav stays visible

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Phone menu: a three-bar toggle that folds into an X and slides out Submit, account, FAQ, and Contact links from the right

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* My ROMs: show the unsupported-browser note only after mount to fix a hydration mismatch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Light v2 restyle of the remaining v1 pages: tokens, radii, status colors, primary buttons, and display-face titles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let the page scroll while selects and option menus are open (no scroll lock, no scrollbar jump)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Label account links as creator-only: Become a creator / Creator log in, with a note that players need no account

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Footer: Play / Create / Hackdex columns, legal links beside the copyright

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Desktop compact bar: fold nav, search, and Submit into a Menu dropdown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Submit form: admin-only on-behalf option; creators confirm they made the hack, with a ToS note by the button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Edit header: tags render as published with an Edit tags pill that opens the picker in a modal

Byline moves under the title in edit mode to match the published order.
TagSelector gets v2 tokens and a phone layout with category chips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tag picker: replace drag-to-reorder with a review view (card slots, Put on card, checkbox removal)

The picker ends in a selected-count bar with Review. Review shows the two
card tags with Make 1st/2nd and the rest with Put on card (asks which to
replace) and undoable checkbox removal. Categories show pick counts and the
picker opens on the first category. Escape inside a menu no longer closes
the surrounding dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tag review: fixed height with only Also tagged scrolling; icon arrows on Back and Make 1st/2nd

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Submit for review: confirm modal with verification contact check, success screen, and While you wait card

The modal shows the contact for a last look (editable) or asks for one,
skippable, and submitForReview saves it in the same update. Success shows
what happens next. In review, a While you wait card replaces the checklist.
Also fixes submitForReview not revalidating the cached hack metadata, which
left the page on Draft after submitting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Admin view of unsubmitted drafts: explanatory notice with progress and contact; block approving drafts

Admins opening someone else's draft see an Unsubmitted draft notice with
dates, required checklist progress, and the verification contact instead
of the creator's private-draft copy. Approve and Create review thread are
hidden for drafts, approveHack refuses them, and the approve page
redirects. Admins in the editor can't submit on the creator's behalf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Start form: editable page address with live availability check; taken addresses block create instead of getting a suffix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AI disclosure: per-hack levels for six areas, label in the rail, creator form in a modal, required checklist item

Six nullable ai_level columns (content limited to none/some/most), an optional
note, and ai_disclosed_at. The label is the round 10 design: headline (Contains
AI / AI in code only / No direct AI usage), strip, and a collapsible breakdown.
Hacks without one show a muted not-filled-in state.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* AI disclosure: Discover filter (Any / No AI content / No direct AI usage) and a levels table in the FAQ

Hacks without a disclosure still show under the strict options, since most use
no AI and reports catch the rest. The URL keeps it as ?ai=no-content|none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* AI disclosure: new versions confirm the label (Still accurate) or update it before uploading

Confirming re-stamps the label only once the upload lands. The first upload
skips the step since the draft checklist already requires the label.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* AI disclosure: Needs AI label nudge on the dashboard, linking to the editor with the form open

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* AI disclosure: approve page warns when a hack has no AI label yet, without blocking

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* AI label: mouse-only strip hover so taps don't leave a cell looking selected; FAQ levels explained in one short paragraph instead of a table

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* AI label: link strip cells and breakdown rows only while the breakdown is open

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* AI form: intro copy matches the FAQ (vibes-based levels, intentional additions, one example of what doesn't count)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* AI form: on phones the save hint gets its own line and Cancel / Save split the width

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Security: move hack page reads out of the server actions file

getHackMetadata and getHackDownloads lived in a "use server" file, so each was a
public endpoint. Anyone could post a slug and get a private draft or pending
hack, including the creator's email and verification contact, read with the
service client. They now live in a plain server module that only the pages
import, and those pages already gate what they render.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Security: keep upload keys inside the hack the caller can edit

Signing trusted a client-supplied object key, so a creator could get an upload
URL for another hack's screenshot or patch. Saving covers took any key too, and
removing one later deleted that file from storage.

- Screenshot keys must sit under `${slug}/` (new keys only; existing rows stay
  valid), and storage cleanup only touches that folder.
- Patch keys are made on the server with a random suffix (new versions,
  reuploads, the archive form), and confirm steps check the key was made for
  this hack.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Security: patch download route applies the patcher's permission checks

/api/patches/[id]/download signed any patch by id, so pending hacks, unpublished
versions, and archived versions were downloadable anonymously. It now goes
through getPatchDownloadUrl (published, not archived, the hack's download
permission, and a parent hack the caller can see) and 404s otherwise.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Editing: keep the About and Gallery editors mounted across tab switches

Switching tabs unmounted the editors, which dropped staged screenshot changes
(and unregistered their save) and showed a stale description while the staged
one still published. Their panels now stay mounted and are hidden instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Editing: upload staged screenshots before publishing anything on Save

Save changes wrote the text fields first and uploaded screenshots after, so a
failed upload left half the edit published. Committers now have a prepare step
(uploads) that runs before any write. Edits made while a save is running also
stay staged instead of being cleared.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Uploads: stop when the storage PUT fails, and check the file exists before finalizing

A failed PUT resolved normally, so the version was still created and could be
made current without a file behind it. Clients now check the response, and
confirming a patch upload or reupload checks the object is in storage first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Uploads: refresh the hack page's cached data after finalizing a patch

confirmPatchUpload only refreshed Discover, so a draft's first upload could keep
showing no patch (or an old version) for up to four hours.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Dialogs: Shift+Tab right after opening stays inside the dialog

Focus starts on the panel, and the trap only wrapped from its first control, so
Shift+Tab escaped to the page behind. The trap now wraps from anywhere outside
its controls and skips inert or hidden ones. The Discover filter sheet had a
copy of the same logic and now uses useDialog.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Upload page: fit phones, and make the base ROM badge readable in light mode

File inputs kept their intrinsic width, pushing the page to 410px at 360px. They
now fill their container, and the base ROM upload's label sits above it. The
badge used white text on a pale background in light mode.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Editing: enforce title, summary, and screenshot limits on the server

updateHack accepted any title or summary length, so a 110-character summary
autosaved and listed hacks could publish one. It now rejects titles over 64 and
summaries over 100, cover saves reject more than 10 screenshots, and drafts
don't autosave a summary while it's over the limit (the counter is already
red). The limits live in one shared module.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Versions: unique (hack, version) index so concurrent uploads can't duplicate one

The app checked for an existing version before inserting, but two uploads
finishing together could both pass. Postgres now enforces it, and the
duplicate-key error reads the same as the existing check. (Distinct upload keys
came with the storage key change.)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* AI label: fixed breakdown id instead of useId to clear a hydration warning

useId produced different ids on the server and client for the rail label on a
creator's draft, so aria-controls and id didn't match after hydration. The
label takes a fixed id; the form's preview copy uses its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* AI form: open ?ai=1 after mount so the page doesn't 500 on the server

The dashboard's Needs AI label link rendered the modal open during SSR, and
Modal portals into document.body, which threw and returned a 500 before the
client recovered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* A11y: 44px AI level buttons on phones; sheets don't slide with reduced motion

On phones each level control spans the width with equal segments and 44px
targets (they were 27px). The Discover filter sheet and the phone menu drawer
skip their slide transition when reduced motion is on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Types: replace any in hack, cover, and patch write payloads with generated table types

updateHack's payload, the cover upsert rows, and the patch insert were typed any,
so schema mismatches passed the typecheck. They now use TablesUpdate and
TablesInsert, row maps use inferred types, and the permission helpers take
SupabaseClient<Database>.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Security: patch rows follow hack visibility and only the server creates them

Patch rows were readable by anyone, so drafts' and pending hacks' file names
leaked, and creators could insert a row pointing at another hack's file and then
get it signed through their own hack. Reads now use the same rules as covers and
tags, the client insert policy is gone (finalization inserts with the service
client after its checks), a key can only be registered once, and patch keys use
"__" after the slug so hack "foo" can't claim "foo-bar"'s uploads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Security: draft titles stay private in <title>; the archive wizard action needs archiver rights

generateMetadata treated any signed-in visitor as allowed to see restricted
titles, so a draft 404'd but its title showed in the tab. It now uses the same
edit-permission check as the page. prepareSubmission (the archive wizard) only
checked for a login, so anyone could call it to create an already-submitted hack
without the checklist; it now requires is_archiver like its page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Drafts migration: backfill submitted_at without bumping updated_at

The backfill fired set_hacks_updated_at, which would stamp every production hack
with migration day, reordering dashboards and OpenGraph modified times. The
trigger is disabled just for that update.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Editing: saves recover from network errors and never drop the last edit

- run() catches thrown server actions, so a failed save shows an error instead of
  sticking on "Saving…" with Save disabled.
- Listed hacks stage edits right away, so Save always includes the last one, and
  a draft edit still waiting on its debounce is committed when the editor
  unmounts (clicking Preview mid-typing lost it).
- Gallery Save uploads and saves one snapshot, so a screenshot added while
  saving stays staged instead of being saved without its file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Caching: new drafts aren't stuck on a cached 404; notifications can't skip invalidation; drafts don't rebuild the catalog

- createDraft invalidates the slug's metadata, so an address visited before it
  existed no longer 404s for its creator after creation.
- Upload finalization and approval invalidate right after their writes, and
  their Discord/email notifications are best effort, so a failed webhook can't
  leave stale pages or report a saved upload as failed. Re-approving also
  refreshes metadata.
- updateHack and saveHackCovers only rebuild the Discover catalog for listed
  hacks; draft autosaves were rebuilding it on every typing pause.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Drafts: need a playable patch to submit or approve; base ROM locks once any patch exists

- A hack's first patch always becomes current (a draft's stays private), the
  checklist item is "A playable patch uploaded" (current_patch set), and
  approval refuses non-archive hacks without one. Before, an unpublished upload
  satisfied the checklist and could be approved with nothing to play.
- The base ROM locks as soon as any version exists, and updateHack enforces it
  and checks the ROM id; it only locked once a patch was current, UI-only.
- createDraft enforces the shared title and summary limits and a known base ROM.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* AI disclosure: server-enforced upload confirmation, UTC dates, and archives can be labeled

- New versions pass the AI label stamp the uploader reviewed; confirmPatchUpload
  requires it to match the stored label and re-stamps it with the upload. The
  check was client-only and a failed re-stamp was ignored. confirmAiDisclosure
  is gone, and updateHack returns the stored stamp.
- The label's date and the draft strip's submitted date render in UTC, so the
  server and visitors in other time zones agree (it broke hydration).
- Archives get the AI label on their edit page through a shared AiLabelEditor
  (the in-place editor uses the same component), since the archive form had no
  way to fill it in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Discover: the filter sheet's count includes the search, and committing leaves no extra Back step

The results and the sheet's "Show N hacks" now share one predicate; the sheet
skipped the search query, so it could promise 14 and show 4. Committing replaces
the sheet's own history entry instead of pushing on top of it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Hack page: compact version picker selects; lightbox traps focus and closes on the backdrop; autoFocus survives dialogs

- The module and compact-bar pickers shared one open flag, so the hidden one's
  outside-click handler closed the menu before an option registered. Each copy
  now opens on its own.
- The lightbox uses useDialog (focus trap, Escape, focus back to the opener),
  and clicks outside the picture close it; the image's full-size wrapper used to
  swallow every click.
- useDialog leaves focus on a field that autofocused inside the dialog (the
  submit modal's contact box) and still restores the real opener.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Polish: admin edit note matches how listed hacks save; named gallery handles; no shelf prefetch; seeded hacks submitted

- Admins editing a listed hack were told changes save as they type; listed
  hacks publish on Save.
- Gallery drag handles get an accessible name, staged previews' object URLs are
  released, and file extensions are cleaned so keys pass the server check.
- Home shelves no longer prefetch every visible hack page (HackCard's default
  is off, as on Discover).
- seed.sql marks seeded hacks as submitted, so local pending hacks aren't drafts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Lightbox: page scrolls again after closing

Moving the lightbox onto useDialog left two effects saving and restoring
<html>'s overflow. Cleanups run in declaration order, so useDialog restored it
and then the lightbox put back the "hidden" it had captured, leaving the page
unscrollable. useDialog now owns the <html> lock; the lightbox keeps only the
scrollbar padding and touch blocking, measured before the lock.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Lightbox: the selected thumbnail's ring isn't clipped by the filmstrip

The strip scrolls horizontally, which also clips vertically, so the 2px ring
was cut off; it now has room with p-1. On phones the centering scroll also
overshot: thumbs' offsetLeft was measured from the lightbox, not the strip,
pushing the first thumb past the edge. The strip is now the offset parent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Discover: AI filter radios fill when picked in the phone sheet

The rail (hidden but mounted on phones) and the filter sheet rendered radios
with the same name, so they formed one group across the page. Re-rendering the
rail's checked option unchecked the sheet's pick, leaving no fill. Each filter
instance now gets its own group name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Discover: the phone Filters button stays screen-width with many filters applied

The button and the applied-filter chips share a column that's a flex item, and
flex items won't shrink below their content by default, so the unwrapped chip
row stretched the column (and the button) past the screen. min-w-0 lets the
column fit; the chip row already scrolls sideways.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* HackCard: Ready cards get a light green tint and faint green border instead of an outline

The full-strength border plus ring clashed with the card and split into two lines
with a gap on hover (a border and a box-shadow ring don't share a corner curve).
Ready cards and list rows now tint the surface 4% green (5% in dark) with a border
mixed 22% toward green, 40% on hover: one line, and calm when every card is Ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* HackCard: tag pills keep their contrast on the Ready tint

The pills used a fixed surface-2, which sat too close to the tinted Ready card
(especially in dark mode) and clashed with the green. They now shade whatever
they're on with 8% of the text color, which matches surface-2 on a plain card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Discover: the filter rail's height is plain CSS, fixing a runaway scroll at the page bottom

A scroll listener set the rail's max-height to the viewport minus its current
top. At the end of the results the sticky rail gets pushed up, its top went
negative, the rail grew, which pushed it further, so it raced to its bottom and
snapped back when scrolling up. It's now max-h calc(100dvh - 84px) with no
script; before the rail sticks, its bottom sits just below the fold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hack page: opening "How do I download?" no longer shifts the patch module

The in-module entry row was removed while the tour was open, so everything below
jumped up 42px, and back down on close. The row now stays in place, inactive and
without its beacon, while the tour is open; the floating pill and compact-bar
icon still go away since they don't affect layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Home: downloads milestone becomes an announcement card with room for a note

The v1 pill (ring, shadow, gradient text) becomes a rose-tinted card above the
hero headline: confetti disc, "1,000,000 downloads. Thank you!", and a short
note about the v2 redesign. The whole card replays the confetti; hover deepens
the tint and tilts the icon. No dismiss, no link. The number still comes from
NEXT_PUBLIC_DOWNLOADS_MILESTONE (no trailing +), the note from a constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fresh-look banner on player pages; fix the before-paint theme script that never ran

- A soft rose strip under the header on Discover, hack pages, FAQ, and My ROMs
  (not home, which has the milestone card, and not the editor): "Hackdex has a
  fresh new look. A thank you for 1,000,000 downloads..." The react-icons X is
  vertically centered at any wrap. Dismissal is stored per event, so the next
  milestone brings it back, and a before-paint script hides it for returning
  visitors without a flash.
- The theme init script was exported from a "use client" module, so the root
  layout got a client reference instead of the string and it never ran (dark
  mode only applied after hydration). Both inline scripts now live in
  src/utils/initScripts.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Editor: confirm before in-app links drop unsaved work; failed autosaves offer Retry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Details sheet: Cancel discards the session's edits instead of keeping them for the next save

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Version picker: keep the list on screen on phones, opening upward above the tab bar when needed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Discover filter sheet: focusing a hidden radio or checkbox no longer scrolls the sheet's frame

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Phone menu and filter sheet close when the window grows to desktop, releasing their scroll lock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Start form: a network failure shows a retryable error instead of sticking on "Creating…"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Lightbox: the thumbnail strip swipes on touch again; the page behind still stays put

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hack page tabs and screenshot strips: arrow, Home, and End keys with a roving tabIndex; inset focus ring

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Screenshot manager: reorder with Space and the arrow keys

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Version management: readable text and real hovers on status buttons, legible Re-upload badges, file inputs fit on phones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Account and version menus: surface panels so the highlighted item stands out

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Touch: 40px hit areas for compact editor controls and the version chip; the Details sheet respects reduced motion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Copy: the trending shelf no longer claims a seven-day window; signup asks people to invite a hack's creator instead of sharing it for them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Screenshot manager: an add builds on the latest list and saves run in order, so a delete or reorder during an upload sticks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Header search updates Discover in place when it's already open

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Editor strip: a later successful save no longer hides an earlier failure and its Retry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drafts: warn before reloading while an autosave is waiting or in flight

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Submit and AI dialogs recover from thrown actions; a failed Discord fallback no longer fails a submission that already landed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Home: linked hero names the ROMs in its paragraph instead of a pill; milestone card becomes a full-width strip above the hero

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* My ROMs shows the number of ready base ROMs instead of a dot

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Patch button keeps keyboard focus through patching

It was an inner component, so every status tick remounted it, and busy states used disabled, which blurs a focused button. It's now rendered by a function, and busy and just-patched states use aria-disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Archivers submit someone else's hack through drafts; the archive wizard is gone

The wizard never set is_archive, so it made ordinary hacks that skipped the draft checklist and AI label. Archivers (admins included) now get the on-behalf option on the start form. Archives skip the AI re-check on new versions and the dashboard's Needs AI label badge, since the label is optional there, and AI save errors on the patch page show as a toast instead of behind the modal. The start form also asks for a page address when the title has no ASCII letters or digits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Editor: gallery hydrates cleanly and clears Unsaved on save; archive form matches server limits and cleans cover names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Discover: unchanged filter sheet leaves no extra history entry, copy-link shows on phones, counts format as en-US

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Toasts and Turnstile follow the site theme; phone menu clears the notice banner; focus opens collapsed tags; only the top dialog handles Escape and Tab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Announcement lock records its PID so a killed run's lock clears

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Discover: AI filter by area, with presets that tick their areas and an Allow small usage step for code

The preset radios stay. Choose areas opens a checklist that mirrors the pick; a set matching no preset shows as Custom. Ticking Code yourself allows small use (a bug fix or a few) by default, while the presets stay strict. Custom picks go in the URL as areas joined by ".", and the catalog now carries each hack's six AI levels.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Discover: the AI filter is a saved preference instead of a URL param

It's stored on the device and read before the results show, so it applies on every visit and never travels in a shared link. Changing it anywhere saves it, and Any clears it. The AI use group notes that its settings are saved across sessions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Discover: indent the AI area checklist under Choose areas

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Hack credits, submission, downloads, current patch and AI label are server-only in the database

The owner policies let a creator write any column straight through PostgREST: mark a draft submitted without its checklist, credit someone else, set downloads, or repoint current_patch. hacks_update_guard now covers inserts too and blocks those columns for everyone but admins, the service role and postgres. The app writes them with the service client after its own permission checks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* A first patch upload returns to the hack page, not Manage Versions

The first upload always goes live, but the redirect only looked at the publish checkbox, so a draft's first upload landed away from its checklist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Milestone confetti draws under the phone menu

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Remove CSS left from the v1 hero, wizard and buttons

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Discover: the saved AI filter shows on the results count line instead of as a chip, and Clear leaves it

The count line reads "· No AI content" (or "Some AI usage hidden" for a custom pick) and opens the filter sheet at AI use on phones, or flashes it in the rail on desktop. Clear all, Clear N and Clear filters now reset only the search's filters; the phone Filters badge still counts the AI filter. The empty state offers Change AI filter when one is on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Discover: on phones the copy-link button sits beside Filters

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Hack page: Share and the options menu sit on the right on phones

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Hack page: the About tab's screenshot swipes again

The v2 stage was a static image, which dropped main's Embla carousel. It's a looping carousel again (swipe or drag), kept in sync with the thumbnails, arrow keys and the lightbox; a drag never opens the lightbox.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Start form: title placeholder reads "My Pokémon Romhack"

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Start form: "Page URL" instead of "Page address", and no autofill on its fields

Phones read "address" as a street address and offered the visitor's home address for every field below it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Home shelves: cards are 266px so screenshots sit at exactly 1×

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Combine the branch's five migrations into one v2_redesign migration

None of them have run in production, and db push now applies them in one go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cards: phone screenshots stay at 1× with dimmed neighbors over a blurred backdrop; bars under the shot

On phones the grid stretched shots to the card width (1.13–1.42×), so the pixel
art was smoothed. Now each shot stays at 1×, the neighbors peek in at 40%, and a
blurred copy of the current shot fills the card and crossfades as you swipe.
The dots were invisible over white text boxes, so they're bars under the shot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Start form: creator name placeholder reads "Their name or handle"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cards: dragging the screenshots no longer selects text in Firefox

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fresh-look banner: the confetti icon centers vertically in the banner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fresh-look banner: only shows while NEXT_PUBLIC_DOWNLOADS_MILESTONE is 1000000

Its copy thanks players for 1,000,000 downloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* FAQ: disclose that a large portion of Hackdex's code was written with AI assistance

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AI label: content areas offer None / Some / All instead of Most

Content areas now top out at All (hint: "Most or all of …"). Code keeps its
five-level scale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: pin the Supabase CLI to 2.111.0 so the generated-types check is stable

CLI 2.119.0 (what "latest" resolves to since Sep 30) writes generated types
unformatted, so the check failed for every PR touching supabase/. 2.111.0
reproduces the checked-in src/types/db.ts exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jared Schoeny
2026-10-02 23:36:36 -06:00
committed by GitHub
parent 7ea4c8c8f4
commit b06cb7af93
148 changed files with 8925 additions and 6859 deletions

View File

@@ -13,9 +13,11 @@ jobs:
steps:
- uses: actions/checkout@v4
# Pinned: the type check below compares against the CLI's exact output, and
# 2.119.0 stopped formatting generated types. Bump together with src/types/db.ts.
- uses: supabase/setup-cli@v1
with:
version: latest
version: 2.111.0
- name: Check new migrations are after main
run: |

View File

@@ -117,6 +117,36 @@ async function loadRecipients(file: string): Promise<Recipient[]> {
return recipients;
}
function isRunning(pid: number) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
// EPERM: it exists but belongs to someone else.
return isRecord(error) && error.code === "EPERM";
}
}
/**
* One sender per campaign. The lock holds the sender's PID, so a lock left by a killed run is
* cleared once that process is gone. Anything else in it (a run that hasn't written its PID yet) blocks.
*/
async function takeLock(lockPath: string, retry = true): Promise<Awaited<ReturnType<typeof open>>> {
try {
const lock = await open(lockPath, "wx", 0o600);
await lock.writeFile(String(process.pid));
return lock;
} catch (error) {
if (!isRecord(error) || error.code !== "EEXIST") throw error;
const owner = (await readOptional(lockPath))?.trim() ?? "";
if (retry && /^\d+$/.test(owner) && !isRunning(Number(owner))) {
await unlink(lockPath);
return takeLock(lockPath, false);
}
throw new Error(`${lockPath} exists: another send is running${/^\d+$/.test(owner) ? ` (PID ${owner})` : ""}. Delete it only if no send is running.`);
}
}
/** Fail closed on uncertain delivery. Resolve a pending record in Resend before retrying. */
export async function sendAnnouncement({ announcement, message, recipients, directory, mailer, delayMs = 1000 }: {
announcement: Announcement;
@@ -131,7 +161,7 @@ export async function sendAnnouncement({ announcement, message, recipients, dire
}
await mkdir(directory, { recursive: true, mode: 0o700 });
const lockPath = path.join(directory, "send.lock");
const lock = await open(lockPath, "wx", 0o600);
const lock = await takeLock(lockPath);
try {
// Freeze both message and audience on first send so retries cannot change either.
const payloadPath = path.join(directory, "payload.json");

View File

@@ -322,6 +322,18 @@ test("uncertain delivery stops the run and blocks blind resends", async (t) => {
test("concurrent sending cannot bypass the campaign lock", async (t) => {
const directory = await temporaryDirectory(t);
await writeFile(path.join(directory, "send.lock"), "another process owns this");
await assert.rejects(sendAnnouncement({ announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails }), /EEXIST/);
await assert.rejects(sendAnnouncement({ announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails }), /another send is running/);
assert.equal(await readFile(path.join(directory, "send.lock"), "utf8"), "another process owns this");
// A live owner's PID blocks too.
await writeFile(path.join(directory, "send.lock"), String(process.pid));
await assert.rejects(sendAnnouncement({ announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails }), /another send is running/);
});
test("a lock left by a killed run is cleared", async (t) => {
const directory = await temporaryDirectory(t);
// Above Linux's PID ceiling, so no process has it.
await writeFile(path.join(directory, "send.lock"), "99999999");
t.mock.method(globalThis, "fetch", async () => Response.json({ id: "accepted-email" }));
assert.deepEqual(await sendAnnouncement({ announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails, delayMs: 0 }), { accepted: 1, skipped: 0 });
assert.ok(!(await readdir(directory)).includes("send.lock"));
});

View File

@@ -31,16 +31,16 @@ export default async function AccountPage({ searchParams }: AccountPageProps) {
<div className="flex flex-row justify-between items-end">
<div className="flex flex-col">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">{needsInitialSetup ? 'Finish setting up your account' : 'Your account'}</h1>
<h1 className="font-display text-[28px] leading-[1.1] md:text-[32px]">{needsInitialSetup ? 'Finish setting up your account' : 'Your account'}</h1>
</div>
<p className="mt-2 text-[15px] text-foreground/80 max-w-md">
<p className="mt-2 text-[15px] text-text-2 max-w-md">
{needsInitialSetup ? 'Choose a unique username to get started. You can update other details later.' : 'Manage profile details and avatar.'}
</p>
</div>
{!needsInitialSetup && <AccountOptionsMenu />}
</div>
{passwordUpdated && (
<div className="mt-8 rounded-md bg-green-500/10 ring-1 ring-green-600/40 px-3 py-2 text-sm text-green-300">
<div className="mt-8 rounded-control bg-ready-soft ring-1 ring-ready/40 px-3 py-2 text-sm text-ready">
Your password was updated successfully.
</div>
)}

View File

@@ -31,8 +31,8 @@ export default async function UpdatePasswordPage({ searchParams }: UpdatePasswor
</div>
)}
<div className="card p-6">
<h1 className="text-2xl font-semibold tracking-tight">Change your password</h1>
<p className="mt-1 text-sm text-foreground/70">Enter a new password for your account.</p>
<h1 className="font-display text-[28px] leading-[1.1] md:text-[32px]">Change your password</h1>
<p className="mt-1 text-sm text-text-2">Enter a new password for your account.</p>
<div className="mt-6">
<UpdatePasswordForm />
</div>

View File

@@ -1,11 +1,8 @@
import { NextRequest } from "next/server";
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
import { buildPatchDownloadUrl } from "@/utils/patches/patch-download-url";
import { createClient } from "@/utils/supabase/server";
import { getPatchDownloadUrl } from "@/app/hack/[slug]/actions";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const supabase = await createClient();
// Log suspicious access patterns
const referer = req.headers.get("referer");
@@ -37,24 +34,11 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
});
}
const { data: patch, error } = await supabase
.from("patches")
.select("id, bucket, filename")
.eq("id", Number(id))
.maybeSingle();
if (error || !patch) {
return new Response("Not found", { status: 404 });
}
const workerUrl = buildPatchDownloadUrl(patch.filename);
if (workerUrl) {
return Response.redirect(workerUrl, 302);
}
const client = getMinioClient();
const bucket = patch.bucket || PATCHES_BUCKET;
const url = await client.presignedGetObject(bucket, patch.filename, 60 * 5);
return Response.redirect(url, 302);
// Same rules as the patcher: published, not archived, the hack's download
// permission, and the parent hack visible to this caller (drafts and pending
// hacks aren't). Anything else looks like a missing patch.
const patchId = Number(id);
const res = Number.isInteger(patchId) ? await getPatchDownloadUrl(patchId) : null;
if (!res?.ok) return new Response("Not found", { status: 404 });
return Response.redirect(res.url, 302);
}

View File

@@ -14,12 +14,12 @@ export default function ContactPage() {
return (
<div className="mx-auto my-auto max-w-2xl w-full px-6 py-10">
<div className="card p-6">
<h1 className="text-2xl font-semibold tracking-tight">Contact Hackdex</h1>
<p className="mt-1 text-sm text-foreground/70">
<h1 className="font-display text-[28px] leading-[1.1] md:text-[32px]">Contact Hackdex</h1>
<p className="mt-1 text-sm text-text-2">
Have a question, feedback, or need help? Use this form to reach out to us.
</p>
<p className="mt-1 text-sm text-foreground/60">
For intellectual property concerns (DMCA), please see our <Link className="text-[var(--accent)] hover:underline" href="/terms" prefetch={false}>Terms of Service</Link>.
<p className="mt-1 text-sm text-text-3">
For intellectual property concerns (DMCA), please see our <Link className="text-accent-text hover:underline" href="/terms" prefetch={false}>Terms of Service</Link>.
</p>
<div className="mt-6">
<Suspense fallback={<div>Loading...</div>}>

View File

@@ -21,10 +21,10 @@ export default async function ArchivesPage() {
const initialData = await getArchives({ page: 1, limit: 50 });
return (
<div className="mx-auto my-12 max-w-screen-xl px-6 py-8 w-full">
<div className="mx-auto w-full max-w-[1164px] px-6 py-10">
<div className="mb-6">
<h1 className="text-3xl font-bold tracking-tight">Archive Management</h1>
<p className="mt-2 text-[15px] text-foreground/80">
<h1 className="font-display text-[28px] leading-[1.1] md:text-[32px]">Archive Management</h1>
<p className="mt-2 text-[15px] text-text-2">
Manage all Archive hacks. Archive hacks are informational entries preserved for historical reference.
</p>
</div>

View File

@@ -18,8 +18,9 @@ export default async function DashboardPage() {
if (isAdmin) {
const { data: pendingHacksData } = await supa
.from("hacks")
.select("slug,title,approved,updated_at,downloads,current_patch,version,created_at,created_by,assigned_admin")
.select("slug,title,approved,submitted_at,updated_at,downloads,current_patch,version,created_at,created_by,assigned_admin")
.eq("approved", false)
.not("submitted_at", "is", null)
.order("created_at", { ascending: false });
if (pendingHacksData && pendingHacksData.length > 0) {
@@ -86,7 +87,7 @@ export default async function DashboardPage() {
const { data: hacks } = await supa
.from("hacks")
.select("slug,title,approved,updated_at,downloads,current_patch(id,version),created_at,original_author,is_archive")
.select("slug,title,approved,submitted_at,updated_at,downloads,current_patch(id,version),created_at,original_author,is_archive,ai_disclosed_at")
.is("is_archive", false)
.eq("created_by", user.id)
.order("updated_at", { ascending: false });
@@ -95,12 +96,15 @@ export default async function DashboardPage() {
...h,
version: h.current_patch?.version || "Pre-release",
current_patch: h.current_patch?.id || null,
// Drafts get it from their checklist; this catches hacks from before the label existed.
// Archives can go without one: archivers may not know how the creator used AI.
needsAiLabel: !h.is_archive && h.submitted_at !== null && h.ai_disclosed_at === null,
}));
const seriesAll = await getDownloadsSeriesAll({ days: 30 });
return (
<div className="mx-auto my-auto max-w-screen-2xl px-6 py-8">
<div className="mx-auto my-auto w-full max-w-[1164px] px-6 pb-6 pt-4 md:pt-6">
<DashboardClient
hacks={hacksWithVersions ?? []}
initialSeriesAll={seriesAll}
@@ -117,12 +121,12 @@ export default async function DashboardPage() {
<h2 className="text-xl font-semibold">Archive Management</h2>
<Link
href="/dashboard/archives"
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10"
className="inline-flex h-9 items-center gap-1 rounded-control border border-line-strong bg-surface px-3 text-sm font-medium transition-colors hover:border-text-3"
>
View all archives
</Link>
</div>
<p className="text-sm text-foreground/60">
<p className="text-sm text-text-3">
Archive hacks are informational entries preserved for historical reference. They do not include patch files.
</p>
</div>

View File

@@ -5,6 +5,7 @@ import { fetchInChunks } from "@/utils/array";
import type { Tables } from "@/types/db";
import type { DiscoverData } from "@/types/discover";
import { resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
import { AI_SELECT, aiDisclosureFromRow } from "@/utils/aiDisclosure";
const TRENDING_WINDOW_DAYS = 3;
const DISCOVER_REVALIDATE_SECONDS = 1800;
@@ -44,7 +45,7 @@ async function generateDiscoverData(): Promise<DiscoverData> {
const { data: rows, error: hacksError } = await supabase
.from("hacks")
.select(
"slug,title,summary,base_rom,downloads,created_by,current_patch,custom_version_name,original_author,approved_at,is_archive,completion_status",
`slug,title,summary,base_rom,downloads,created_by,current_patch,custom_version_name,original_author,approved_at,is_archive,completion_status,${AI_SELECT}`,
)
.eq("approved", true)
.eq("is_archive", false);
@@ -242,6 +243,7 @@ async function generateDiscoverData(): Promise<DiscoverData> {
approvedAt: row.approved_at,
publishedAt: currentPatch?.published_at ?? null,
trendingScore: recentDownloads + 8 * Math.log(downloads + 1),
ai: aiDisclosureFromRow(row)?.levels ?? null,
};
});
@@ -255,7 +257,8 @@ async function generateDiscoverData(): Promise<DiscoverData> {
const getCachedDiscoverData = cache(
generateDiscoverData,
["discover-data"],
// Bump the version when DiscoverHack's shape changes, so a deploy never reads the old shape.
["discover-data", "v2"],
{
revalidate: DISCOVER_REVALIDATE_SECONDS,
tags: ["discover"],

View File

@@ -17,24 +17,20 @@ export default async function DiscoverPage() {
const { hacks, generatedAt, tagGroups, ungroupedTags } = await getDiscoverData();
return (
<div className="mx-auto max-w-screen-2xl px-6 py-10">
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Discover ROM hacks</h1>
<p className="mt-2 text-[15px] text-foreground/80 max-w-198">
Hackdex supports developers by only hosting hacks that have been uploaded by the person or team that created them. By using this site, you are supporting the original creators and their labors of love.
</p>
</div>
</div>
<div className="mt-6">
<DiscoverBrowser
catalog={hacks}
generatedAt={generatedAt}
initialState={DISCOVER_DEFAULT_STATE}
tagGroups={tagGroups}
ungroupedTags={ungroupedTags}
/>
<div className="mx-auto w-full max-w-[1164px] px-6 pt-4 md:pt-6">
<div className="mb-5 md:mb-6">
<h1 className="font-display text-[28px] leading-tight md:text-[32px]">Discover ROM hacks</h1>
<p className="mt-2 max-w-[70ch] text-[15px] text-text-2">
Hackdex supports developers by only hosting hacks that have been uploaded by the person or team that created them. By using this site, you are supporting the original creators and their labors of love.
</p>
</div>
<DiscoverBrowser
catalog={hacks}
generatedAt={generatedAt}
initialState={DISCOVER_DEFAULT_STATE}
tagGroups={tagGroups}
ungroupedTags={ungroupedTags}
/>
</div>
);
}

View File

@@ -39,10 +39,15 @@ Use the [Discover page](/discover) to search for hacks or filter by tags, base R
Download activity is the only input to popularity and trending rankings. Placement does not represent a staff recommendation.
### Does Hackdex allow AI-generated content?
Yes, with disclosure. Creators must disclose AI use in both content and code through Hackdex's AI disclosure form. Their disclosure appears on the hack's page to help you decide whether you want to play it.
Yes, with disclosure. Creators must disclose AI use in both content and code through Hackdex's AI disclosure form. Their disclosure appears on the hack's page to help you decide whether you want to play it. On Discover, you can filter to hacks with no AI content or no direct AI usage.
We believe a blanket ban can encourage creators to hide AI use. Requiring disclosure gives creators room to be honest and helps players make an informed choice. Disclosed content must still follow our credits and content rules. See [what creators need to disclose](#ai-disclosure) for details.
### Was this website made with AI? {#site-ai-use}
Partly. A large portion of Hackdex's code was written with AI assistance, and every change is reviewed and tested by a person before it goes live. This covers the website itself, not the hacks listed on it. Each hack's AI use is disclosed by its creator on the hack's page.
Hackdex is [open source](https://github.com/Hackdex-App/hackdex-website), so you can read the code and its full history on GitHub.
### How do I report a hack or page that breaks the rules?
There is a Report option on each hack page. Or you can use the [contact form](/contact). Include the hack's page URL and enough detail for us to locate the issue. For content inside a hack, include the version and where it appears in the game. See our [content guidelines](#content-guidelines) for a summary of what is prohibited.
@@ -130,6 +135,8 @@ Disclose content and code generated or modified with AI that is included in your
Disclosure of AI use for brainstorming and unused experiments is optional when no AI-generated or AI-modified content or code is included in the hack or its page. Disclosure does not replace credits or exempt your hack from our content rules. See [Section 6 of the Terms of Service](/terms#6-creator-responsibilities) for the full requirement.
Pick the level that feels right for how much AI went into each area. It doesn't need to be exact. Code has a finer scale with "A little" and "Most" in between, since one AI bug fix and an AI-written engine are very different. The label covers what you and your team added on purpose, so something like AI code that came with a base you built on doesn't count.
### Who retains ownership of submitted hacks?
Creators retain ownership of their work. Hackdex serves as a distribution and discovery platform. Specific rights and responsibilities are outlined in the Terms of Service available on the platform.

View File

@@ -14,7 +14,7 @@ export const metadata: Metadata = {
export default function FAQPage() {
return (
<div className="mx-auto max-w-screen-lg px-6 py-6 sm:py-12">
<h1 className="text-3xl font-bold">FAQ for Hackdex</h1>
<h1 className="font-display text-[28px] leading-[1.1] md:text-[32px]">FAQ for Hackdex</h1>
<div className="mt-6 prose prose-invert max-w-none faq-prose">
<Markdown>{entriesMd}</Markdown>
</div>

View File

@@ -1,42 +1,174 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/* Dark mode is a class on <html>, set before first paint by the inline script in layout.tsx. */
@custom-variant dark (&:where(.dark, .dark *));
/* ---- tokens ----
Cool-neutral surrounds so the saturated pixel art and the one rose action are the only
warm things on the page. Surfaces step in lightness; shadows are reserved for lift and
overlays. Green means "on this device" and nothing else; error is a separate orange-red
so brand rose never means danger. Legacy names (--background, --border, --accent...)
alias the new set so older components keep working. */
:root {
--background: #ffffff;
--foreground: #171717;
--ring: #f43f5e;
--muted: rgba(0,0,0,0.20);
--border: rgba(0,0,0,0.12);
--accent: #f43f5e;
--accent-700: #be123c;
--accent-foreground: #ffffff;
color-scheme: light;
--rose: #f43f5e;
--rose-deep: #e11d48;
--rose-hover: #be123c;
--plat-gb: #7d8f6a;
--plat-gbc: #9a5be0;
--plat-gba: #3b47c9;
--plat-nds: #9aa4b0;
--bg: #f3f4f6;
--surface: #ffffff;
--surface-2: #fafafa;
--grid-dot-color: rgba(0,0,0,0.08);
--surface-2: #e9ebef;
--surface-3: #dfe2e7;
--well: #dfe2e7;
--text: #1a1d24;
--text-2: #4b5261;
--text-3: #5f6674;
--line: #e3e6ea;
--line-strong: #c9ced6;
--rose-soft: #ffe4e9;
--rose-text: #be123c;
--link: #a8425a;
--ready: #15803d;
--ready-soft: #dcfce7;
--on-ready: #ffffff;
--error: #c2410c;
--error-soft: #ffedd5;
--on-error: #ffffff;
--warn: #a16207;
--warn-soft: #fef3c7;
--screen: #0d1017;
--shadow-rest: 0 1px 2px rgba(20, 24, 33, .05);
--shadow-lift: 0 8px 20px -8px rgba(20, 24, 33, .22), 0 2px 4px rgba(20, 24, 33, .06);
--shadow-overlay: 0 16px 40px -12px rgba(20, 24, 33, .3);
/* legacy aliases */
--background: var(--bg);
--foreground: var(--text);
--ring: var(--rose);
--muted: rgba(0,0,0,0.20);
--border: var(--line);
--accent: var(--rose);
--accent-700: var(--rose-hover);
--r-card: 12px;
--r-control: 8px;
--r-frame: 4px;
}
.dark {
color-scheme: dark;
--bg: #15171b;
--surface: #1f2126;
--surface-2: #2a2d33;
--surface-3: #363a42;
--well: #101215;
--text: #f1f2f4;
--text-2: #b3b8c2;
--text-3: #8a909c;
--line: #2e3138;
--line-strong: #3d414a;
--rose-soft: #3b1220;
--rose-text: #fb7185;
--link: #e28a98;
--ready: #4ade80;
--ready-soft: #14301f;
--on-ready: #052e16;
--error: #fb923c;
--error-soft: #3a1f0e;
--on-error: #431407;
--warn: #fbbf24;
--warn-soft: #3a2e0e;
--screen: #07080b;
--shadow-rest: none;
--shadow-lift: 0 8px 20px -8px rgba(0, 0, 0, .6);
--shadow-overlay: 0 16px 40px -12px rgba(0, 0, 0, .7);
--muted: rgba(255,255,255,0.20);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-surface-3: var(--surface-3);
--color-well: var(--well);
--color-text: var(--text);
--color-text-2: var(--text-2);
--color-text-3: var(--text-3);
--color-line: var(--line);
--color-line-strong: var(--line-strong);
--color-accent: var(--rose);
--color-accent-deep: var(--rose-deep);
--color-accent-hover: var(--rose-hover);
--color-accent-soft: var(--rose-soft);
--color-accent-text: var(--rose-text);
--color-link: var(--link);
--color-ready: var(--ready);
--color-ready-soft: var(--ready-soft);
--color-on-ready: var(--on-ready);
--color-error: var(--error);
--color-error-soft: var(--error-soft);
--color-on-error: var(--on-error);
--color-warn: var(--warn);
--color-warn-soft: var(--warn-soft);
--color-screen: var(--screen);
--font-sans: var(--font-archivo);
--font-mono: var(--font-geist-mono);
--shadow-rest: var(--shadow-rest);
--shadow-lift: var(--shadow-lift);
--shadow-overlay: var(--shadow-overlay);
--radius-card: var(--r-card);
--radius-control: var(--r-control);
--radius-frame: var(--r-frame);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--muted: rgba(255,255,255,0.20);
--border: rgba(255,255,255,0.10);
--accent: #f43f5e;
--accent-700: #be123c;
--accent-foreground: #ffffff;
--surface: rgba(255,255,255,0.04);
--surface-2: rgba(255,255,255,0.06);
--grid-dot-color: rgba(255,255,255,0.08);
/* Display type: the body family, semi-condensed and bold. Contrast comes from width and weight, not a second face. */
@utility font-display {
font-stretch: 88%;
font-weight: 700;
letter-spacing: -0.01em;
}
/* Grows a compact control's touch area to 40px with an invisible ::after, keeping its look. Touch screens only, so it never steals mouse clicks from neighbors. */
@utility tap-target {
position: relative;
}
@media (pointer: coarse) {
.tap-target::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: max(100%, 40px);
height: max(100%, 40px);
transform: translate(-50%, -50%);
}
}
/* Platform cue color: a dot before a base ROM name. */
[data-platform="GB"] { --plat: var(--plat-gb); }
[data-platform="GBC"] { --plat: var(--plat-gbc); }
[data-platform="GBA"] { --plat: var(--plat-gba); }
[data-platform="NDS"] { --plat: var(--plat-nds); }
.plat-dot::before { content: ""; display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--plat, var(--text-3)); margin-right: 6px; }
/* Green dot meaning exactly one thing: a base ROM is on this device. */
/* Set before paint by freshLookInitScript once the visitor dismissed the banner. */
html[data-fresh-look-dismissed] .fresh-look-banner { display: none; }
.ready-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--ready); }
/* Text links: muted rose, underline fades in on hover. */
.text-link-hd { color: var(--link); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: transparent; transition: text-decoration-color 120ms; }
.text-link-hd:hover { text-decoration-color: currentColor; }
.m-safe {
margin: env(safe-area-inset-bottom) env(safe-area-inset-right) env(safe-area-inset-top) env(safe-area-inset-left);
}
@@ -56,9 +188,6 @@
.p-safe {
padding: env(safe-area-inset-bottom) env(safe-area-inset-right) env(safe-area-inset-top) env(safe-area-inset-left);
}
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
.pt-safe {
padding-top: env(safe-area-inset-top);
}
@@ -70,10 +199,21 @@
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: var(--bg);
color: var(--text);
font-family: var(--font-archivo), system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
font-variant-numeric: tabular-nums;
}
/* Opt-in nearest-neighbor scaling for pixel art shown at an integer multiple of its native size. */
.pixelated { image-rendering: pixelated; }
:focus-visible { outline: 2px solid var(--rose); outline-offset: 2px; }
/* Containers that take focus programmatically (dialogs, sheets, option lists) do not show it. */
/* Tabs are exempt: their roving tabIndex makes unselected ones -1, yet arrow keys still focus them. */
[tabindex="-1"]:not([role="tab"]):focus-visible, [role="listbox"]:focus-visible, [role="menu"]:focus-visible { outline: none; }
/* Inset, since tab strips scroll and would clip a ring drawn outside. */
[role="tab"]:focus-visible { outline-offset: -2px; }
/* Stable layout when overlays lock page scrolling. */
html {
@@ -97,61 +237,21 @@ html { scroll-padding-top: var(--anchor-offset); }
.faq-prose { --anchor-offset: 8px; }
@media (min-width: 768px) { .faq-prose { --anchor-offset: 16px; } }
.bg-grid { background-image: radial-gradient(var(--grid-dot-color) 1px, transparent 1px); background-size: 22px 22px; }
.card {
background: linear-gradient(to bottom right, var(--surface-2), var(--surface));
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-card);
box-shadow: var(--shadow-rest);
}
/* Interpolatable ready-ring for homepage hero hack cards (Tailwind ring-* does not transition cleanly) */
.hero-hack-card {
box-shadow: 0 0 0 1px var(--border);
transition: box-shadow 500ms ease;
}
.hero-hack-card[data-ready="true"] {
box-shadow: 0 0 0 1px rgb(52 211 153 / 0.5); /* emerald-400/50 */
}
@keyframes heroHackFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.hero-hack-image {
animation: heroHackFadeIn 500ms ease forwards;
}
.card-simple {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
}
.frosted {
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
backdrop-filter: blur(8px);
}
.gradient-text {
background: linear-gradient(90deg, #f43f5e, #f97316 40%, #f59e0b);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.aurora {
position: absolute;
inset: -20% -10% -20% -10%;
background: radial-gradient(60% 60% at 20% 20%, rgba(244,63,94,0.20), transparent 60%),
radial-gradient(50% 50% at 80% 10%, rgba(249,115,22,0.18), transparent 60%),
radial-gradient(40% 40% at 50% 80%, rgba(245,158,11,0.18), transparent 60%);
opacity: 0.7;
pointer-events: none;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-card);
}
.elevate {
box-shadow: 0 10px 30px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.06);
box-shadow: var(--shadow-lift);
}
/* Animations */
@@ -161,20 +261,27 @@ html { scroll-padding-top: var(--anchor-offset); }
100% { transform: translateY(0); }
}
@keyframes pulseSoft {
0%, 100% { box-shadow: 0 0 0 0 rgba(244,63,94,0.35); }
50% { box-shadow: 0 0 0 8px rgba(244,63,94,0); }
}
@keyframes popIn {
0% { transform: scale(0.9); opacity: 0.6; }
60% { transform: scale(1.06); opacity: 1; }
100% { transform: scale(1); }
}
/* Indeterminate sweep inside the patch button while the patcher works. */
@keyframes progressSweep { from { transform: translateX(-100%); } to { transform: translateX(400%); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.anim-fade { animation: fadeIn 160ms ease-out; }
.anim-float { animation: floatY 2.4s ease-in-out infinite; }
.anim-pulse { animation: pulseSoft 2.4s ease-in-out infinite; }
.anim-pop { animation: popIn 220ms cubic-bezier(0.16, 1, 0.3, 1); }
/* A tag just added in the picker: pops in with a rose ring that fades. */
@keyframes tagRingFade { from { box-shadow: 0 0 0 2px var(--rose-soft); } to { box-shadow: 0 0 0 2px transparent; } }
/* A checkmark stroke drawing itself in (path length under 30). */
@keyframes drawStroke { from { stroke-dashoffset: 30; } to { stroke-dashoffset: 0; } }
.anim-draw { stroke-dasharray: 30; animation: drawStroke 500ms 150ms ease-out both; }
/* A row that just moved in a list. */
@keyframes rowFlash { from { background-color: var(--rose-soft); } to { background-color: transparent; } }
.anim-row-flash { animation: rowFlash 1s ease-out; }
.anim-tag-new { animation: popIn 220ms cubic-bezier(0.16, 1, 0.3, 1), tagRingFade 1.2s ease-out 200ms both; }
/* Onboarding beacon: rose dot ringed against the page, breathing halo behind it.
The halo's static look is the reduced-motion resting state. */
@@ -216,113 +323,31 @@ html { scroll-padding-top: var(--anchor-offset); }
onboardingBeaconScale 1.5s cubic-bezier(0, 0, 0.2, 1) infinite,
onboardingBeaconFade 1.5s linear infinite;
}
@media (prefers-color-scheme: dark) {
.onboarding-beacon::after {
.dark .onboarding-beacon::after {
background: rgba(244, 63, 94, 0.95);
animation:
onboardingBeaconScale 1.5s cubic-bezier(0, 0, 0.2, 1) infinite,
onboardingBeaconFadeDark 1.5s linear infinite;
}
}
@keyframes heroConnectorDash {
to { stroke-dashoffset: -24; }
}
.hero-connector-path {
stroke-dasharray: 6 6;
animation: heroConnectorDash 1.2s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.anim-fade,
.anim-float,
.anim-pulse,
.anim-pop,
.onboarding-beacon::after,
.hero-connector-path,
.hero-hack-image {
.anim-tag-new,
.anim-row-flash,
.anim-draw,
.onboarding-beacon::after {
animation: none !important;
}
}
.shine-wrap { position: relative; overflow: hidden; border-radius: inherit; }
.shine-wrap::after {
content: "";
position: absolute;
inset: 0; pointer-events: none;
background: linear-gradient(120deg, transparent 46%, rgba(255,255,255,0.06) 50%, transparent 54%);
transform: translateX(-100%);
transition: transform 900ms ease;
border-radius: inherit;
}
.shine-wrap:hover::after { transform: translateX(100%); }
.shine-wrap:disabled::after { display: none; }
/* Gradient background similar to gradient-text but for surfaces */
.gradient-bg {
background: linear-gradient(90deg, #f43f5e, #f97316 40%, #f59e0b);
color: var(--accent-foreground);
}
/* Premium gradient button */
.btn-premium {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
padding-left: 1.25rem;
padding-right: 1.25rem;
color: #fff;
background: transparent;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12), inset 0 1px 0 rgba(255,255,255,0.22), 0 10px 22px rgba(244,63,94,0.22), 0 4px 10px rgba(249,115,22,0.15);
transition: transform 160ms ease, filter 160ms ease, box-shadow 160ms ease;
}
.btn-premium::before {
content: "";
position: absolute;
inset: 0;
/* Keep square so the reveal edge can be straight; parent clips corners */
border-radius: 0;
background: linear-gradient(135deg, #e11d48, #f43f5e 30%, #f97316 65%, #f59e0b);
/* Start fully hidden; reveal via clip-path animation */
clip-path: polygon(0 0, 0 0, 0 100%, 0 100%);
will-change: clip-path;
z-index: 0;
}
.btn-premium > * { position: relative; z-index: 2; }
.btn-premium:not(:disabled)::before { animation: gradientRevealClip 520ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
.btn-premium:hover {
filter: brightness(1.04);
transform: translateY(-1px);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.16), inset 0 1px 0 rgba(255,255,255,0.28), 0 14px 28px rgba(244,63,94,0.26), 0 6px 14px rgba(249,115,22,0.18);
}
.btn-premium:active {
transform: translateY(0);
filter: brightness(0.98);
}
.btn-premium:disabled {
background: transparent;
color: var(--muted);
box-shadow: 0 0 0 1px var(--muted);
filter: grayscale(35%) brightness(0.9);
transform: scale(0.98);
}
.btn-premium:not(:disabled) {
animation: popIn 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes gradientRevealClip {
0% { clip-path: polygon(0 0, 0 0, 0 100%, 0 100%); }
100% { clip-path: polygon(0 0, calc(100% + 16px) 0, 100% 100%, 0 100%); }
}
/* Typography plugin theme to match CSS variables in both light and dark */
.prose {
--tw-prose-body: var(--foreground);
--tw-prose-headings: var(--foreground);
--tw-prose-lead: var(--foreground);
--tw-prose-links: var(--accent);
--tw-prose-links: var(--link);
--tw-prose-bold: var(--foreground);
--tw-prose-quotes: var(--foreground);
--tw-prose-code: var(--foreground);

View File

@@ -1,235 +1,24 @@
"use server";
import { createClient, createServiceClient } from "@/utils/supabase/server";
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
import { getMinioClient, objectExists, PATCHES_BUCKET } from "@/utils/minio/server";
import { buildPatchDownloadUrl } from "@/utils/patches/patch-download-url";
import { isInformationalArchiveHack, canEditAsCreator, canEditAsAdmin } from "@/utils/hack";
import { sendDiscordMessageEmbed } from "@/utils/discord";
import { headers } from "next/headers";
import { validateEmail } from "@/utils/auth";
import { revalidatePath, revalidateTag } from "next/cache";
import { unstable_cache as cache } from "next/cache";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { Database, Constants } from "@/types/db";
import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches";
import { CUSTOM_VERSION_NAME_MAX_LENGTH, resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
import type { SelectablePatch } from "@/types/patcher";
import { CUSTOM_VERSION_NAME_MAX_LENGTH } from "@/utils/patches/hack-display-version";
import { revalidateDiscoverCatalog } from "@/app/discover/revalidate";
import { isPatchKeyFor, newPatchKey } from "@/utils/storageKeys";
import type { PatchFormat } from "@/utils/patching";
const PATCHES_DOWNLOAD_PERMISSION_VALUES = Constants.public.Enums[
"Patches Download Permission"
] as readonly Database["public"]["Enums"]["Patches Download Permission"][];
export interface HackMetadata {
hack: {
slug: string;
title: string;
summary: string;
description: string;
base_rom: string;
created_at: string;
updated_at: string | null;
current_patch: number | null;
box_art: string | null;
social_links: unknown;
created_by: string;
approved: boolean;
original_author: string | null;
permission_from: string | null;
language: string | null;
is_archive: boolean;
completion_status: Database["public"]["Enums"]["Completion Status"] | null;
verification_contact_info: string | null;
};
displayVersion: string;
images: string[];
tags: string[];
profile: {
username: string | null;
avatar_url: string | null;
verified: boolean;
email: string | null;
} | null;
otherHacks: {
slug: string;
title: string;
summary: string;
}[];
patch: {
id: number;
filename: string;
version: string | null;
created_at: string;
changelog: string | null;
} | null;
patcherSelector: {
selectablePatches: SelectablePatch[];
defaultPatchId: number | null;
};
}
export async function getHackMetadata(slug: string): Promise<HackMetadata | null> {
const runner = cache(
async () => {
const supabase = await createServiceClient();
const { data: hack, error } = await supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,created_at,updated_at,current_patch,custom_version_name,box_art,social_links,created_by,approved,original_author,permission_from,language,is_archive,completion_status,verification_contact_info")
.eq("slug", slug)
.maybeSingle();
if (error || !hack) return null;
// Security: Don't return verification_contact_info if hack is approved
if (hack.approved) {
hack.verification_contact_info = null;
}
// Fetch covers
let images: string[] = [];
const { data: covers } = await supabase
.from("hack_covers")
.select("url, position")
.eq("hack_slug", slug)
.order("position", { ascending: true });
if (covers && covers.length > 0) {
images = getCoverUrls(covers.map(c => c.url));
}
// Fetch tags
const { data: tagRows } = await supabase
.from("hack_tags")
.select("order,tags(name)")
.eq("hack_slug", slug);
const tags = sortOrderedTags(
(tagRows || [])
.map((r) => ({
name: r.tags.name,
order: r.order,
}))
).map((t) => t.name);
// Fetch profile
const { data: profile } = await supabase
.from("profiles")
.select("id,username,avatar_url,verified")
.eq("id", hack.created_by as string)
.maybeSingle();
// Meant to only be available to admins (gated in server-side page rendering)
let userEmail: string | null = null;
if (profile) {
const { data: userData } = await supabase.auth.admin.getUserById(profile.id);
userEmail = userData?.user?.email || null;
}
// Get other approved hacks by the same author (non-archive hacks only)
let otherHacks: {
slug: string;
title: string;
summary: string;
}[] = [];
if (!hack.is_archive && !hack.original_author) {
const { data: otherHacksData } = await supabase
.from("hacks")
.select("slug,title,summary")
.eq("created_by", hack.created_by)
.eq("approved", true)
.eq("is_archive", false)
.neq("slug", hack.slug)
.order("downloads", { ascending: false })
.limit(10);
otherHacks = otherHacksData ?? [];
}
// Get patch info
let patch: {
id: number;
filename: string;
version: string | null;
created_at: string;
changelog: string | null;
} | null = null;
if (hack.current_patch != null) {
const { data: patchData } = await supabase
.from("patches")
.select("id,bucket,filename,version,created_at,changelog")
.eq("id", hack.current_patch)
.maybeSingle();
if (patchData) {
patch = {
id: patchData.id,
filename: patchData.filename,
version: patchData.version || null,
created_at: patchData.created_at,
changelog: patchData.changelog || null,
};
}
}
const { savedPatchIds, selectablePatches, defaultPatchId } = await getPatcherSelectablePatches(supabase, slug, hack.current_patch);
const displayVersion = resolveHackDisplayVersion({
isArchive: hack.is_archive,
isCustomPatcherActive: savedPatchIds.length > 0,
customVersionName: hack.custom_version_name,
customDefaultPatchVersion: selectablePatches[0]?.version,
currentPatchVersion: patch?.version,
});
return {
hack,
displayVersion,
images,
tags,
profile: profile ? {
username: profile.username,
avatar_url: profile.avatar_url,
verified: profile.verified,
email: userEmail,
} : null,
otherHacks,
patch,
patcherSelector: {
selectablePatches,
defaultPatchId,
}
};
},
[`hack:${slug}:metadata`],
{
revalidate: 14400, // 4 hours
tags: ["hack", `hack:${slug}:metadata`],
}
);
return runner();
}
export async function getHackDownloads(slug: string): Promise<number | null> {
const runner = cache(
async () => {
const supabase = await createServiceClient();
const { data: hack, error } = await supabase
.from("hacks")
.select("downloads")
.eq("slug", slug)
.maybeSingle();
if (error || !hack) return null;
return hack.downloads || 0;
},
[`hack:${slug}:downloads`],
{
revalidate: 600, // 10 minutes
tags: ["hack", `hack:${slug}:downloads`],
}
);
return runner();
}
type GetSignedPatchUrlResult = {
ok: true;
url: string;
@@ -723,7 +512,8 @@ export async function rollbackToVersion(slug: string, patchId: number): Promise<
}
// Update current_patch
const { error: updateHackErr } = await supabase
// current_patch is server-only in the database.
const { error: updateHackErr } = await (await createServiceClient())
.from("hacks")
.update({ current_patch: patchId })
.eq("slug", slug);
@@ -852,6 +642,7 @@ export async function updatePatchVersion(slug: string, patchId: number, version:
.update({ version: trimmedVersion, updated_at: new Date().toISOString() })
.eq("id", patchId);
if (updateErr?.code === "23505") return { ok: false, error: "That version already exists for this hack." };
if (updateErr) return { ok: false, error: updateErr.message };
revalidateTag(`hack:${slug}:metadata`);
@@ -926,7 +717,7 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis
// If newer than current_patch and no patcher patches, update current_patch
if (willBecomeCurrent) {
const { error: updateHackErr } = await supabase
const { error: updateHackErr } = await serviceClient
.from("hacks")
.update({ current_patch: patchId })
.eq("slug", slug);
@@ -940,11 +731,12 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis
return { ok: true, willBecomeCurrent };
}
/** Signs a replacement upload for an existing version; the key is made here, inside this hack's files. */
export async function reuploadPatchVersion(
slug: string,
patchId: number,
objectKey: string
): Promise<{ ok: true; presignedUrl: string } | { ok: false; error: string }> {
format: PatchFormat
): Promise<{ ok: true; presignedUrl: string; objectKey: string } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" };
@@ -968,7 +760,7 @@ export async function reuploadPatchVersion(
// Verify patch belongs to this hack
const { data: patch, error: pErr } = await supabase
.from("patches")
.select("id, parent_hack, filename")
.select("id, parent_hack, filename, version")
.eq("id", patchId)
.maybeSingle();
if (pErr || !patch || patch.parent_hack !== slug) {
@@ -976,11 +768,12 @@ export async function reuploadPatchVersion(
}
// Generate presigned URL for upload
const objectKey = newPatchKey(slug, `${patch.version ?? "patch"}-reupload`, format === "xdelta" ? "xdelta" : "bps");
const client = getMinioClient();
const url = await client.presignedPutObject(PATCHES_BUCKET, objectKey, 60 * 10);
// Update patch filename after upload (caller should handle the actual upload and update)
return { ok: true, presignedUrl: url };
return { ok: true, presignedUrl: url, objectKey };
}
export async function confirmReuploadPatchVersion(
@@ -1018,9 +811,15 @@ export async function confirmReuploadPatchVersion(
return { ok: false, error: "Patch not found" };
}
// Only a key reuploadPatchVersion signed for this hack.
if (!isPatchKeyFor(slug, objectKey)) return { ok: false, error: "Invalid patch upload" };
if (!(await objectExists(PATCHES_BUCKET, objectKey))) return { ok: false, error: "The patch didn't finish uploading. Please try again." };
// Update patch filename and format (derived from object key extension)
const format = objectKey.toLowerCase().endsWith(".xdelta") ? "xdelta" : "bps";
const serviceClient = await createServiceClient();
const { count: keyUses } = await serviceClient.from("patches").select("id", { count: "exact", head: true }).eq("filename", objectKey);
if (keyUses) return { ok: false, error: "That upload was already used. Please upload again." };
const { error: updateErr } = await serviceClient
.from("patches")
.update({ filename: objectKey, format, updated_at: new Date().toISOString() })

View File

@@ -1,5 +1,5 @@
import { createClient } from "@/utils/supabase/server";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { approveHack } from "@/app/hack/actions";
import Button from "@/components/Button";
import Link from "next/link";
@@ -25,11 +25,13 @@ export default async function ApprovePage({ params }: ApprovePageProps) {
// Fetch hack data
const { data: hack, error } = await supabase
.from("hacks")
.select("title, approved, approved_at, approved_by, created_by")
.select("title, approved, approved_at, approved_by, created_by, submitted_at, ai_disclosed_at")
.eq("slug", slug)
.maybeSingle();
if (error || !hack) return notFound();
// Drafts can't be approved until the creator submits them.
if (!hack.approved && hack.submitted_at === null) redirect(`/hack/${slug}`);
const { data: creatorProfile, error: creatorProfileError } = await supabase
.from("profiles")
@@ -74,10 +76,10 @@ export default async function ApprovePage({ params }: ApprovePageProps) {
{hack.approved ? (
<div className="card p-6">
<div className="flex items-center gap-3 mb-4">
<FaCircleCheck className="text-green-500 flex-shrink-0" size={24} />
<FaCircleCheck className="text-ready flex-shrink-0" size={24} />
<h1 className="text-2xl">{hack.title}</h1>
</div>
<p className="text-foreground/75">
<p className="text-text-2">
This hack has already been approved
{hack.approved_by && approverUsername
? <span> by <span className="font-semibold">@{approverUsername}</span></span>
@@ -95,19 +97,28 @@ export default async function ApprovePage({ params }: ApprovePageProps) {
) : (
<div className="card p-6">
<div className="flex items-center gap-4 mb-4">
<FaTriangleExclamation className="text-yellow-500 flex-shrink-0" size={24} />
<FaTriangleExclamation className="text-warn flex-shrink-0" size={24} />
<h1 className="text-2xl">
Are you sure you want to approve <span className="font-semibold">{hack.title}</span>?
</h1>
</div>
<p className="text-foreground/75 mb-6">
<p className="text-text-2 mb-6">
By approving this hack, it will become visible to the public.
</p>
{/* Hacks submitted before the label existed skipped the checklist item; the grace period lets them through. */}
{!hack.ai_disclosed_at && (
<p className="mb-6 flex items-start gap-2 rounded-control bg-warn-soft px-3 py-2 text-sm text-text-2">
<FaTriangleExclamation className="mt-0.5 flex-none text-warn" size={14} />
<span>
<b className="font-semibold text-text">No AI label yet.</b> You can ask the creator to add one, or approve anyway.
</span>
</p>
)}
<form action={handleApprove} className="flex flex-col gap-3 justify-center md:justify-start">
{/* Checkbox to verify the hack creator */}
<div className="flex items-center gap-2">
{creatorProfile.verified ? (
<p className="text-foreground/75"><span className="font-semibold">@{creatorProfile.username}</span> is already verified.</p>
<p className="text-text-2"><span className="font-semibold">@{creatorProfile.username}</span> is already verified.</p>
) : (
<>
<input type="checkbox" name="verified" id="verified" />

View File

@@ -38,20 +38,20 @@ export default async function ChangelogPage({ params }: ChangelogPageProps) {
<div className="mb-6">
<Link
href={`/hack/${slug}`}
className="inline-flex items-center text-sm text-foreground/60 hover:text-foreground mb-2"
className="inline-flex items-center text-sm text-text-3 hover:text-text mb-2"
>
<FaChevronLeft size={14} className="mr-1" />
Back to hack
</Link>
<h1 className="text-3xl font-bold tracking-tight">Changelog</h1>
<p className="mt-1 text-sm text-foreground/60">
<h1 className="font-display text-[28px] leading-[1.1] md:text-[32px]">Changelog</h1>
<p className="mt-1 text-sm text-text-3">
{hack.title}
</p>
</div>
{patchesWithChangelogs.length === 0 ? (
<div className="card p-8 text-center">
<p className="text-foreground/60">No changelogs available yet.</p>
<p className="text-text-3">No changelogs available yet.</p>
</div>
) : (
<div className="space-y-6">
@@ -62,12 +62,12 @@ export default async function ChangelogPage({ params }: ChangelogPageProps) {
<h2 className="text-xl font-semibold">
{patch.version}
{hack.current_patch === patch.id && (
<span className="ml-2 inline-flex items-center rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<span className="ml-2 inline-flex items-center rounded-full bg-ready-soft px-2 py-0.5 text-xs font-medium text-ready">
Current
</span>
)}
</h2>
<p className="mt-1 text-sm text-foreground/60">
<p className="mt-1 text-sm text-text-3">
{new Date(patch.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
@@ -76,7 +76,7 @@ export default async function ChangelogPage({ params }: ChangelogPageProps) {
</p>
</div>
</div>
<div className="prose prose-sm max-w-none text-foreground/80">
<div className="prose prose-sm max-w-none text-text-2">
<Markdown headingLevelOffset={1}>{patch.changelog || ""}</Markdown>
</div>
</div>

View File

@@ -1,11 +1,13 @@
import { notFound, redirect } from "next/navigation";
import HackForm from "@/components/Hack/HackForm";
import HackEditForm from "@/components/Hack/HackEditForm";
import { createClient } from "@/utils/supabase/server";
import { FaChevronLeft, FaChevronRight, FaPlus } from "react-icons/fa6";
import { FaChevronLeft } from "react-icons/fa6";
import Link from "next/link";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { checkEditPermission } from "@/utils/hack";
import { getCachedTagsWithUsage } from "@/data/tags";
import { AI_SELECT, aiDisclosureFromRow } from "@/utils/aiDisclosure";
import ArchiveAiLabel from "@/components/Hack/ArchiveAiLabel";
interface EditPageProps {
params: Promise<{ slug: string }>;
@@ -22,7 +24,7 @@ export default async function EditHackPage({ params }: EditPageProps) {
const { data: hack } = await supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,language,completion_status,box_art,social_links,created_by,current_patch,original_author,permission_from,is_archive,tags_updated_at")
.select(`slug,title,summary,description,base_rom,language,completion_status,box_art,social_links,created_by,current_patch,original_author,permission_from,is_archive,tags_updated_at,approved,submitted_at,${AI_SELECT}`)
.eq("slug", slug)
.maybeSingle();
if (!hack) return notFound();
@@ -36,6 +38,10 @@ export default async function EditHackPage({ params }: EditPageProps) {
if (!permission.canEdit) {
redirect(`/hack/${slug}`);
}
// Regular hacks are edited in place on the hack page; only archives use this form.
if (!isArchive) {
redirect(`/hack/${slug}?edit=1`);
}
let coverKeys: string[] = [];
let signedCoverUrls: string[] = [];
@@ -93,37 +99,23 @@ export default async function EditHackPage({ params }: EditPageProps) {
};
return (
<div className="mx-auto max-w-screen-lg px-6 py-10">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between">
<h1 className="flex items-center text-3xl tracking-tight">
Edit
<FaChevronRight size={22} className="inline-block mx-2 text-foreground/50 align-middle" />
<span className="font-bold">{hack.title}</span>
<div className="mx-auto w-full max-w-[1164px] px-6 pb-6 pt-4 md:pt-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<h1 className="font-display text-[28px] leading-tight md:text-[32px]">
<span className="font-normal text-text-3">Edit</span> {hack.title}
</h1>
<div className="flex items-center gap-2 md:flex-row flex-col md:self-end lg:self-auto mt-8 lg:mt-0">
<Link href={`/hack/${slug}`} className="inline-flex items-center justify-center h-12 md:h-10 w-full md:w-auto rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10">
<FaChevronLeft size={16} className="inline-block mr-1" />
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<Link href={`/hack/${slug}`} className="inline-flex h-10 items-center justify-center rounded-control border border-line-strong bg-surface px-3 text-sm font-medium transition-colors hover:border-text-3">
<FaChevronLeft size={14} className="mr-1.5" />
Back to hack
</Link>
{!isArchive && <>
<Link
href={`/hack/${slug}/versions`}
className="inline-flex items-center justify-center h-12 md:h-10 w-full md:w-auto px-4 text-sm font-medium rounded-md border border-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] transition-colors"
>
Manage Versions
</Link>
<Link
href={`/hack/${slug}/edit/patch`}
className="inline-flex items-center justify-center h-12 md:h-10 w-full md:w-auto px-4 text-sm font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors"
>
<FaPlus size={14} className="mr-2" />
Upload New Version
</Link>
</>}
</div>
</div>
<div className="mt-4 lg:mt-8">
<HackForm mode="edit" slug={slug} initial={initial} catalogTags={catalogTags} tagsUpdatedAt={tagsUpdatedAt} />
<HackEditForm slug={slug} initial={initial} catalogTags={catalogTags} tagsUpdatedAt={tagsUpdatedAt} />
</div>
<div className="mt-6">
<ArchiveAiLabel slug={slug} initial={aiDisclosureFromRow(hack)} />
</div>
</div>
);

View File

@@ -5,6 +5,7 @@ import Link from "next/link";
import { FaChevronLeft } from "react-icons/fa6";
import { isInformationalArchiveHack, isDownloadableArchiveHack, canEditAsCreator, canEditAsAdmin, canEditAsArchiver } from "@/utils/hack";
import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches";
import { AI_SELECT, aiDisclosureFromRow } from "@/utils/aiDisclosure";
interface EditPatchPageProps {
params: Promise<{ slug: string }>;
@@ -20,7 +21,7 @@ export default async function EditPatchPage({ params }: EditPatchPageProps) {
const { data: hack } = await supabase
.from("hacks")
.select("slug,base_rom,created_by,title,current_patch,original_author,permission_from,is_archive,custom_version_name")
.select(`slug,base_rom,created_by,title,current_patch,original_author,permission_from,is_archive,custom_version_name,${AI_SELECT}`)
.eq("slug", slug)
.maybeSingle();
if (!hack) return notFound();
@@ -64,9 +65,9 @@ export default async function EditPatchPage({ params }: EditPatchPageProps) {
return (
<div className="mx-auto max-w-screen-lg px-6 py-10">
<h1 className="flex flex-col text-4xl tracking-tight max-w-[480px]">
<span className="text-foreground/70 mr-2 text-xl">Upload new version for</span>
<span className="gradient-text font-bold">{hack.title}</span>
<h1 className="flex max-w-[480px] flex-col gap-1">
<span className="text-[15px] font-medium text-text-2">Upload a new version of</span>
<span className="font-display text-[32px] leading-[1.1] text-balance">{hack.title}</span>
</h1>
<div className="mt-8 card p-5 max-w-[480px]">
@@ -77,11 +78,13 @@ export default async function EditPatchPage({ params }: EditPatchPageProps) {
isCustomPatcherActive={isCustomPatcherActive}
customVersionName={isCustomPatcherActive ? hack.custom_version_name : undefined}
currentVersion={currentVersion}
ai={aiDisclosureFromRow(hack)}
isArchive={hack.is_archive}
/>
</div>
<div className="mt-8 flex items-center justify-center">
<Link href={`/hack/${slug}`} className="items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10">
<Link href={`/hack/${slug}`} className="items-center justify-center rounded-control border border-line bg-surface-2 px-3 py-2 text-sm font-medium hover:bg-surface-2">
<FaChevronLeft size={16} className="inline-block mr-1" />
Back to hack
</Link>

View File

@@ -1,4 +1,4 @@
import { getHackMetadata } from "@/app/hack/[slug]/actions";
import { getHackMetadata } from "@/app/hack/[slug]/metadata";
import { baseRoms } from "@/data/baseRoms";
import { isArchiveHack } from "@/utils/hack";
import type { Metadata } from "next";

View File

@@ -0,0 +1,228 @@
import { createServiceClient } from "@/utils/supabase/server";
import { unstable_cache as cache } from "next/cache";
import { AI_SELECT, aiDisclosureFromRow, type AiDisclosure } from "@/utils/aiDisclosure";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { Database } from "@/types/db";
import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches";
import { resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
import type { SelectablePatch } from "@/types/patcher";
// Page data for /hack/[slug]. These read with the service client, so they must
// stay out of "use server" files: every export there is a public endpoint that
// anyone can call directly, which leaked private drafts and creator emails.
// Callers gate what they render (drafts, admin-only email and contact).
export interface HackMetadata {
hack: {
slug: string;
title: string;
summary: string;
description: string;
base_rom: string;
created_at: string;
updated_at: string | null;
current_patch: number | null;
box_art: string | null;
social_links: unknown;
created_by: string;
approved: boolean;
original_author: string | null;
permission_from: string | null;
language: string | null;
is_archive: boolean;
completion_status: Database["public"]["Enums"]["Completion Status"] | null;
verification_contact_info: string | null;
/** null while the creator is still drafting; set once they submit for review. */
submitted_at: string | null;
};
/** null until the creator fills in the AI disclosure form. */
ai: AiDisclosure | null;
displayVersion: string;
images: string[];
tags: string[];
profile: {
username: string | null;
avatar_url: string | null;
verified: boolean;
email: string | null;
} | null;
otherHacks: {
slug: string;
title: string;
summary: string;
}[];
patch: {
id: number;
filename: string;
version: string | null;
created_at: string;
changelog: string | null;
} | null;
patcherSelector: {
selectablePatches: SelectablePatch[];
defaultPatchId: number | null;
};
}
export async function getHackMetadata(slug: string): Promise<HackMetadata | null> {
const runner = cache(
async () => {
const supabase = await createServiceClient();
const { data: hack, error } = await supabase
.from("hacks")
.select(`slug,title,summary,description,base_rom,created_at,updated_at,current_patch,custom_version_name,box_art,social_links,created_by,approved,original_author,permission_from,language,is_archive,completion_status,verification_contact_info,submitted_at,${AI_SELECT}`)
.eq("slug", slug)
.maybeSingle();
if (error || !hack) return null;
// Security: Don't return verification_contact_info if hack is approved
if (hack.approved) {
hack.verification_contact_info = null;
}
// Fetch covers
let images: string[] = [];
const { data: covers } = await supabase
.from("hack_covers")
.select("url, position")
.eq("hack_slug", slug)
.order("position", { ascending: true });
if (covers && covers.length > 0) {
images = getCoverUrls(covers.map(c => c.url));
}
// Fetch tags
const { data: tagRows } = await supabase
.from("hack_tags")
.select("order,tags(name)")
.eq("hack_slug", slug);
const tags = sortOrderedTags(
(tagRows || [])
.map((r) => ({
name: r.tags.name,
order: r.order,
}))
).map((t) => t.name);
// Fetch profile
const { data: profile } = await supabase
.from("profiles")
.select("id,username,avatar_url,verified")
.eq("id", hack.created_by as string)
.maybeSingle();
// Meant to only be available to admins (gated in server-side page rendering)
let userEmail: string | null = null;
if (profile) {
const { data: userData } = await supabase.auth.admin.getUserById(profile.id);
userEmail = userData?.user?.email || null;
}
// Get other approved hacks by the same author (non-archive hacks only)
let otherHacks: {
slug: string;
title: string;
summary: string;
}[] = [];
if (!hack.is_archive && !hack.original_author) {
const { data: otherHacksData } = await supabase
.from("hacks")
.select("slug,title,summary")
.eq("created_by", hack.created_by)
.eq("approved", true)
.eq("is_archive", false)
.neq("slug", hack.slug)
.order("downloads", { ascending: false })
.limit(10);
otherHacks = otherHacksData ?? [];
}
// Get patch info
let patch: {
id: number;
filename: string;
version: string | null;
created_at: string;
changelog: string | null;
} | null = null;
if (hack.current_patch != null) {
const { data: patchData } = await supabase
.from("patches")
.select("id,bucket,filename,version,created_at,changelog")
.eq("id", hack.current_patch)
.maybeSingle();
if (patchData) {
patch = {
id: patchData.id,
filename: patchData.filename,
version: patchData.version || null,
created_at: patchData.created_at,
changelog: patchData.changelog || null,
};
}
}
const { savedPatchIds, selectablePatches, defaultPatchId } = await getPatcherSelectablePatches(supabase, slug, hack.current_patch);
const displayVersion = resolveHackDisplayVersion({
isArchive: hack.is_archive,
isCustomPatcherActive: savedPatchIds.length > 0,
customVersionName: hack.custom_version_name,
customDefaultPatchVersion: selectablePatches[0]?.version,
currentPatchVersion: patch?.version,
});
return {
hack,
ai: aiDisclosureFromRow(hack),
displayVersion,
images,
tags,
profile: profile ? {
username: profile.username,
avatar_url: profile.avatar_url,
verified: profile.verified,
email: userEmail,
} : null,
otherHacks,
patch,
patcherSelector: {
selectablePatches,
defaultPatchId,
}
};
},
[`hack:${slug}:metadata`],
{
revalidate: 14400, // 4 hours
tags: ["hack", `hack:${slug}:metadata`],
}
);
return runner();
}
export async function getHackDownloads(slug: string): Promise<number | null> {
const runner = cache(
async () => {
const supabase = await createServiceClient();
const { data: hack, error } = await supabase
.from("hacks")
.select("downloads")
.eq("slug", slug)
.maybeSingle();
if (error || !hack) return null;
return hack.downloads || 0;
},
[`hack:${slug}:downloads`],
{
revalidate: 600, // 10 minutes
tags: ["hack", `hack:${slug}:downloads`],
}
);
return runner();
}

View File

@@ -5,15 +5,15 @@ export default async function NotFoundPage() {
return (
<div className="mx-auto my-auto max-w-screen-2xl px-6 py-8 h-[calc(100vh-64px)] flex items-center justify-center">
<div className="flex flex-col items-center justify-center gap-4">
<h1 className="text-4xl font-bold">Not Found</h1>
<p className="text-lg text-foreground/60 text-center">The hack you are looking for does not exist or has not yet been approved.</p>
<h1 className="font-display text-[28px] leading-[1.1] md:text-[32px]">Not Found</h1>
<p className="text-lg text-text-3 text-center">The hack you are looking for does not exist or has not yet been approved.</p>
<Link
href="/discover"
className="inline-flex mt-4 h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md bg-[var(--accent)] px-5 text-base font-semibold sm:font-medium text-[var(--accent-foreground)] transition-colors hover:bg-[var(--accent-700)] elevate"
className="inline-flex mt-4 h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-control bg-accent-deep px-5 text-base font-semibold sm:font-medium text-white transition-colors hover:bg-accent-hover elevate"
>
View all hacks
</Link>
<p className="mt-4 md:text-sm text-foreground/60 text-center">Is this your hack? <Link href="/login" className="text-[var(--accent)] hover:underline">Log in</Link> to make changes.</p>
<p className="mt-4 md:text-sm text-text-3 text-center">Is this your hack? <Link href="/login" className="text-accent-text hover:underline">Log in</Link> to make changes.</p>
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import { getHackDownloads, getHackMetadata } from "@/app/hack/[slug]/actions";
import { getHackDownloads, getHackMetadata } from "@/app/hack/[slug]/metadata";
import {
getHackPageMetadata,
type HackDetailPageProps,

View File

@@ -1,9 +1,11 @@
import { getHackDownloads, getHackMetadata } from "@/app/hack/[slug]/actions";
import { getHackDownloads, getHackMetadata } from "@/app/hack/[slug]/metadata";
import {
getHackPageMetadata,
type HackDetailPageProps,
} from "@/app/hack/[slug]/hack-page-shared";
import HackDetailView from "@/components/Hack/HackDetailView";
import HackDetailView, { type DraftEditorData } from "@/components/Hack/HackDetailView";
import { getDraftChecklist } from "@/app/submit/actions";
import { getCachedTagsWithUsage } from "@/data/tags";
import {
checkEditPermission,
checkPatchEditPermission,
@@ -16,17 +18,24 @@ import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: HackDetailPageProps) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { slug } = await params;
return getHackPageMetadata(slug, Boolean(user));
const metadata = await getHackMetadata(slug);
let includeRestricted = false;
if (metadata && (!metadata.hack.approved || isArchiveHack(metadata.hack))) {
// Same gate as the page body; otherwise any signed-in visitor got a draft's title in <title>.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
includeRestricted = !!user && (await checkEditPermission(metadata.hack, user.id, supabase)).canEdit;
}
return getHackPageMetadata(slug, includeRestricted);
}
export default async function HackSessionDetail({
params,
}: HackDetailPageProps) {
searchParams,
}: HackDetailPageProps & { searchParams: Promise<{ preview?: string; edit?: string }> }) {
const supabase = await createClient();
const {
data: { user },
@@ -87,6 +96,39 @@ export default async function HackSessionDetail({
? Boolean(await getHackReviewThread(hack.slug))
: false;
// Hacks are edited in place here. Unlisted ones open in edit mode for their
// creator (?preview=1 shows the player view); listed ones, and anyone else's
// (an admin reviewing), show the player view until ?edit=1.
const { preview, edit } = await searchParams;
const isOwner = hack.created_by === userId;
const editsInPlace = canEdit && !isArchive && ((isOwner && !hack.approved) || edit === "1");
let editor: DraftEditorData | undefined;
if (editsInPlace) {
const [checklist, catalogTags, { data: covers }, { data: row }, { count: patchCount }] = await Promise.all([
getDraftChecklist(slug),
getCachedTagsWithUsage(),
supabase.from("hack_covers").select("url").eq("hack_slug", slug).order("position", { ascending: true }),
supabase.from("hacks").select("tags_updated_at").eq("slug", slug).maybeSingle(),
supabase.from("patches").select("id", { count: "exact", head: true }).eq("parent_hack", slug),
]);
if (checklist) {
editor = {
stage: hack.approved ? "listed" : hack.submitted_at === null ? "draft" : "review",
notOwner: !isOwner,
checklist,
catalogTags,
tagsUpdatedAt: row?.tags_updated_at ?? new Date(0).toISOString(),
coverKeys: (covers ?? []).map((c) => c.url),
preview: !hack.approved && preview === "1",
hasPatches: (patchCount ?? 0) > 0,
};
}
}
// Admins looking at someone else's unsubmitted draft see how far along it is.
const adminDraftChecklist = isAdmin && !editor && !hack.approved && hack.submitted_at === null ? await getDraftChecklist(slug) : null;
const draftProgress = adminDraftChecklist ? { done: adminDraftChecklist.required.filter((r) => r.done).length, total: adminDraftChecklist.required.length } : undefined;
return (
<HackDetailView
metadata={metadata}
@@ -95,6 +137,8 @@ export default async function HackSessionDetail({
canUploadPatch={canUploadPatch}
isAdmin={isAdmin}
hasReviewThread={hasReviewThread}
editor={editor}
draftProgress={draftProgress}
/>
);
}

View File

@@ -63,28 +63,28 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
<div className="mb-6">
<Link
href={`/hack/${slug}`}
className="inline-flex items-center text-sm text-foreground/60 hover:text-foreground mb-3"
className="inline-flex items-center text-sm text-text-3 hover:text-text mb-3"
>
<FaChevronLeft size={14} className="mr-1" />
Back to hack
</Link>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight mb-2">
<h1 className="font-display text-[28px] leading-[1.1] md:text-[32px] mb-2">
{canEdit ? "Manage Versions" : "Version History"}
</h1>
<p className="text-sm text-foreground/60 mb-4">
<p className="text-sm text-text-3 mb-4">
{hack.title}
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Link
href={`/hack/${slug}/changelog`}
className="inline-flex items-center justify-center h-10 px-4 text-sm font-medium rounded-md border border-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] transition-colors"
className="inline-flex items-center justify-center h-10 px-4 text-sm font-medium rounded-control border border-line bg-surface-2 hover:bg-surface-3 transition-colors"
>
View Changelog
</Link>
{canEdit && (
<Link
href={`/hack/${slug}/edit/patch`}
className="inline-flex items-center justify-center h-10 px-4 text-sm font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors"
className="inline-flex items-center justify-center h-10 px-4 text-sm font-semibold rounded-control bg-accent-deep text-white hover:bg-accent-hover transition-colors"
>
<FaPlus size={14} className="mr-2" />
Upload New Version
@@ -142,16 +142,16 @@ function VersionStatusGuide({
return (
<CollapsibleCard
title="Version Status Guide"
className="mb-6 bg-[var(--surface-1)] border border-[var(--border)]/50 rounded-lg"
className="mb-6 bg-surface border border-line/50 rounded-card"
>
<div className="space-y-5 sm:space-y-2.5 text-sm text-foreground/80">
<div className="space-y-5 sm:space-y-2.5 text-sm text-text-2">
{showCurrentGuide && (
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 shrink-0 w-fit">
<span className="inline-flex items-center gap-1 rounded-full bg-ready-soft px-2 py-0.5 text-xs font-medium text-ready shrink-0 w-fit">
<FaStar size={10} />
Current
</span>
<p className="text-foreground/70">
<p className="text-text-2">
{canEdit ?
<>The version used by the <strong>Latest published patch</strong> option. This is the default downloader version when <strong>Custom</strong> patcher versions are not active.</> :
"This is the version you will download when using the patch button on the hack page."
@@ -161,22 +161,22 @@ function VersionStatusGuide({
)}
{canEdit && (
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 shrink-0 w-fit">
<span className="inline-flex items-center gap-1 rounded-full bg-ready-soft px-2 py-0.5 text-xs font-medium text-ready shrink-0 w-fit">
<FaStar size={10} />
Default
</span>
<p className="text-foreground/70">
<p className="text-text-2">
The first version in the Custom patcher list. This is the version players will download by default if they don't select a different version.
</p>
</div>
)}
{showPatchableGuide && (
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 shrink-0 w-fit">
<span className="inline-flex items-center gap-1 rounded-full bg-ready-soft px-2 py-0.5 text-xs font-medium text-ready shrink-0 w-fit">
<FaStar size={10} />
Patchable
</span>
<p className="text-foreground/70">
<p className="text-text-2">
{canEdit ?
"Additional Custom versions available to choose from before using the patch button on the hack page." :
"This version can be selected before using the patch button on the hack page."
@@ -186,10 +186,10 @@ function VersionStatusGuide({
)}
{canEdit && <>
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400 shrink-0 w-fit">
<span className="inline-flex items-center rounded-full bg-warn-soft px-2 py-0.5 text-xs font-medium text-warn shrink-0 w-fit">
Unpublished
</span>
<p className="text-foreground/70">
<p className="text-text-2">
Versions that are only visible to you, and will not appear in the public version list or changelog.
</p>
</div>
@@ -197,7 +197,7 @@ function VersionStatusGuide({
<span className="inline-flex items-center rounded-full bg-gray-500/20 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400 shrink-0 w-fit">
Archived
</span>
<p className="text-foreground/70">
<p className="text-text-2">
Same as unpublished, but archived versions are hidden from normal view on this page. Check "Show archived versions" to view and restore them.
</p>
</div>

View File

@@ -1,7 +1,7 @@
"use server";
import { createClient, createServiceClient } from "@/utils/supabase/server";
import type { TablesInsert, Database } from "@/types/db";
import type { TablesInsert, TablesUpdate, Database } from "@/types/db";
import { getMinioClient, PATCHES_BUCKET, COVERS_BUCKET } from "@/utils/minio/server";
import { revalidatePath, revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
@@ -18,6 +18,11 @@ import {
postHackReviewMessage,
} from "@/utils/hack-review";
import { revalidateDiscoverCatalog } from "@/app/discover/revalidate";
import { aiColumns, parseAiLevels, type AiLevels } from "@/utils/aiDisclosure";
import { isCoverKeyFor, newPatchKey } from "@/utils/storageKeys";
import { MAX_COVERS, SUMMARY_MAX, TITLE_MAX } from "@/data/hackLimits";
import { baseRoms } from "@/data/baseRoms";
import type { PatchFormat } from "@/utils/patching";
export async function updateHack(args: {
slug: string;
@@ -36,6 +41,12 @@ export async function updateHack(args: {
github?: string;
} | null;
tags?: string[];
/** Only for hacks uploaded on someone else's behalf; neither may be cleared. */
original_author?: string;
permission_from?: string;
verification_contact_info?: string | null;
/** Replaces the whole AI disclosure and marks it confirmed now. */
ai?: { levels: AiLevels; note: string | null };
}) {
const supabase = await createClient();
const {
@@ -45,7 +56,7 @@ export async function updateHack(args: {
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author, permission_from, is_archive")
.select("slug, created_by, current_patch, original_author, permission_from, is_archive, approved, base_rom")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
@@ -56,11 +67,25 @@ export async function updateHack(args: {
return { ok: false, error: "Forbidden" } as const;
}
const updatePayload: TablesInsert<"hacks"> | any = {};
if (args.title !== undefined) updatePayload.title = args.title;
if (args.summary !== undefined) updatePayload.summary = args.summary;
const updatePayload: TablesUpdate<"hacks"> = {};
if (args.title !== undefined) {
if (!args.title.trim() || args.title.length > TITLE_MAX) return { ok: false, error: `Keep the title between 1 and ${TITLE_MAX} characters` } as const;
updatePayload.title = args.title;
}
if (args.summary !== undefined) {
if (args.summary.length > SUMMARY_MAX) return { ok: false, error: `Keep the summary to ${SUMMARY_MAX} characters` } as const;
updatePayload.summary = args.summary;
}
if (args.description !== undefined) updatePayload.description = args.description;
if (args.base_rom !== undefined) updatePayload.base_rom = args.base_rom;
if (args.base_rom !== undefined) {
if (!baseRoms.some((r) => r.id === args.base_rom)) return { ok: false, error: "Choose a base ROM from the list" } as const;
// Patches are built against one ROM; switching it after an upload would make players patch the wrong game.
if (args.base_rom !== hack.base_rom) {
const { count } = await supabase.from("patches").select("id", { count: "exact", head: true }).eq("parent_hack", args.slug);
if (count) return { ok: false, error: "The base ROM is locked once a patch is uploaded" } as const;
}
updatePayload.base_rom = args.base_rom;
}
if (args.language !== undefined) updatePayload.language = args.language;
if (args.completion_status !== undefined) {
if (args.completion_status === null) {
@@ -71,9 +96,30 @@ export async function updateHack(args: {
if (args.version !== undefined) updatePayload.version = args.version;
if (args.box_art !== undefined) updatePayload.box_art = args.box_art;
if (args.social_links !== undefined) updatePayload.social_links = args.social_links;
if (args.original_author !== undefined || args.permission_from !== undefined) {
if (!hack.original_author) {
return { ok: false, error: "This hack was not uploaded on someone else's behalf" } as const;
}
if (args.original_author?.trim() === "" || args.permission_from?.trim() === "") {
return { ok: false, error: "Name the creator and where they gave permission" } as const;
}
if (args.original_author !== undefined) updatePayload.original_author = args.original_author.trim();
if (args.permission_from !== undefined) updatePayload.permission_from = args.permission_from.trim();
}
if (args.verification_contact_info !== undefined) {
updatePayload.verification_contact_info = args.verification_contact_info?.trim() || null;
}
if (args.ai !== undefined) {
const levels = parseAiLevels(args.ai.levels);
if (!levels) return { ok: false, error: "Pick a level for every area of the AI label" } as const;
if ((args.ai.note?.length ?? 0) > 1000) return { ok: false, error: "Keep the AI explanation under 1,000 characters" } as const;
Object.assign(updatePayload, aiColumns(levels, args.ai.note));
}
if (Object.keys(updatePayload).length > 0) {
const { error: uErr } = await supabase
// The service role: credits and the AI label are server-only in the database, and the checks above already ran.
const service = await createServiceClient();
const { error: uErr } = await service
.from("hacks")
.update(updatePayload)
.eq("slug", args.slug);
@@ -91,7 +137,7 @@ export async function updateHack(args: {
.eq("hack_slug", args.slug);
if (curErr) return { ok: false, error: curErr.message } as const;
const currentIds = new Set((currentLinks || []).map((r: any) => r.tag_id as number));
const currentIds = new Set((currentLinks || []).map((r) => r.tag_id));
const desiredSet = new Set(desiredIds);
// Remove links for tags that are no longer present
@@ -132,8 +178,10 @@ export async function updateHack(args: {
revalidateTag(`hack:${args.slug}:metadata`);
revalidatePath(`/hack/${args.slug}`);
revalidateDiscoverCatalog();
return { ok: true } as const;
// Only listed hacks are in the catalog; drafts autosave constantly and would rebuild it each time.
if (hack.approved) revalidateDiscoverCatalog();
// The upload step confirms against this stamp.
return { ok: true, aiDisclosedAt: updatePayload.ai_disclosed_at ?? null } as const;
}
export async function saveHackCovers(args: { slug: string; coverUrls: string[] }) {
@@ -145,7 +193,7 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] }
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author, permission_from, is_archive")
.select("slug, created_by, current_patch, original_author, permission_from, is_archive, approved")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
@@ -164,10 +212,15 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] }
.order("position", { ascending: true });
if (cErr) return { ok: false, error: cErr.message } as const;
const existingAltMap = new Map((currentRows || []).map((r: any) => [r.url as string, (r.alt as string | null) || null]));
const existingIdMap = new Map((currentRows || []).map((r: any) => [r.url as string, r.id as number]));
const currentUrls = new Set((currentRows || []).map((r: any) => r.url as string));
const existingAltMap = new Map((currentRows || []).map((r) => [r.url, r.alt || null]));
const existingIdMap = new Map((currentRows || []).map((r) => [r.url, r.id]));
const currentUrls = new Set((currentRows || []).map((r) => r.url));
const desiredSet = new Set(args.coverUrls);
if (args.coverUrls.length > MAX_COVERS) return { ok: false, error: `Up to ${MAX_COVERS} screenshots` } as const;
// New keys must be this hack's own uploads; otherwise removing one later would delete another hack's file.
if (args.coverUrls.some((u) => !currentUrls.has(u) && !isCoverKeyFor(args.slug, u))) {
return { ok: false, error: "Invalid screenshot" } as const;
}
const toRemove = Array.from(currentUrls).filter((u) => !desiredSet.has(u));
@@ -179,9 +232,9 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] }
.eq("hack_slug", args.slug)
.in("url", toRemove);
if (delErr) return { ok: false, error: delErr.message } as const;
// Best-effort removal of orphaned files from S3
// Best-effort removal of orphaned files from S3, only inside this hack's folder
const client = getMinioClient();
for (const key of toRemove) {
for (const key of toRemove.filter((k) => isCoverKeyFor(args.slug, k))) {
try {
await client.removeObject(COVERS_BUCKET, key);
} catch (e) {
@@ -192,12 +245,14 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] }
// Upsert desired rows (insert new and update existing positions/alts)
if (args.coverUrls.length > 0) {
const rows = args.coverUrls.map((url, idx) => {
const base: any = { hack_slug: args.slug, url, position: idx + 1, alt: existingAltMap.get(url) || null };
const id = existingIdMap.get(url);
base.id = id || undefined; // include pk for existing rows per Supabase upsert requirement
return base;
});
// Existing rows keep their pk, which the upsert needs.
const rows = args.coverUrls.map((url, idx): TablesInsert<"hack_covers"> => ({
id: existingIdMap.get(url),
hack_slug: args.slug,
url,
position: idx + 1,
alt: existingAltMap.get(url) || null,
}));
const updatedRows = rows.filter((r) => r.id !== undefined);
const newRows = rows.filter((r) => r.id === undefined);
@@ -216,12 +271,13 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] }
revalidateTag(`hack:${args.slug}:metadata`);
revalidatePath(`/hack/${args.slug}`);
revalidateDiscoverCatalog();
if (hack.approved) revalidateDiscoverCatalog();
return { ok: true } as const;
}
export async function presignNewPatchVersion(args: { slug: string; version: string; objectKey?: string }) {
/** Signs an upload for a new version. The key is made here so it can only land in this hack's files. */
export async function presignNewPatchVersion(args: { slug: string; version: string; format: PatchFormat }) {
const supabase = await createClient();
const {
data: { user },
@@ -255,8 +311,7 @@ export async function presignNewPatchVersion(args: { slug: string; version: stri
.maybeSingle();
if (existing) return { ok: false, error: "That version already exists for this hack." } as const;
const safeVersion = args.version.replace(/[^a-zA-Z0-9._-]+/g, "-");
const objectKey = args.objectKey || `${args.slug}-${safeVersion}.bps`;
const objectKey = newPatchKey(args.slug, args.version, args.format === "xdelta" ? "xdelta" : "bps");
const client = getMinioClient();
// 10 minutes to upload
@@ -285,6 +340,7 @@ export async function presignCoverUpload(args: { slug: string; objectKey: string
if (!permission.canEdit) {
return { ok: false, error: "Forbidden" } as const;
}
if (!isCoverKeyFor(args.slug, args.objectKey)) return { ok: false, error: "Invalid screenshot path" } as const;
const client = getMinioClient();
// 10 minutes to upload
@@ -310,11 +366,14 @@ export async function approveHack(slug: string, verified?: boolean) {
// Check if hack exists
const { data: hack, error: hErr } = await serviceClient
.from("hacks")
.select("slug, approved, title, created_by")
.select("slug, approved, title, created_by, submitted_at, current_patch, is_archive")
.eq("slug", slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
if (!hack) return { ok: false, error: "Hack not found" } as const;
// Only the creator can submit; a draft isn't in the queue yet.
if (hack.submitted_at === null) return { ok: false, error: "This hack hasn't been submitted for review yet." } as const;
if (!hack.approved && !hack.is_archive && hack.current_patch === null) return { ok: false, error: "This hack has no playable patch yet." } as const;
if (verified === true) {
const { error: updateErr } = await serviceClient
@@ -329,6 +388,7 @@ export async function approveHack(slug: string, verified?: boolean) {
// If already approved, return success
if (hack.approved) {
revalidateTag(`hack:${slug}:metadata`);
revalidatePath(`/hack/${slug}`);
revalidateDiscoverCatalog();
return { ok: true } as const;
@@ -345,7 +405,11 @@ export async function approveHack(slug: string, verified?: boolean) {
.eq("slug", slug);
if (updateErr) return { ok: false, error: updateErr.message } as const;
// Before the notifications, so a failed one can't leave the page cached as unapproved.
revalidateDiscoverCatalog();
revalidateTag(`hack:${slug}:metadata`);
revalidateTag(`hack:${slug}:downloads`);
revalidatePath(`/hack/${slug}`);
try {
const { data: creatorData, error: creatorError } = await serviceClient.auth.admin.getUserById(hack.created_by);
@@ -380,25 +444,26 @@ export async function approveHack(slug: string, verified?: boolean) {
}
if (process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL) {
const { data: profile } = await serviceClient.from('profiles').select('*').eq('id', hack.created_by).single();
const displayName = profile?.username ? `@${profile.username}` : user.id;
const embed: APIEmbed = {
title: `:tada: ${hack.title} :tada:`,
description: `A new hack by **${displayName}** is now live!`,
color: 0x40f56a,
url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${slug}`,
footer: {
text: `This message brought to you by Hackdex`
try {
const { data: profile } = await serviceClient.from('profiles').select('*').eq('id', hack.created_by).single();
const displayName = profile?.username ? `@${profile.username}` : user.id;
const embed: APIEmbed = {
title: `:tada: ${hack.title} :tada:`,
description: `A new hack by **${displayName}** is now live!`,
color: 0x40f56a,
url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${slug}`,
footer: {
text: `This message brought to you by Hackdex`
}
}
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL, [
embed,
]);
} catch (error) {
console.error(`[HackApprove] Failed to announce ${slug} on Discord:`, error);
}
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL, [
embed,
]);
}
revalidateTag(`hack:${slug}:metadata`);
revalidateTag(`hack:${slug}:downloads`);
revalidatePath(`/hack/${slug}`);
redirect(`/hack/${slug}`);
}

View File

@@ -1,18 +1,22 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Archivo, Geist_Mono } from "next/font/google";
import { Analytics } from "@vercel/analytics/react";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import MobileFooterSpacer from "@/components/MobileFooterSpacer";
import { BaseRomProvider } from "@/contexts/BaseRomContext";
import { AuthProvider } from "@/contexts/AuthContext";
import NoticeBanner from "@/components/NoticeBanner";
import AppToaster from "@/components/AppToaster";
import MobileTabs from "@/components/MobileTabs";
import FreshLookBanner from "@/components/FreshLookBanner";
import { freshLookInitScript, themeInitScript } from "@/utils/initScripts";
const geistSans = Geist({
variable: "--font-geist-sans",
/** One family. Body at full width; headings use the width axis (see `font-display` in globals.css). */
const archivo = Archivo({
variable: "--font-archivo",
subsets: ["latin"],
axes: ["wdth"],
});
const geistMono = Geist_Mono({
@@ -39,20 +43,21 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript + freshLookInitScript }} />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
className={`${archivo.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
>
<AuthProvider>
<BaseRomProvider>
<div className="fixed inset-0 -z-10">
<div className="aurora" />
</div>
<NoticeBanner />
<Header />
<FreshLookBanner />
<main className="flex-1 flex flex-col">{children}</main>
<Footer />
<MobileFooterSpacer />
<MobileTabs />
</BaseRomProvider>
</AuthProvider>
<AppToaster />

View File

@@ -9,17 +9,17 @@ export default async function ForgotPasswordPage({ searchParams }: ForgotPasswor
const { redirectTo } = await searchParams;
return (
<div className="mx-auto my-auto max-w-md w-full px-6 py-10">
<div className="card p-6">
<h1 className="text-2xl font-semibold tracking-tight">Forgot password</h1>
<p className="mt-1 text-sm text-foreground/70">Enter your email and we'll send you a reset link.</p>
<div className="mx-auto my-auto max-w-md w-full px-3 py-10 md:px-6">
<div className="card px-4 py-6 md:p-8">
<h1 className="font-display text-[28px] leading-[1.1]">Forgot password</h1>
<p className="mt-1 text-sm text-text-2">Enter your email and we'll send you a reset link.</p>
<div className="mt-6">
<ForgotPasswordForm />
</div>
<p className="mt-6 text-sm text-foreground/70">
<p className="mt-6 text-sm text-text-2">
Remember now?
<Link
className="ml-1 text-[var(--accent)] hover:underline"
className="ml-1 text-link-hd"
href={redirectTo ? `/login?redirectTo=${encodeURIComponent(redirectTo)}` : "/login"}
>
Back to login

View File

@@ -9,16 +9,22 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const { redirectTo } = await searchParams;
return (
<div className="mx-auto my-auto max-w-md w-full px-6 py-10">
<div className="card p-6">
<h1 className="text-2xl font-semibold tracking-tight">Welcome back</h1>
<p className="mt-1 text-sm text-foreground/70">Log in to manage your account and submissions.</p>
<div className="mx-auto my-auto max-w-md w-full px-3 py-10 md:px-6">
<div className="card px-4 py-6 md:p-8">
<h1 className="font-display text-[28px] leading-[1.1]">Creator log in</h1>
<p className="mt-1 text-sm text-text-2">Manage your hacks, upload new versions, and see downloads.</p>
<p className="mt-4 rounded-control bg-surface-2 px-3 py-2.5 text-sm text-text-2">
Here to play? You don&rsquo;t need an account.{" "}
<Link href="/discover" className="text-link-hd">
Browse hacks
</Link>
</p>
<div className="mt-6">
<LoginForm />
</div>
<p className="mt-6 text-sm text-foreground/70">
New here?
<Link className="ml-1 text-[var(--accent)] hover:underline" href={redirectTo ? `/signup?redirectTo=${encodeURIComponent(redirectTo)}` : "/signup"}>Create an account</Link>
<p className="mt-6 text-sm text-text-2">
New creator?
<Link className="ml-1 text-link-hd" href={redirectTo ? `/signup?redirectTo=${encodeURIComponent(redirectTo)}` : "/signup"}>Become a creator</Link>
</p>
</div>
</div>

View File

@@ -1,15 +1,12 @@
import { Metadata } from "next";
import Link from "next/link";
import { FaArrowRightLong } from "react-icons/fa6";
import { createServiceClient } from "@/utils/supabase/server";
import HackCard from "@/components/HackCard";
import Button from "@/components/Button";
import { FiArrowRight } from "react-icons/fi";
import { getDiscoverData } from "@/app/discover/actions";
import MilestoneCelebration from "@/components/Home/MilestoneCelebration";
import HeroPatchDiagram from "@/components/Home/HeroPatchDiagram";
import HeroAuthCta from "@/components/Home/HeroAuthCta";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { HackCardAttributes } from "@/components/HackCard";
import { resolveHackDisplayVersion } from "@/utils/patches/hack-display-version";
import { HomeHero, HowItWorksGate, ReadyShelf } from "@/components/Home/HomeHero";
import Shelf from "@/components/Home/Shelf";
import type { HackCardAttributes } from "@/components/HackCard";
import type { DiscoverHack } from "@/types/discover";
export const metadata: Metadata = {
alternates: {
@@ -20,255 +17,92 @@ export const metadata: Metadata = {
export const dynamic = "error";
export const revalidate = 1800;
const SHELF_SIZE = 12;
const STEPS = [
["Link your base ROM", "Point Hackdex at your legally-obtained ROM file. It's verified and matched automatically."],
["It stays on your device", "Your ROM is cached locally in your browser, never uploaded, and ready whenever you come back."],
["Patch hack after hack", "Pick any hack built for your ROM and patch it in your browser in seconds, with no re-uploading between hacks."],
] as const;
/** Only what a card needs, so the whole catalog can ride along for the ready shelf without the discover extras. */
function toCard(h: DiscoverHack): HackCardAttributes {
return {
slug: h.slug,
title: h.title,
author: h.author,
covers: h.covers,
tags: h.tags,
downloads: h.downloads,
baseRomId: h.baseRomId,
version: h.version,
summary: h.summary,
is_archive: h.is_archive,
completion_status: h.completion_status,
};
}
function byDateDesc(key: "approvedAt" | "publishedAt") {
return (a: DiscoverHack, b: DiscoverHack) => (b[key] ? Date.parse(b[key]!) : 0) - (a[key] ? Date.parse(a[key]!) : 0);
}
export default async function Home() {
const downloadsMilestone = process.env.NEXT_PUBLIC_DOWNLOADS_MILESTONE?.trim();
const supabase = await createServiceClient();
const milestone = process.env.NEXT_PUBLIC_DOWNLOADS_MILESTONE?.trim() || undefined;
const { hacks } = await getDiscoverData();
const catalog = hacks.map(toCard);
// Fetch top 6 approved hacks ordered by downloads
const { data: popularHacks } = await supabase
.from("hacks")
.select("slug,title,summary,description,base_rom,downloads,created_by,current_patch,custom_version_name,original_author,is_archive")
.eq("approved", true)
.not("current_patch", "is", null)
.is("is_archive", false)
.order("downloads", { ascending: false })
.limit(6);
const trending = [...hacks].sort((a, b) => b.trendingScore - a.trendingScore).slice(0, SHELF_SIZE).map(toCard);
const newest = [...hacks].sort(byDateDesc("approvedAt")).slice(0, SHELF_SIZE).map(toCard);
const updated = [...hacks].filter((h) => h.publishedAt).sort(byDateDesc("publishedAt")).slice(0, SHELF_SIZE).map(toCard);
let hackData: HackCardAttributes[] = [];
if (popularHacks && popularHacks.length > 0) {
const slugs = popularHacks.map((h) => h.slug);
// Fetch covers
const { data: coverRows } = await supabase
.from("hack_covers")
.select("hack_slug,url,position")
.in("hack_slug", slugs)
.order("position", { ascending: true });
const coversBySlug = new Map<string, string[]>();
if (coverRows && coverRows.length > 0) { const coverKeys = coverRows.map((c) => c.url);
const signedUrls = getCoverUrls(coverKeys);
const urlToSignedUrl = new Map<string, string>();
coverKeys.forEach((key, idx) => {
urlToSignedUrl.set(key, signedUrls[idx]);
});
coverRows.forEach((c) => {
const arr = coversBySlug.get(c.hack_slug) || [];
const signed = urlToSignedUrl.get(c.url);
if (signed) {
arr.push(signed);
coversBySlug.set(c.hack_slug, arr);
}
});
}
// Fetch tags
const { data: tagRows } = await supabase
.from("hack_tags")
.select("hack_slug,order,tags(name,category)")
.in("hack_slug", slugs);
const tagsBySlug = new Map<string, { name: string; order: number }[]>();
(tagRows || []).forEach((r: any) => {
if (!r.tags?.name) return;
const arr = tagsBySlug.get(r.hack_slug) || [];
arr.push({
name: r.tags.name,
order: r.order,
});
tagsBySlug.set(r.hack_slug, arr);
});
// Fetch versions
const patchIds = popularHacks
.map((hack) => hack.current_patch)
.filter((id): id is number => typeof id === "number");
const versionsByPatchId = new Map<number, string>();
if (patchIds.length > 0) {
const { data: patchRows } = await supabase
.from("patches")
.select("id,version")
.in("id", patchIds);
(patchRows || []).forEach((patch) => {
versionsByPatchId.set(patch.id, patch.version || "Pre-release");
});
}
const customDefaultVersionsBySlug = new Map<string, string>();
const customPatcherSlugs = new Set<string>();
const { data: customPatchRows } = await supabase
.from("hack_patcher_patches")
.select("hack_slug, sort_order, patches!inner(version)")
.in("hack_slug", slugs)
.order("sort_order", { ascending: true });
(customPatchRows || []).forEach((row: any) => {
customPatcherSlugs.add(row.hack_slug);
if (customDefaultVersionsBySlug.has(row.hack_slug)) return;
const patch = Array.isArray(row.patches) ? row.patches[0] : row.patches;
if (patch?.version) customDefaultVersionsBySlug.set(row.hack_slug, patch.version);
});
const mappedVersions = new Map<string, string>();
popularHacks.forEach((hack) => {
const currentPatchVersion = typeof hack.current_patch === "number"
? versionsByPatchId.get(hack.current_patch) || "Pre-release"
: "";
mappedVersions.set(hack.slug, resolveHackDisplayVersion({
isArchive: hack.is_archive,
isCustomPatcherActive: customPatcherSlugs.has(hack.slug),
customVersionName: hack.custom_version_name,
customDefaultPatchVersion: customDefaultVersionsBySlug.get(hack.slug),
currentPatchVersion,
}));
});
// Fetch profiles
const userIds = [...new Set(popularHacks.map((h) => h.created_by).filter(Boolean))];
const { data: profiles } = await supabase
.from("profiles")
.select("id,username")
.in("id", userIds);
const usernameById = new Map<string, string>();
(profiles || []).forEach((p) => usernameById.set(p.id, p.username ? `@${p.username}` : "Unknown"));
// Map data to HackCard format
hackData = popularHacks.map((r) => ({
slug: r.slug,
title: r.title,
author: r.original_author ? r.original_author : usernameById.get(r.created_by as string) || "Unknown",
covers: coversBySlug.get(r.slug) || [],
tags: sortOrderedTags(tagsBySlug.get(r.slug) || []),
downloads: r.downloads,
baseRomId: r.base_rom,
version: mappedVersions.get(r.slug) || "Pre-release",
summary: r.summary,
description: r.description,
is_archive: false,
}));
}
return (
<div>
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-grid opacity-30 pointer-events-none" />
<div className="mx-auto max-w-screen-2xl px-6 pt-12 pb-8 sm:py-20">
<div className="grid items-center gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,34rem)] xl:grid-cols-[minmax(0,1fr)_minmax(0,42rem)]">
<div>
{downloadsMilestone && (
<MilestoneCelebration milestone={downloadsMilestone} />
)}
<h1 className="mt-5 text-4xl font-bold tracking-tight sm:text-6xl text-balance">
Bring your ROM once.
<br />
<span className="gradient-text">Play everything it unlocks.</span>
</h1>
<p className="mt-4 max-w-xl text-[15px] leading-relaxed text-foreground/80">
Link your legally-obtained base ROM one time and Hackdex caches it on your
device. Patch any Pokémon ROM hack built for it, right in your browser,
without ever re-uploading. Your files never leave your device.
</p>
<div className="mt-8 mx-auto flex w-full max-w-[320px] flex-col items-start gap-3 sm:mx-0 sm:max-w-none sm:flex-row sm:items-center">
<Link
href="/discover"
prefetch={false}
className="inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md bg-[var(--accent)] px-5 text-base font-semibold sm:font-medium text-[var(--accent-foreground)] transition-colors hover:bg-[var(--accent-700)] elevate"
>
Explore hacks
</Link>
<Link
href="/submit"
prefetch={false}
className="inline-flex h-14 w-full sm:h-12 sm:w-auto items-center justify-center rounded-md border border-white/10 bg-white/10 px-5 text-base font-semibold sm:font-medium text-foreground transition-colors hover:bg-white/15 elevate"
>
Submit a patch
</Link>
<HeroAuthCta />
</div>
<p className="mt-6 text-xs text-foreground/60">
Supports Game Boy, Game Boy Color, Game Boy Advance, and Nintendo DS.
</p>
</div>
<HeroPatchDiagram />
</div>
</div>
</section>
<div className="mx-auto w-full max-w-[1164px] px-6 pt-2 md:pt-6">
{milestone && <MilestoneCelebration milestone={milestone} />}
<HomeHero catalog={catalog} />
<section className={`mx-auto max-w-screen-2xl px-6 ${hackData.length > 0 ? "pt-6 sm:pt-12" : "py-6 sm:py-12"}`}>
<h2 className="text-sm font-semibold uppercase tracking-widest text-foreground/60">How it works</h2>
<div className="relative mt-4 grid gap-8 sm:grid-cols-3 sm:gap-6">
<div
className="pointer-events-none absolute top-3.5 right-[calc(16.666%-0.875rem)] left-3.5 hidden h-px bg-[var(--border)] sm:block"
aria-hidden="true"
/>
<div>
<span className="relative z-10 flex h-7 w-7 items-center justify-center rounded-full bg-[var(--background)] text-[13px] font-semibold text-[var(--accent)] shadow-[0_0_0_8px_var(--background)] ring-2 ring-[var(--accent)]/30">
1
</span>
<div className="mt-3 text-[15px] font-semibold tracking-tight">Link your base ROM</div>
<p className="mt-1 max-w-xs text-sm text-foreground/70">
Point Hackdex at your legally-obtained ROM file. It&apos;s verified and matched automatically.
</p>
</div>
<div>
<span className="relative z-10 flex h-7 w-7 items-center justify-center rounded-full bg-[var(--background)] text-[13px] font-semibold text-[var(--accent)] shadow-[0_0_0_8px_var(--background)] ring-2 ring-[var(--accent)]/30">
2
</span>
<div className="mt-3 text-[15px] font-semibold tracking-tight">It stays on your device</div>
<p className="mt-1 max-w-xs text-sm text-foreground/70">
Your ROM is cached locally in your browser, never uploaded, and ready whenever you come back.
</p>
</div>
<div>
<span className="relative z-10 flex h-7 w-7 items-center justify-center rounded-full bg-[var(--background)] text-[13px] font-semibold text-[var(--accent)] shadow-[0_0_0_8px_var(--background)] ring-2 ring-[var(--accent)]/30">
3
</span>
<div className="mt-3 text-[15px] font-semibold tracking-tight">Patch hack after hack</div>
<p className="mt-1 max-w-xs text-sm text-foreground/70">
Pick any hack built for your ROM and patch it in your browser in seconds, with no re-uploading between hacks.
</p>
</div>
</div>
<div className="mt-12 mb-4 mx-auto flex flex-col items-center max-w-[320px] sm:mt-16">
<Link href="/faq" prefetch={false} className="inline-flex items-center rounded-full elevate border border-black/10 bg-black/5 dark:border-white/10 dark:bg-white/5 px-4 py-1.5 text-sm text-foreground hover:bg-black/10 dark:hover:bg-white/10">
<span className="font-medium">New to Hackdex?</span>
<span className="ml-1 underline underline-offset-2">Read the FAQ</span>
<FaArrowRightLong size={12} aria-hidden className="ml-1" />
</Link>
</div>
</section>
<ReadyShelf catalog={catalog} />
<Shelf title="Trending this week" blurb="Popular over the last few days." href="/discover" hacks={trending} />
<Shelf title="New on Hackdex" blurb="The latest hacks to be listed." href="/discover?s=new" hacks={newest} />
<Shelf title="Recently updated" blurb="Fresh patches from creators still at work." href="/discover?s=updated" hacks={updated} />
{hackData.length > 0 && (
<section className="mx-auto max-w-screen-2xl px-6 py-12">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">Popular ROM hacks</h2>
<p className="mt-1 text-sm text-foreground/70">Most downloaded patches</p>
</div>
<Link
href="/discover"
prefetch={false}
className="text-sm font-medium text-foreground/80 hover:text-foreground hover:underline"
>
View all <FaArrowRightLong className="inline ml-1" size={12} />
</Link>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{hackData.map((hack) => (
<HackCard key={hack.slug} hack={hack} />
<HowItWorksGate>
<section className="scroll-mt-[76px] pt-3 md:pt-3" aria-labelledby="how">
<h2 id="how" className="mb-4 text-[13px] font-semibold uppercase tracking-[.12em] text-text-3">
How it works
</h2>
<ol className="relative grid gap-5 md:grid-cols-3 md:gap-6">
<span className="absolute left-3.5 top-3.5 hidden h-px bg-line-strong md:block md:right-[calc(33.333%-14px)]" aria-hidden />
{STEPS.map(([title, body], i) => (
<li key={title} className="grid grid-cols-[28px_1fr] gap-x-3 md:block">
<span className="relative z-[1] inline-flex h-7 w-7 items-center justify-center rounded-full bg-bg text-[13px] font-bold text-accent-text shadow-[0_0_0_8px_var(--bg),inset_0_0_0_2px_var(--rose)]">
{i + 1}
</span>
<b className="self-center text-[15px] font-semibold md:mt-3 md:block">{title}</b>
<p className="col-start-2 mt-1 text-sm text-text-2 md:max-w-[32ch]">{body}</p>
</li>
))}
</div>
<div className="sm:hidden flex justify-center mt-6">
<Button
variant="secondary"
size="lg"
className="w-48"
</ol>
<p className="mt-6 flex justify-center md:mt-8">
<Link
href="/faq"
prefetch={false}
className="inline-flex items-center gap-1 rounded-full border border-line-strong bg-surface px-3.5 py-1.5 text-sm shadow-rest transition-colors hover:border-text-3 hover:bg-surface-2"
>
<Link href="/discover" prefetch={false} className="inline-flex items-center">
View all
</Link>
</Button>
</div>
<span className="mr-0.5 font-medium">New to ROM hacks?</span> Read the FAQ <FiArrowRight className="h-3.5 w-3.5" />
</Link>
</p>
</section>
)}
</HowItWorksGate>
<p className="mt-7 flex justify-center md:mt-10">
<Link
href="/discover"
prefetch={false}
className="inline-flex h-12 w-full items-center justify-center rounded-control bg-surface-2 px-6 text-[15px] font-semibold text-text transition-colors hover:bg-line md:w-auto"
>
Browse all {catalog.length.toLocaleString("en-US")} hacks
</Link>
</p>
</div>
);
}

View File

@@ -3,9 +3,9 @@ import BaseRomReadyCount from "@/components/Roms/BaseRomReadyCount";
export default function RomsPage() {
return (
<div className="mx-auto max-w-screen-lg px-6 py-10">
<h1 className="text-3xl font-bold tracking-tight">Your Base ROMs <BaseRomReadyCount /></h1>
<p className="mt-2 text-[15px] text-foreground/80">
<div className="mx-auto w-full max-w-[1164px] px-6 pb-6 pt-4 md:pt-6">
<h1 className="font-display flex items-center gap-3 text-[28px] leading-tight md:text-[32px]">My ROMs <BaseRomReadyCount /></h1>
<p className="mt-2 max-w-[70ch] text-[15px] text-text-2">
Link your legally-obtained base ROM files from your device so the patcher can auto-detect them. Files never leave your
device.
</p>

View File

@@ -9,19 +9,25 @@ export default async function SignupPage({ searchParams }: SignupPageProps) {
const { redirectTo } = await searchParams;
return (
<div className="mx-auto my-auto max-w-md w-full px-6 py-10">
<div className="card p-6">
<h1 className="text-2xl font-semibold tracking-tight">Create your account</h1>
<p className="mt-1 text-sm text-foreground/70">Sign up to submit hacks and manage your profile.</p>
<p className="mt-4 text-sm border border-amber-200 bg-amber-50 text-amber-950/80 dark:border-amber-700/60 dark:bg-amber-950/60 dark:text-amber-50/80 p-4 rounded-md">
Only sign up to submit hacks that <span className="text-amber-950 dark:text-amber-50 font-bold">you own</span>. Hacks not by the original creator will be rejected.
<div className="mx-auto my-auto max-w-md w-full px-3 py-10 md:px-6">
<div className="card px-4 py-6 md:p-8">
<h1 className="font-display text-[28px] leading-[1.1]">Become a creator</h1>
<p className="mt-1 text-sm text-text-2">An account lets you share your hacks on Hackdex.</p>
<p className="mt-4 rounded-control bg-surface-2 px-3 py-2.5 text-sm text-text-2">
Here to play? You don&rsquo;t need an account.{" "}
<Link href="/discover" className="text-link-hd">
Browse hacks
</Link>
</p>
<p className="mt-3 text-sm rounded-control bg-warn-soft p-3 text-text">
Only share hacks <span className="font-semibold">you made</span>. If you&rsquo;d like to see someone else&rsquo;s hack here, ask its creator to share it on Hackdex.
</p>
<div className="mt-6">
<SignupForm />
</div>
<p className="mt-6 text-sm text-foreground/70">
Already have an account?
<Link className="ml-1 text-[var(--accent)] hover:underline" href="/login">Log in</Link>
<p className="mt-6 text-sm text-text-2">
Already a creator?
<Link className="ml-1 text-link-hd" href="/login">Log in</Link>
</p>
</div>
</div>

View File

@@ -1,13 +1,15 @@
"use server";
import { createClient } from "@/utils/supabase/server";
import type { TablesInsert, Database } from "@/types/db";
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
import { createClient, createServiceClient } from "@/utils/supabase/server";
import type { TablesInsert } from "@/types/db";
import { objectExists, PATCHES_BUCKET } from "@/utils/minio/server";
import { sendDiscordMessageEmbed } from "@/utils/discord";
import { APIEmbed } from "discord-api-types/v10";
import { slugify } from "@/utils/format";
import { isPatchKeyFor } from "@/utils/storageKeys";
import { SUMMARY_MAX, TITLE_MAX } from "@/data/hackLimits";
import { baseRoms } from "@/data/baseRoms";
import { checkEditPermission, checkPatchEditPermission } from "@/utils/hack";
import { getCachedTagsWithUsage, resolveTagIdsInOrder } from "@/data/tags";
import type { PatchFormat } from "@/utils/patching";
import {
ensureHackReviewThread,
@@ -15,6 +17,7 @@ import {
postHackReviewMessage,
} from "@/utils/hack-review";
import { revalidateDiscoverCatalog } from "@/app/discover/revalidate";
import { revalidatePath, revalidateTag } from "next/cache";
type HackInsert = TablesInsert<"hacks">;
@@ -22,181 +25,37 @@ function patchFormatFromObjectKey(objectKey: string): PatchFormat {
return objectKey.toLowerCase().endsWith(".xdelta") ? "xdelta" : "bps";
}
async function ensureUniqueSlug(base: string, supabase: Awaited<ReturnType<typeof createClient>>) {
let candidate = base;
let suffix = 2;
// Loop until slug is unique
while (true) {
const { data, error } = await supabase
.from("hacks")
.select("slug")
.eq("slug", candidate)
.maybeSingle();
if (error && error.code !== "PGRST116") throw error;
if (!data) return candidate;
candidate = `${base}-${suffix++}`;
}
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* Whether any hack already uses this slug. Uses the service client because RLS
* hides other creators' drafts, which still own their slugs.
*/
async function isSlugTaken(slug: string) {
const service = await createServiceClient();
const { count, error } = await service.from("hacks").select("slug", { count: "exact", head: true }).eq("slug", slug);
if (error) throw error;
return (count ?? 0) > 0;
}
export async function prepareSubmission(formData: FormData) {
/** Live availability check for the start form's page URL. */
export async function checkSlugAvailable(slug: string) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { ok: false, error: "Unauthorized" } as const;
}
const title = (formData.get("title") as string)?.trim();
const summary = (formData.get("summary") as string)?.trim();
const description = (formData.get("description") as string)?.trim();
const base_rom = (formData.get("base_rom") as string)?.trim();
const language = (formData.get("language") as string)?.trim();
const completion_status = (formData.get("completion_status") as string)?.trim() || null;
const version = (formData.get("version") as string)?.trim();
const box_art = (formData.get("box_art") as string)?.trim() || null;
const discord = (formData.get("discord") as string)?.trim();
const twitter = (formData.get("twitter") as string)?.trim();
const pokecommunity = (formData.get("pokecommunity") as string)?.trim();
const github = (formData.get("github") as string)?.trim();
const tags = (formData.get("tags") as string)?.split(",").map((t) => t.trim()).filter(Boolean) || [];
const original_author = (formData.get("original_author") as string)?.trim() || null;
const permission_from = (formData.get("permission_from") as string)?.trim() || null;
const verification_contact_info = (formData.get("verification_contact_info") as string)?.trim() || null;
const is_archive = formData.get("is_archive") === "true";
// For archives, version is not required; for regular hacks, it is
if (!title || !summary || !description || !base_rom || !language || !completion_status || (!is_archive && !version)) {
return { ok: false, error: "Missing required fields" } as const;
}
// For archives, original_author is required
if (is_archive && !original_author) {
return { ok: false, error: "Original author is required for Archive hacks" } as const;
}
const baseSlug = slugify(title);
const slug = await ensureUniqueSlug(baseSlug, supabase);
const social_links: HackInsert["social_links"] =
discord || twitter || pokecommunity || github
? {
discord: discord || undefined,
twitter: twitter || undefined,
pokecommunity: pokecommunity || undefined,
github: github || undefined,
}
: null;
const insertPayload: HackInsert = {
slug,
title,
summary,
description,
base_rom,
language,
completion_status: completion_status as Database["public"]["Enums"]["Completion Status"],
version: version || "Archive",
created_by: user.id,
downloads: 0,
box_art,
social_links,
approved: is_archive, // Auto-approve archives
is_archive,
patch_url: "",
original_author: original_author || null,
permission_from: permission_from || null,
verification_contact_info: verification_contact_info || null,
current_patch: null, // Archives don't have patches
} as HackInsert;
const { error: insertErr } = await supabase.from("hacks").insert(insertPayload);
if (insertErr) {
return { ok: false, error: insertErr.message } as const;
}
if (!is_archive) {
try {
const { data: profile } = await supabase.from("profiles").select("username").eq("id", user.id).single();
const reviewThread = await ensureHackReviewThread({
slug,
title,
author: profile?.username ? `@${profile.username}` : user.id,
isClaimed: false,
});
if (!reviewThread && process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) {
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL, [{
title: `Review thread creation failed: ${title}`,
description: "The hack was saved, but its Discord review thread could not be created.",
color: 0xef4444,
url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${slug}`,
}]);
}
} catch (error) {
console.error(`[HackReview] Failed to create a review thread for ${slug}:`, error);
if (process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) {
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL, [{
title: `Review thread creation failed: ${title}`,
description: "The hack was saved, but its Discord review thread could not be created.",
color: 0xef4444,
url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${slug}`,
}]);
}
}
}
// Tags: restrict to existing only (order follows form submission)
if (tags.length > 0) {
const catalog = await getCachedTagsWithUsage();
const resolved = resolveTagIdsInOrder(tags, catalog);
if (resolved.length > 0) {
const hackTags = resolved.map((t, i) => ({ hack_slug: slug, tag_id: t.id, order: i + 1 }));
const { error: htErr } = await supabase.from("hack_tags").insert(hackTags);
if (htErr) return { ok: false, error: htErr.message } as const;
}
}
return { ok: true, slug } as const;
if (!user || !SLUG_PATTERN.test(slug) || slug.length > 64) return false;
return !(await isSlugTaken(slug));
}
export async function saveHackCovers(args: { slug: string; coverUrls: string[] }) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" } as const;
// Ensure hack exists and user has permission
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author, permission_from, is_archive")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
if (!hack) return { ok: false, error: "Hack not found" } as const;
const permission = await checkEditPermission(hack, user.id, supabase);
if (!permission.canEdit) {
return { ok: false, error: "Forbidden" } as const;
}
// Insert covers (overwrite positions)
if (args.coverUrls && args.coverUrls.length > 0) {
// Clear any existing rows first (idempotency on retry)
await supabase.from("hack_covers").delete().eq("hack_slug", args.slug);
const rows = args.coverUrls.map((url, idx) => ({ hack_slug: args.slug, url, position: idx + 1 }));
const { error: cErr } = await supabase.from("hack_covers").insert(rows);
if (cErr) return { ok: false, error: cErr.message } as const;
}
return { ok: true } as const;
}
export async function presignPatchAndSaveCovers(args: {
export async function confirmPatchUpload(args: {
slug: string;
version: string;
coverUrls: string[];
objectKey: string;
version: string;
firstUpload?: boolean;
publishAutomatically?: boolean;
/** The AI label stamp the uploader reviewed; required once the hack already has a version. */
aiReviewedAt?: string | null;
}) {
const supabase = await createClient();
const {
@@ -204,10 +63,9 @@ export async function presignPatchAndSaveCovers(args: {
} = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" } as const;
// Ensure hack exists and user has permission
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author, permission_from, is_archive")
.select("slug, created_by, title, current_patch, original_author, permission_from, is_archive, approved, assigned_admin, verification_contact_info, submitted_at, ai_disclosed_at")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
@@ -220,43 +78,24 @@ export async function presignPatchAndSaveCovers(args: {
if (!permission.canEdit) {
return { ok: false, error: "Forbidden" } as const;
}
// Only a key signed for this hack, so nobody can register another hack's file.
if (!isPatchKeyFor(args.slug, args.objectKey)) return { ok: false, error: "Invalid patch upload" } as const;
if (!(await objectExists(PATCHES_BUCKET, args.objectKey))) return { ok: false, error: "The patch didn't finish uploading. Please try again." } as const;
// Patch rows are server-only (RLS has no insert policy), so writes below use the service client.
const service = await createServiceClient();
const { count: keyUses } = await service.from("patches").select("id", { count: "exact", head: true }).eq("filename", args.objectKey);
if (keyUses) return { ok: false, error: "That upload was already used. Please upload again." } as const;
// Insert covers (overwrite positions)
if (args.coverUrls && args.coverUrls.length > 0) {
// Clear any existing rows first (idempotency on retry)
await supabase.from("hack_covers").delete().eq("hack_slug", args.slug);
const rows = args.coverUrls.map((url, idx) => ({ hack_slug: args.slug, url, position: idx + 1 }));
const { error: cErr } = await supabase.from("hack_covers").insert(rows);
if (cErr) return { ok: false, error: cErr.message } as const;
}
const client = getMinioClient();
// 10 minutes to upload
const url = await client.presignedPutObject(PATCHES_BUCKET, args.objectKey, 60 * 10);
return { ok: true, presignedUrl: url, objectKey: args.objectKey } as const;
}
export async function confirmPatchUpload(args: { slug: string; objectKey: string; version: string, firstUpload?: boolean; publishAutomatically?: boolean }) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" } as const;
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, title, current_patch, original_author, permission_from, is_archive, approved, assigned_admin, verification_contact_info")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
if (!hack) return { ok: false, error: "Hack not found" } as const;
const permission = await checkPatchEditPermission(hack, user.id, supabase);
if (permission.error) {
return { ok: false, error: permission.error } as const;
}
if (!permission.canEdit) {
return { ok: false, error: "Forbidden" } as const;
// New versions re-confirm the AI label. The client sends the stamp of the label it showed, so a
// direct call can't skip the step and a label changed meanwhile isn't confirmed unseen. Archives
// skip it: the label is optional there, since archivers may not know how the creator used AI.
const { count: priorPatches } = await service.from("patches").select("id", { count: "exact", head: true }).eq("parent_hack", args.slug);
const confirmsAi = !hack.is_archive && (priorPatches ?? 0) > 0;
if (confirmsAi) {
if (!hack.ai_disclosed_at) return { ok: false, error: "Add the AI label before uploading a new version." } as const;
if (!args.aiReviewedAt || Date.parse(args.aiReviewedAt) !== Date.parse(hack.ai_disclosed_at)) {
return { ok: false, error: "The AI label changed since you checked it. Review it again." } as const;
}
}
// Enforce unique version per hack defensively (avoid race with presign step)
@@ -269,7 +108,8 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
if (vErr) return { ok: false, error: vErr.message } as const;
if (existing) return { ok: false, error: "That version already exists for this hack." } as const;
let shouldPublishAutomatically = !!args.publishAutomatically;
// A hack's first patch always becomes current: there's nothing else to play, and a draft's stays private anyway.
let shouldPublishAutomatically = !!args.publishAutomatically || hack.current_patch === null;
let didUpdateCurrentPatch = false;
if (shouldPublishAutomatically) {
const { data: customPatcherRows, error: customPatcherErr } = await supabase
@@ -282,27 +122,23 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
}
// Create patch row
const patchInsert: any = {
const patchInsert: TablesInsert<"patches"> = {
bucket: PATCHES_BUCKET,
filename: args.objectKey,
version: args.version,
parent_hack: args.slug,
format: patchFormatFromObjectKey(args.objectKey),
published: shouldPublishAutomatically,
...(shouldPublishAutomatically ? { published_at: new Date().toISOString() } : {}),
};
// Set published status based on publishAutomatically flag
if (shouldPublishAutomatically) {
patchInsert.published = true;
patchInsert.published_at = new Date().toISOString();
} else {
patchInsert.published = false;
}
const { data: patch, error: pErr } = await supabase
const { data: patch, error: pErr } = await service
.from("patches")
.insert(patchInsert)
.select("id, created_at")
.single();
// The unique index catches a concurrent upload of the same version.
if (pErr?.code === "23505") return { ok: false, error: "That version already exists for this hack." } as const;
if (pErr) return { ok: false, error: pErr.message } as const;
// Only update current_patch if publishAutomatically is true
@@ -321,7 +157,7 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
}
if (shouldUpdateCurrentPatch) {
const { error: uErr } = await supabase
const { error: uErr } = await service
.from("hacks")
.update({ current_patch: patch.id })
.eq("slug", args.slug);
@@ -330,69 +166,243 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
}
}
if (confirmsAi) {
const { error: aiErr } = await service.from("hacks").update({ ai_disclosed_at: new Date().toISOString() }).eq("slug", args.slug);
if (aiErr) console.error(`[confirmPatchUpload] Couldn't re-stamp the AI label for ${args.slug}:`, aiErr);
}
if (hack.approved && didUpdateCurrentPatch) {
revalidateDiscoverCatalog();
}
// Right after the writes, so a failed notification below can't skip it. The hack page reads
// cached metadata (patch, version); without this it can show the old one for hours.
revalidateTag(`hack:${args.slug}:metadata`);
revalidatePath(`/hack/${args.slug}`);
revalidatePath(`/hack/${args.slug}/versions`);
const { data: profile } = await supabase
.from("profiles")
.select("username")
.eq("id", hack.created_by)
.single();
const displayName = profile?.username ? `@${profile.username}` : hack.created_by;
const uploadedByDifferentUser = hack.created_by !== user.id;
const embed: APIEmbed = args.firstUpload ? {
title: hack.title,
description: `A new hack by **${displayName}** is pending approval by an admin.`
+ (uploadedByDifferentUser ? ` (Uploaded by ${user.id})` : "")
+ (hack.verification_contact_info ? `\n\n**Verification contact info:**\n${hack.verification_contact_info}` : ""),
color: 0x40f56a,
url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${args.slug}`,
footer: { text: "This message brought to you by Hackdex" },
} : {
title: `New update for ${hack.title}`,
description: `**${hack.title}** has been updated to **${args.version}**`,
color: 0x40f56a,
url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${args.slug}`,
footer: {
text: hack.approved
? "This message brought to you by Hackdex"
: "This hack is still pending approval",
},
};
// Notifications are best effort: the upload is already saved, so a Discord error mustn't report failure.
try {
const { data: profile } = await supabase
.from("profiles")
.select("username")
.eq("id", hack.created_by)
.single();
const displayName = profile?.username ? `@${profile.username}` : hack.created_by;
const uploadedByDifferentUser = hack.created_by !== user.id;
const embed: APIEmbed = args.firstUpload ? {
title: hack.title,
description: `A new hack by **${displayName}** is pending approval by an admin.`
+ (uploadedByDifferentUser ? ` (Uploaded by ${user.id})` : "")
+ (hack.verification_contact_info ? `\n\n**Verification contact info:**\n${hack.verification_contact_info}` : ""),
color: 0x40f56a,
url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${args.slug}`,
footer: { text: "This message brought to you by Hackdex" },
} : {
title: `New update for ${hack.title}`,
description: `**${hack.title}** has been updated to **${args.version}**`,
color: 0x40f56a,
url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${args.slug}`,
footer: {
text: hack.approved
? "This message brought to you by Hackdex"
: "This hack is still pending approval",
},
};
if (hack.approved) {
if (process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL) {
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL, [embed]);
}
} else {
let reviewThread = null;
if (!hack.is_archive) {
try {
reviewThread = await getHackReviewThread(args.slug);
if (!reviewThread && args.firstUpload) {
reviewThread = await ensureHackReviewThread({
slug: args.slug,
title: hack.title,
author: displayName,
isClaimed: hack.assigned_admin !== null,
});
if (hack.approved) {
if (process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL) {
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL, [embed]);
}
} else if (hack.submitted_at === null) {
// Still a private draft: reviewers hear about it when the creator submits, not per upload.
} else {
let reviewThread = null;
if (!hack.is_archive) {
try {
reviewThread = await getHackReviewThread(args.slug);
if (!reviewThread && args.firstUpload) {
reviewThread = await ensureHackReviewThread({
slug: args.slug,
title: hack.title,
author: displayName,
isClaimed: hack.assigned_admin !== null,
});
}
} catch (error) {
console.error(`[HackReview] Failed to load or create the review thread for ${args.slug}:`, error);
}
} catch (error) {
console.error(`[HackReview] Failed to load or create the review thread for ${args.slug}:`, error);
}
if (reviewThread) {
await postHackReviewMessage(reviewThread, { embeds: [embed] });
} else if (process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) {
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL, [embed]);
}
}
if (reviewThread) {
await postHackReviewMessage(reviewThread, { embeds: [embed] });
} else if (process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) {
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL, [embed]);
}
} catch (error) {
console.error(`[confirmPatchUpload] Notification failed for ${args.slug}:`, error);
}
// Redirect to versions page if not publishing automatically, otherwise to hack page
const redirectTo = args.publishAutomatically ? `/hack/${args.slug}` : `/hack/${args.slug}/versions`;
// Back to the hack (a draft's checklist) when the patch went live, including a first upload whatever the box said;
// an unpublished one goes to Manage Versions, where it can be published.
const redirectTo = shouldPublishAutomatically ? `/hack/${args.slug}` : `/hack/${args.slug}/versions`;
return { ok: true, patchId: patch.id, redirectTo } as const;
}
/**
* Starts a private draft from the three things a page needs (title, base ROM,
* summary). The creator fills in the rest on the edit page and submits when
* the checklist is clear. Nothing is visible to reviewers until then.
*/
export async function createDraft(formData: FormData) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" } as const;
const title = (formData.get("title") as string | null)?.trim() ?? "";
const base_rom = (formData.get("base_rom") as string | null)?.trim() ?? "";
const summary = (formData.get("summary") as string | null)?.trim() ?? "";
const behalf = formData.get("who") === "behalf";
const original_author = behalf ? (formData.get("original_author") as string | null)?.trim() || null : null;
const permission_from = behalf ? (formData.get("permission_from") as string | null)?.trim() || null : null;
if (!title || !base_rom || summary.length < 10) return { ok: false, error: "Add a title, a base ROM, and a summary to continue." } as const;
if (title.length > TITLE_MAX) return { ok: false, error: `Keep the title to ${TITLE_MAX} characters.` } as const;
if (summary.length > SUMMARY_MAX) return { ok: false, error: `Keep the summary to ${SUMMARY_MAX} characters.` } as const;
if (!baseRoms.some((r) => r.id === base_rom)) return { ok: false, error: "Choose a base ROM from the list." } as const;
if (behalf && (!original_author || !permission_from)) return { ok: false, error: "Name the creator and where they gave permission." } as const;
if (behalf) {
// Listing someone else's hack is for archivers (admins included).
const { data: isArchiver } = await supabase.rpc("is_archiver");
if (!isArchiver) return { ok: false, error: "Only archivers can submit someone else's hack." } as const;
}
// The form sends the address it showed, so a taken one is an error rather than a silent "-2".
const slug = (formData.get("slug") as string | null)?.trim() || slugify(title);
if (!SLUG_PATTERN.test(slug) || slug.length > 64) return { ok: false, error: "Use lowercase letters, numbers, and dashes for the page URL." } as const;
if (await isSlugTaken(slug)) return { ok: false, error: "Another hack already uses this page URL." } as const;
const insertPayload: HackInsert = {
slug,
title,
summary,
description: "",
base_rom,
language: "English",
completion_status: null,
version: "",
created_by: user.id,
downloads: 0,
approved: false,
is_archive: false,
patch_url: "",
original_author,
permission_from,
current_patch: null,
submitted_at: null,
};
// The service role, since the database only lets the server set original_author and permission_from.
const service = await createServiceClient();
const { error } = await service.from("hacks").insert(insertPayload);
if (error) return { ok: false, error: error.message } as const;
// Someone may have visited this address before it existed; that "not found" is cached.
revalidateTag(`hack:${slug}:metadata`);
return { ok: true, slug } as const;
}
/** What still stands between a draft and the review queue. Empty means it can be submitted. */
export async function getDraftChecklist(slug: string) {
const supabase = await createClient();
const [{ data: hack }, { count: covers }, { count: tags }] = await Promise.all([
supabase.from("hacks").select("base_rom,summary,description,completion_status,language,original_author,permission_from,ai_disclosed_at,current_patch").eq("slug", slug).maybeSingle(),
supabase.from("hack_covers").select("id", { count: "exact", head: true }).eq("hack_slug", slug),
supabase.from("hack_tags").select("tag_id", { count: "exact", head: true }).eq("hack_slug", slug),
]);
if (!hack) return null;
const description = hack.description.trim();
const required = [
{ key: "base", label: "Base ROM chosen", done: !!hack.base_rom },
// Uploaded isn't enough: players need a current version to patch with.
{ key: "patch", label: "A playable patch uploaded", done: hack.current_patch !== null, href: "edit/patch" },
{ key: "summary", label: "Summary under 100 characters", done: hack.summary.trim().length > 0 && hack.summary.length <= 100 },
{ key: "description", label: "A description", done: description.length > 0 },
{ key: "completion", label: "Completion status set", done: !!hack.completion_status },
{ key: "shots", label: "At least one screenshot", done: (covers ?? 0) > 0 },
{ key: "tags", label: "At least one tag", done: (tags ?? 0) > 0 },
{ key: "ai", label: "AI label filled in", done: !!hack.ai_disclosed_at },
...(hack.original_author ? [{ key: "permission", label: "Where the creator gave permission", done: !!hack.permission_from }] : []),
];
const recommended = [
{ key: "tags3", label: "Add at least 3 tags so players can find it", done: (tags ?? 0) >= 3 },
{ key: "long", label: "Description is short (under 200 characters)", done: description.replace(/\s+/g, " ").length >= 200 },
{ key: "shots3", label: "Three or more screenshots", done: (covers ?? 0) >= 3 },
];
return { required, recommended };
}
/**
* Moves a draft into the review queue: stamps submitted_at, opens the Discord
* review thread, and tells the admins. Refuses while required items are open.
* Pass `contact` to save the verification contact in the same step.
*/
export async function submitForReview(slug: string, contact?: string) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" } as const;
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, title, created_by, approved, submitted_at, assigned_admin, verification_contact_info, is_archive, original_author, permission_from, current_patch")
.eq("slug", slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
if (!hack) return { ok: false, error: "Hack not found" } as const;
const permission = await checkEditPermission(hack, user.id, supabase);
if (!permission.canEdit) return { ok: false, error: "Forbidden" } as const;
if (hack.submitted_at !== null) return { ok: false, error: "This hack has already been submitted." } as const;
const checklist = await getDraftChecklist(slug);
const open = checklist?.required.filter((item) => !item.done) ?? [];
if (open.length > 0) return { ok: false, error: `Still needed: ${open.map((i) => i.label[0].toLowerCase() + i.label.slice(1)).join(", ")}.` } as const;
const submittedAt = new Date().toISOString();
const verification = contact === undefined ? hack.verification_contact_info : contact.trim() || null;
// submitted_at is server-only in the database, so the checks above can't be skipped.
const service = await createServiceClient();
const { error: uErr } = await service.from("hacks").update({ submitted_at: submittedAt, verification_contact_info: verification }).eq("slug", slug);
if (uErr) return { ok: false, error: uErr.message } as const;
// The page reads cached metadata; without this it keeps showing "Draft".
revalidateTag(`hack:${slug}:metadata`);
revalidatePath(`/hack/${slug}`);
const { data: profile } = await supabase.from("profiles").select("username").eq("id", hack.created_by).single();
const displayName = profile?.username ? `@${profile.username}` : hack.created_by;
const embed: APIEmbed = {
title: hack.title,
description: `A new hack by **${displayName}** is pending approval by an admin.`
+ (verification ? `\n\n**Verification contact info:**\n${verification}` : ""),
color: 0x40f56a,
url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${slug}`,
footer: { text: "This message brought to you by Hackdex" },
};
try {
const reviewThread = await ensureHackReviewThread({ slug, title: hack.title, author: displayName, isClaimed: hack.assigned_admin !== null });
if (reviewThread) await postHackReviewMessage(reviewThread, { embeds: [embed] });
else if (process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL, [embed]);
} catch (error) {
console.error(`[HackReview] Failed to announce ${slug} for review:`, error);
// Best effort: the hack is already in the queue, so a Discord outage mustn't fail the submission.
if (process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) {
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL, [embed]).catch((fallbackError) =>
console.error(`[HackReview] Webhook fallback for ${slug} also failed:`, fallbackError),
);
}
}
return { ok: true, submittedAt } as const;
}

View File

@@ -1,9 +1,7 @@
import SubmitPageClient from "@/components/Submit/SubmitPageClient";
import { getCachedTagsWithUsage } from "@/data/tags";
import StartDraftForm from "@/components/Submit/StartDraftForm";
import { createClient } from "@/utils/supabase/server";
import SubmitAuthOverlay from "@/components/Submit/SubmitAuthOverlay";
import { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
alternates: {
@@ -12,11 +10,10 @@ export const metadata: Metadata = {
};
export default async function SubmitPage() {
const catalogTags = await getCachedTagsWithUsage();
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
let needsInitialSetup = false;
let canCreateArchive = false;
let canSubmitForOthers = false;
if (user) {
const { data: profile } = await supabase
.from('profiles')
@@ -25,30 +22,16 @@ export default async function SubmitPage() {
.maybeSingle();
needsInitialSetup = !profile || profile.username == null;
// Check if user is archiver (or admin)
// Archivers (admins included) can list someone else's hack.
const { data: isArchiver } = await supabase.rpc("is_archiver");
canCreateArchive = !!isArchiver;
canSubmitForOthers = !!isArchiver;
}
const dummy = !user || needsInitialSetup;
return (
<div className="mx-auto max-w-[900px] px-6 py-10 w-full">
<h1 className="text-3xl font-bold tracking-tight">Submit your ROM hack</h1>
<p className="mt-2 text-[15px] text-foreground/80">Share your hack so others can discover and play it.</p>
<div className="mt-2 text-sm border border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/60 dark:text-amber-100 p-4 rounded-md">
<p className="font-bold mb-2">Important:</p>
<div className="flex flex-col gap-1">
<p className="text-amber-950/80 dark:text-amber-50/80">
Hackdex will only accept submissions for hacks that <span className="text-amber-950 dark:text-amber-50 font-bold">you own</span>.
If it is not your hack, try reaching out to the original creator to see if they are interested in submitting it themselves.
All hacks must also comply with the <Link href="/terms" prefetch={false} className="text-amber-950 dark:text-amber-50 font-bold hover:underline">Terms of Service</Link>.
</p>
<p className="text-amber-950/80 dark:text-amber-50/80">
If you attempt to circumvent this policy, <span className="text-amber-950 dark:text-amber-50 font-bold italic">your submission will be rejected</span> and your account may be banned.
</p>
</div>
</div>
<div className="mt-8">
<SubmitPageClient canCreateArchive={canCreateArchive} dummy={!user || needsInitialSetup} catalogTags={catalogTags} />
<div className="mx-auto w-full max-w-[1164px] px-6 pb-6 pt-8 md:pt-10">
<div className="flex flex-col items-center gap-5">
<StartDraftForm disabled={dummy} canSubmitForOthers={canSubmitForOthers} />
</div>
{!user ? (
<SubmitAuthOverlay
@@ -73,6 +56,3 @@ export default async function SubmitPage() {
</div>
);
}

View File

@@ -73,62 +73,62 @@ export default function AccountForm({ user, profile }: { user: User | null, prof
updateProfile({ fullname, website, avatar_url: url })
}}
/>
<div className="text-sm text-foreground/70">
<div className="text-xl text-center sm:text-left font-semibold text-foreground">{fullname || <span className="italic text-foreground/80">No name</span>}</div>
<div className="text-sm text-center sm:text-left text-foreground/70">{username ? `@${username}` : <span className="italic text-foreground/60">No username</span>}</div>
<div className="text-sm text-text-2">
<div className="text-xl text-center sm:text-left font-semibold text-text">{fullname || <span className="italic text-text-2">No name</span>}</div>
<div className="text-sm text-center sm:text-left text-text-2">{username ? `@${username}` : <span className="italic text-text-3">No username</span>}</div>
<div className="mt-3">Update your profile details and avatar.</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2 sm:col-span-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<input id="email" type="text" value={user?.email || ''} disabled className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm text-foreground/70 ring-1 ring-inset ring-[var(--border)]" />
<label htmlFor="email" className="text-sm text-text-2">Email</label>
<input id="email" type="text" value={user?.email || ''} disabled className="h-11 rounded-control bg-surface-2 px-3 text-sm text-text-2 ring-1 ring-inset ring-line" />
</div>
<div className="grid gap-2">
<label htmlFor="fullName" className="text-sm text-foreground/80">Name</label>
<label htmlFor="fullName" className="text-sm text-text-2">Name</label>
<input
id="fullName"
type="text"
value={fullname || ''}
onChange={(e) => setFullname(e.target.value)}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
className="h-11 rounded-control bg-surface-2 px-3 text-sm ring-1 ring-inset ring-line focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
</div>
<div className="grid gap-2">
<label htmlFor="username" className="text-sm text-foreground/80">Username</label>
<label htmlFor="username" className="text-sm text-text-2">Username</label>
<input
id="username"
type="text"
value={username || ''}
disabled
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm text-foreground/70 ring-1 ring-inset ring-[var(--border)]"
className="h-11 rounded-control bg-surface-2 px-3 text-sm text-text-2 ring-1 ring-inset ring-line"
/>
</div>
<div className="grid gap-2 sm:col-span-2">
<label htmlFor="website" className="text-sm text-foreground/80">Website <span className="text-foreground/50">(optional)</span></label>
<label htmlFor="website" className="text-sm text-text-2">Website <span className="text-text-3">(optional)</span></label>
<input
id="website"
type="url"
value={website || ''}
onChange={(e) => setWebsite(e.target.value)}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
className="h-11 rounded-control bg-surface-2 px-3 text-sm ring-1 ring-inset ring-line focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
</div>
<div className="sm:col-span-2 flex flex-col justify-center items-center gap-4 mt-4 sm:flex-row sm:justify-end">
<button
className="shine-wrap btn-premium h-14 min-w-48 sm:h-11 sm:min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
className="inline-flex items-center justify-center rounded-control bg-accent-deep px-5 text-white transition-colors hover:enabled:bg-accent-hover disabled:opacity-60 h-14 min-w-48 sm:h-11 sm:min-w-[7.5rem] text-sm font-semibold disabled:cursor-not-allowed"
onClick={() => updateProfile({ fullname, website, avatar_url })}
disabled={loading || !hasChanges}
>
<span>{loading ? 'Saving...' : 'Update profile'}</span>
</button>
<form action="/auth/signout" method="post">
<button className="inline-flex h-14 min-w-48 sm:h-11 sm:min-w-[7.5rem] items-center justify-center rounded-md border border-red-600/40 bg-red-600/5 dark:border-red-400/40 dark:bg-red-400/5 px-4 text-sm font-medium text-red-600/90 dark:text-red-400/80 transition-colors hover:bg-red-600/5 dark:hover:bg-red-400/10" type="submit">
<button className="inline-flex h-14 min-w-48 sm:h-11 sm:min-w-[7.5rem] items-center justify-center rounded-control border border-error/40 bg-error-soft px-4 text-sm font-medium text-error transition-colors hover:bg-error-soft" type="submit">
Sign out
</button>
</form>

View File

@@ -10,19 +10,19 @@ export default function AccountOptionsMenu() {
<MenuButton
aria-label="More options"
title="Options"
className="group inline-flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
className="group inline-flex h-8 w-8 items-center justify-center rounded-control ring-1 ring-line bg-surface-2 text-text-2 hover:bg-surface-3 hover:text-text transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-line"
>
<FiMoreVertical size={18} />
</MenuButton>
<MenuItems
<MenuItems modal={false}
transition
className="absolute right-0 z-10 mt-2 w-56 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
className="absolute right-0 z-10 mt-2 w-56 origin-top-right overflow-hidden rounded-control border border-line bg-surface shadow-overlay focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem
as="a"
href="/account/update-password?from=account"
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-surface-2"
>
Update password
</MenuItem>

View File

@@ -132,31 +132,31 @@ export default function AccountSetupForm({ user }: { user: User }) {
}
const stateColor =
usernameState.status === 'available' ? 'text-emerald-600 dark:text-emerald-400' :
usernameState.status === 'unavailable' || usernameState.status === 'invalid' ? 'text-red-600 dark:text-red-400' :
'text-foreground/70'
usernameState.status === 'available' ? 'text-ready dark:text-ready' :
usernameState.status === 'unavailable' || usernameState.status === 'invalid' ? 'text-error dark:text-error' :
'text-text-2'
const bgColor = isError
? 'bg-rose-500/5 dark:bg-rose-500/5'
? 'bg-error-soft dark:bg-error-soft'
: isAvailable
? 'bg-emerald-600/5 dark:bg-emerald-500/5'
: 'bg-[var(--surface-2)]'
? 'bg-ready-soft dark:bg-ready-soft'
: 'bg-surface-2'
const ringColor = isError
? 'ring-rose-500/60'
? 'ring-error/40'
: isAvailable
? 'ring-emerald-600/70 dark:ring-emerald-500/60'
: 'ring-[var(--border)]'
? 'ring-ready/40 dark:ring-ready/40'
: 'ring-line'
const focusRing = isError
? 'focus:ring-rose-500/70'
? 'focus:ring-error/40'
: isAvailable
? 'focus:ring-emerald-600/80 dark:focus:ring-emerald-500/70'
: 'focus:ring-[var(--ring)]'
? 'focus:ring-ready/40 dark:focus:ring-ready/40'
: 'focus:ring-accent/40'
return (
<div className="grid gap-6">
<div className="grid gap-2">
<label htmlFor="username" className="text-sm text-foreground/80">Choose a username<span className="text-red-500">*</span></label>
<label htmlFor="username" className="text-sm text-text-2">Choose a username<span className="text-error">*</span></label>
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-foreground/60">@</span>
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-text-3">@</span>
<input
id="username"
type="text"
@@ -168,22 +168,22 @@ export default function AccountSetupForm({ user }: { user: User }) {
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="yourname"
className={`h-11 w-full rounded-md ${bgColor} pl-8 pr-10 text-sm ring-1 ring-inset ${ringColor} focus:outline-none focus:ring-2 ${focusRing}`}
className={`h-11 w-full rounded-control ${bgColor} pl-8 pr-10 text-sm ring-1 ring-inset ${ringColor} focus:outline-none focus:ring-2 ${focusRing}`}
aria-describedby="username-help username-status"
aria-invalid={usernameState.status === 'invalid' || usernameState.status === 'unavailable'}
/>
{isAvailable && (
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-emerald-600 dark:text-emerald-400" aria-hidden="true">
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-ready" aria-hidden="true">
<FiCheck />
</span>
)}
{isUnavailable && (
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-rose-600 dark:text-rose-400" aria-hidden="true">
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-error" aria-hidden="true">
<FiX />
</span>
)}
</div>
<div id="username-help" className="text-[13px] text-foreground/60">
<div id="username-help" className="text-[13px] text-text-3">
3&ndash;20 chars. Letters, numbers, underscores.
</div>
{username.length > 0 && (
@@ -191,23 +191,23 @@ export default function AccountSetupForm({ user }: { user: User }) {
{usernameState.message}
</div>
)}
<div className={`mt-1 card px-3 py-4 text-sm text-foreground/60 min-h-17 flex items-center overflow-hidden ${isAvailable ? '' : 'opacity-50 cursor-not-allowed'}`} aria-live="polite">
<div className={`mt-1 card px-3 py-4 text-sm text-text-3 min-h-17 flex items-center overflow-hidden ${isAvailable ? '' : 'opacity-50 cursor-not-allowed'}`} aria-live="polite">
<div className="flex w-full min-w-0 flex-col gap-2">
<p className="px-2">After you have uploaded your first hack to Hackdex, others can find your profile at:</p>
<div className="flex w-full min-w-0 items-center h-10">
<div className="flex-1 min-w-0 h-full flex items-center overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-color-muted pl-2 text-foreground/70 border-l border-y border-[var(--border)] rounded-l-md">
<span className={`inline-block whitespace-nowrap text-foreground/80 ${isAvailable ? 'select-all' : 'select-none opacity-70'}`}>{profileUrl}</span>
<div className="flex-1 min-w-0 h-full flex items-center overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-color-muted pl-2 text-text-2 border-l border-y border-line rounded-l-md">
<span className={`inline-block whitespace-nowrap text-text-2 ${isAvailable ? 'select-all' : 'select-none opacity-70'}`}>{profileUrl}</span>
</div>
<button
type="button"
onClick={isAvailable ? handleCopy : undefined}
disabled={!isAvailable}
className={`relative inline-flex items-center justify-center rounded-r-md px-2 h-full border border-[var(--border)] ${isAvailable ? 'text-foreground/70 hover:border-foreground/50 hover:bg-foreground/10' : 'opacity-50 text-foreground/50'}`}
className={`relative inline-flex items-center justify-center rounded-r-md px-2 h-full border border-line ${isAvailable ? 'text-text-2 hover:border-foreground/50 hover:bg-foreground/10' : 'opacity-50 text-text-3'}`}
aria-label={isAvailable ? (copied ? 'Copied' : 'Copy profile URL') : 'Copy disabled until username is available'}
aria-disabled={!isAvailable}
>
{isAvailable && copied ? <FiCheck /> : <FiCopy />}
<span className={`pointer-events-none absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-[var(--surface-2)] px-2 py-0.5 text-[11px] text-foreground ring-1 ring-inset ring-[var(--border)] shadow-sm transition-all duration-150 ${copied ? 'opacity-100 -translate-y-1' : 'opacity-0 translate-y-0'}`}>
<span className={`pointer-events-none absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-surface-2 px-2 py-0.5 text-[11px] text-text ring-1 ring-inset ring-line shadow-sm transition-all duration-150 ${copied ? 'opacity-100 -translate-y-1' : 'opacity-0 translate-y-0'}`}>
Copied
</span>
</button>
@@ -220,13 +220,13 @@ export default function AccountSetupForm({ user }: { user: User }) {
<button
type="button"
onClick={handleSubmit}
className="shine-wrap btn-premium h-14 w-full sm:h-11 sm:w-auto min-w-56 text-sm font-semibold disabled:cursor-not-allowed dark:disabled:opacity-70 disabled:[box-shadow:0_0_0_1px_var(--border)]"
className="inline-flex items-center justify-center rounded-control bg-accent-deep px-5 text-white transition-colors hover:enabled:bg-accent-hover disabled:opacity-60 h-14 w-full sm:h-11 sm:w-auto min-w-56 text-sm font-semibold disabled:cursor-not-allowed"
disabled={!canSubmit}
>
<span>{submitting ? 'Creating…' : 'Create my account'}</span>
</button>
<form action="/auth/signout" method="post">
<button className="text-[13px] text-foreground/60 hover:text-foreground/80 underline-offset-4 hover:underline px-0 h-auto" type="submit">
<button className="text-[13px] text-text-3 hover:text-text-2 underline-offset-4 hover:underline px-0 h-auto" type="submit">
Sign out
</button>
</form>

View File

@@ -33,12 +33,12 @@ export default function UpdatePasswordForm() {
return (
<form className="grid gap-5 group">
{state?.ok === false && state.error && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
<div className="rounded-control bg-error-soft ring-1 ring-error/40 px-3 py-2 text-sm text-error">
{state.error}
</div>
)}
<div className="grid gap-2">
<label htmlFor="newPassword" className="text-sm text-foreground/80">New password</label>
<label htmlFor="newPassword" className="text-sm text-text-2">New password</label>
<div className="relative">
<input
id="newPassword"
@@ -47,17 +47,17 @@ export default function UpdatePasswordForm() {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Minimum 6 characters"
className={`h-11 w-full rounded-md px-3 pr-10 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
className={`h-11 w-full rounded-control px-3 pr-10 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-accent/40 ${
password && passwordError ?
"ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
"ring-error/40 bg-error-soft dark:ring-error/40 dark:bg-error-soft" :
"bg-surface-2 ring-line"
}`}
required
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/10"
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded hover:bg-surface-2"
aria-label={showPassword ? "Hide password" : "Show password"}
title={showPassword ? "Hide password" : "Show password"}
>
@@ -65,12 +65,12 @@ export default function UpdatePasswordForm() {
</button>
</div>
{password && passwordError && (
<span className="text-xs text-red-500/70">{passwordError}</span>
<span className="text-xs text-error">{passwordError}</span>
)}
</div>
<div className="grid gap-2">
<label htmlFor="confirmPassword" className="text-sm text-foreground/80">Confirm new password</label>
<label htmlFor="confirmPassword" className="text-sm text-text-2">Confirm new password</label>
<div className="relative">
<input
id="confirmPassword"
@@ -79,17 +79,17 @@ export default function UpdatePasswordForm() {
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="Re-enter your new password"
className={`h-11 w-full rounded-md px-3 pr-10 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
className={`h-11 w-full rounded-control px-3 pr-10 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-accent/40 ${
confirm && !passwordsMatch ?
"ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
"ring-error/40 bg-error-soft dark:ring-error/40 dark:bg-error-soft" :
"bg-surface-2 ring-line"
}`}
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword((v) => !v)}
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/10"
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded hover:bg-surface-2"
aria-label={showConfirmPassword ? "Hide password" : "Show password"}
title={showConfirmPassword ? "Hide password" : "Show password"}
>
@@ -97,13 +97,13 @@ export default function UpdatePasswordForm() {
</button>
</div>
{confirm && !passwordsMatch && (
<span className="text-xs text-red-500/70">Passwords do not match.</span>
<span className="text-xs text-error">Passwords do not match.</span>
)}
</div>
<div className="flex flex-col items-center gap-3">
{!isValid && (password || confirm) ? (
<span className="text-xs text-red-500/70 italic h-3 group-has-focus:invisible">Passwords must match and be at least 6 characters.</span>
<span className="text-xs text-error italic h-3 group-has-focus:invisible">Passwords must match and be at least 6 characters.</span>
) : (
<div className="h-3" />
)}
@@ -111,7 +111,7 @@ export default function UpdatePasswordForm() {
type="submit"
formAction={formAction}
disabled={!isValid || isPending}
className="shine-wrap btn-premium h-11 min-w-[9rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
className="inline-flex items-center justify-center rounded-control bg-accent-deep px-5 text-white transition-colors hover:enabled:bg-accent-hover disabled:opacity-60 h-11 min-w-[9rem] text-sm font-semibold disabled:cursor-not-allowed"
>
<span>Update password</span>
</button>

View File

@@ -2,24 +2,27 @@
import type { CSSProperties } from "react";
import { Toaster } from "sonner";
import { useDarkMode } from "@/hooks/useDarkMode";
export default function AppToaster() {
// Follow the theme toggle; "system" would follow the OS even after the visitor picks the other theme.
const dark = useDarkMode();
return (
<Toaster
position="top-center"
offset="72px"
theme="system"
theme={dark ? "dark" : "light"}
toastOptions={{
classNames: {
toast:
"rounded-xl border border-black/10 bg-white text-zinc-950 shadow-2xl shadow-black/15 dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-50 dark:shadow-black/30",
"rounded-card border border-black/10 bg-white text-zinc-950 shadow-2xl shadow-black/15 dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-50 dark:shadow-black/30",
icon: "text-zinc-950 dark:text-zinc-50",
title: "text-sm font-medium text-zinc-950 dark:text-zinc-50",
description: "text-sm text-zinc-600 dark:text-zinc-300",
actionButton:
"rounded-md bg-[var(--accent)] px-3 py-1.5 text-sm font-medium text-[var(--accent-foreground)]",
"rounded-control bg-accent-deep px-3 py-1.5 text-sm font-medium text-white",
cancelButton:
"rounded-md border border-black/10 bg-black/5 px-3 py-1.5 text-sm text-zinc-950 dark:border-white/15 dark:bg-white/10 dark:text-zinc-50",
"rounded-control border border-black/10 bg-surface-2 px-3 py-1.5 text-sm text-zinc-950 dark:border-white/15 dark:text-zinc-50",
},
}}
style={

View File

@@ -11,17 +11,17 @@ export default function ForgotPasswordForm() {
return (
<form className="grid gap-5 group">
{state?.ok && !state.error && (
<div className="rounded-md bg-green-500/10 ring-1 ring-green-600/40 px-3 py-2 text-sm text-green-300">
<div className="rounded-control bg-ready-soft px-3 py-2 text-sm text-ready">
If that email exists, you'll receive a reset link shortly.
</div>
)}
{state?.ok === false && state.error && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
<div className="rounded-control bg-error-soft px-3 py-2 text-sm text-error">
{state.error}
</div>
)}
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<label htmlFor="email" className="text-sm font-medium">Email</label>
<input
id="email"
name="resetEmail"
@@ -29,10 +29,10 @@ export default function ForgotPasswordForm() {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
className={`h-11 w-full rounded-control border bg-surface-2 px-3 text-sm text-text outline-none transition-[border-color,box-shadow] placeholder:text-text-3 focus:border-line-strong focus:ring-2 focus:ring-accent/40 ${
email && !emailValid ?
"not-focus:ring-red-600/40 not-focus:bg-red-500/10 dark:not-focus:ring-red-400/40 dark:not-focus:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
"not-focus:border-error" :
"border-line"
}`}
required
/>
@@ -43,7 +43,7 @@ export default function ForgotPasswordForm() {
type="submit"
formAction={formAction}
disabled={!emailValid || isPending}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
className="inline-flex h-11 w-full items-center justify-center rounded-control bg-accent-deep px-5 text-sm font-semibold text-white transition-colors hover:enabled:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60"
>
<span>Send reset link</span>
</button>

View File

@@ -4,6 +4,7 @@ import React, { useActionState, useEffect} from "react";
import Link from "next/link";
import { FiEye, FiEyeOff } from "react-icons/fi";
import { Turnstile } from "next-turnstile";
import { useDarkMode } from "@/hooks/useDarkMode";
import { AuthActionState, login } from "@/app/login/actions";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuthContext } from "@/contexts/AuthContext";
@@ -17,6 +18,7 @@ export default function LoginForm() {
const [turnstileToken, setTurnstileToken] = React.useState<string | undefined>(undefined);
const [turnstileError, setTurnstileError] = React.useState<string | null>(null);
const [turnstileKey, setTurnstileKey] = React.useState(0);
const dark = useDarkMode();
const searchParams = useSearchParams();
const urlError = searchParams.get("error");
const [state, formAction] = useActionState<AuthActionState, FormData>(login, null);
@@ -65,17 +67,17 @@ export default function LoginForm() {
<input type="hidden" name="redirectTo" value={redirectTo} />
)}
{(errorMessage) && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
<div className="rounded-control bg-error-soft px-3 py-2 text-sm text-error">
{errorMessage}
</div>
)}
{turnstileError && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
<div className="rounded-control bg-error-soft px-3 py-2 text-sm text-error">
{turnstileError}
</div>
)}
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<label htmlFor="email" className="text-sm font-medium">Email</label>
<input
id="email"
name="email"
@@ -83,17 +85,17 @@ export default function LoginForm() {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
className={`h-11 w-full rounded-control border bg-surface-2 px-3 text-sm text-text outline-none transition-[border-color,box-shadow] placeholder:text-text-3 focus:border-line-strong focus:ring-2 focus:ring-accent/40 ${
email && !emailValid ?
"not-focus:ring-red-600/40 not-focus:bg-red-500/10 dark:not-focus:ring-red-400/40 dark:not-focus:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
"not-focus:border-error" :
"border-line"
}`}
required
/>
</div>
<div className="grid gap-2">
<label htmlFor="password" className="text-sm text-foreground/80">Password</label>
<label htmlFor="password" className="text-sm font-medium">Password</label>
<div className="relative">
<input
id="password"
@@ -102,17 +104,17 @@ export default function LoginForm() {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Minimum 6 characters"
className={`h-11 w-full rounded-md px-3 pr-10 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
className={`h-11 w-full rounded-control border bg-surface-2 px-3 pr-10 text-sm text-text outline-none transition-[border-color,box-shadow] placeholder:text-text-3 focus:border-line-strong focus:ring-2 focus:ring-accent/40 ${
password && !passwordValid ?
"not-focus:ring-red-600/40 not-focus:bg-red-500/10 dark:not-focus:ring-red-400/40 dark:not-focus:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
"not-focus:border-error" :
"border-line"
}`}
required
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/10"
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded-control text-text-3 hover:bg-surface hover:text-text"
aria-label={showPassword ? "Hide password" : "Show password"}
title={showPassword ? "Hide password" : "Show password"}
>
@@ -124,7 +126,7 @@ export default function LoginForm() {
<div className="flex justify-end">
<Link
href={redirectTo ? `/login/forgot?redirectTo=${encodeURIComponent(redirectTo)}` : "/login/forgot"}
className="text-xs text-foreground/70 hover:underline"
className="text-xs text-link-hd"
>
Forgot your password?
</Link>
@@ -132,12 +134,13 @@ export default function LoginForm() {
<div className="flex flex-col items-center gap-3">
{!isValid && (email || password) ? (
<span className="text-xs text-red-500/70 italic h-3 group-has-focus:invisible">Please enter a valid email and password.</span>
<span className="text-xs text-error h-3 group-has-focus:invisible">Please enter a valid email and password.</span>
) : (
<div className="h-3" />
)}
<Turnstile
key={turnstileKey}
// The widget reads its theme once, so a theme change remounts it.
key={`${turnstileKey}-${dark}`}
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onVerify={(token) => {
setTurnstileToken(token);
@@ -151,13 +154,13 @@ export default function LoginForm() {
onExpire={() => {
setTurnstileToken(undefined);
}}
theme="auto"
theme={dark ? "dark" : "light"}
/>
<button
type="submit"
formAction={formAction}
disabled={!isValid || !turnstileToken}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
className="inline-flex h-11 w-full items-center justify-center rounded-control bg-accent-deep px-5 text-sm font-semibold text-white transition-colors hover:enabled:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60"
>
<span>Log in</span>
</button>

View File

@@ -4,6 +4,7 @@ import React, { useActionState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { FiEye, FiEyeOff } from "react-icons/fi";
import { Turnstile } from "next-turnstile";
import { useDarkMode } from "@/hooks/useDarkMode";
import { AuthActionState, signup } from "@/app/signup/actions";
import { useAuthContext } from "@/contexts/AuthContext";
import { validateEmail, validatePassword } from "@/utils/auth";
@@ -22,6 +23,7 @@ export default function SignupForm() {
const [turnstileToken, setTurnstileToken] = React.useState<string | undefined>(undefined);
const [turnstileError, setTurnstileError] = React.useState<string | null>(null);
const [turnstileKey, setTurnstileKey] = React.useState(0);
const dark = useDarkMode();
const [state, formAction, isPending] = useActionState<AuthActionState, FormData>(signup, { error: null });
const passwordsMatch = password === confirm;
@@ -63,17 +65,17 @@ export default function SignupForm() {
<input type="hidden" name="redirectTo" value={redirectTo} />
)}
{(state?.error && !isPending) && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
<div className="rounded-control bg-error-soft px-3 py-2 text-sm text-error">
{state?.error}
</div>
)}
{turnstileError && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
<div className="rounded-control bg-error-soft px-3 py-2 text-sm text-error">
{turnstileError}
</div>
)}
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<label htmlFor="email" className="text-sm font-medium">Email</label>
<input
id="email"
name="email"
@@ -81,20 +83,20 @@ export default function SignupForm() {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
className={`h-11 w-full rounded-control border bg-surface-2 px-3 text-sm text-text outline-none transition-[border-color,box-shadow] placeholder:text-text-3 focus:border-line-strong focus:ring-2 focus:ring-accent/40 ${
email && emailError ?
"ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
"border-error" :
"border-line"
}`}
required
/>
{email && emailError && (
<span className="text-xs text-red-500/70">{emailError}</span>
<span className="text-xs text-error">{emailError}</span>
)}
</div>
<div className="grid gap-2">
<label htmlFor="password" className="text-sm text-foreground/80">Password</label>
<label htmlFor="password" className="text-sm font-medium">Password</label>
<div className="relative">
<input
id="password"
@@ -103,17 +105,17 @@ export default function SignupForm() {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Minimum 6 characters"
className={`h-11 w-full rounded-md px-3 pr-10 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
className={`h-11 w-full rounded-control border bg-surface-2 px-3 pr-10 text-sm text-text outline-none transition-[border-color,box-shadow] placeholder:text-text-3 focus:border-line-strong focus:ring-2 focus:ring-accent/40 ${
password && passwordError ?
"ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
"border-error" :
"border-line"
}`}
required
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/10"
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded-control text-text-3 hover:bg-surface hover:text-text"
aria-label={showPassword ? "Hide password" : "Show password"}
title={showPassword ? "Hide password" : "Show password"}
>
@@ -121,12 +123,12 @@ export default function SignupForm() {
</button>
</div>
{password && passwordError && (
<span className="text-xs text-red-500/70">{passwordError}</span>
<span className="text-xs text-error">{passwordError}</span>
)}
</div>
<div className="grid gap-2">
<label htmlFor="confirm" className="text-sm text-foreground/80">Confirm password</label>
<label htmlFor="confirm" className="text-sm font-medium">Confirm password</label>
<div className="relative">
<input
id="confirm"
@@ -135,17 +137,17 @@ export default function SignupForm() {
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="Re-enter password"
className={`h-11 w-full rounded-md px-3 pr-10 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
className={`h-11 w-full rounded-control border bg-surface-2 px-3 pr-10 text-sm text-text outline-none transition-[border-color,box-shadow] placeholder:text-text-3 focus:border-line-strong focus:ring-2 focus:ring-accent/40 ${
confirm && !passwordsMatch ?
"ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
"border-error" :
"border-line"
}`}
required
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/10"
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded-control text-text-3 hover:bg-surface hover:text-text"
aria-label={showPassword ? "Hide password" : "Show password"}
title={showPassword ? "Hide password" : "Show password"}
>
@@ -153,25 +155,25 @@ export default function SignupForm() {
</button>
</div>
{confirm && !passwordsMatch && (
<span className="text-xs text-red-500/70">Passwords do not match.</span>
<span className="text-xs text-error">Passwords do not match.</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-foreground/80">
<div className="flex items-center gap-2 text-xs text-text-2">
<input
id="accept-terms"
name="acceptTerms"
type="checkbox"
checked={acceptedTerms}
onChange={(e) => setAcceptedTerms(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-[var(--border)] bg-[var(--surface-2)] text-[var(--ring)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
className="h-4 w-4 accent-[var(--rose-deep)]"
required
/>
<label htmlFor="accept-terms" className="space-x-1">
<span>I agree to the</span>
<a
href="/terms"
className="font-medium text-sky-500 hover:text-sky-400 underline underline-offset-2"
className="font-medium text-link-hd"
target="_blank"
rel="noopener noreferrer"
>
@@ -182,7 +184,8 @@ export default function SignupForm() {
<div className="flex flex-col items-center gap-3 mt-2">
<Turnstile
key={turnstileKey}
// The widget reads its theme once, so a theme change remounts it.
key={`${turnstileKey}-${dark}`}
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onVerify={(token) => {
setTurnstileToken(token);
@@ -196,13 +199,13 @@ export default function SignupForm() {
onExpire={() => {
setTurnstileToken(undefined);
}}
theme="auto"
theme={dark ? "dark" : "light"}
/>
<button
type="submit"
formAction={formAction}
disabled={!isValid || isPending || !turnstileToken}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold hover:cursor-pointer dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
className="inline-flex h-11 w-full items-center justify-center rounded-control bg-accent-deep px-5 text-sm font-semibold text-white transition-colors hover:enabled:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60"
>
<span>Sign up</span>
</button>

View File

@@ -25,15 +25,16 @@ export default function BaseRomCard({
onEnsurePermission?: () => void;
onImportCache?: () => void;
}) {
// Green means "on this device"; amber is a permission prompt; error is the separate orange-red.
const ringAndBg = isCached
? "ring-emerald-400/40 bg-emerald-500/10"
? "border-ready/40 bg-ready-soft/60"
: isLinked
? status === "granted"
? "ring-emerald-400/40 bg-emerald-500/10"
? "border-ready/40 bg-ready-soft/60"
: status === "prompt"
? "ring-amber-400/40 bg-amber-500/10"
: "ring-rose-400/40 bg-rose-500/10"
: "card ring-[var(--border)]";
? "border-warn/40 bg-warn-soft/60"
: "border-error/40 bg-error-soft/60"
: "border-line bg-surface";
const statusText = isCached
? "Cached copy available"
@@ -48,25 +49,25 @@ export default function BaseRomCard({
: "Not linked";
const dotColor = isCached
? "bg-emerald-400"
? "bg-ready"
: isLinked
? status === "granted"
? "bg-emerald-400"
? "bg-ready"
: status === "prompt"
? "bg-amber-400"
: "bg-rose-400"
: "bg-white/30";
? "bg-warn"
: "bg-error"
: "bg-line-strong";
return (
<div className={`rounded-[12px] text-foreground p-4 ring-1 flex flex-col ${ringAndBg}`}>
<div className={`flex flex-col rounded-card border p-4 text-text shadow-rest ${ringAndBg}`}>
<div className="flex flex-1 items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-[10px]">
<span className="rounded-full bg-[var(--surface-2)] px-2 py-0.5 ring-1 ring-[var(--border)]">{platform}</span>
<span className="rounded-full bg-[var(--surface-2)] px-2 py-0.5 ring-1 ring-[var(--border)]">{region}</span>
<div className="flex items-center gap-1.5 text-[11px] text-text-2">
<span className="plat-dot rounded-full bg-surface-2 px-2 py-0.5" data-platform={platform}>{platform}</span>
<span className="rounded-full bg-surface-2 px-2 py-0.5">{region}</span>
</div>
<div className="mt-2 text-[15px] font-semibold tracking-tight">{name}</div>
<div className="mt-1 text-xs text-foreground/60">{statusText}</div>
<div className="mt-1 text-xs text-text-3">{statusText}</div>
</div>
<span className={`h-2 w-2 rounded-full ${dotColor}`} title={isLinked ? status : "Not linked"} />
</div>
@@ -76,14 +77,14 @@ export default function BaseRomCard({
{isCached ? (
<button
onClick={onRemoveCache}
className="inline-flex h-9 items-center justify-center rounded-md border border-red-400/30 bg-red-400/20 px-3 text-sm font-medium text-foreground transition-colors hover:bg-red-400/30"
className="inline-flex h-9 items-center justify-center rounded-control border border-error/40 bg-surface px-3 text-sm font-medium text-error transition-colors hover:bg-error-soft"
>
Remove cache
</button>
) : isLinked ? (
<button
onClick={onUnlink}
className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-sm font-medium text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10"
className="inline-flex h-9 items-center justify-center rounded-control border border-line-strong bg-surface px-3 text-sm font-medium text-text transition-colors hover:border-text-3"
>
Unlink
</button>
@@ -92,7 +93,7 @@ export default function BaseRomCard({
{!isCached && isLinked && status !== "granted" && (
<button
onClick={onEnsurePermission}
className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-sm font-medium text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10"
className="inline-flex h-9 items-center justify-center rounded-control border border-line-strong bg-surface px-3 text-sm font-medium text-text transition-colors hover:border-text-3"
>
Re-authorize
</button>
@@ -101,7 +102,7 @@ export default function BaseRomCard({
{!isCached && isLinked && status === "granted" && (
<button
onClick={onImportCache}
className="inline-flex h-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-sm font-medium text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10"
className="inline-flex h-9 items-center justify-center rounded-control border border-line-strong bg-surface px-3 text-sm font-medium text-text transition-colors hover:border-text-3"
>
Cache copy
</button>

View File

@@ -9,20 +9,18 @@ type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
};
const base =
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:opacity-60 disabled:cursor-not-allowed";
"inline-flex items-center justify-center rounded-control font-semibold transition-[background-color,transform] duration-[120ms] active:scale-[.98] disabled:opacity-60 disabled:cursor-not-allowed disabled:active:scale-100";
const variants: Record<string, string> = {
primary:
"bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)]",
secondary:
"bg-white/10 text-foreground border border-white/10 hover:bg-white/15",
ghost: "bg-transparent text-foreground hover:bg-white/5",
primary: "bg-accent-deep text-white hover:bg-accent-hover",
secondary: "bg-surface-2 text-text hover:bg-line",
ghost: "bg-transparent text-text hover:bg-surface-2",
};
const sizes: Record<string, string> = {
sm: "h-9 px-3 text-sm",
md: "h-11 px-4 text-sm",
lg: "h-12 px-5 text-base",
lg: "h-12 px-6 text-[15px]",
};
export default function Button({
@@ -51,5 +49,3 @@ export default function Button({
</button>
);
}

View File

@@ -60,17 +60,17 @@ export default function ContactForm() {
return (
<form className="grid gap-5 group">
{(state.error && !isPending) && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
<div className="rounded-control bg-error-soft ring-1 ring-error/40 px-3 py-2 text-sm text-error">
{state.error}
</div>
)}
{(state.success && !isPending) && (
<div className="rounded-md bg-green-500/10 ring-1 ring-green-600/40 px-3 py-2 text-sm text-green-300">
<div className="rounded-control bg-ready-soft ring-1 ring-ready/40 px-3 py-2 text-sm text-ready">
{state.success}
</div>
)}
<div className="grid gap-2">
<label htmlFor="topic" className="text-sm text-foreground/80">Topic</label>
<label htmlFor="topic" className="text-sm text-text-2">Topic</label>
<Select
id="topic"
name="topic"
@@ -81,14 +81,14 @@ export default function ContactForm() {
label: topicLabels[key],
}))}
/>
<span className="text-xs text-foreground/60">
<span className="text-xs text-text-3">
Choose the most relevant topic so we can help you better.
</span>
</div>
{topic === "bug" && (
<div className="p-4 rounded-md bg-[var(--surface-2)] border border-[var(--border)]">
<p className="text-sm text-foreground/90">
<div className="p-4 rounded-control bg-surface-2 border border-line">
<p className="text-sm text-text">
<strong>Note:</strong> This form is meant only for bugs with the Hackdex website. For bugs found in a rom hack, please reach out to the original creator of that hack.
</p>
</div>
@@ -96,7 +96,7 @@ export default function ContactForm() {
<div className="grid gap-2 md:grid-cols-2 md:gap-4">
<div className="grid gap-2">
<label htmlFor="name" className="text-sm text-foreground/80">Name (optional)</label>
<label htmlFor="name" className="text-sm text-text-2">Name (optional)</label>
<input
id="name"
name="name"
@@ -104,12 +104,12 @@ export default function ContactForm() {
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
className="h-11 rounded-control bg-surface-2 px-3 text-sm ring-1 ring-inset ring-line focus:outline-none focus:ring-2 focus:ring-accent/40"
autoComplete="name"
/>
</div>
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<label htmlFor="email" className="text-sm text-text-2">Email</label>
<input
id="email"
name="email"
@@ -117,21 +117,21 @@ export default function ContactForm() {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
email && emailError ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : "ring-[var(--border)]"
className={`h-11 rounded-control bg-surface-2 px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-accent/40 ${
email && emailError ? "ring-error/40 bg-error-soft dark:ring-error/40 dark:bg-error-soft" : "ring-line"
}`}
required
autoComplete="email"
/>
{email && emailError && (
<span className="text-xs text-red-500/70">{emailError}</span>
<span className="text-xs text-error">{emailError}</span>
)}
</div>
</div>
{(topic === "bug" || topic === "creator" || topic === "account") && (
<div className="grid gap-2">
<label htmlFor="contextUrl" className="text-sm text-foreground/80">Related URL (optional)</label>
<label htmlFor="contextUrl" className="text-sm text-text-2">Related URL (optional)</label>
<input
id="contextUrl"
name="contextUrl"
@@ -139,15 +139,15 @@ export default function ContactForm() {
value={contextUrl}
onChange={(e) => setContextUrl(e.target.value)}
placeholder="https://hackdex.app/..."
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
className="h-11 rounded-control bg-surface-2 px-3 text-sm ring-1 ring-inset ring-line focus:outline-none focus:ring-2 focus:ring-accent/40"
inputMode="url"
/>
<span className="text-xs text-foreground/60">Linking the exact page helps us investigate faster.</span>
<span className="text-xs text-text-3">Linking the exact page helps us investigate faster.</span>
</div>
)}
<div className="grid gap-2">
<label htmlFor="message" className="text-sm text-foreground/80">Message</label>
<label htmlFor="message" className="text-sm text-text-2">Message</label>
<textarea
id="message"
name="message"
@@ -160,7 +160,7 @@ export default function ContactForm() {
? "Please provide enough detail to help us triage. Avoid sharing sensitive data."
: "How can we help?"
}
className="min-h-[8rem] rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
className="min-h-[8rem] rounded-control bg-surface-2 px-3 py-2 text-sm ring-1 ring-inset ring-line focus:outline-none focus:ring-2 focus:ring-accent/40"
required
/>
</div>
@@ -170,7 +170,7 @@ export default function ContactForm() {
type="submit"
formAction={formAction}
disabled={!isValid || isPending}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold hover:cursor-pointer disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
className="inline-flex items-center justify-center rounded-control bg-accent-deep px-5 text-white transition-colors hover:enabled:bg-accent-hover disabled:opacity-60 h-11 min-w-[7.5rem] text-sm font-semibold hover:cursor-pointer disabled:cursor-not-allowed"
aria-label="Send message"
title="Send message"
>

View File

@@ -104,47 +104,47 @@ export default function ArchiverManagement() {
return (
<div className="mt-12">
<h2 className="text-xl font-semibold mb-4">Archiver Role Management</h2>
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-5">
<div className="rounded-card border border-line bg-surface-2 p-5">
{error && (
<div className="mb-4 rounded-md border border-red-600/30 bg-red-500/10 p-3 text-sm text-red-600 dark:text-red-400">
<div className="mb-4 rounded-control border border-error/40 bg-error-soft p-3 text-sm text-error">
{error}
</div>
)}
{/* Search for users */}
<div className="mb-6">
<label className="block text-sm font-medium text-foreground/80 mb-2">Add archiver</label>
<label className="block text-sm font-medium text-text-2 mb-2">Add archiver</label>
<div className="relative">
<FiSearch className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-foreground/50" />
<FiSearch className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text-3" />
<input
type="text"
value={searchQuery}
onChange={handleSearchChange}
placeholder="Search by username or user ID..."
className="w-full rounded-md bg-[var(--background)] px-10 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
className="w-full rounded-control bg-[var(--background)] px-10 py-2 text-sm ring-1 ring-inset ring-line focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
{searching && (
<FiLoader className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-foreground/50 animate-spin" />
<FiLoader className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text-3 animate-spin" />
)}
</div>
{searchResults.length > 0 && (
<div className="mt-2 rounded-md border border-[var(--border)] bg-[var(--background)] max-h-48 overflow-y-auto">
<div className="mt-2 rounded-control border border-line bg-[var(--background)] max-h-48 overflow-y-auto">
{searchResults.map((user) => (
<div
key={user.id}
className="flex items-center justify-between px-3 py-2 hover:bg-[var(--surface-2)] border-b border-[var(--border)] last:border-b-0"
className="flex items-center justify-between px-3 py-2 hover:bg-surface-2 border-b border-line last:border-b-0"
>
<div className="flex flex-col">
<span className="text-sm font-medium">{user.username ? `@${user.username}` : "No username"}</span>
<span className="text-xs text-foreground/60">{user.id}</span>
<span className="text-xs text-text-3">{user.id}</span>
</div>
{isArchiver(user.id) ? (
<span className="text-xs text-foreground/60">Already archiver</span>
<span className="text-xs text-text-3">Already archiver</span>
) : (
<button
type="button"
onClick={() => addArchiver(user.id)}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs font-medium hover:bg-black/5 dark:hover:bg-white/10"
className="inline-flex items-center gap-1 rounded-control border border-line bg-surface-2 px-2 py-1 text-xs font-medium hover:bg-surface-2"
>
<FiPlus className="h-3 w-3" />
Add
@@ -158,28 +158,28 @@ export default function ArchiverManagement() {
{/* Current archivers list */}
<div>
<label className="block text-sm font-medium text-foreground/80 mb-2">
<label className="block text-sm font-medium text-text-2 mb-2">
Current archivers ({archivers.length})
</label>
{loading ? (
<div className="text-sm text-foreground/60">Loading...</div>
<div className="text-sm text-text-3">Loading...</div>
) : archivers.length === 0 ? (
<div className="text-sm text-foreground/60">No archivers assigned</div>
<div className="text-sm text-text-3">No archivers assigned</div>
) : (
<div className="space-y-2">
{archivers.map((archiver) => (
<div
key={archiver.id}
className="flex items-center justify-between rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2"
className="flex items-center justify-between rounded-control border border-line bg-[var(--background)] px-3 py-2"
>
<div className="flex flex-col">
<span className="text-sm font-medium">{archiver.username ? `@${archiver.username}` : "No username"}</span>
<span className="text-xs text-foreground/60">{archiver.id}</span>
<span className="text-xs text-text-3">{archiver.id}</span>
</div>
<button
type="button"
onClick={() => removeArchiver(archiver.id)}
className="inline-flex items-center gap-1 rounded-md border border-red-600/40 bg-red-600/5 dark:border-red-400/40 dark:bg-red-400/5 px-2 py-1 text-xs font-medium text-red-600/90 dark:text-red-400/80 hover:bg-red-600/10 dark:hover:bg-red-400/10"
className="inline-flex items-center gap-1 rounded-control border border-error/40 bg-error-soft px-2 py-1 text-xs font-medium text-error hover:bg-error-soft"
>
<FiX className="h-3 w-3" />
Remove

View File

@@ -94,7 +94,7 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
if (!data.ok) {
return (
<div className="rounded-md border border-red-600/30 bg-red-500/10 p-4 text-sm text-red-600 dark:text-red-400">
<div className="rounded-control border border-error/40 bg-error-soft p-4 text-sm text-error">
{data.error}
</div>
);
@@ -107,16 +107,16 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
{/* Search and filters */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="relative flex-1 w-full">
<FiSearch className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-foreground/50" />
<FiSearch className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text-3" />
<input
type="text"
value={search}
onChange={handleSearchChange}
placeholder="Search by title, author, or base ROM..."
className="w-full rounded-md bg-[var(--surface-2)] px-10 py-3 md:py-2 text-lg md:text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
className="w-full rounded-control bg-surface-2 px-10 py-3 md:py-2 text-lg md:text-sm ring-1 ring-inset ring-line focus:outline-none focus:ring-2 focus:ring-accent/40"
/>
{search !== debouncedSearch && (
<FiLoader className="absolute right-3 top-1/2 -translate-y-1/2 h-6 w-6 md:h-4 md:w-4 text-foreground/50 animate-spin" />
<FiLoader className="absolute right-3 top-1/2 -translate-y-1/2 h-6 w-6 md:h-4 md:w-4 text-text-3 animate-spin" />
)}
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
@@ -143,7 +143,7 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
<button
type="button"
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-6 md:px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/10"
className="rounded-control border border-line bg-surface-2 px-6 md:px-3 py-2 text-sm hover:bg-surface-2"
>
<FiArrowDown className={`h-4 w-4 ${sortOrder !== "asc" ? "rotate-180" : ""}`} />
</button>
@@ -151,20 +151,20 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
</div>
{/* Results count */}
<div className="text-sm text-foreground/60">
<div className="text-sm text-text-3">
Showing {archives.length} of {total} archive{total !== 1 ? "s" : ""}
</div>
{/* Table */}
<div className="overflow-hidden rounded-lg border border-[var(--border)]">
<div className="overflow-hidden rounded-card border border-line">
{loading ? (
<div className="p-8 text-center text-sm text-foreground/60">Loading...</div>
<div className="p-8 text-center text-sm text-text-3">Loading...</div>
) : archives.length === 0 ? (
<div className="p-8 text-center text-sm text-foreground/60">No archives found</div>
<div className="p-8 text-center text-sm text-text-3">No archives found</div>
) : (
<>
{/* Desktop header */}
<div className="hidden lg:grid grid-cols-12 gap-4 bg-[var(--surface-2)] px-4 py-2 text-xs text-foreground/60">
<div className="hidden lg:grid grid-cols-12 gap-4 bg-surface-2 px-4 py-2 text-xs text-text-3">
<div className="col-span-1 text-center">Type</div>
<div className="col-span-3">Title</div>
<div className="col-span-2">Original Author</div>
@@ -173,7 +173,7 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
<div className="col-span-1 text-xs">Archived by</div>
<div className="col-span-2 text-right">Actions</div>
</div>
<div className="divide-y divide-[var(--border)]">
<div className="divide-y divide-line">
{archives.map((archive) => {
const baseRom = baseRoms.find((r) => r.id === archive.base_rom);
const createdDate = new Date(archive.created_at).toLocaleDateString();
@@ -199,25 +199,25 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
/>
)}
</div>
<Link href={`/hack/${archive.slug}`} target="_blank" className="group flex items-center gap-3 col-span-3 min-w-0 hover:text-foreground">
<Link href={`/hack/${archive.slug}`} target="_blank" className="group flex items-center gap-3 col-span-3 min-w-0 hover:text-text">
<div className="flex flex-col items-start min-w-0">
<div className="truncate font-medium group-hover:underline">{archive.title}</div>
<div className="mt-0.5 text-xs text-foreground/60 group-hover:text-foreground group-hover:underline">/{archive.slug}</div>
<div className="mt-0.5 text-xs text-text-3 group-hover:text-text group-hover:underline">/{archive.slug}</div>
</div>
<FiExternalLink className="h-4 w-4 text-foreground/80 group-hover:text-foreground flex-shrink-0" />
<FiExternalLink className="h-4 w-4 text-text-2 group-hover:text-text flex-shrink-0" />
</Link>
<div className="col-span-2 text-foreground/80">{archive.original_author || "—"}</div>
<div className="col-span-2 text-foreground/80">{archive.permission_from || "—"}</div>
<div className="col-span-1 text-foreground/80">{baseRom?.name || archive.base_rom}</div>
<div className="col-span-1 text-foreground/80 text-xs">
<div className="col-span-2 text-text-2">{archive.original_author || "—"}</div>
<div className="col-span-2 text-text-2">{archive.permission_from || "—"}</div>
<div className="col-span-1 text-text-2">{baseRom?.name || archive.base_rom}</div>
<div className="col-span-1 text-text-2 text-xs">
<div className="truncate">{creator}</div>
<div className="text-[10px] text-foreground/60">{createdDate}</div>
<div className="text-[10px] text-text-3">{createdDate}</div>
</div>
<div className="col-span-2 flex items-center justify-end gap-2">
{isDownloadable && (
<Link
href={`/hack/${archive.slug}/stats`}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
className="inline-flex h-8 w-8 items-center justify-center rounded-control hover:bg-surface-2"
title="View Stats"
>
<FiBarChart2 className="h-4 w-4" />
@@ -225,7 +225,7 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
)}
<Link
href={`/hack/${archive.slug}/edit`}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
className="inline-flex h-8 w-8 items-center justify-center rounded-control hover:bg-surface-2"
title="Edit"
>
<FiEdit2 className="h-4 w-4" />
@@ -235,10 +235,10 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
type="button"
onClick={() => handleDelete(archive.slug)}
disabled={deletingSlug === archive.slug}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-red-600/10 disabled:opacity-50"
className="inline-flex h-8 w-8 items-center justify-center rounded-control hover:bg-error-soft disabled:opacity-50"
title="Delete"
>
<FiTrash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
<FiTrash2 className="h-4 w-4 text-error" />
</button>
)}
</div>
@@ -262,26 +262,26 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
)}
<Link href={`/hack/${archive.slug}`} target="_blank">
<div className="text-lg font-bold group-hover:underline">{archive.title}</div>
<div className="text-xs text-foreground/60 group-hover:underline">/{archive.slug}</div>
<div className="text-xs text-text-3 group-hover:underline">/{archive.slug}</div>
</Link>
</div>
<FiExternalLink className="h-4 w-4 text-foreground/80 group-hover:text-foreground flex-shrink-0" />
<FiExternalLink className="h-4 w-4 text-text-2 group-hover:text-text flex-shrink-0" />
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/60">
<div className="flex flex-wrap items-center gap-2 text-xs text-text-3">
<span className="font-bold">Author: {archive.original_author || "—"}</span>
<span>|</span>
{archive.permission_from && <span className="font-bold">Permission: {archive.permission_from || "—"}</span>}
{archive.permission_from && <span>|</span>}
<span className="font-bold">Base: {baseRom?.name || archive.base_rom}</span>
</div>
<div className="flex flex-wrap items-center text-xs italic text-foreground/60">
<div className="flex flex-wrap items-center text-xs italic text-text-3">
Archived by {creator} on {createdDate}
</div>
<div className="flex items-center gap-2 mt-2">
{isDownloadable && (
<Link
href={`/hack/${archive.slug}/stats`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs hover:bg-black/5 dark:hover:bg-white/10"
className="inline-flex items-center gap-1 rounded-control border border-line bg-surface-2 px-2 py-1 text-xs hover:bg-surface-2"
>
<FiBarChart2 className="h-3 w-3" />
Stats
@@ -289,7 +289,7 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
)}
<Link
href={`/hack/${archive.slug}/edit`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs hover:bg-black/5 dark:hover:bg-white/10"
className="inline-flex items-center gap-1 rounded-control border border-line bg-surface-2 px-2 py-1 text-xs hover:bg-surface-2"
>
<FiEdit2 className="h-3 w-3" />
Edit
@@ -299,7 +299,7 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
type="button"
onClick={() => handleDelete(archive.slug)}
disabled={deletingSlug === archive.slug}
className="inline-flex items-center gap-1 rounded-md border border-red-600/40 bg-red-600/5 dark:border-red-400/40 dark:bg-red-400/5 px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-600/10 dark:hover:bg-red-400/10 disabled:opacity-50"
className="inline-flex items-center gap-1 rounded-control border border-error/40 bg-error-soft px-2 py-1 text-xs text-error hover:bg-error-soft disabled:opacity-50"
>
<FiTrash2 className="h-3 w-3" />
Delete
@@ -322,19 +322,19 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || loading}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
className="inline-flex items-center gap-1 rounded-control border border-line bg-surface-2 px-3 py-2 text-sm hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<FiChevronLeft className="h-4 w-4" />
Previous
</button>
<div className="text-sm text-foreground/60">
<div className="text-sm text-text-3">
Page {page} of {totalPages}
</div>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || loading}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
className="inline-flex items-center gap-1 rounded-control border border-line bg-surface-2 px-3 py-2 text-sm hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
<FiChevronRight className="h-4 w-4" />

View File

@@ -11,11 +11,13 @@ export type HackRow = {
slug: string;
title: string;
approved: boolean;
submitted_at: string | null;
updated_at: string | null;
downloads: number;
current_patch: number | null;
version: string;
created_at: string;
needsAiLabel: boolean;
};
export default function DashboardClient({
@@ -30,7 +32,7 @@ export default function DashboardClient({
const [selectedSlugs, setSelectedSlugs] = React.useState<string[]>(() => hacks.map((h) => h.slug));
const totalDownloads = React.useMemo(() => hacks.reduce((acc, h) => acc + (h.downloads || 0), 0), [hacks]);
const pendingCount = hacks.filter((h) => !h.approved).length;
const pendingCount = hacks.filter((h) => !h.approved && h.submitted_at !== null).length;
const localCutover = React.useMemo(() => {
const now = new Date();
const utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0));
@@ -46,23 +48,23 @@ export default function DashboardClient({
<div className="mx-auto max-w-screen-2xl">
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-start gap-3 lg:gap-4">
<div className="flex flex-col grow-1">
<h1 className="text-3xl font-bold tracking-tight">Creator Dashboard</h1>
<p className="mt-1 text-[18px] text-foreground/90">Welcome back, {displayName}!</p>
<p className="mt-4 text-[15px] text-foreground/60">
<h1 className="font-display text-[28px] leading-tight md:text-[32px]">Creator dashboard</h1>
<p className="mt-1 text-[17px] text-text-2">Welcome back, {displayName}!</p>
<p className="mt-3 text-[13px] text-text-3">
Analytics update daily at 00:00 UTC. Today&apos;s data will be available after {localCutover}.
</p>
</div>
<div className="flex flex-col ml-auto my-4 w-full md:flex-row md:w-auto md:mb-0 lg:my-0 gap-2">
<Link
href="/account"
className="inline-flex h-12 px-4 items-center justify-center w-full md:w-auto md:h-10 rounded-md text-sm font-medium ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)] hover:cursor-pointer"
className="inline-flex h-11 w-full items-center justify-center rounded-control border border-line-strong bg-surface px-4 text-sm font-medium transition-colors hover:border-text-3 md:h-10 md:w-auto"
>
Account Settings
</Link>
<form action="/auth/signout" method="post">
<button
type="submit"
className="inline-flex h-12 px-8 items-center justify-center w-full md:w-auto md:h-10 rounded-md border border-red-600/40 bg-red-600/5 dark:border-red-400/40 dark:bg-red-400/5 text-sm font-medium text-red-600/90 dark:text-red-400/80 transition-colors hover:bg-red-600/5 dark:hover:bg-red-400/10 hover:cursor-pointer"
className="inline-flex h-11 w-full items-center justify-center rounded-control border border-error/40 bg-surface px-6 text-sm font-medium text-error transition-colors hover:bg-error-soft md:h-10 md:w-auto"
>
Sign out
</button>
@@ -105,9 +107,9 @@ export default function DashboardClient({
function StatCard({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-4">
<div className="text-[13px] text-foreground/70">{label}</div>
<div className="mt-1 text-2xl font-semibold">{value}</div>
<div className="rounded-card border border-line bg-surface p-4 shadow-rest">
<div className="text-[13px] text-text-3">{label}</div>
<div className="mt-1 font-display text-2xl tabular-nums">{value}</div>
</div>
);
}
@@ -132,8 +134,8 @@ function SlugMultiSelect({
onClick={() => onChange(selected ? values.filter((s) => s !== h.slug) : [...values, h.slug])}
className={`shrink-0 rounded-full px-3 py-2 text-sm ring-1 ring-inset transition-colors hover:cursor-pointer ${
selected
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35"
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
? "bg-accent-deep/15 text-[var(--foreground)] ring-[var(--accent)]/35"
: "bg-surface-2 text-text-2 ring-line hover:bg-surface-2"
}`}
>
{h.title}
@@ -144,7 +146,7 @@ function SlugMultiSelect({
<button
type="button"
onClick={() => onChange(hacks.map((h) => h.slug))}
className="shrink-0 rounded-full ml-auto px-3 py-2 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10 hover:cursor-pointer"
className="shrink-0 rounded-full ml-auto px-3 py-2 text-sm ring-1 ring-inset transition-colors bg-surface-2 text-text-2 ring-line hover:bg-surface-2 hover:cursor-pointer"
>
Select all
</button>
@@ -153,7 +155,7 @@ function SlugMultiSelect({
<button
type="button"
onClick={() => onChange([])}
className={`shrink-0 rounded-full px-3 py-2 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10 hover:cursor-pointer ${values.length === 0 ? "ml-auto" : ""}`}
className={`shrink-0 rounded-full px-3 py-2 text-sm ring-1 ring-inset transition-colors bg-surface-2 text-text-2 ring-line hover:bg-surface-2 hover:cursor-pointer ${values.length === 0 ? "ml-auto" : ""}`}
>
Clear
</button>

View File

@@ -125,9 +125,9 @@ export default function DownloadsChart({ selectedSlugs }: { selectedSlugs: strin
);
return (
<div className="h-[40vh] sm:h-72 w-full max-w-full overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="h-[40vh] sm:h-72 w-full max-w-full overflow-hidden rounded-card border border-line bg-surface-2 p-3">
{datasets.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/70">Select at least one hack to display.</div>
<div className="flex h-full items-center justify-center text-sm text-text-2">Select at least one hack to display.</div>
) : (
<Line data={data} options={options} className="!w-full !h-full block" style={{ width: "100%", height: "100%" }} />
)}

View File

@@ -41,28 +41,28 @@ export default function HackInsights({ hack }: { hack: HackRow }) {
}, [open, data, getInsights, hack.slug]);
return (
<div className="rounded-lg border border-[var(--border)]">
<div className="rounded-card border border-line">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-[var(--surface-2)]"
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-surface-2"
>
<div className="min-w-0">
<div className="truncate font-medium">{hack.title}</div>
<div className="mt-0.5 text-xs text-foreground/60">/{hack.slug}</div>
<div className="mt-0.5 text-xs text-text-3">/{hack.slug}</div>
</div>
<div className="text-sm text-foreground/70">{open ? "Hide" : "Show"} insights</div>
<div className="text-sm text-text-2">{open ? "Hide" : "Show"} insights</div>
</button>
{open && (
<div className="px-4 pb-4">
{loading || !data ? (
<div className="py-6 text-sm text-foreground/70">Loading insights…</div>
<div className="py-6 text-sm text-text-2">Loading insights…</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
<div className="md:col-span-2 rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="md:col-span-2 rounded-control border border-line bg-surface-2 p-3">
<div className="text-sm font-medium mb-2">Downloads per version</div>
{data.versionCounts.length === 0 ? (
<div className="text-sm text-foreground/70">No downloads yet. Upload a patch to see data.</div>
<div className="text-sm text-text-2">No downloads yet. Upload a patch to see data.</div>
) : (
<Bar
data={{
@@ -84,14 +84,14 @@ export default function HackInsights({ hack }: { hack: HackRow }) {
/>
)}
</div>
<div className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="rounded-control border border-line bg-surface-2 p-3">
<div className="text-sm font-medium mb-2">Latest-version adoption</div>
{data.isNewToday ? (
<div className="text-sm text-foreground/70">New upload today—please check back tomorrow (UTC) for analytics.</div>
<div className="text-sm text-text-2">New upload today—please check back tomorrow (UTC) for analytics.</div>
) : (
<div>
<div className="text-3xl font-semibold">{Math.round(data.adoptionRate * 100)}%</div>
<div className="mt-1 text-xs text-foreground/70">{data.latestUniqueDevices} of {data.totalUniqueDevices} unique devices on latest</div>
<div className="mt-1 text-xs text-text-2">{data.latestUniqueDevices} of {data.totalUniqueDevices} unique devices on latest</div>
</div>
)}
</div>

View File

@@ -2,7 +2,7 @@
import React from "react";
import Link from "next/link";
import { FiExternalLink, FiEdit2, FiUpload, FiShare2, FiBarChart2, FiMoreVertical, FiCheck } from "react-icons/fi";
import { FiAlertTriangle, FiExternalLink, FiEdit2, FiUpload, FiShare2, FiBarChart2, FiMoreVertical, FiCheck } from "react-icons/fi";
import { TbVersions } from "react-icons/tb";
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react";
import ActionSheet from "@/components/Primitives/ActionSheet";
@@ -11,51 +11,69 @@ type HackRow = {
slug: string;
title: string;
approved: boolean;
submitted_at: string | null;
updated_at: string | null;
downloads: number;
version: string;
needsAiLabel: boolean;
};
/** Opens the hack's editor with the AI form already up. */
function NeedsAiLabel({ slug }: { slug: string }) {
return (
<Link
href={`/hack/${slug}?edit=1&ai=1`}
className="inline-flex w-fit items-center gap-1 rounded-full bg-warn-soft px-2 py-0.5 text-xs font-medium text-warn ring-1 ring-warn/30 transition-colors hover:ring-warn/60"
>
<FiAlertTriangle className="h-3 w-3" /> Needs AI label
</Link>
);
}
function StatusBadge({ hack, className = "" }: { hack: HackRow; className?: string }) {
const base = `inline-flex items-center gap-1 rounded-full px-2 py-0.5 ring-1 ${className}`;
if (hack.approved) return <span className={`${base} bg-ready-soft text-ready ring-ready/30`}>Approved</span>;
if (hack.submitted_at === null) return <span className={`${base} bg-surface-2 text-text-2 ring-line-strong`}>Draft</span>;
return <span className={`${base} bg-warn-soft text-warn ring-warn/30`}>In review</span>;
}
export default function HackList({ hacks }: { hacks: HackRow[] }) {
const [activeSlug, setActiveSlug] = React.useState<string | null>(null);
const [sheetOpen, setSheetOpen] = React.useState(false);
if (hacks.length === 0) {
return (
<div className="mt-4 rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-6 text-sm text-foreground/80">
<div className="mt-4 rounded-control border border-line bg-surface-2 p-6 text-sm text-text-2">
You haven&apos;t uploaded any hacks yet. <Link className="underline" href="/submit">Submit a hack</Link> to get started.
</div>
);
}
return (
<div className="mt-4 overflow-hidden rounded-lg border border-[var(--border)]">
<div className="mt-4 overflow-hidden rounded-card border border-line">
{/* Header row (desktop only) */}
<div className="hidden lg:grid grid-cols-12 bg-[var(--surface-2)] px-4 py-2 text-xs text-foreground/60">
<div className="hidden lg:grid grid-cols-12 bg-surface-2 px-4 py-2 text-xs text-text-3">
<div className="col-span-4">Title</div>
<div className="col-span-2">Status</div>
<div className="col-span-2">Version</div>
<div className="col-span-2">Downloads</div>
<div className="col-span-2 text-right">Actions</div>
</div>
<div className="divide-y divide-[var(--border)]">
<div className="divide-y divide-line">
{hacks.map((h) => (
<div key={h.slug} className="px-4 py-3 text-sm">
{/* Desktop row */}
<div className="hidden lg:grid grid-cols-12 items-center">
<Link href={`/hack/${h.slug}`} target="_blank" className="group flex items-center gap-4 col-span-4 min-w-0 hover:text-foreground">
<Link href={`/hack/${h.slug}`} target="_blank" className="group flex items-center gap-4 col-span-4 min-w-0 hover:text-text">
<div className="flex flex-col items-start">
<div className="truncate font-medium group-hover:underline">{h.title}</div>
<div className="mt-0.5 text-xs text-foreground/60 group-hover:text-foreground group-hover:underline">/{h.slug}</div>
<div className="mt-0.5 text-xs text-text-3 group-hover:text-text group-hover:underline">/{h.slug}</div>
</div>
<FiExternalLink className="h-4 w-4 text-foreground/80 group-hover:text-foreground" />
<FiExternalLink className="h-4 w-4 text-text-2 group-hover:text-text" />
</Link>
<div className="col-span-2">
{h.approved ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-400 ring-1 ring-emerald-600/30">Approved</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-400 ring-1 ring-amber-600/30">Pending</span>
)}
<div className="col-span-2 flex flex-col items-start gap-1">
<StatusBadge hack={h} className="text-xs" />
{h.needsAiLabel && <NeedsAiLabel slug={h.slug} />}
</div>
<div className="col-span-2">{h.version}</div>
<div className="col-span-2">{h.downloads}</div>
@@ -63,7 +81,7 @@ export default function HackList({ hacks }: { hacks: HackRow[] }) {
<IconTooltipButton href={`/hack/${h.slug}/stats`} target="_blank" label="Stats">
<FiBarChart2 className="h-4 w-4" />
</IconTooltipButton>
<IconTooltipButton href={`/hack/${h.slug}/edit`} label="Edit">
<IconTooltipButton href={`/hack/${h.slug}?edit=1`} label="Edit">
<FiEdit2 className="h-4 w-4" />
</IconTooltipButton>
<IconTooltipButton href={`/hack/${h.slug}/versions`} label="Manage versions">
@@ -77,16 +95,13 @@ export default function HackList({ hacks }: { hacks: HackRow[] }) {
<div className="flex flex-col items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium break-words">{h.title}</div>
<div className="mt-0.5 text-xs text-foreground/60 break-all">/{h.slug}</div>
<div className="mt-0.5 text-xs text-text-3 break-all">/{h.slug}</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
{h.approved ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-400 ring-1 ring-emerald-600/30">Approved</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-amber-400 ring-1 ring-amber-600/30">Pending</span>
)}
<span className="rounded-full bg-[var(--surface-2)] px-2 py-0.5 ring-1 ring-[var(--border)]">{h.version}</span>
<span className="rounded-full bg-[var(--surface-2)] px-2 py-0.5 ring-1 ring-[var(--border)]">{h.downloads} downloads</span>
<StatusBadge hack={h} />
{h.needsAiLabel && <NeedsAiLabel slug={h.slug} />}
<span className="rounded-full bg-surface-2 px-2 py-0.5 ring-1 ring-line">{h.version}</span>
<span className="rounded-full bg-surface-2 px-2 py-0.5 ring-1 ring-line">{h.downloads} downloads</span>
</div>
</div>
<IconTooltipButton onClick={() => { setActiveSlug(h.slug); setSheetOpen(true); }} label="More" ariaLabel="More">
@@ -121,7 +136,7 @@ function ShareIconButton({ slug }: { slug: string }) {
return (
<IconTooltipButton onClick={handleClick} label={copied ? "Copied!" : "Share link"} ariaLabel="Share link">
{copied ? <FiCheck className="h-4 w-4 text-emerald-400/80" /> : <FiShare2 className="h-4 w-4" />}
{copied ? <FiCheck className="h-4 w-4 text-ready" /> : <FiShare2 className="h-4 w-4" />}
</IconTooltipButton>
);
}
@@ -131,7 +146,7 @@ function buildActions(slug: string | null) {
return [
{ key: "view", label: "View", href: `/hack/${slug}`, icon: <FiExternalLink className="h-4 w-4" /> },
{ key: "stats", label: "Stats", href: `/hack/${slug}/stats`, icon: <FiBarChart2 className="h-4 w-4" /> },
{ key: "edit", label: "Edit", href: `/hack/${slug}/edit`, icon: <FiEdit2 className="h-4 w-4" /> },
{ key: "edit", label: "Edit", href: `/hack/${slug}?edit=1`, icon: <FiEdit2 className="h-4 w-4" /> },
{ key: "versions", label: "Manage versions", href: `/hack/${slug}/versions`, icon: <TbVersions className="h-4 w-4" /> },
{ key: "share", label: "Share link", onClick: () => copyShare(slug), icon: <FiShare2 className="h-4 w-4" /> },
];
@@ -168,7 +183,7 @@ function IconTooltipButton({ href, target, onClick, label, ariaLabel, children }
href={href}
target={target}
aria-label={ariaLabel ?? label}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
className="inline-flex h-8 w-8 items-center justify-center rounded-control hover:bg-surface-2"
>
{children}
</Link>
@@ -177,7 +192,7 @@ function IconTooltipButton({ href, target, onClick, label, ariaLabel, children }
type="button"
onClick={onClick}
aria-label={ariaLabel ?? label}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
className="inline-flex h-8 w-8 items-center justify-center rounded-control hover:bg-surface-2"
>
{children}
</button>
@@ -214,7 +229,7 @@ function Tooltip({ label, children }: { label: string; children: React.ReactNode
<span
ref={refs.setFloating as any}
style={floatingStyles as React.CSSProperties}
className="hidden lg:block z-50 whitespace-nowrap rounded-md bg-black/80 px-2 py-1 text-[11px] text-white shadow-md dark:bg-white/90 dark:text-black"
className="hidden lg:block z-50 whitespace-nowrap rounded-control bg-black/80 px-2 py-1 text-[11px] text-white shadow-md dark:bg-white/90 dark:text-black"
role="tooltip"
>
{label}

View File

@@ -8,7 +8,7 @@ import { FaCircleCheck, FaRegCircle, FaUserCheck } from "react-icons/fa6";
import { assignHacksToAdminForReview } from "@/app/dashboard/actions";
import { useRouter } from "next/navigation";
export type PendingHack = HackRow & {
export type PendingHack = Omit<HackRow, "needsAiLabel"> & {
created_by: string;
creator_username: string | null;
creator_full_name: string | null;
@@ -93,7 +93,7 @@ export default function PendingHacks({ hacks, userId }: PendingHacksProps) {
setClaimMode={setClaimMode}
onClaimSelected={onClaimSelected}
>
<div className="flex items-center justify-center text-foreground/80 my-4">
<div className="flex items-center justify-center text-text-2 my-4">
No pending hacks to review! 🎉
</div>
</PendingHacksContainer>
@@ -115,7 +115,7 @@ export default function PendingHacks({ hacks, userId }: PendingHacksProps) {
setClaimMode={setClaimMode}
onClaimSelected={onClaimSelected}
>
<div className="flex items-center justify-center text-foreground/80 m-4">
<div className="flex items-center justify-center text-text-2 m-4">
None of the currently {hacks.length} pending hacks have been claimed by you for review.
</div>
</PendingHacksContainer>
@@ -137,7 +137,7 @@ export default function PendingHacks({ hacks, userId }: PendingHacksProps) {
onClaimSelected={onClaimSelected}
>
{/* Header row (desktop only) */}
<div className="hidden lg:grid grid-cols-12 bg-amber-500/5 px-4 py-2 text-xs text-amber-900/80 dark:text-amber-200/80">
<div className="hidden lg:grid grid-cols-12 bg-warn-soft px-4 py-2 text-xs text-warn">
<div className="col-span-4">Title</div>
<div className="col-span-4">Creator</div>
<div className="col-span-4">Created</div>
@@ -156,7 +156,7 @@ export default function PendingHacks({ hacks, userId }: PendingHacksProps) {
);
})}
{filter === "recent" && filteredHacks.length < hacks.length && (
<div className="flex items-center justify-center text-foreground/80 m-4">
<div className="flex items-center justify-center text-text-2 m-4">
+ {hacks.length - filteredHacks.length} older pending hacks
</div>
)}
@@ -199,7 +199,7 @@ function PendingHacksContainer({
<div className="mb-2 flex flex-col md:flex-row gap-4 md:gap-2">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">Pending hacks</h2>
<span className="inline-flex items-center rounded-full bg-amber-500/10 px-2.5 py-0.5 text-sm font-medium text-amber-900/90 dark:bg-amber-500/20 dark:text-amber-100 border border-amber-600/30">
<span className="inline-flex items-center rounded-full bg-warn-soft px-2.5 py-0.5 text-sm font-medium text-warn border border-warn/40">
{filteredCount}
</span>
</div>
@@ -207,14 +207,14 @@ function PendingHacksContainer({
{totalCount > 0 && !claimMode && (
<div className="flex items-center gap-2 w-full md:ml-auto md:max-w-2xs">
<button
className="h-10 inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm text-foreground/90 dark:bg-[var(--surface-2)] dark:text-foreground/90 hover:bg-black/5 dark:hover:bg-white/10 hover:cursor-pointer"
className="h-10 inline-flex items-center gap-1 rounded-control border border-line bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-2 hover:cursor-pointer"
onClick={() => setClaimMode(!claimMode)}
>
<FaUserCheck className="h-4 w-4 text-foreground/90 dark:text-foreground/90" />
<FaUserCheck className="h-4 w-4 text-text" />
<span>Claim</span>
</button>
<select
className="w-full h-10 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm text-foreground/90 dark:bg-[var(--surface-2)] dark:text-foreground/90"
className="w-full h-10 rounded-control border border-line bg-surface-2 px-3 py-2 text-sm text-text"
value={filter}
onChange={(e) => setFilter(e.target.value as Filter)}
>
@@ -229,15 +229,15 @@ function PendingHacksContainer({
<div className="flex items-center gap-2 ml-auto md:max-w-2xs">
<button
disabled={loading || selectedCount === 0}
className="min-w-max h-10 inline-flex items-center gap-1 rounded-md border border-emerald-600/30 bg-emerald-500/5 hover:bg-emerald-500/10 hover:cursor-pointer px-3 py-2 text-sm text-foreground/90 dark:bg-emerald-500/20 dark:text-foreground/90 disabled:border-[var(--border)]/30 disabled:bg-[var(--surface-2)]/50 disabled:text-foreground/60 disabled:cursor-not-allowed"
className="min-w-max h-10 inline-flex items-center gap-1 rounded-control border border-ready/40 bg-ready-soft hover:bg-ready-soft hover:cursor-pointer px-3 py-2 text-sm text-text disabled:border-line/30 disabled:bg-surface-2/50 disabled:text-text-3 disabled:cursor-not-allowed"
onClick={onClaimSelected}
>
<FaUserCheck className="h-4 w-4 text-foreground/90 dark:text-foreground/90 mr-1" />
<FaUserCheck className="h-4 w-4 text-text mr-1" />
<span>Claim selected</span>
</button>
<button
disabled={loading}
className="h-10 inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm text-foreground/90 dark:bg-[var(--surface-2)] dark:text-foreground/90 enabled:hover:bg-black/5 dark:enabled:hover:bg-white/10 enabled:hover:cursor-pointer disabled:border-[var(--border)]/30 disabled:bg-[var(--surface-2)]/50 disabled:text-foreground/60 disabled:cursor-not-allowed"
className="h-10 inline-flex items-center gap-1 rounded-control border border-line bg-surface-2 px-3 py-2 text-sm text-text enabled:hover:bg-surface-2 dark:enabled:hover:bg-white/10 enabled:hover:cursor-pointer disabled:border-line/30 disabled:bg-surface-2/50 disabled:text-text-3 disabled:cursor-not-allowed"
onClick={() => setClaimMode(false)}
>
<span>Cancel</span>
@@ -245,20 +245,20 @@ function PendingHacksContainer({
</div>
)}
</div>
<div className="mb-2 text-sm text-foreground/60">
<div className="mb-2 text-sm text-text-3">
Use "Claim" to select hacks whose creator you have already reached out to for verification.
</div>
{error && (
<div className="mb-2 rounded-md border border-red-600/30 bg-red-500/10 p-3 text-sm text-red-600 dark:text-red-400">
<div className="mb-2 rounded-control border border-error/40 bg-error-soft p-3 text-sm text-error">
{error}
</div>
)}
{lastClaimedCount > 0 && (
<div className="mb-2 rounded-md border border-emerald-600/30 bg-emerald-500/10 p-3 text-sm text-emerald-900/90 dark:text-emerald-200/90">
<div className="mb-2 rounded-control border border-ready/40 bg-ready-soft p-3 text-sm text-ready">
{lastClaimedCount} hacks claimed successfully!
</div>
)}
<div className="overflow-hidden rounded-lg border border-amber-600/30 bg-amber-500/5">
<div className="overflow-hidden rounded-card border border-warn/40 bg-warn-soft">
{children}
</div>
</div>
@@ -286,8 +286,8 @@ function PendingHackCard({ hack, userId, selectMode, selected, onSelect }: Pendi
const creator = hack.creator_username ? `@${hack.creator_username}` : "Unknown";
const hasMissingPatch = hack.current_patch === null;
const bgClasses = hasMissingPatch
? "bg-amber-50/5 dark:bg-amber-900/5 hover:bg-amber-400/10 dark:hover:bg-amber-600/10"
: "bg-amber-500/5 hover:bg-amber-500/10";
? "bg-warn-soft dark:bg-warn-soft hover:bg-warn-soft dark:hover:bg-warn-soft"
: "bg-warn-soft hover:bg-warn-soft";
const content = <>
{/* Desktop row */}
@@ -296,22 +296,22 @@ function PendingHackCard({ hack, userId, selectMode, selected, onSelect }: Pendi
<div className="flex items-center gap-3">
<div className="flex flex-col items-start min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<div className="truncate font-medium text-amber-900/90 dark:text-amber-200/90 group-hover:underline">{hack.title}</div>
<div className="truncate font-medium text-warn group-hover:underline">{hack.title}</div>
{hasMissingPatch && (
<div className="inline-flex items-center gap-1 rounded-full bg-amber-600/20 dark:bg-amber-600/40 px-2 py-0.5 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<FiAlertTriangle className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
<div className="inline-flex items-center gap-1 rounded-full bg-warn-soft px-2 py-0.5 text-xs font-medium text-warn">
<FiAlertTriangle className="h-3 w-3 text-warn" />
<span>Missing Patch</span>
</div>
)}
</div>
<div className="mt-0.5 text-xs text-amber-900/60 dark:text-amber-200/60 group-hover:underline">/{hack.slug}</div>
<div className="mt-0.5 text-xs text-warn group-hover:underline">/{hack.slug}</div>
{hack.assigned_admin && hack.assigned_admin === userId ? (
<div className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<FaCircleCheck className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
<div className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-warn">
<FaCircleCheck className="h-3 w-3 text-warn" />
<span>Assigned to you</span>
</div>
) : hack.assigned_admin_username && (
<div className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<div className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-warn">
<span>Assigned to <span className="font-bold">@{hack.assigned_admin_username}</span></span>
</div>
)}
@@ -319,12 +319,12 @@ function PendingHackCard({ hack, userId, selectMode, selected, onSelect }: Pendi
</div>
</div>
<div className="col-span-4 flex flex-col min-w-0">
{hack.creator_full_name && <div className="text-xs text-amber-900/70 dark:text-amber-200/70">{hack.creator_full_name}</div>}
<div className="text-amber-900/90 dark:text-amber-200/90">
{hack.creator_full_name && <div className="text-xs text-warn">{hack.creator_full_name}</div>}
<div className="text-warn">
{creator}
{hack.creator_verified && (
<div className="group/verified relative inline-flex items-center group ml-1">
<FaCircleCheck className="text-amber-950/90 dark:text-amber-100/90" size={12} />
<FaCircleCheck className="text-warn" size={12} />
<div className="pointer-events-none absolute left-1/2 top-full z-10 mt-1 hidden -translate-x-1/2 whitespace-nowrap rounded bg-black px-2 py-1 text-xs text-white opacity-0 group-hover/verified:block group-hover/verified:opacity-100">
Creator is verified
</div>
@@ -332,18 +332,18 @@ function PendingHackCard({ hack, userId, selectMode, selected, onSelect }: Pendi
)}
</div>
{hack.creator_email && (
<div className="text-xs text-amber-900/60 dark:text-amber-200/60 truncate mt-0.5">{hack.creator_email}</div>
<div className="text-xs text-warn truncate mt-0.5">{hack.creator_email}</div>
)}
</div>
<div className="col-span-4 min-w-0">
<div className="flex items-center gap-3">
<div className="text-amber-900/90 dark:text-amber-200/90 flex-1">{createdDate}</div>
<div className="text-warn flex-1">{createdDate}</div>
{selectMode && selected ? (
<FaCircleCheck className="h-6 w-6 text-foreground/80 flex-shrink-0" />
<FaCircleCheck className="h-6 w-6 text-text-2 flex-shrink-0" />
) : selectMode ? (
<FaRegCircle className="h-6 w-6 text-foreground/80 flex-shrink-0" />
<FaRegCircle className="h-6 w-6 text-text-2 flex-shrink-0" />
) : (
<FiExternalLink className="h-4 w-4 text-foreground/80 flex-shrink-0" />
<FiExternalLink className="h-4 w-4 text-text-2 flex-shrink-0" />
)}
</div>
</div>
@@ -355,16 +355,16 @@ function PendingHackCard({ hack, userId, selectMode, selected, onSelect }: Pendi
<div className="flex items-center gap-2 flex-wrap">
<div className="font-medium wrap-break-word">{hack.title}</div>
{hasMissingPatch && (
<div className="inline-flex items-center gap-1 rounded-full bg-amber-600/20 dark:bg-amber-600/40 px-2 py-0.5 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<FiAlertTriangle className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
<div className="inline-flex items-center gap-1 rounded-full bg-warn-soft px-2 py-0.5 text-xs font-medium text-warn">
<FiAlertTriangle className="h-3 w-3 text-warn" />
<span>Missing Patch</span>
</div>
)}
</div>
<div className="mt-0.5 text-xs text-foreground/60 break-all">/{hack.slug}</div>
<div className="flex flex-col gap-1 text-xs text-amber-900/90 dark:text-amber-200/90 mt-2">
<div className="mt-0.5 text-xs text-text-3 break-all">/{hack.slug}</div>
<div className="flex flex-col gap-1 text-xs text-warn mt-2">
{(hack.creator_full_name || hack.creator_email) && (
<div className="text-amber-900/70 dark:text-amber-200/70 break-all flex flex-wrap items-center gap-2">
<div className="text-warn break-all flex flex-wrap items-center gap-2">
{hack.creator_full_name && <span>{hack.creator_full_name}</span>}
{hack.creator_full_name && hack.creator_email && <span>•</span>}
{hack.creator_email && <span>{hack.creator_email}</span>}
@@ -377,22 +377,22 @@ function PendingHackCard({ hack, userId, selectMode, selected, onSelect }: Pendi
</div>
</div>
{hack.assigned_admin && hack.assigned_admin === userId ? (
<div className="mt-4 inline-flex items-center gap-1 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<FaCircleCheck className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
<div className="mt-4 inline-flex items-center gap-1 text-xs font-medium text-warn">
<FaCircleCheck className="h-3 w-3 text-warn" />
<span>Assigned to you</span>
</div>
) : hack.assigned_admin_username && (
<div className="mt-4 inline-flex items-center gap-1 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<div className="mt-4 inline-flex items-center gap-1 text-xs font-medium text-warn">
<span>Assigned to <span className="font-bold">@{hack.assigned_admin_username}</span></span>
</div>
)}
</div>
{selectMode && selected ? (
<FaCircleCheck className="h-6 w-6 text-foreground/80 flex-shrink-0" />
<FaCircleCheck className="h-6 w-6 text-text-2 flex-shrink-0" />
) : selectMode ? (
<FaRegCircle className="h-6 w-6 text-foreground/80 flex-shrink-0" />
<FaRegCircle className="h-6 w-6 text-text-2 flex-shrink-0" />
) : (
<FiExternalLink className="h-4 w-4 text-foreground/80 flex-shrink-0" />
<FiExternalLink className="h-4 w-4 text-text-2 flex-shrink-0" />
)}
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,498 @@
"use client";
import React from "react";
import { createPortal } from "react-dom";
import { FiChevronDown, FiSearch, FiX } from "react-icons/fi";
import { baseGameLabel, baseRoms, PLATFORM_NAMES, type Platform } from "@/data/baseRoms";
/** Rail order: the platforms with the most hacks first. */
const RAIL_PLATFORMS: Platform[] = ["GBA", "GBC", "GB", "NDS"];
import { DISCOVER_COMPLETION_STATUSES } from "@/app/discover/search-params";
import type { DiscoverHack } from "@/types/discover";
import { AI_AREAS, AI_PRESETS, aiPresetOf, matchesAiFilter, normalizeAiFilter, NO_AI_FILTER, type AiArea, type AiFilter } from "@/utils/aiDisclosure";
import { useCloseOnDesktop, useDialog } from "@/hooks/useDialog";
/** The filterable part of the Discover URL state. The sheet edits a draft copy of this. */
export type FilterState = {
tags: string[];
baseRoms: string[];
completionStatuses: string[];
onlyReady: boolean;
ai: AiFilter;
};
export const EMPTY_FILTERS: FilterState = { tags: [], baseRoms: [], completionStatuses: [], onlyReady: false, ai: NO_AI_FILTER };
/** Disclosure id of the AI checklist; opened up front when the pick is custom. */
export const AI_AREAS_DISCLOSURE = "ai:areas";
export type TagGroup = { name: string; tags: string[] };
export type RomDump = { id: string; label: string };
export type RomGame = { key: string; label: string; platform: Platform; ids: string[]; dumps: RomDump[] };
/** Grouping key only, never shown: revisions and regional dumps of one game share it. */
function gameName(name: string) {
return baseGameLabel(name).replace(/\s*\((Rev \d+|FR|DE|JP)\)\s*$/i, "");
}
/**
* Base ROMs grouped by game. A game with several dumps (FireRed Rev 0 / Rev 1,
* regional releases) nests them under one row; each dump keeps its own id
* because each hashes differently and a hack is built against exactly one.
*/
export const ROM_GAMES: RomGame[] = (() => {
const byKey = new Map<string, RomGame>();
for (const rom of baseRoms) {
const label = gameName(rom.name);
const key = `${rom.platform}:${label}`;
const game = byKey.get(key) ?? { key, label, platform: rom.platform, ids: [], dumps: [] };
game.ids.push(rom.id);
game.dumps.push({ id: rom.id, label: baseGameLabel(rom.name) });
byKey.set(key, game);
}
return [...byKey.values()];
})();
/** This search's filters, which Clear resets. The AI filter is a saved preference, so it isn't one. */
export function countClearable(f: FilterState) {
return f.tags.length + f.baseRoms.length + f.completionStatuses.length + (f.onlyReady ? 1 : 0);
}
/** Everything narrowing the results, for the phone's Filters badge. */
export function countActive(f: FilterState) {
return countClearable(f) + (f.ai.hide.length > 0 ? 1 : 0);
}
export function toggleValue<T>(list: readonly T[], value: T) {
return list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
}
/** Catalog-wide counts so a filter's number tells you what it will do before you tap it. */
export function useFacetCounts(catalog: DiscoverHack[]) {
return React.useMemo(() => {
const byBase = new Map<string, number>();
const byCompletion = new Map<string, number>();
const byTag = new Map<string, number>();
for (const h of catalog) {
if (h.baseRomId) byBase.set(h.baseRomId, (byBase.get(h.baseRomId) ?? 0) + 1);
const c = h.completion_status ?? "Complete";
byCompletion.set(c, (byCompletion.get(c) ?? 0) + 1);
for (const t of h.tags) byTag.set(t.name, (byTag.get(t.name) ?? 0) + 1);
}
return {
base: (ids: string[]) => ids.reduce((n, id) => n + (byBase.get(id) ?? 0), 0),
completion: (c: string) => byCompletion.get(c) ?? 0,
tag: (t: string) => byTag.get(t) ?? 0,
ai: (f: AiFilter) => catalog.filter((h) => matchesAiFilter(h.ai, f)).length,
};
}, [catalog]);
}
interface FieldsProps {
value: FilterState;
onChange: (next: FilterState) => void;
tagGroups: TagGroup[];
counts: ReturnType<typeof useFacetCounts>;
readyCount: number;
/** Groups whose disclosure is open; the sheet starts with everything closed except groups that already have a pick. */
open: Set<string>;
onToggleOpen: (id: string) => void;
tall?: boolean;
}
/** The rail body. Shared by the desktop rail (live) and the phone sheet (draft). */
export function FilterFields({ value, onChange, tagGroups, counts, readyCount, open, onToggleOpen, tall }: FieldsProps) {
const [romQ, setRomQ] = React.useState("");
const [tagQ, setTagQ] = React.useState("");
const romQuery = romQ.trim().toLowerCase();
const tagQuery = tagQ.trim().toLowerCase();
const rowH = tall ? "min-h-11" : "min-h-8";
// The rail and the phone sheet are both mounted on phones; a shared radio name made them one group,
// so the rail's pick unchecked the sheet's.
const aiGroup = `discover-ai-${React.useId()}`;
const setBaseRoms = (baseRomsNext: string[]) => onChange({ ...value, baseRoms: baseRomsNext, onlyReady: baseRomsNext.length > 0 ? false : value.onlyReady });
// The game row selects every dump; once all are on, it clears them all.
const toggleGame = (ids: string[]) => {
const all = ids.every((id) => value.baseRoms.includes(id));
setBaseRoms(all ? value.baseRoms.filter((id) => !ids.includes(id)) : [...new Set([...value.baseRoms, ...ids])]);
};
const toggleDump = (id: string) => setBaseRoms(toggleValue(value.baseRoms, id));
const aiPreset = aiPresetOf(value.ai);
const setAi = (ai: AiFilter) => onChange({ ...value, ai: normalizeAiFilter(ai) });
// Ticking code yourself allows small use by default; only the presets are strict about it.
const toggleAiArea = (k: AiArea) =>
setAi({ hide: toggleValue(value.ai.hide, k), smallCode: k === "code" ? !value.ai.hide.includes("code") : value.ai.smallCode });
const visibleGames = ROM_GAMES.map((g) => ({ ...g, dumps: g.dumps.filter((d) => !romQuery || g.label.toLowerCase().includes(romQuery) || d.label.toLowerCase().includes(romQuery)) })).filter((g) => g.dumps.length > 0);
const visibleGroups = tagGroups
.map((g) => ({ ...g, tags: g.tags.filter((t) => !tagQuery || t.toLowerCase().includes(tagQuery)) }))
.filter((g) => g.tags.length > 0);
return (
<>
{readyCount > 0 && (
<Check
className={`-mx-2 mb-2 px-2 py-1.5 font-medium ${rowH}`}
ready
checked={value.onlyReady}
onChange={() => onChange({ ...value, onlyReady: !value.onlyReady, baseRoms: value.onlyReady ? value.baseRoms : [] })}
label={
<span className="inline-flex items-center gap-2">
<span className="ready-dot" /> Ready to patch
</span>
}
count={readyCount}
/>
)}
<Group title="Base ROM">
<Find value={romQ} onChange={setRomQ} placeholder="Find a base ROM" />
{RAIL_PLATFORMS.map((platform) => {
const games = visibleGames.filter((g) => g.platform === platform);
if (games.length === 0) return null;
const id = `rom:${platform}`;
const picked = games.filter((g) => g.ids.some((i) => value.baseRoms.includes(i))).length;
return (
<Disclosure
key={id}
id={id}
label={PLATFORM_NAMES[platform]}
detail={picked > 0 ? `${picked} selected` : String(games.length)}
expanded={open.has(id) || romQuery.length > 0}
onToggle={() => onToggleOpen(id)}
tall={tall}
>
{games.map((g) => {
const ids = g.dumps.map((d) => d.id);
const on = ids.filter((i) => value.baseRoms.includes(i)).length;
if (g.dumps.length === 1) {
return <Check key={g.key} className={rowH} label={g.dumps[0].label} count={counts.base(ids)} checked={on === 1} onChange={() => toggleDump(ids[0])} />;
}
const gid = `game:${g.key}`;
const expanded = open.has(gid) || romQuery.length > 0;
return (
<div key={g.key}>
{/* The box selects every dump; the rest of the row opens the list. */}
<div className={`group/check flex items-center gap-2.5 rounded-md text-sm ${rowH}`}>
<CheckBox checked={on === ids.length} indeterminate={on > 0 && on < ids.length} onChange={() => toggleGame(ids)} ariaLabel={`All ${g.label} revisions`} />
<button
type="button"
aria-expanded={expanded}
aria-controls={gid}
onClick={() => onToggleOpen(gid)}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 self-stretch text-left"
>
<span className="truncate">{g.label}</span>
<FiChevronDown className={`h-4 w-4 flex-none text-text-3 transition-transform duration-[160ms] ${expanded ? "-rotate-180" : ""}`} aria-hidden />
<small className="ml-auto text-xs tabular-nums text-text-3">{counts.base(ids)}</small>
</button>
</div>
{expanded && (
<div id={gid} className="ml-[9px] border-l border-line pl-4">
{g.dumps.map((d) => (
<Check key={d.id} className={rowH} label={d.label} count={counts.base([d.id])} checked={value.baseRoms.includes(d.id)} onChange={() => toggleDump(d.id)} />
))}
</div>
)}
</div>
);
})}
</Disclosure>
);
})}
{romQuery && visibleGames.length === 0 && <p className="mt-2 text-[13px] text-text-3">No base ROMs match.</p>}
</Group>
<Group title="Completion">
{DISCOVER_COMPLETION_STATUSES.map((c) => (
<Check
key={c}
className={rowH}
label={c}
count={counts.completion(c)}
checked={value.completionStatuses.includes(c)}
onChange={() => onChange({ ...value, completionStatuses: toggleValue(value.completionStatuses, c) })}
/>
))}
</Group>
<Group title="AI use" note="These settings are saved across sessions." flashTarget>
<div role="radiogroup" aria-label="AI use">
{AI_PRESETS.map((p) => (
<Radio
key={p.value}
name={aiGroup}
className={rowH}
label={p.label}
hint={"hint" in p ? p.hint : undefined}
count={counts.ai(p.filter)}
checked={aiPreset === p.value}
onChange={() => setAi(p.filter)}
/>
))}
{/* Shows while the areas match no preset; ticking back to a preset's areas selects it again. */}
{aiPreset === "custom" && (
<Radio name={aiGroup} className={`${rowH} animate-[fadeIn_180ms_ease-out]`} label="Custom" count={counts.ai(value.ai)} checked onChange={() => {}} />
)}
</div>
<Disclosure
id={AI_AREAS_DISCLOSURE}
label="Choose areas"
detail={value.ai.hide.length > 0 ? `${value.ai.hide.length} hidden` : ""}
expanded={open.has(AI_AREAS_DISCLOSURE)}
onToggle={() => onToggleOpen(AI_AREAS_DISCLOSURE)}
tall={tall}
>
{/* Indented to line up with the "Choose areas" label, past its chevron. */}
<div className="pl-[22px]">
<p className="mb-0.5 text-xs font-semibold text-text-3">Hide hacks with AI in</p>
{AI_AREAS.map((a) => (
<React.Fragment key={a.key}>
<Check className={rowH} label={a.name} checked={value.ai.hide.includes(a.key)} onChange={() => toggleAiArea(a.key)} />
{a.key === "code" && value.ai.hide.includes("code") && (
<Check
className={`${rowH} ml-7 animate-[fadeIn_180ms_ease-out]`}
label="Allow small usage"
hint="A bug fix or a few"
checked={value.ai.smallCode}
onChange={() => setAi({ ...value.ai, smallCode: !value.ai.smallCode })}
/>
)}
</React.Fragment>
))}
</div>
</Disclosure>
</Group>
<Group title="Tags">
<Find value={tagQ} onChange={setTagQ} placeholder="Find a tag" />
{visibleGroups.map((group) => {
const id = `tag:${group.name}`;
const picked = group.tags.filter((t) => value.tags.includes(t)).length;
return (
<Disclosure
key={id}
id={id}
label={group.name}
detail={picked > 0 ? `${picked} selected` : String(group.tags.length)}
expanded={open.has(id) || tagQuery.length > 0}
onToggle={() => onToggleOpen(id)}
tall={tall}
>
{group.tags.map((t) => (
<Check
key={t}
className={rowH}
label={t}
count={counts.tag(t)}
checked={value.tags.includes(t)}
onChange={() => onChange({ ...value, tags: toggleValue(value.tags, t) })}
/>
))}
</Disclosure>
);
})}
{tagQuery && visibleGroups.length === 0 && <p className="mt-2 text-[13px] text-text-3">No tags match.</p>}
</Group>
</>
);
}
/** `flashTarget` marks the group flashAiGroup scrolls to. */
function Group({ title, note, flashTarget, children }: { title: string; note?: string; flashTarget?: boolean; children: React.ReactNode }) {
return (
<section data-flash-target={flashTarget || undefined} className="border-t border-line py-4 first-of-type:border-t-0">
<h3 className={`${note ? "" : "mb-2 "}text-[13px] font-semibold text-text-3`}>{title}</h3>
{note && <p className="mb-2 mt-0.5 text-xs text-text-3">{note}</p>}
{children}
</section>
);
}
/** Scrolls the AI use group into view inside `root` (the rail or the sheet) and flashes it. */
export function flashAiGroup(root: ParentNode | null) {
const group = root?.querySelector<HTMLElement>("[data-flash-target]");
if (!group) return;
group.scrollIntoView({ block: "nearest", behavior: "smooth" });
group.classList.remove("anim-row-flash");
void group.offsetWidth; // restart the animation on a repeat tap
group.classList.add("anim-row-flash");
}
function Find({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
return (
<label className="relative mb-2 block text-text-3">
<FiSearch className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label={placeholder}
className="h-[34px] w-full rounded-control border border-line-strong bg-surface pl-8 pr-2.5 text-[13px] text-text outline-none transition-colors placeholder:text-text-3 focus:border-accent"
/>
</label>
);
}
/** `detail` sits at the right of the row: a count, or how many are picked. */
function Disclosure({ id, label, detail, expanded, onToggle, tall, children }: { id: string; label: string; detail: string; expanded: boolean; onToggle: () => void; tall?: boolean; children: React.ReactNode }) {
return (
<div className="[&+&]:mt-0.5">
<button
type="button"
aria-expanded={expanded}
aria-controls={id}
onClick={onToggle}
className={`flex w-full items-center gap-1.5 rounded-md px-0.5 text-left text-[13px] font-semibold text-text-2 transition-colors hover:bg-surface-2 hover:text-text ${tall ? "min-h-11" : "min-h-8"}`}
>
<FiChevronDown className={`h-4 w-4 flex-none text-text-3 transition-transform duration-[160ms] ${expanded ? "-rotate-180" : ""}`} />
{label}
<small className="ml-auto text-xs font-medium text-text-3">{detail}</small>
</button>
{expanded && (
<div id={id} className="pb-2 pl-0.5 pt-1">
{children}
</div>
)}
</div>
);
}
const BOX =
"relative h-[18px] w-[18px] flex-none rounded-[5px] border-[1.5px] border-line-strong bg-surface transition-colors duration-[120ms] group-hover/check:border-text-3 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent after:absolute after:left-[5px] after:top-[1.5px] after:h-[9px] after:w-[5px] after:rotate-45 after:scale-[.6] after:border-b-2 after:border-r-2 after:border-white after:opacity-0 after:transition-[transform,opacity] after:duration-[120ms] after:content-[''] peer-checked:after:scale-100 peer-checked:after:opacity-100 peer-indeterminate:border-accent-deep peer-indeterminate:bg-accent-deep peer-indeterminate:after:left-[4px] peer-indeterminate:after:top-[7px] peer-indeterminate:after:h-0 peer-indeterminate:after:w-2 peer-indeterminate:after:rotate-0 peer-indeterminate:after:scale-100 peer-indeterminate:after:border-r-0 peer-indeterminate:after:opacity-100";
/** The box alone. `indeterminate` draws the dash for a parent whose children are only partly selected. */
function CheckBox({ checked, indeterminate = false, onChange, ready = false, ariaLabel }: { checked: boolean; indeterminate?: boolean; onChange: () => void; ready?: boolean; ariaLabel?: string }) {
const ref = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<label className="relative inline-flex cursor-pointer">
<input ref={ref} type="checkbox" checked={checked} onChange={onChange} aria-label={ariaLabel} className="peer sr-only" />
<span className={`${BOX} ${ready ? "peer-checked:border-ready peer-checked:bg-ready" : "peer-checked:border-accent-deep peer-checked:bg-accent-deep"}`} />
</label>
);
}
/**
* Checkbox row: box, label (and hint), count. The whole row toggles. Rows are `relative` so the
* sr-only input sits in the row: focusing it then scrolls the list, not the sheet's frame.
*/
function Check({ label, hint, count, checked, onChange, className = "", ready = false }: { label: React.ReactNode; hint?: string; count?: number; checked: boolean; onChange: () => void; className?: string; ready?: boolean }) {
return (
<label className={`group/check relative flex cursor-pointer select-none gap-2.5 rounded-md text-sm ${hint ? "items-start py-1.5" : "items-center"} ${className}`}>
<input type="checkbox" checked={checked} onChange={onChange} className="peer sr-only" />
<span className={`${BOX} ${hint ? "mt-px" : ""} ${ready ? "peer-checked:border-ready peer-checked:bg-ready" : "peer-checked:border-accent-deep peer-checked:bg-accent-deep"}`} />
<span className="min-w-0 flex-1 truncate">
{label}
{hint && <small className="mt-0.5 block text-xs leading-[1.35] text-text-3">{hint}</small>}
</span>
{count !== undefined && <small className="text-xs tabular-nums text-text-3">{count}</small>}
</label>
);
}
/** Radio row for single-choice groups, same layout as Check. The hint wraps under the label. */
function Radio({ name, label, hint, count, checked, onChange, className = "" }: { name: string; label: string; hint?: string; count: number; checked: boolean; onChange: () => void; className?: string }) {
return (
<label className={`group/check relative flex cursor-pointer select-none items-start gap-2.5 rounded-md py-1.5 text-sm ${className}`}>
<input type="radio" name={name} checked={checked} onChange={onChange} className="peer sr-only" />
<span className="relative mt-px h-[18px] w-[18px] flex-none rounded-full border-[1.5px] border-line-strong bg-surface transition-[border-color,border-width] duration-[120ms] group-hover/check:border-text-3 peer-checked:border-[5px] peer-checked:border-accent-deep peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent" />
<span className="min-w-0 flex-1">
{label}
{hint && <small className="mt-0.5 block text-xs leading-[1.35] text-text-3">{hint}</small>}
</span>
<small className="mt-0.5 text-xs tabular-nums text-text-3">{count}</small>
</label>
);
}
interface SheetProps {
active: number;
total: number;
onClose: () => void;
onCommit: () => void;
onClear: () => void;
children: React.ReactNode;
}
/**
* Phone filter sheet. Taps edit a draft; the footer commits it. Close, scrim,
* Escape, and history Back discard. 90% tall so the whole rail fits.
*/
export function FilterSheet({ active, total, onClose, onCommit, onClear, children }: SheetProps) {
const panelRef = React.useRef<HTMLDivElement>(null);
const [mounted, setMounted] = React.useState(false);
const [inView, setInView] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
// Focus, scroll lock, Tab trap, and Escape come from the shared dialog hook.
useDialog(panelRef, onClose, mounted);
useCloseOnDesktop(onClose, mounted);
React.useEffect(() => {
if (!mounted) return;
// Paint at the final height first, then slide in, so the panel never travels as a tall content box.
const enter = requestAnimationFrame(() => requestAnimationFrame(() => setInView(true)));
return () => cancelAnimationFrame(enter);
}, [mounted]);
if (!mounted) return null;
const show = total === 1 ? "Show 1 hack" : `Show ${total.toLocaleString("en-US")} hacks`;
return createPortal(
<div className="fixed inset-0 z-50 flex flex-col justify-end overflow-hidden md:hidden" role="presentation">
<button type="button" className="absolute inset-0 bg-[rgba(13,16,23,.4)] anim-fade" aria-label="Dismiss filters" onClick={onClose} />
<div
ref={panelRef}
id="discover-filter-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="discover-filter-title"
tabIndex={-1}
className={`relative flex h-[90%] max-h-[90%] min-h-0 flex-none flex-col overflow-hidden rounded-t-[20px] bg-surface text-text shadow-overlay outline-none transition-transform duration-[240ms] motion-reduce:transition-none ease-[cubic-bezier(.2,.8,.2,1)] ${
inView ? "translate-y-0" : "translate-y-full"
}`}
>
<div className="flex flex-none justify-center pb-1 pt-2.5" aria-hidden>
<span className="h-1 w-8 rounded-full bg-line-strong" />
</div>
<div className="grid flex-none grid-cols-[1fr_auto_1fr] items-center gap-2 border-b border-line px-3 pb-2.5">
{active > 0 ? (
<button type="button" className="text-link-hd justify-self-start text-sm" onClick={onClear}>
Clear {active}
</button>
) : (
<span />
)}
<h2 id="discover-filter-title" className="text-center text-[17px] font-semibold">
Filters
</h2>
<button type="button" aria-label="Close" onClick={onClose} className="inline-flex h-11 w-11 items-center justify-center justify-self-end rounded-control text-text-2 hover:bg-surface-2 hover:text-text">
<FiX className="h-5 w-5" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-4 pt-1">{children}</div>
<div className="flex-none border-t border-line bg-surface p-4 pb-[calc(16px+env(safe-area-inset-bottom,0px))]">
{total === 0 && <p className="mb-2 text-center text-[13px] text-text-3">No hacks match. Clear filters.</p>}
<button
type="button"
onClick={onCommit}
disabled={total === 0}
className="h-12 w-full rounded-control bg-accent-deep text-[15px] font-semibold text-white transition-colors hover:bg-accent-hover disabled:bg-surface-2 disabled:text-text-3"
>
{show}
</button>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -24,7 +24,7 @@ export default function DiscoverLastUpdated({
}, [generatedAt]);
return (
<p className="mt-4 text-center text-xs text-foreground/60">
<p className="mt-4 text-center text-xs text-text-3">
Last updated{" "}
<time dateTime={generatedAt}>
{minutesAgo} {minutesAgo === 1 ? "minute" : "minutes"} ago

View File

@@ -0,0 +1,22 @@
import { aiFilterToken, NO_AI_FILTER, parseAiFilter, type AiFilter } from "@/utils/aiDisclosure";
const KEY = "hackdex:discover-ai";
// The AI filter is the visitor's standing preference, so it's saved on this device and never
// put in the URL (a shared link shouldn't carry it). The other filters are one-off searches.
export function readSavedAiFilter(): AiFilter {
try {
return parseAiFilter(localStorage.getItem(KEY) ?? undefined);
} catch {
return NO_AI_FILTER;
}
}
/** "Any" clears it. */
export function saveAiFilter(filter: AiFilter) {
try {
if (filter.hide.length > 0) localStorage.setItem(KEY, aiFilterToken(filter));
else localStorage.removeItem(KEY);
} catch {}
}

View File

@@ -11,6 +11,12 @@ import {
const SEARCH_URL_DEBOUNCE_MS = 300;
/**
* Fired after something outside Discover (the header search) rewrites a /discover URL in place.
* Same-route navigations don't remount Discover or fire popstate, so it listens for this too.
*/
export const DISCOVER_URL_EVENT = "hackdex:discover-url";
type UrlSyncMode = "push" | "replace";
interface UseDiscoverUrlStateArgs {
@@ -110,7 +116,11 @@ export function useDiscoverUrlState({ currentState, onUrlStateChange }: UseDisco
currentStateRef.current = nextState;
};
window.addEventListener("popstate", applyUrlState);
return () => window.removeEventListener("popstate", applyUrlState);
window.addEventListener(DISCOVER_URL_EVENT, applyUrlState);
return () => {
window.removeEventListener("popstate", applyUrlState);
window.removeEventListener(DISCOVER_URL_EVENT, applyUrlState);
};
}, [clearSearchUrlTimeout]);
return {

View File

@@ -1,72 +1,85 @@
import Image from "next/image";
import Link from "next/link";
import FooterAccountLinks from "@/components/FooterAccountLinks";
const PLAY = [
{ href: "/discover", label: "Discover" },
{ href: "/roms", label: "My ROMs" },
{ href: "/faq", label: "FAQ" },
];
const ABOUT = [
{ href: "/contact", label: "Contact" },
{ href: "https://github.com/orgs/Hackdex-App/projects/4", label: "Roadmap" },
{ href: "https://github.com/Hackdex-App/hackdex-website", label: "GitHub" },
];
// Footer links render on every page, so they skip viewport prefetch (it showed up as Edge Requests far above page views).
const FOOTER_LINK = "w-fit whitespace-nowrap py-[3px] hover:text-text hover:underline hover:underline-offset-[3px]";
function Column({ title, children }: { title: string; children: React.ReactNode }) {
return (
<nav aria-label={title} className="flex flex-col">
<h2 className="mb-1.5 text-xs font-semibold text-text">{title}</h2>
{children}
</nav>
);
}
/** Site footer: blurb and three link columns (Play / Create / Hackdex), then copyright, legal links, and trademarks. */
export default function Footer() {
return (
<footer className="border-t border-white/10">
<div className="mx-auto max-w-screen-2xl px-6 py-8 text-sm text-foreground/70">
<div className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
<div>
<p>© 2025-{new Date().getFullYear()} Hackdex</p>
<p className="mt-2 max-w-2xl text-xs text-foreground/60">
Pokémon, Nintendo, Game Boy, Game Boy Color, Game Boy Advance, and Nintendo DS are
trademarks of their respective owners. Hackdex is an independent fan project and is not
affiliated with, endorsed, or sponsored by Nintendo, The Pokémon Company, or GAME FREAK.
Please support them by purchasing their most recent games.
</p>
<p className="mt-2 max-w-2xl text-xs text-foreground/60">
We host only patch files, never ROMs. When using our patcher, your legally-obtained ROMs never leave your device.
</p>
</div>
{/* Viewport prefetch of these links from every page was showing up as
Edge Requests on /terms, /submit, /roms, etc. far above their page-view share. */}
<div className="grid grid-cols-1 gap-x-12 gap-y-8 sm:gap-y-4 sm:grid-cols-2 md:grid-cols-3 md:min-w-[465px]">
<div>
<div className="mb-3 pb-2 border-b border-white/10 text-xs font-medium uppercase tracking-wider text-foreground/80">Platform</div>
<ul className="space-y-1.5 sm:space-y-2">
<li>
<Link href="/discover" prefetch={false} className="block py-1 hover:underline">Discover</Link>
</li>
<li>
<Link href="/submit" prefetch={false} className="block py-1 hover:underline">Submit</Link>
</li>
<li>
<Link href="/login" prefetch={false} className="block py-1 hover:underline">Log in</Link>
</li>
</ul>
</div>
<div>
<div className="mb-3 pb-2 border-b border-white/10 text-xs font-medium uppercase tracking-wider text-foreground/80">Legal</div>
<ul className="space-y-1.5 sm:space-y-2">
<li>
<Link href="/terms" prefetch={false} className="block py-1 hover:underline">Terms of Service</Link>
</li>
<li>
<Link href="/privacy" prefetch={false} className="block py-1 hover:underline">Privacy Policy</Link>
</li>
</ul>
</div>
<div>
<div className="mb-3 pb-2 border-b border-white/10 text-xs font-medium uppercase tracking-wider text-foreground/80">Support</div>
<ul className="space-y-1.5 sm:space-y-2">
<li>
<Link href="/faq" prefetch={false} className="block py-1 hover:underline">FAQ</Link>
</li>
<li>
<Link href="/contact" prefetch={false} className="block py-1 hover:underline">Contact</Link>
</li>
<li>
<Link href="https://github.com/Hackdex-App/hackdex-website" className="block py-1 hover:underline">GitHub</Link>
</li>
<li>
<Link href="https://github.com/orgs/Hackdex-App/projects/4" className="block py-1 hover:underline">Roadmap</Link>
</li>
</ul>
</div>
<footer className="mt-16 border-t border-line text-[13px] text-text-2">
{/* Phones: extra bottom room so the floating "How do I download?" pill on hack pages never covers the links. */}
<div className="mx-auto max-w-[1164px] px-6 pb-24 pt-8 md:pb-10">
<div className="grid grid-cols-[repeat(3,auto)] justify-between gap-x-4 gap-y-7 md:grid-cols-[1.4fr_1fr_1fr_1fr] md:gap-x-6">
<div className="col-span-3 md:col-span-1">
<Link href="/" prefetch={false} className="inline-flex items-center gap-2 font-display text-[16px] text-text">
<Image src="/logo.png" alt="" width={22} height={22} className="rounded-[6px]" unoptimized />
Hackdex
</Link>
<p className="mt-2 max-w-[34ch] text-text-3">Patch Pokémon ROM hacks in your browser. We host only patch files, never ROMs, and your ROMs never leave your device.</p>
</div>
<Column title="Play">
{PLAY.map((l) => (
<Link key={l.href} href={l.href} prefetch={false} className={FOOTER_LINK}>
{l.label}
</Link>
))}
</Column>
<Column title="Create">
<Link href="/submit" prefetch={false} className={FOOTER_LINK}>
Submit a hack
</Link>
<FooterAccountLinks className={FOOTER_LINK} />
</Column>
<Column title="Hackdex">
{ABOUT.map((l) => (
<Link key={l.href} href={l.href} prefetch={false} className={FOOTER_LINK}>
{l.label}
</Link>
))}
</Column>
</div>
<div className="mt-8 border-t border-line pt-5">
<p className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<span>© 2025-{new Date().getFullYear()} Hackdex</span>
<Link href="/terms" prefetch={false} className={FOOTER_LINK}>
Terms
</Link>
<Link href="/privacy" prefetch={false} className={FOOTER_LINK}>
Privacy
</Link>
</p>
<p className="mt-2 max-w-[90ch] text-xs text-text-3">
Pokémon, Nintendo, Game Boy, Game Boy Color, Game Boy Advance, and Nintendo DS are trademarks of their
respective owners. Hackdex is an independent fan project and is not affiliated with, endorsed, or
sponsored by Nintendo, The Pokémon Company, or GAME FREAK. Please support them by purchasing their most
recent games.
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import Link from "next/link";
import { useAuthContext } from "@/contexts/AuthContext";
/**
* The footer's account links: "Become a creator" and "Creator log in" for
* guests, "Dashboard" once a session is known. The auth context starts empty on
* both server and client, so the static footer always ships the guest links and
* the swap happens after mount without a hydration mismatch.
*/
export default function FooterAccountLinks({ className }: { className: string }) {
const { user } = useAuthContext();
const links = user
? [{ href: "/dashboard", label: "Dashboard" }]
: [
{ href: "/signup", label: "Become a creator" },
{ href: "/login", label: "Creator log in" },
];
return links.map((l) => (
<Link key={l.href} href={l.href} className={className}>
{l.label}
</Link>
));
}

View File

@@ -0,0 +1,68 @@
"use client";
import React from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { FiX } from "react-icons/fi";
import { PiConfettiBold } from "react-icons/pi";
import { FRESH_LOOK_STORAGE_KEY } from "@/utils/initScripts";
/** Player-facing pages. Home has the milestone card instead; editors, dashboards, and admin pages skip it. */
const PAGES = [/^\/discover$/, /^\/hack\/[^/]+$/, /^\/faq$/, /^\/roms$/];
/** The copy thanks players for this milestone, so the banner only runs while it's the one celebrated. */
const SHOWN = process.env.NEXT_PUBLIC_DOWNLOADS_MILESTONE?.trim() === "1000000";
/**
* "Hackdex has a fresh new look" strip under the header, for visitors who land
* somewhere other than the homepage. Dismissing hides it on this device until the
* next event (see FRESH_LOOK_STORAGE_KEY); freshLookInitScript hides it before paint.
*/
export default function FreshLookBanner() {
if (!SHOWN) return null;
return (
// The fallback is what the server renders (search params aren't known for static pages).
<React.Suspense fallback={<Banner />}>
<BannerOutsideEditor />
</React.Suspense>
);
}
function BannerOutsideEditor() {
return useSearchParams().get("edit") === "1" ? null : <Banner />;
}
function Banner() {
const pathname = usePathname();
const [dismissed, setDismissed] = React.useState(false);
if (dismissed || !PAGES.some((page) => page.test(pathname))) return null;
function dismiss() {
setDismissed(true);
document.documentElement.setAttribute("data-fresh-look-dismissed", "");
try {
localStorage.setItem(FRESH_LOOK_STORAGE_KEY, "1");
} catch {
// Private browsing: it just comes back next visit.
}
}
return (
<div className="fresh-look-banner border-b border-[color-mix(in_srgb,var(--rose)_22%,var(--line))] bg-[color-mix(in_srgb,var(--rose)_8%,var(--surface))]">
<div className="mx-auto flex max-w-[1164px] items-center gap-2.5 py-2 pl-6 pr-3 text-[13.5px] leading-[1.45] md:pr-4">
<PiConfettiBold aria-hidden className="h-4 w-4 flex-none text-accent-text" />
<p className="min-w-0 flex-1">
<b className="font-bold text-text">Hackdex has a fresh new look.</b>{" "}
<span className="text-text-2">A thank you for 1,000,000 downloads: faster browsing, clearer hack pages, and a better user experience.</span>
</p>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss"
className="inline-flex h-9 w-9 flex-none items-center justify-center rounded-control text-text-3 transition-colors hover:bg-text/[.08] hover:text-text"
>
<FiX className="h-4 w-4" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import React from "react";
import Link from "next/link";
import { toast } from "sonner";
import Modal from "@/components/Primitives/Modal";
import AiLabel, { AI_AREA_ICONS } from "@/components/Hack/AiLabel";
import { NETWORK_ERROR } from "@/utils/networkError";
import { AI_AREAS, AI_PRESETS, AI_LEVEL_LABEL, matchesAiFilter, type AiDisclosure, type AiLevel, type AiLevels } from "@/utils/aiDisclosure";
const NOTE_MAX = 1000;
/**
* The creator's AI disclosure form with a live preview of the label. Every
* area needs a pick before Save, so a new disclosure starts with none chosen.
* `onSave` resolves true once the change is saved (or staged), closing the modal.
*/
export default function AiDisclosureModal({
visible,
initial,
onClose,
onSave,
}: {
visible: boolean;
initial: AiDisclosure | null;
onClose: () => void;
onSave: (levels: AiLevels, note: string | null) => Promise<boolean>;
}) {
return (
<Modal title="AI use" visible={visible} onClose={onClose} className="max-w-[860px]">
<AiDisclosureForm initial={initial} onCancel={onClose} onSave={onSave} />
</Modal>
);
}
function AiDisclosureForm({ initial, onCancel, onSave }: { initial: AiDisclosure | null; onCancel: () => void; onSave: (levels: AiLevels, note: string | null) => Promise<boolean> }) {
const [levels, setLevels] = React.useState<Partial<AiLevels>>(initial?.levels ?? {});
const [note, setNote] = React.useState(initial?.note ?? "");
const [busy, setBusy] = React.useState(false);
const complete = AI_AREAS.every((a) => levels[a.key] !== undefined);
const preview: AiLevels = Object.fromEntries(AI_AREAS.map((a) => [a.key, levels[a.key] ?? "none"])) as AiLevels;
async function save() {
if (!complete || busy) return;
setBusy(true);
// Callers report their own errors; a thrown action (network) would otherwise leave Save stuck.
const ok = await onSave(preview, note.trim() || null).catch(() => {
toast.error(NETWORK_ERROR);
return false;
});
setBusy(false);
if (ok) onCancel();
}
return (
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_280px] md:items-start">
<div>
<p className="-mt-2 text-[13px] leading-[1.45] text-text-2">
Pick the level that feels right for each area. It doesn&rsquo;t need to be exact. Count AI-generated work you and your team added to the hack or this page on purpose, even if you edited it afterward. Something like AI code that came with a base you built on doesn&rsquo;t count.{" "}
<Link href="/faq#ai-disclosure" prefetch={false} target="_blank" className="text-link-hd">
What the levels mean
</Link>
</p>
{AI_AREAS.map((area, i) => {
const Icon = AI_AREA_ICONS[area.key];
const picked = levels[area.key];
const hints: Partial<Record<AiLevel, string>> = area.hints;
const hint = picked && hints[picked];
return (
<React.Fragment key={area.key}>
{(i === 0 || area.key === "code") && (
<p className="mt-4 text-[11px] font-bold uppercase tracking-[.04em] text-text-3">{i === 0 ? "What players see and hear" : "Under the hood"}</p>
)}
<fieldset className={`py-2.5 ${i === 0 || area.key === "code" ? "" : "border-t border-line"}`}>
<legend className="sr-only">{area.name}</legend>
<div className="flex flex-col items-start gap-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<span aria-hidden className="inline-flex items-center gap-2 text-sm">
<Icon className="h-4 w-4 text-text-3" /> {area.name}
</span>
<div className="inline-flex flex-wrap rounded-control bg-surface-2 p-0.5 max-sm:grid max-sm:w-full max-sm:auto-cols-fr max-sm:grid-flow-col">
{area.levels.map((level) => (
<label
key={level}
className="inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-md px-2.5 py-1 text-[12.5px] max-sm:min-h-11 max-sm:px-1 text-text-2 transition-colors hover:text-text has-[:checked]:bg-surface has-[:checked]:font-semibold has-[:checked]:text-text has-[:checked]:shadow-rest has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-accent"
>
<input
type="radio"
name={`ai-${area.key}`}
value={level}
checked={picked === level}
onChange={() => setLevels((prev) => ({ ...prev, [area.key]: level }))}
className="sr-only"
/>
{AI_LEVEL_LABEL[level]}
</label>
))}
</div>
</div>
{hint && <p className="mt-1.5 text-[12.5px] text-text-2">{hint}</p>}
</fieldset>
</React.Fragment>
);
})}
<label className="mt-1 block border-t border-line pt-3">
<span className="block text-sm font-semibold">
Anything you want to explain? <span className="font-normal text-text-3">Optional</span>
</span>
<span className="mb-2 mt-0.5 block text-[12.5px] text-text-3">Shown to players under the breakdown. Tell them what AI was used for, how, or why.</span>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={NOTE_MAX}
rows={3}
placeholder="e.g. The title screen started as an AI image that I traced and recolored."
className="w-full resize-y rounded-control border border-line-strong bg-surface px-2.5 py-2 text-[13.5px] leading-[1.45] text-text outline-none transition-[border-color,box-shadow] placeholder:text-text-3 focus:border-accent focus:shadow-[0_0_0_3px_var(--rose-soft)]"
/>
</label>
</div>
<div className="md:sticky md:top-0">
<p className="mb-2 text-[11.5px] font-semibold text-text-3">Players will see</p>
<AiLabel id="ai-breakdown-preview" disclosure={{ levels: preview, note: note.trim() || null, disclosedAt: new Date().toISOString() }} defaultOpen />
<p className="mt-3 flex flex-wrap items-center gap-1.5 text-xs text-text-3">
Shows under
{AI_PRESETS.map((f) => (
<FilterChip key={f.value} on={matchesAiFilter(preview, f.filter)}>
{f.label}
</FilterChip>
))}
</p>
</div>
{/* Phones: the hint gets its own line and the buttons split the width below it. */}
<div className="flex flex-col gap-2.5 border-t border-line pt-4 sm:flex-row sm:items-center md:col-span-2">
<span className="text-xs text-text-3 empty:hidden sm:mr-auto" aria-live="polite">
{!complete && "Pick a level for every area to save."}
</span>
<div className="grid grid-cols-2 gap-2.5 sm:ml-auto sm:flex">
<button type="button" onClick={onCancel} className="inline-flex h-10 items-center justify-center rounded-control px-4 text-sm font-medium text-text-2 transition-colors hover:bg-surface-2 hover:text-text max-sm:bg-surface-2">
Cancel
</button>
<button
type="button"
onClick={save}
disabled={!complete || busy}
className="inline-flex h-10 items-center justify-center rounded-control bg-accent-deep px-[18px] text-sm font-semibold text-white transition-colors hover:enabled:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
);
}
function FilterChip({ on, children }: { on: boolean; children: React.ReactNode }) {
return (
<span
className={`rounded-full border px-2 text-[11.5px] font-semibold leading-5 transition-colors duration-[160ms] ${on ? "border-transparent bg-surface-2 text-text" : "border-line-strong text-text-3 line-through"}`}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import React from "react";
import Link from "next/link";
import type { IconType } from "react-icons";
import { FiChevronDown, FiCode, FiFlag, FiGlobe, FiImage, FiMessageSquare, FiMusic } from "react-icons/fi";
import { AI_AREAS, AI_HEADLINES, AI_LEVEL_LABEL, AI_LEVEL_STEP, aiKind, type AiArea, type AiDisclosure } from "@/utils/aiDisclosure";
export const AI_AREA_ICONS: Record<AiArea, IconType> = {
graphics: FiImage,
music: FiMusic,
story: FiMessageSquare,
translation: FiGlobe,
events: FiFlag,
code: FiCode,
};
/** Strip shade per step, from empty to solid. The same level word always gets the same shade. */
const SHADE = [
"bg-surface-2 text-line-strong",
"bg-[color-mix(in_srgb,var(--text)_18%,var(--surface-2))] text-text",
"bg-[color-mix(in_srgb,var(--text)_42%,var(--surface-2))] text-surface",
"bg-[color-mix(in_srgb,var(--text)_70%,var(--surface-2))] text-surface",
"bg-text text-surface",
];
const listFormat = new Intl.ListFormat("en", { type: "conjunction" });
/**
* The hack page's AI label: a headline, a six-cell strip (five content areas,
* then code), and a collapsible breakdown listing only the areas with AI.
* While the breakdown is open, hovering a row highlights its cell and the other way around.
* `id` names the breakdown for aria-controls; a fixed one (not useId) because
* useId came out different on the server and client here.
*/
export default function AiLabel({ disclosure, defaultOpen = false, id = "ai-breakdown" }: { disclosure: AiDisclosure; defaultOpen?: boolean; id?: string }) {
const { levels, note } = disclosure;
const [open, setOpen] = React.useState(defaultOpen);
const [hot, setHot] = React.useState<AiArea | null>(null);
const used = AI_AREAS.filter((a) => levels[a.key] !== "none");
const unused = AI_AREAS.filter((a) => levels[a.key] === "none").map((a) => a.short);
// Links a cell to its row, so only while the breakdown is open (otherwise the cell looks clickable).
// Mouse only: a tap fires enter without leave, which left the cell looking selected on phones.
const hover = (key: AiArea) => ({
onPointerEnter: (e: React.PointerEvent) => open && e.pointerType === "mouse" && setHot(key),
onPointerLeave: () => setHot(null),
});
return (
<div className="rounded-card border border-line bg-surface px-3.5 py-3 shadow-rest">
<div className="flex items-baseline justify-between gap-2.5">
<b className="font-display text-[20px] font-extrabold leading-[1.1] tracking-[-.015em]">{AI_HEADLINES[aiKind(levels)]}</b>
{/* The year shows only outside the current one, so a page cached across New Year can differ once. */}
<span suppressHydrationWarning className="whitespace-nowrap text-[12.5px] text-text-2">
Updated {shortDate(disclosure.disclosedAt)}
</span>
</div>
<div
role="img"
aria-label={AI_AREAS.map((a) => `${a.name}: ${AI_LEVEL_LABEL[levels[a.key]]}`).join(", ")}
className="mt-2.5 grid grid-cols-[repeat(5,1fr)_6px_1fr] gap-[3px]"
>
{AI_AREAS.map((a) => {
const Icon = AI_AREA_ICONS[a.key];
return (
<React.Fragment key={a.key}>
{a.key === "code" && <span />}
<span
{...hover(a.key)}
title={`${a.name}: ${AI_LEVEL_LABEL[levels[a.key]]}`}
className={`flex h-[30px] items-center justify-center rounded-[5px] transition-[translate,box-shadow] duration-[140ms] ease-out ${SHADE[AI_LEVEL_STEP[levels[a.key]]]} ${
hot === a.key ? "-translate-y-0.5 shadow-[0_0_0_2px_var(--surface),0_0_0_3.5px_var(--text)]" : ""
}`}
>
<Icon className="h-[15px] w-[15px]" />
</span>
</React.Fragment>
);
})}
</div>
<div aria-hidden className="mt-1 grid grid-cols-[5fr_6px_1fr] gap-[3px] text-[10.5px] text-text-3">
<span>What you see and hear</span>
<span />
<span className="text-center">Code</span>
</div>
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((v) => !v)}
className="group mt-2.5 flex w-full items-center gap-1.5 border-t border-line pt-1.5 text-left text-[13px] text-text-2 transition-colors hover:text-text"
>
Breakdown
<FiChevronDown className="ml-auto h-4 w-4 text-text-3 transition-transform duration-[180ms] group-aria-expanded:rotate-180" />
</button>
<div id={id} className={`grid transition-[grid-template-rows] duration-[240ms] ease-[cubic-bezier(.2,.8,.2,1)] ${open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"}`} inert={!open}>
<div className="min-h-0 overflow-hidden">
{used.length > 0 && (
<ul className="mt-2 border-t-2 border-text">
{used.map((a) => {
const Icon = AI_AREA_ICONS[a.key];
const level = levels[a.key];
// "A little" reads quieter than the other levels.
const quiet = level === "little";
return (
<li
key={a.key}
{...hover(a.key)}
className={`grid grid-cols-[16px_1fr_auto_44px] items-center gap-2 border-b border-line py-1.5 text-[13.5px] leading-[1.35] transition-colors duration-[140ms] ${hot === a.key ? "bg-surface-2" : ""}`}
>
<Icon className="h-4 w-4 text-text-2" />
<span>{a.name}</span>
<Meter step={AI_LEVEL_STEP[level]} quiet={quiet} />
<span className={`text-right text-[12.5px] ${quiet ? "font-medium text-text-3" : "font-semibold"}`}>{AI_LEVEL_LABEL[level]}</span>
</li>
);
})}
</ul>
)}
<p className={`text-[12.5px] leading-[1.4] text-text-3 ${used.length ? "pt-1.5" : "mt-2 border-t-2 border-text pt-1.5"}`}>
{used.length === 0 ? "No AI in any area." : unused.length > 0 && `No AI in ${listFormat.format(unused)}.`}
</p>
{note && (
<div className="mt-2.5 whitespace-pre-line border-l-2 border-line-strong py-px pl-2.5 text-[13px] leading-[1.45] text-text-2">
<small className="mb-0.5 block text-[11.5px] font-semibold text-text-3">From the creator</small>
{note}
</div>
)}
<p className="mt-2.5 text-[11.5px] text-text-3">
Reported by the creator.{" "}
<Link href="/faq#ai-disclosure" prefetch={false} className="text-link-hd">
What the levels mean
</Link>
</p>
</div>
</div>
</div>
);
}
function Meter({ step, quiet }: { step: number; quiet: boolean }) {
return (
<span aria-hidden className="inline-grid grid-cols-[repeat(4,8px)] gap-0.5">
{[1, 2, 3, 4].map((i) => (
<i key={i} className={`h-2 rounded-[2px] ${i > step ? "bg-surface-3" : quiet ? "bg-text-3" : "bg-text"}`} />
))}
</span>
);
}
/** Shown for hacks from before disclosure was required, until the creator fills it in. */
export function AiLabelMissing({ children }: { children?: React.ReactNode }) {
return (
<div className="rounded-card border border-dashed border-line-strong px-3.5 py-3 text-[13px] leading-[1.45] text-text-3">
The creator hasn&rsquo;t filled in the AI label yet.
{children}
</div>
);
}
/** UTC so the server and every visitor render the same text (a local time zone broke hydration). */
function shortDate(iso: string) {
const d = new Date(iso);
const thisYear = d.getUTCFullYear() === new Date().getUTCFullYear();
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: thisYear ? undefined : "numeric", timeZone: "UTC" });
}

View File

@@ -0,0 +1,61 @@
"use client";
import React from "react";
import { useSearchParams } from "next/navigation";
import { FiEdit2, FiPlus } from "react-icons/fi";
import AiLabel from "@/components/Hack/AiLabel";
import AiDisclosureModal from "@/components/Hack/AiDisclosureModal";
import type { AiDisclosure, AiLevels } from "@/utils/aiDisclosure";
/**
* The AI label as players see it, plus a pill that opens the disclosure form.
* `onSave` decides where it goes (the page editor for drafts, a direct save on
* the archive form) and returns the stored stamp, or null when it failed.
* `?ai=1` opens the form on load (the dashboard's "Needs AI label" link).
*/
export default function AiLabelEditor({
initial,
emptyText,
onSave,
}: {
initial: AiDisclosure | null;
emptyText: string;
onSave: (levels: AiLevels, note: string | null) => Promise<{ disclosedAt: string } | null>;
}) {
const [disclosure, setDisclosure] = React.useState(initial);
const [open, setOpen] = React.useState(false);
// After mount: the modal portals into document.body, which doesn't exist while server rendering.
const openOnLoad = useSearchParams().get("ai") === "1";
React.useEffect(() => {
if (openOnLoad) setOpen(true);
}, [openOnLoad]);
const Icon = disclosure ? FiEdit2 : FiPlus;
return (
<>
{disclosure ? (
<AiLabel disclosure={disclosure} />
) : (
<p className="rounded-card border border-dashed border-line-strong px-3.5 py-3 text-[13px] leading-[1.45] text-text-3">{emptyText}</p>
)}
<button
type="button"
onClick={() => setOpen(true)}
className="mt-2.5 inline-flex items-center gap-1.5 rounded-full border border-dashed border-line-strong px-2.5 py-[3px] text-[13px] font-medium leading-tight text-text-2 transition-colors hover:border-solid hover:border-text-3 hover:bg-surface hover:text-text"
>
<Icon className="h-3 w-3" /> {disclosure ? "Edit AI label" : "Add AI label"}
</button>
<AiDisclosureModal
visible={open}
initial={disclosure}
onClose={() => setOpen(false)}
onSave={async (levels, note) => {
const saved = await onSave(levels, note);
if (saved) setDisclosure({ levels, note, disclosedAt: saved.disclosedAt });
return saved !== null;
}}
/>
</>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { updateHack } from "@/app/hack/actions";
import AiLabelEditor from "@/components/Hack/AiLabelEditor";
import type { AiDisclosure } from "@/utils/aiDisclosure";
/** AI label on the archive edit page. Archives use the older form, so this saves on its own. */
export default function ArchiveAiLabel({ slug, initial }: { slug: string; initial: AiDisclosure | null }) {
const router = useRouter();
return (
<section className="rounded-card border border-line bg-surface p-5">
<h2 className="text-[15px] font-semibold">AI label</h2>
<p className="mb-3 mt-1 text-[13px] text-text-2">Fill this in if you know how the original creator used AI. Leave it empty if you don&rsquo;t.</p>
<div className="max-w-[320px]">
<AiLabelEditor
initial={initial}
emptyText="No AI label yet. Players see that the creator hasn't filled it in."
onSave={async (levels, note) => {
const res = await updateHack({ slug, ai: { levels, note } });
if (!res.ok) {
toast.error(res.error);
return null;
}
router.refresh();
return { disclosedAt: res.aiDisclosedAt ?? new Date().toISOString() };
}}
/>
</div>
</section>
);
}

View File

@@ -34,7 +34,7 @@ function getTitle(state: BaseRomErrorModalState): string {
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/60">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-3">
{children}
</h3>
);
@@ -54,14 +54,14 @@ function HashBlock({
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-foreground/70">{label}</span>
<span className="text-xs font-medium text-text-2">{label}</span>
<div
className={`flex items-center gap-2.5 rounded-md bg-[var(--surface-1)] px-3 py-2 ring-1 ring-inset ${
isWrong ? "ring-red-500/30" : "ring-emerald-500/30"
className={`flex items-center gap-2.5 rounded-control bg-surface px-3 py-2 ring-1 ring-inset ${
isWrong ? "ring-error/40" : "ring-ready/40"
}`}
>
<Icon
className={`shrink-0 ${isWrong ? "text-red-500" : "text-emerald-500"}`}
className={`shrink-0 ${isWrong ? "text-error" : "text-ready"}`}
size={14}
aria-hidden
/>
@@ -77,14 +77,14 @@ function ModalBody({ state }: { state: BaseRomErrorModalState }) {
switch (state.kind) {
case "archive":
return (
<p className="text-sm leading-relaxed text-foreground/80">
<p className="text-sm leading-relaxed text-text-2">
Archive files like <code>.zip</code> need to be extracted first. After extracting, upload{" "}
<strong>{state.requiredExtensionPhrase}</strong> for <strong>{state.requiredRomName}</strong>.
</p>
);
case "unrecognized":
return (
<p className="text-sm leading-relaxed text-foreground/80">
<p className="text-sm leading-relaxed text-text-2">
<code>{state.fileName}</code> isn&apos;t a ROM file. This hack requires{" "}
<strong>{state.requiredRomName}</strong> — please select{" "}
<strong>{state.requiredExtensionPhrase}</strong>.
@@ -95,7 +95,7 @@ function ModalBody({ state }: { state: BaseRomErrorModalState }) {
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-2">
<SectionLabel>What happened</SectionLabel>
<p className="text-base leading-relaxed text-foreground/90">
<p className="text-base leading-relaxed text-text">
{state.matchedRomName ? (
<>
This file looks like <strong>{state.matchedRomName}</strong>, but this hack needs{" "}
@@ -109,7 +109,7 @@ function ModalBody({ state }: { state: BaseRomErrorModalState }) {
</p>
</section>
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm leading-relaxed text-amber-900 dark:text-amber-100">
<div className="rounded-control border border-warn/40 bg-warn-soft p-3 text-sm leading-relaxed text-warn">
Patching against a non-matching ROM (even if that ROM is fully playable) can cause crashes, glitches, or broken saves.
</div>
@@ -123,14 +123,14 @@ function ModalBody({ state }: { state: BaseRomErrorModalState }) {
<section className="flex flex-col gap-2">
<SectionLabel>Common reasons</SectionLabel>
<ul className="list-disc space-y-1.5 pl-5 text-sm leading-relaxed text-foreground/80">
<ul className="list-disc space-y-1.5 pl-5 text-sm leading-relaxed text-text-2">
<li>Wrong revision or region</li>
<li>The ROM was modified or trimmed</li>
<li>The file is corrupted or incomplete</li>
</ul>
</section>
<div className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-3 text-xs leading-relaxed text-foreground/60">
<div className="rounded-control border border-line bg-surface-2 p-3 text-xs leading-relaxed text-text-3">
Hackdex cannot advise on where to find ROMs. The only legal source is ripping from your own cartridge.
</div>
</div>
@@ -172,13 +172,13 @@ const BaseRomErrorModal: React.FC<BaseRomErrorModalProps> = ({ state, onClose })
role="dialog"
aria-modal="true"
aria-labelledby="base-rom-error-title"
className="relative z-[101] flex h-full w-full flex-col card backdrop-blur-lg dark:!bg-black/70 rounded-none md:h-auto md:max-h-[85vh] md:w-full md:max-w-lg md:rounded-lg"
className="relative z-[101] flex h-full w-full flex-col card backdrop-blur-lg dark:!bg-black/70 rounded-none md:h-auto md:max-h-[85vh] md:w-full md:max-w-lg md:rounded-card"
>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
className="absolute top-4 right-4 p-1.5 rounded-control text-text-3 hover:text-text hover:bg-surface-2 transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40"
>
<FiX size={20} />
</button>
@@ -188,7 +188,7 @@ const BaseRomErrorModal: React.FC<BaseRomErrorModalProps> = ({ state, onClose })
{title}
</h2>
{state.kind !== "unrecognized" && (
<p className="mt-2 text-sm text-foreground/80">
<p className="mt-2 text-sm text-text-2">
Selected file: <code>{state.fileName}</code>
</p>
)}
@@ -197,11 +197,11 @@ const BaseRomErrorModal: React.FC<BaseRomErrorModalProps> = ({ state, onClose })
</div>
</div>
<div className="border-t border-[var(--border)] p-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:border-0 md:p-6 md:pt-0">
<div className="border-t border-line p-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:border-0 md:p-6 md:pt-0">
<button
type="button"
onClick={onClose}
className="inline-flex h-14 md:h-11 w-full items-center justify-center rounded-md bg-[var(--accent)] px-4 text-sm font-semibold text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
className="inline-flex h-14 md:h-11 w-full items-center justify-center rounded-control bg-accent-deep px-4 text-sm font-semibold text-white hover:bg-accent-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
Choose a different file
</button>

View File

@@ -2,102 +2,57 @@
import { buildDiscoverSearchParams, DISCOVER_DEFAULT_STATE } from "@/app/discover/search-params";
import Link from "next/link";
import { useState, useRef, useLayoutEffect, useEffect, useCallback } from "react";
import { FaChevronDown } from "react-icons/fa6";
import { useEffect, useRef, useState } from "react";
import { FiChevronDown } from "react-icons/fi";
interface CollapsibleTagsProps {
tags: string[];
}
/**
* Tags collapse to one row with the next row peeking under a fade, so the
* reader sees there is more before opening it in place. No control when one row fits.
*/
export default function CollapsibleTags({ tags }: { tags: string[] }) {
const ref = useRef<HTMLUListElement | null>(null);
const [overflows, setOverflows] = useState(false);
const [open, setOpen] = useState(false);
// Tag height: ~24px (16px height + 8px y-padding), gap: 8px
// Max height: 1.25 × 24px + 2 × 8px = 46px - 4px (for aesthetic padding)
const MAX_HEIGHT = 42;
export default function CollapsibleTags({ tags }: CollapsibleTagsProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [needsExpansion, setNeedsExpansion] = useState(false);
const [naturalHeight, setNaturalHeight] = useState<number | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const checkIfExpansionNeeded = useCallback(() => {
if (!contentRef.current) return;
const height = contentRef.current.scrollHeight + 16; // Add 16px for padding
setNaturalHeight(height);
setNeedsExpansion(height > MAX_HEIGHT);
}, []);
// Use useLayoutEffect to check synchronously before paint
useLayoutEffect(() => {
checkIfExpansionNeeded();
}, [tags, checkIfExpansionNeeded]);
// Also check on window resize
useEffect(() => {
window.addEventListener("resize", checkIfExpansionNeeded);
return () => {
window.removeEventListener("resize", checkIfExpansionNeeded);
const el = ref.current;
if (!el) return;
const check = () => {
const row = el.firstElementChild?.clientHeight ?? 0;
setOverflows(el.scrollHeight > row + 2);
};
}, [checkIfExpansionNeeded]);
check();
const ro = new ResizeObserver(check);
ro.observe(el);
return () => ro.disconnect();
}, [tags]);
if (tags.length === 0) return null;
const collapsed = overflows && !open;
return (
<div className="flex flex-col w-full">
<div className="mt-3.5">
<div
className="grid transition-all duration-300 ease-in-out"
style={
!isExpanded
? {
gridTemplateRows: `${MAX_HEIGHT}px`,
borderBottom: "2px solid var(--border)",
}
: naturalHeight
? {
gridTemplateRows: `${naturalHeight}px`,
borderBottom: "0px solid transparent",
}
: {
gridTemplateRows: "1fr",
borderBottom: "0px solid transparent",
}
}
className={collapsed ? "max-h-10 overflow-hidden [mask-image:linear-gradient(to_bottom,#000_24px,transparent_40px)]" : ""}
>
<div className="overflow-hidden p-0.5">
<div
ref={contentRef}
className="flex flex-wrap gap-2"
>
{tags.map((t) => (
{/* Tabbing into the tags opens them, so focus never lands on a hidden one. */}
<ul ref={ref} onFocus={() => collapsed && setOpen(true)} className="flex flex-wrap gap-1.5" aria-label="Tags">
{tags.map((t) => (
<li key={t}>
<Link
key={t}
prefetch={false}
href={`/discover?${buildDiscoverSearchParams({
...DISCOVER_DEFAULT_STATE,
tags: [t],
}).toString()}`}
href={`/discover?${buildDiscoverSearchParams({ ...DISCOVER_DEFAULT_STATE, tags: [t] }).toString()}`}
aria-label={`View hacks tagged ${t}`}
className="rounded-full bg-(--surface-2) px-2.5 py-1 text-xs ring-1 ring-(--border) transition-colors md:cursor-pointer md:hover:bg-(--surface-3)"
className="inline-block rounded-full bg-surface-2 px-2.5 py-1 text-[13px] leading-tight text-text-2 transition-colors hover:bg-line hover:text-text"
>
{t}
</Link>
))}
</div>
</div>
</li>
))}
</ul>
</div>
{needsExpansion && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center justify-center gap-2 w-full md:w-auto md:justify-start md:mt-2 px-4 md:px-0 pt-2 pb-4 md:py-0 text-sm md:text-xs font-medium text-foreground/70 hover:text-foreground transition-colors active:opacity-70 md:active:opacity-100"
aria-expanded={isExpanded}
>
<span>{isExpanded ? "Show less" : "Show more"}</span>
<span
className={`text-foreground/60 shrink-0 transition-transform duration-300 ease-in-out ${
isExpanded ? "rotate-180" : ""
}`}
>
<FaChevronDown className="w-3.5 h-3.5 md:w-3 md:h-3" />
</span>
{overflows && (
<button type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)} className="mt-1.5 inline-flex items-center gap-1 text-[13px] text-text-2 transition-colors hover:text-text">
{open ? "Show less" : "Show more"} <FiChevronDown className={`h-4 w-4 transition-transform duration-[160ms] ${open ? "rotate-180" : ""}`} />
</button>
)}
</div>

View File

@@ -48,10 +48,10 @@ export default function CreateReviewThreadMenuItem({
type="button"
disabled={isPending}
onClick={handleCreate}
className="block w-full px-3 py-2 text-left text-sm text-foreground/80 font-medium data-focus:bg-black/5 dark:data-focus:bg-white/10 disabled:opacity-50"
className="block w-full px-3 py-2 text-left text-sm text-text-2 font-medium data-focus:bg-surface-2 disabled:opacity-50"
>
<FaDiscord
className="mr-2 inline-block align-middle mb-0.5 text-foreground/80"
className="mr-2 inline-block align-middle mb-0.5 text-text-2"
size={12}
/>
{isPending ? "Creating review thread…" : "Create review thread"}

View File

@@ -111,12 +111,12 @@ export default function DownloadPermissionSettings({
const summary = (
<>
<span className="text-foreground/45">Live: </span>
<span className="text-foreground/80 font-medium">{optionLabel(savedPermission)}</span>
<span className="text-text-3">Live: </span>
<span className="text-text-2 font-medium">{optionLabel(savedPermission)}</span>
{hasUnsavedChanges && (
<>
<span className="text-foreground/40"> · Draft: </span>
<span className="text-foreground/70 font-medium">{optionLabel(selectedPermission)}</span>
<span className="text-text-3"> · Draft: </span>
<span className="text-text-2 font-medium">{optionLabel(selectedPermission)}</span>
</>
)}
</>
@@ -128,13 +128,13 @@ export default function DownloadPermissionSettings({
titleId="patch-download-permissions-heading"
leading={<FaUserGear size={20} />}
summary={summary}
className="mb-6 rounded-lg border border-[var(--border)]/70 border-l-[3px] border-l-[var(--accent)]/40 bg-[var(--surface-2)]"
className="mb-6 rounded-card border border-line/70 border-l-[3px] border-l-[var(--accent)]/40 bg-surface-2"
>
<div>
<p className="text-xs sm:text-sm text-foreground/60 leading-snug md:-mt-4 mb-6">
<p className="text-xs sm:text-sm text-text-3 leading-snug md:-mt-4 mb-6">
Changing this setting will allow users to download the patch file directly from this page as an alternative to using the built-in patcher.
{isCustomPatcherActive && (
<> With <strong className="font-medium text-foreground/70">Custom</strong> patcher versions active, "Current only" applies to every version in your Custom patcher list—not the Current badge alone.</>
<> With <strong className="font-medium text-text-2">Custom</strong> patcher versions active, "Current only" applies to every version in your Custom patcher list—not the Current badge alone.</>
)}
</p>
<RadioCardsBody
@@ -149,7 +149,7 @@ export default function DownloadPermissionSettings({
type="button"
onClick={handleSave}
disabled={saving || !hasUnsavedChanges}
className="inline-flex items-center justify-center min-w-20 h-8 px-3 text-xs font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-[var(--accent)] shrink-0"
className="inline-flex items-center justify-center min-w-20 h-8 px-3 text-xs font-semibold rounded-control bg-accent-deep text-white hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-accent-deep shrink-0"
>
{saving ? "Saving…" : "Save"}
</button>
@@ -159,14 +159,14 @@ export default function DownloadPermissionSettings({
>
{showSaved ? (
<span
className={`text-emerald-600 dark:text-emerald-400 font-medium transition-opacity duration-[450ms] ease-out ${
className={`text-ready dark:text-ready font-medium transition-opacity duration-[450ms] ease-out ${
savedFadeOut ? "opacity-0" : "opacity-100"
}`}
>
Setting updated.
</span>
) : error ? (
<span className="text-red-400">{error}</span>
<span className="text-error">{error}</span>
) : null}
</div>
</div>
@@ -201,27 +201,27 @@ function RadioCardsBody({
role="radio"
aria-checked={isUiSelected}
onClick={() => onSelect(opt.value)}
className={`w-full text-left rounded-md border-2 px-3 py-2.5 sm:px-2 sm:py-1.5 transition-colors flex gap-3 sm:gap-2 items-center touch-manipulation min-h-[2.75rem] sm:min-h-[2.25rem] ${
className={`w-full text-left rounded-control border-2 px-3 py-2.5 sm:px-2 sm:py-1.5 transition-colors flex gap-3 sm:gap-2 items-center touch-manipulation min-h-[2.75rem] sm:min-h-[2.25rem] ${
isUiSelected
? "border-[var(--accent)] bg-[var(--surface-2)]"
: "border-[var(--border)] bg-[var(--surface-2)]/50 hover:bg-[var(--surface-2)]"
? "border-accent bg-surface-2"
: "border-line bg-surface-2/50 hover:bg-surface-2"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded-full border-2 flex items-center justify-center ${
isUiSelected ? "border-[var(--accent)]" : "border-[var(--border)]"
isUiSelected ? "border-accent" : "border-line"
}`}
aria-hidden
>
{isUiSelected && <span className="h-1.5 w-1.5 rounded-full bg-[var(--accent)]" />}
{isUiSelected && <span className="h-1.5 w-1.5 rounded-full bg-accent-deep" />}
</span>
<span className="min-w-0 flex-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 sm:gap-x-1.5 sm:gap-y-0 leading-tight">
<span className="text-sm font-semibold sm:text-xs">{opt.label}</span>
<span className="text-xs text-foreground/55 sm:text-[11px]">
<span className="text-xs text-text-3 sm:text-[11px]">
{patchDownloadOptionDescription(opt.value, isCustomPatcherActive)}
</span>
{showSavedBadge && (
<span className="inline-flex items-center rounded px-1 py-px text-[10px] font-medium uppercase tracking-wide text-foreground/60 bg-foreground/5 ring-1 ring-[var(--border)]">
<span className="inline-flex items-center rounded px-1 py-px text-[10px] font-medium uppercase tracking-wide text-text-3 bg-foreground/5 ring-1 ring-line">
Saved
</span>
)}

View File

@@ -1,37 +1,35 @@
"use client";
import React from "react";
import { ImDownload } from "react-icons/im";
import { FiDownload } from "react-icons/fi";
import { formatCompactNumber } from "@/utils/format";
import type { DownloadEvent } from "@/types/util";
/** Download count that ticks up (with a small pop) when this page's patch is applied. */
export default function DownloadsBadge({ slug, initialCount }: { slug: string; initialCount: number }) {
const [count, setCount] = React.useState<number>(initialCount);
const [anim, setAnim] = React.useState<boolean>(false);
React.useEffect(() => {
let t: number | undefined;
function onApplied(e: Event) {
const detail = (e as DownloadEvent).detail;
if (detail.slug !== slug) return;
setCount((c) => c + 1);
setAnim(true);
const t = setTimeout(() => setAnim(false), 600);
return () => clearTimeout(t);
t = window.setTimeout(() => setAnim(false), 600);
}
window.addEventListener("hack:patch-applied", onApplied);
return () => window.removeEventListener("hack:patch-applied", onApplied);
return () => {
window.removeEventListener("hack:patch-applied", onApplied);
if (t) window.clearTimeout(t);
};
}, [slug]);
return (
<div
className={`inline-flex items-center gap-2 rounded-full ring-1 ring-[var(--border)] bg-[var(--surface-2)] px-3 py-1 text-sm text-foreground/85 transition-transform ${
anim ? "scale-110 shadow-md font-bold" : ""
}`}
>
<ImDownload size={16} className="text-foreground/85" />
<span className={`inline-flex origin-left items-center gap-1.5 tabular-nums transition-transform ${anim ? "scale-110 font-semibold text-ready" : ""}`}>
<FiDownload className="h-3.5 w-3.5 text-text-3" />
<span>{formatCompactNumber(count)}</span>
</div>
</span>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import React from "react";
import Markdown from "@/components/Markdown/Markdown";
import { FIELD, useAutosave, useDraftEditing } from "./DraftEditing";
/** Markdown description with a Write / Preview switch, in the About tab. Autosaves when typing pauses. */
export default function DraftAbout({ description: initial }: { description: string }) {
const { save } = useDraftEditing();
const [text, setText] = React.useState(initial);
const [mode, setMode] = React.useState<"write" | "preview">("write");
useAutosave(text, (v) => void save({ description: v.trim() }), 1000);
return (
<section>
<div className="mb-2.5 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<h2 className="text-lg font-semibold leading-tight">About</h2>
<div role="group" aria-label="Mode" className="inline-flex rounded-control bg-surface-2 p-0.5 text-[13px] font-medium">
{(["write", "preview"] as const).map((m) => (
<button
key={m}
type="button"
aria-pressed={mode === m}
onClick={() => setMode(m)}
className={`rounded-[7px] px-3 py-1 transition-colors ${mode === m ? "bg-surface text-text shadow-rest" : "text-text-2 hover:text-text"}`}
>
{m === "write" ? "Write" : "Preview"}
</button>
))}
</div>
</div>
{mode === "write" ? (
<>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={16}
aria-label="Description"
placeholder="Say what the hack changes and what players should expect. Markdown works here."
className={`${FIELD} min-h-[320px] resize-y py-3 font-mono text-[13px] leading-[1.55]`}
/>
<p className="mt-2 text-xs text-text-3">Markdown. Headings, lists, links and ||spoilers|| all work.</p>
</>
) : text.trim() ? (
<div className="prose prose-sm max-w-[70ch] text-text-2">
<Markdown headingLevelOffset={1}>{text}</Markdown>
</div>
) : (
<p className="text-sm italic text-text-3">Nothing written yet.</p>
)}
</section>
);
}

View File

@@ -0,0 +1,17 @@
"use client";
import AiLabelEditor from "@/components/Hack/AiLabelEditor";
import type { AiDisclosure } from "@/utils/aiDisclosure";
import { useDraftEditing } from "./DraftEditing";
/** The AI label on the in-place editor. Saves through the page, so drafts save right away and listed hacks stage it until Save changes. */
export default function DraftAiLabel({ initial }: { initial: AiDisclosure | null }) {
const { save } = useDraftEditing();
return (
<AiLabelEditor
initial={initial}
emptyText="Players see here how much AI went into the hack. Every hack needs one, even with no AI use."
onSave={async (levels, note) => ((await save({ ai: { levels, note } })) ? { disclosedAt: new Date().toISOString() } : null)}
/>
);
}

View File

@@ -0,0 +1,244 @@
"use client";
import React from "react";
import { FiEdit2, FiLock } from "react-icons/fi";
import Sheet from "@/components/Sheet";
import Select from "@/components/Primitives/Select";
import { baseRoms, PLATFORM_NAMES } from "@/data/baseRoms";
import type { Database } from "@/types/db";
import { FIELD, useDraftEditing } from "./DraftEditing";
type Completion = Database["public"]["Enums"]["Completion Status"];
type Social = { discord?: string; twitter?: string; pokecommunity?: string; github?: string };
export interface DraftDetailsValues {
base_rom: string;
language: string;
completion_status: Completion | null;
box_art: string | null;
social_links: Social | null;
/** Set on hacks uploaded on someone else's behalf; shows the creator fields. */
original_author: string | null;
permission_from: string | null;
/** Left out once the hack is listed, since admins only read it during review. */
verification_contact_info?: string | null;
}
interface DraftDetailsProps {
values: DraftDetailsValues;
/** A verified patch pins the base ROM; the field shows locked. */
baseLocked: boolean;
/** The rail. Its groups open the sheet through EditDetailsLink. */
children: React.ReactNode;
}
const OpenCtx = React.createContext<(() => void) | null>(null);
/** "Edit" link for a rail group heading; opens the details sheet on the fields that group shows. */
export function EditDetailsLink() {
const open = React.useContext(OpenCtx);
if (!open) return null;
return (
<button type="button" onClick={open} className="tap-target inline-flex items-center gap-1 text-[12px] font-medium text-link hover:underline hover:underline-offset-[3px]">
<FiEdit2 className="h-3 w-3" /> Edit
</button>
);
}
const LANGUAGES = ["English", "Spanish", "French", "German", "Italian", "Portuguese", "Japanese", "Chinese", "Korean", "Other"];
const COMPLETION: Completion[] = ["Complete", "Demo", "Alpha", "Beta"];
const SOCIAL: { key: keyof Social; label: string; placeholder: string }[] = [
{ key: "discord", label: "Discord", placeholder: "https://discord.gg/…" },
{ key: "twitter", label: "Twitter / X", placeholder: "https://x.com/…" },
{ key: "pokecommunity", label: "PokéCommunity", placeholder: "https://www.pokecommunity.com/threads/…" },
{ key: "github", label: "GitHub", placeholder: "https://github.com/…" },
];
const urlLike = (s: string) => !s || /^https?:\/\//i.test(s);
type Form = {
base: string;
language: string;
completion: Completion | "";
boxArt: string;
social: Record<keyof Social, string>;
creator: string;
permission: string;
contact: string;
};
function toForm(values: DraftDetailsValues): Form {
return {
base: values.base_rom,
language: values.language,
completion: values.completion_status ?? "",
boxArt: values.box_art ?? "",
social: {
discord: values.social_links?.discord ?? "",
twitter: values.social_links?.twitter ?? "",
pokecommunity: values.social_links?.pokecommunity ?? "",
github: values.social_links?.github ?? "",
},
creator: values.original_author ?? "",
permission: values.permission_from ?? "",
contact: values.verification_contact_info ?? "",
};
}
/** Wraps the rail and owns the details sheet for the fields that are not edited in place: base ROM, language, completion, box art, links, and the creator and review fields. Saves (or stages, on listed hacks) on the footer button. */
export default function DraftDetails({ values, baseLocked, children }: DraftDetailsProps) {
const [open, setOpen] = React.useState(false);
// What the sheet opens with: the last saved or applied values, so Cancel drops only this session's edits.
const [applied, setApplied] = React.useState(() => toForm(values));
const openSheet = React.useCallback(() => setOpen(true), []);
return (
<OpenCtx.Provider value={openSheet}>
{children}
{open && (
<DetailsSheet
initial={applied}
baseLocked={baseLocked}
behalf={values.original_author !== null}
askContact={values.verification_contact_info !== undefined}
onApplied={(form) => {
setApplied(form);
setOpen(false);
}}
onClose={() => setOpen(false)}
/>
)}
</OpenCtx.Provider>
);
}
interface DetailsSheetProps {
initial: Form;
baseLocked: boolean;
behalf: boolean;
askContact: boolean;
onApplied: (form: Form) => void;
onClose: () => void;
}
/** One editing session; mounts with the sheet, so closing it any way but Save/Apply discards its edits. */
function DetailsSheet({ initial, baseLocked, behalf, askContact, onApplied, onClose }: DetailsSheetProps) {
const { save, live } = useDraftEditing();
const [saving, setSaving] = React.useState(false);
const [form, setForm] = React.useState(initial);
const { base, language, completion, boxArt, social, creator, permission, contact } = form;
const set = <K extends keyof Form>(key: K, value: Form[K]) => setForm((prev) => ({ ...prev, [key]: value }));
const invalid = !urlLike(boxArt) || SOCIAL.some((s) => !urlLike(social[s.key])) || (behalf && (!creator.trim() || !permission.trim()));
async function done() {
const links = Object.fromEntries(Object.entries(social).filter(([, v]) => v.trim())) as Social;
setSaving(true);
const ok = await save({
...(baseLocked ? {} : { base_rom: base }),
language,
...(completion ? { completion_status: completion } : {}),
box_art: boxArt.trim() || null,
social_links: Object.keys(links).length ? links : null,
...(behalf ? { original_author: creator, permission_from: permission } : {}),
...(askContact ? { verification_contact_info: contact } : {}),
});
setSaving(false);
if (ok) onApplied(form);
}
const baseRom = baseRoms.find((r) => r.id === base);
return (
<Sheet
title="Details"
onClose={onClose}
footer={
<>
<button type="button" onClick={onClose} className="inline-flex h-10 items-center rounded-control px-3 text-sm font-medium text-text-2 hover:bg-surface-2 hover:text-text">
Cancel
</button>
<button
type="button"
disabled={saving || invalid}
onClick={done}
className="inline-flex h-10 items-center rounded-control bg-accent-deep px-4 text-sm font-semibold text-white transition-colors hover:enabled:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60"
>
{saving ? "Saving…" : live ? "Save" : "Apply"}
</button>
</>
}
>
<div className="flex flex-col gap-5">
<Field label="Base ROM">
{baseLocked ? (
<div className="flex items-start gap-2 rounded-control bg-surface-2 px-3 py-2.5 text-sm">
<FiLock className="mt-0.5 h-4 w-4 flex-none text-text-3" />
<div>
<span className="plat-dot font-medium" data-platform={baseRom?.platform}>
{baseRom?.name ?? base}
</span>
<p className="mt-0.5 text-xs text-text-3">Locked. Your patch was verified against this ROM.</p>
</div>
</div>
) : (
<Select value={base} onChange={(v) => set("base", v)} options={baseRoms.map((r) => ({ value: r.id, label: r.name, description: PLATFORM_NAMES[r.platform] }))} />
)}
</Field>
<Field label="Language">
<Select value={language} onChange={(v) => set("language", v)} options={LANGUAGES.map((l) => ({ value: l, label: l }))} />
</Field>
<Field label="Completion status">
<Select value={completion} onChange={(v) => set("completion", v as Completion)} placeholder="Choose one" options={COMPLETION.map((c) => ({ value: c, label: c }))} />
</Field>
<Field label="Box art URL" hint="Optional. Shown in the rail with a download link.">
<input value={boxArt} onChange={(e) => set("boxArt", e.target.value)} placeholder="https://…" className={`${FIELD} h-10 ${urlLike(boxArt) ? "" : "border-error"}`} />
</Field>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Links</legend>
{SOCIAL.map((s) => (
<Field key={s.key} label={s.label} small>
<input
value={social[s.key]}
onChange={(e) => set("social", { ...social, [s.key]: e.target.value })}
placeholder={s.placeholder}
className={`${FIELD} h-10 ${urlLike(social[s.key]) ? "" : "border-error"}`}
/>
</Field>
))}
</fieldset>
{behalf && (
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Creator</legend>
<Field label="Creator’s name" small>
<input value={creator} onChange={(e) => set("creator", e.target.value)} className={`${FIELD} h-10 ${creator.trim() ? "" : "border-error"}`} />
</Field>
<Field label="Where they gave permission" small>
<input
value={permission}
onChange={(e) => set("permission", e.target.value)}
placeholder="Discord DM, PokéCommunity thread, email…"
className={`${FIELD} h-10 ${permission.trim() ? "" : "border-error"}`}
/>
</Field>
</fieldset>
)}
{askContact && (
<Field label="Verification contact" hint="Optional, but speeds up verification. Only admins see it, while they review the hack. Name where your post history shows you made it, like one of your socials and the communities you’re active in, or link a public post where you mention submitting to Hackdex.">
<textarea value={contact} onChange={(e) => set("contact", e.target.value)} rows={4} placeholder={"@yourname, active in RH Hideout\nor a link to your post about it"} className={`${FIELD} resize-y py-2`} />
</Field>
)}
</div>
</Sheet>
);
}
function Field({ label, hint, small = false, children }: { label: string; hint?: string; small?: boolean; children: React.ReactNode }) {
return (
<label className="flex flex-col gap-1.5">
<span className={small ? "text-xs font-medium text-text-2" : "text-sm font-medium"}>{label}</span>
{children}
{hint && <span className="text-xs text-text-3">{hint}</span>}
</label>
);
}

View File

@@ -0,0 +1,260 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { updateHack } from "@/app/hack/actions";
import { NETWORK_ERROR } from "@/utils/networkError";
export type SaveStatus = "idle" | "saving" | "saved" | "error";
type Result = { ok: true } | { ok: false; error: string };
type UpdateArgs = Omit<Parameters<typeof updateHack>[0], "slug">;
/** `prepare` does the failure-prone work (uploads) before anything publishes; `commit` writes to the page. */
type Committer = { dirty: boolean; prepare: () => Promise<Result>; commit: () => Promise<Result> };
interface DraftEditing {
slug: string;
/** Drafts save as you go. Listed hacks stage changes until Save, since every save publishes. */
live: boolean;
status: SaveStatus;
/** Manual mode only: something is staged and not yet saved. */
dirty: boolean;
/** Live mode: saves that failed and haven't been superseded by a later one. */
failed: boolean;
/**
* Runs a save now, reflects it in the strip, and refreshes server data once it lands.
* `key` names what it writes (e.g. "title"): a failure is kept for retry until a later save of the same key lands.
*/
run: (work: () => Promise<Result>, key?: string) => Promise<boolean>;
/** Reruns every failed save. */
retry: () => void;
/** Partial hack update. Live: saves now. Manual: stages for Save. */
save: (args: UpdateArgs) => Promise<boolean>;
/** Manual mode: runs every committer's prepare, then writes the staged fields and the commits. */
saveAll: () => Promise<boolean>;
register: (id: string, committer: Committer | null) => void;
/** useAutosave reports a change waiting out its debounce (+1) and leaving it (-1), so reloads can warn. */
trackWaiting: (delta: 1 | -1) => void;
}
const Ctx = React.createContext<DraftEditing | null>(null);
/** Shared save plumbing for every editing island on a hack page in edit mode. */
export function DraftEditingProvider({ slug, live, children }: { slug: string; live: boolean; children: React.ReactNode }) {
const router = useRouter();
const [status, setStatus] = React.useState<SaveStatus>("idle");
const [pending, setPending] = React.useState<UpdateArgs>({});
const [committerDirty, setCommitterDirty] = React.useState(0);
const committers = React.useRef(new Map<string, Committer>());
const inFlight = React.useRef(0);
const refreshTimer = React.useRef<number | undefined>(undefined);
// Saves are whole-value writes, so retrying the latest failed one per key is safe.
const failedSaves = React.useRef(new Map<string, () => Promise<Result>>());
const [failedCount, setFailedCount] = React.useState(0);
const waitingAutosaves = React.useRef(0);
const trackWaiting = React.useCallback((delta: 1 | -1) => {
waitingAutosaves.current += delta;
}, []);
const run = React.useCallback<DraftEditing["run"]>(
async (work, key) => {
inFlight.current += 1;
setStatus("saving");
// Server actions throw on network failures; without this the strip stuck on "Saving…".
let res: Result;
try {
res = await work();
} catch {
res = { ok: false, error: NETWORK_ERROR };
} finally {
inFlight.current -= 1;
}
if (key) {
if (res.ok) failedSaves.current.delete(key);
else failedSaves.current.set(key, work);
setFailedCount(failedSaves.current.size);
}
if (!res.ok) {
setStatus("error");
toast.error(res.error);
return false;
}
// A save that lands doesn't clear an earlier failure: keep "Couldn't save" and Retry until that one lands too.
if (inFlight.current === 0) setStatus(failedSaves.current.size > 0 ? "error" : "saved");
window.clearTimeout(refreshTimer.current);
refreshTimer.current = window.setTimeout(() => router.refresh(), 500);
return true;
},
[router],
);
const save = React.useCallback<DraftEditing["save"]>(
async (args) => {
if (live) return run(() => updateHack({ slug, ...args }), Object.keys(args).sort().join());
setPending((prev) => ({ ...prev, ...args }));
return true;
},
[live, run, slug],
);
const register = React.useCallback<DraftEditing["register"]>((id, committer) => {
if (committer) committers.current.set(id, committer);
else committers.current.delete(id);
setCommitterDirty([...committers.current.values()].filter((c) => c.dirty).length);
}, []);
const saveAll = React.useCallback(async () => {
const fields = pending;
const work = [...committers.current.values()].filter((c) => c.dirty);
const ok = await run(async () => {
// Uploads first, so a failed one leaves the published page untouched.
for (const c of work) {
const res = await c.prepare();
if (!res.ok) return res;
}
if (Object.keys(fields).length > 0) {
const res = await updateHack({ slug, ...fields });
if (!res.ok) return res;
}
for (const c of work) {
const res = await c.commit();
if (!res.ok) return res;
}
return { ok: true };
});
if (ok) {
// Keep anything edited while the save was running.
setPending((prev) => {
const next = { ...prev };
for (const k of Object.keys(fields) as (keyof UpdateArgs)[]) if (next[k] === fields[k]) delete next[k];
return next;
});
toast.success("Changes published");
}
return ok;
}, [pending, run, slug]);
const retry = React.useCallback(() => {
for (const [key, work] of failedSaves.current) void run(work, key);
}, [run]);
const dirty = !live && (Object.keys(pending).length > 0 || committerDirty > 0);
const failed = live && failedCount > 0;
useLeaveGuard(dirty || failed);
// Live edits are unsaved for a moment (debounce, then the request); a reload then would drop them.
React.useEffect(() => {
if (!live) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
if (waitingAutosaves.current > 0 || inFlight.current > 0) e.preventDefault();
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [live]);
const value = React.useMemo(
() => ({ slug, live, status, dirty, failed, run, retry, save, saveAll, register, trackWaiting }),
[slug, live, status, dirty, failed, run, retry, save, saveAll, register, trackWaiting],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
/**
* Asks before leaving with unsaved work: beforeunload for reloads and other sites,
* and a capture-phase click check for in-app links, which never fire beforeunload.
*/
function useLeaveGuard(active: boolean) {
React.useEffect(() => {
if (!active) return;
let leaving = false;
const onBeforeUnload = (e: BeforeUnloadEvent) => {
if (!leaving) e.preventDefault();
};
// Runs before React's handlers, so preventDefault here stops next/link too.
const onClick = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const link = e.target instanceof Element ? e.target.closest("a[href]") : null;
if (!(link instanceof HTMLAnchorElement) || link.target === "_blank" || link.hasAttribute("download")) return;
const url = new URL(link.href);
if (url.origin !== location.origin || (url.pathname === location.pathname && url.search === location.search)) return;
if (window.confirm("Discard your unsaved changes?")) leaving = true;
else {
e.preventDefault();
e.stopPropagation();
}
};
window.addEventListener("beforeunload", onBeforeUnload);
document.addEventListener("click", onClick, true);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
document.removeEventListener("click", onClick, true);
};
}, [active]);
}
export function useDraftEditing() {
const ctx = React.useContext(Ctx);
if (!ctx) throw new Error("useDraftEditing must be used inside DraftEditingProvider");
return ctx;
}
/** Same as useDraftEditing, but null outside edit mode (the strip is also rendered in draft preview). */
export function useDraftEditingOptional() {
return React.useContext(Ctx);
}
/** Registers work that runs on Save in manual mode: `prepare` (e.g. uploading staged screenshots) runs before anything publishes. */
export function useCommitter(dirty: boolean, commit: () => Promise<Result>, prepare?: () => Promise<Result>) {
const { register } = useDraftEditing();
const id = React.useId();
const commitRef = React.useRef(commit);
const prepareRef = React.useRef(prepare);
commitRef.current = commit;
prepareRef.current = prepare;
React.useEffect(() => {
register(id, { dirty, prepare: () => prepareRef.current?.() ?? Promise.resolve({ ok: true }), commit: () => commitRef.current() });
return () => register(id, null);
}, [id, dirty, register]);
}
/**
* Calls commit with the latest value once it has stopped changing for `delay` ms (drafts) or right
* away (listed hacks, which only stage). Skips the initial value and flushes a pending one on unmount.
*/
export function useAutosave<T>(value: T, commit: (value: T) => void, delay = 800) {
// Listed hacks only stage (nothing is sent), so do it right away and Save always has the last edit.
const { live, trackWaiting } = useDraftEditing();
const wait = live ? delay : 0;
const last = React.useRef(value);
const waiting = React.useRef(false);
const commitRef = React.useRef(commit);
commitRef.current = commit;
const setWaiting = React.useCallback(
(next: boolean) => {
if (waiting.current !== next) trackWaiting(next ? 1 : -1);
waiting.current = next;
},
[trackWaiting],
);
React.useEffect(() => {
if (Object.is(value, last.current)) return;
last.current = value;
setWaiting(true);
const t = window.setTimeout(() => {
setWaiting(false);
commitRef.current(value);
}, wait);
return () => window.clearTimeout(t);
}, [value, wait, setWaiting]);
// Commit a change still waiting when the editor goes away, e.g. clicking Preview mid-typing.
React.useEffect(
() => () => {
if (!waiting.current) return;
setWaiting(false);
commitRef.current(last.current);
},
[setWaiting],
);
}
export const FIELD = "w-full rounded-control border border-line bg-surface-2 px-3 text-sm text-text outline-none transition-[border-color,box-shadow] placeholder:text-text-3 focus:border-line-strong focus:ring-2 focus:ring-accent/40";

View File

@@ -0,0 +1,256 @@
"use client";
import React from "react";
import { toast } from "sonner";
import { FiStar, FiTrash2, FiUpload } from "react-icons/fi";
import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, arrayMove, rectSortingStrategy, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { presignCoverUpload, saveHackCovers } from "@/app/hack/actions";
import type { Platform } from "@/data/baseRoms";
import { getCoverUrls, newCoverKey } from "@/utils/format";
import { useCommitter, useDraftEditing } from "./DraftEditing";
import { MAX_COVERS } from "@/data/hackLimits";
interface Cover {
key: string;
url: string;
/** Staged upload (manual mode): sent when the page is saved. */
file?: File;
}
interface DraftGalleryProps {
covers: Cover[];
platform: Platform | undefined;
}
function allowedSizes(platform: Platform | undefined) {
if (platform === "GB" || platform === "GBC") return [{ w: 160, h: 144 }];
if (platform === "GBA") return [{ w: 240, h: 160 }];
if (platform === "NDS") return [{ w: 256, h: 192 }, { w: 256, h: 384 }];
return [];
}
function readSize(file: File) {
return new Promise<{ w: number; h: number } | null>((resolve) => {
const img = document.createElement("img");
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
resolve({ w: img.naturalWidth, h: img.naturalHeight });
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(null);
};
img.src = url;
});
}
async function upload(slug: string, key: string, file: File) {
const presigned = await presignCoverUpload({ slug, objectKey: key });
if (!presigned.ok) throw new Error(presigned.error);
const put = await fetch(presigned.presignedUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type || "image/png" } });
if (!put.ok) throw new Error("Upload failed");
}
/**
* Screenshot manager in the Gallery tab. The first shot is the card cover;
* star moves a shot there. Drafts save every change at once; listed hacks
* stage changes (uploads included) until the page is saved.
*/
export default function DraftGallery({ covers: initial, platform }: DraftGalleryProps) {
const { slug, live, run } = useDraftEditing();
const [covers, setCovers] = React.useState<Cover[]>(initial);
const [uploading, setUploading] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// A stable id, or dnd-kit's counter differs between server and client and hydration fails.
const dndId = React.useId();
const sizes = allowedSizes(platform);
const sizeHint = sizes.length ? sizes.map((s) => `${s.w}×${s.h}`).join(" or ") : "native resolution";
const keys = (list: Cover[]) => list.map((c) => c.key).join("\n");
// What the server has: the prop until a save lands, then the saved list (the refresh lags behind).
const [saved, setSaved] = React.useState(() => keys(initial));
const changed = keys(covers) !== saved;
// The list prepare uploaded; commit saves exactly that, so a shot added mid-save stays staged.
const saving = React.useRef<Cover[]>([]);
useCommitter(
changed,
async () => {
const list = saving.current;
const res = await saveHackCovers({ slug, coverUrls: list.map((c) => c.key) });
if (!res.ok) return res;
setSaved(keys(list));
setCovers((prev) => prev.map((c) => (c.file && list.some((s) => s.key === c.key) ? { key: c.key, url: c.url } : c)));
return res;
},
// Uploads run before any of the page's changes publish.
async () => {
saving.current = covers;
try {
for (const c of saving.current) if (c.file) await upload(slug, c.key, c.file);
return { ok: true };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "Upload failed" };
}
},
);
// The latest list, for work that finishes after an await (an add can outlive a delete or reorder).
const coversRef = React.useRef(covers);
coversRef.current = covers;
// Saves send the whole list, so they run in order: an older list landing last would undo newer edits.
const saveQueue = React.useRef<Promise<unknown>>(Promise.resolve());
const persist = (next: Cover[]) => {
coversRef.current = next;
setCovers(next);
if (live) saveQueue.current = saveQueue.current.then(() => run(() => saveHackCovers({ slug, coverUrls: next.map((c) => c.key) }), "covers"));
};
async function addFiles(files: File[]) {
const room = MAX_COVERS - coversRef.current.length;
if (room <= 0) {
toast.error(`Up to ${MAX_COVERS} screenshots.`);
return;
}
const picked = files.slice(0, room);
setUploading(picked.length);
const added: Cover[] = [];
let rejected = 0;
try {
for (const [i, file] of picked.entries()) {
const size = await readSize(file);
if (!size || (sizes.length > 0 && !sizes.some((s) => s.w === size.w && s.h === size.h))) {
rejected += 1;
continue;
}
const key = newCoverKey(slug, i, file.name);
if (live) {
await upload(slug, key, file);
added.push({ key, url: getCoverUrls([key])[0] });
} else {
added.push({ key, url: URL.createObjectURL(file), file });
}
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(0);
}
if (rejected > 0) toast.error(`${rejected} ${rejected === 1 ? "image was" : "images were"} skipped. Screenshots must be ${sizeHint}.`);
if (added.length > 0) persist([...coversRef.current, ...added]);
}
function onDragEnd({ active, over }: DragEndEvent) {
if (!over || active.id === over.id) return;
const from = covers.findIndex((c) => c.key === active.id);
const to = covers.findIndex((c) => c.key === over.id);
if (from === -1 || to === -1) return;
persist(arrayMove(covers, from, to));
}
function feature(i: number) {
if (i === 0) return;
persist(arrayMove(covers, i, 0));
}
function remove(i: number) {
if (live && !window.confirm("Delete this screenshot?")) return;
if (covers[i].file) URL.revokeObjectURL(covers[i].url);
persist(covers.filter((_, j) => j !== i));
}
// Staged previews are object URLs; free the rest when the gallery goes away.
React.useEffect(() => () => coversRef.current.forEach((c) => c.url.startsWith("blob:") && URL.revokeObjectURL(c.url)), []);
return (
<div className="flex flex-col gap-5">
<p className="max-w-[70ch] text-sm text-text-2">
Screenshots at {sizeHint}. The starred one is the cover players see in search results. Drag to reorder.
</p>
<DndContext id={dndId} sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={covers.map((c) => c.key)} strategy={rectSortingStrategy}>
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fill,minmax(264px,1fr))]">
{covers.map((c, i) => (
<Shot key={c.key} cover={c} index={i} featured={i === 0} onFeature={() => feature(i)} onRemove={() => remove(i)} />
))}
{covers.length < MAX_COVERS && (
<li>
<button
type="button"
disabled={uploading > 0}
onClick={() => inputRef.current?.click()}
className="flex aspect-[3/2] w-full flex-col items-center justify-center gap-1.5 rounded-card border-2 border-dashed border-line-strong bg-well p-3 text-center text-sm text-text-2 transition-colors hover:border-text-3 hover:text-text disabled:cursor-wait disabled:opacity-70"
>
<FiUpload className="h-6 w-6" />
<b className="font-semibold">{uploading > 0 ? `Uploading ${uploading}…` : "Add screenshots"}</b>
<span className="text-xs text-text-3">PNG or GIF, up to {MAX_COVERS}</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/png,image/gif,image/jpeg"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (files.length) void addFiles(files);
}}
/>
</li>
)}
</ul>
</SortableContext>
</DndContext>
</div>
);
}
function Shot({ cover, index, featured, onFeature, onRemove }: { cover: Cover; index: number; featured: boolean; onFeature: () => void; onRemove: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: cover.key });
return (
<li ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={isDragging ? "z-10 opacity-70" : ""}>
<div className={`group/shot relative flex justify-center rounded-card bg-well p-3 ${featured ? "shadow-[inset_0_0_0_2px_var(--rose)]" : ""}`}>
<img
src={cover.url}
alt=""
draggable={false}
{...attributes}
{...listeners}
aria-label={`Screenshot ${index + 1}${featured ? ", the cover" : ""}. Drag, or press Space and use the arrow keys, to reorder.`}
className="pixelated aspect-[3/2] w-full max-w-[240px] cursor-grab touch-none rounded-frame object-cover object-top active:cursor-grabbing"
/>
{featured && <span className="pointer-events-none absolute left-3 top-3 rounded-full bg-accent-deep px-2 py-0.5 text-[11px] font-semibold text-white">Cover</span>}
<span className="absolute bottom-3 right-3 flex gap-2 opacity-0 transition-opacity group-focus-within/shot:opacity-100 group-hover/shot:opacity-100 max-md:opacity-100">
<button
type="button"
onClick={onFeature}
aria-pressed={featured}
aria-label={featured ? "Cover screenshot" : "Make cover"}
title={featured ? "Cover screenshot" : "Make cover"}
className={`tap-target inline-flex h-8 w-8 items-center justify-center rounded-full bg-surface text-text shadow-rest transition-colors hover:bg-surface-2 ${featured ? "text-accent" : ""}`}
>
<FiStar className={`h-4 w-4 ${featured ? "fill-current" : ""}`} />
</button>
<button
type="button"
onClick={onRemove}
aria-label="Delete screenshot"
title="Delete screenshot"
className="tap-target inline-flex h-8 w-8 items-center justify-center rounded-full bg-surface text-text shadow-rest transition-colors hover:bg-error-soft hover:text-error"
>
<FiTrash2 className="h-4 w-4" />
</button>
</span>
</div>
</li>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import React from "react";
import type { CatalogTagRow } from "@/types/catalogTag";
import { FIELD, useAutosave, useDraftEditing } from "./DraftEditing";
import DraftTags from "./DraftTags";
import { SUMMARY_MAX, TITLE_MAX } from "@/data/hackLimits";
interface DraftHeaderProps {
title: string;
summary: string;
tags: string[];
catalogTags: CatalogTagRow[];
tagsUpdatedAt: string;
/** The byline, placed under the title as on the published page. */
children: React.ReactNode;
}
/** Title, summary and tags edited in place of the hack page header. Each field autosaves once typing pauses. */
export default function DraftHeader({ title: initialTitle, summary: initialSummary, tags: initialTags, catalogTags, tagsUpdatedAt, children }: DraftHeaderProps) {
const { save, live } = useDraftEditing();
const [title, setTitle] = React.useState(initialTitle);
const [summary, setSummary] = React.useState(initialSummary);
const [tags, setTags] = React.useState(initialTags);
const [summaryFocused, setSummaryFocused] = React.useState(false);
useAutosave(title, (v) => {
if (v.trim()) void save({ title: v.trim() });
});
// Drafts wait until it fits (the counter is red meanwhile); listed hacks stage it so Save reports the limit.
useAutosave(summary, (v) => {
if (!live || v.trim().length <= SUMMARY_MAX) void save({ summary: v.trim() });
});
useAutosave(tags, (v) => void save({ tags: v }), 500);
const nearLimit = summary.length > SUMMARY_MAX - 10;
return (
<>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={TITLE_MAX}
aria-label="Title"
placeholder="Hack title"
className="-mx-2 w-[calc(100%+16px)] rounded-control border border-transparent bg-transparent px-2 font-display text-[28px] leading-[1.1] text-text outline-none transition-colors hover:border-line focus:border-line-strong focus:bg-surface md:text-[clamp(32px,3.4vw,40px)]"
/>
{children}
<label className="relative mt-3 block max-w-[70ch]">
<span className="sr-only">Summary</span>
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
onFocus={() => setSummaryFocused(true)}
onBlur={() => setSummaryFocused(false)}
rows={2}
maxLength={SUMMARY_MAX + 20}
placeholder="One or two sentences players see on the card."
className={`${FIELD} resize-none py-2 pr-16 text-[14px] leading-[1.45] md:text-[15px] ${summary.length > SUMMARY_MAX ? "border-error" : ""}`}
/>
{(summaryFocused || nearLimit) && (
<span className={`pointer-events-none absolute bottom-2 right-3 text-xs tabular-nums ${summary.length > SUMMARY_MAX ? "text-error" : "text-text-3"}`}>
{summary.length}/{SUMMARY_MAX}
</span>
)}
</label>
<DraftTags tags={tags} onChange={setTags} catalogTags={catalogTags} tagsUpdatedAt={tagsUpdatedAt} />
</>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import React from "react";
import { FiEdit2, FiPlus } from "react-icons/fi";
import Modal from "@/components/Primitives/Modal";
import TagSelector from "@/components/Submit/TagSelector";
import type { CatalogTagRow } from "@/types/catalogTag";
import { useDraftEditing } from "./DraftEditing";
const SAVE_LABEL = { idle: null, saving: "Saving…", saved: "Saved", error: "Couldn't save" } as const;
/**
* Tags as the published page shows them, plus a pill that opens the picker in
* a modal, so the edit header keeps the published layout. Changes go through
* the parent's autosave (live drafts show its status by Done); tags added in
* the modal pop in once it closes.
*/
export default function DraftTags({ tags, onChange, catalogTags, tagsUpdatedAt }: { tags: string[]; onChange: (next: string[]) => void; catalogTags: CatalogTagRow[]; tagsUpdatedAt: string }) {
const { status, live } = useDraftEditing();
const [open, setOpen] = React.useState(false);
const [fresh, setFresh] = React.useState<string[]>([]);
const openedWith = React.useRef<string[]>([]);
function openPicker() {
openedWith.current = tags;
setFresh([]);
setOpen(true);
}
function closePicker() {
setOpen(false);
setFresh(tags.filter((t) => !openedWith.current.includes(t)));
}
const Icon = tags.length ? FiEdit2 : FiPlus;
return (
<div className="mt-3.5">
<ul className="flex flex-wrap items-center gap-1.5" aria-label="Tags">
{tags.map((t) => (
<li key={t} className={`rounded-full bg-surface-2 px-2.5 py-1 text-[13px] leading-tight text-text-2 ${fresh.includes(t) ? "anim-tag-new" : ""}`}>
{t}
</li>
))}
<li className="flex items-center gap-2.5">
<button
type="button"
onClick={openPicker}
className="tap-target inline-flex items-center gap-1.5 rounded-full border border-dashed border-line-strong px-2.5 py-[3px] text-[13px] font-medium leading-tight text-text-2 transition-colors hover:border-solid hover:border-text-3 hover:bg-surface hover:text-text"
>
<Icon className="h-3 w-3" /> {tags.length ? "Edit tags" : "Add tags"}
</button>
{tags.length === 0 && <span className="text-xs text-text-3">Players filter by these on Discover.</span>}
</li>
</ul>
<Modal title="Tags" visible={open} onClose={closePicker} className="max-w-2xl">
<TagSelector
value={tags}
onChange={onChange}
catalogTags={catalogTags}
newTagsCutoff={new Date(tagsUpdatedAt)}
done={
<>
<span className="mr-auto text-xs text-text-3" aria-live="polite">
{live && SAVE_LABEL[status]}
</span>
<button type="button" onClick={closePicker} className="inline-flex h-10 items-center rounded-control bg-accent-deep px-[18px] text-sm font-semibold text-white transition-colors hover:bg-accent-hover">
Done
</button>
</>
}
/>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,320 @@
"use client";
import React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { FiAlertCircle, FiCheck, FiEdit3, FiEye } from "react-icons/fi";
import { submitForReview } from "@/app/submit/actions";
import { useDraftEditingOptional } from "@/components/Hack/Draft/DraftEditing";
import { EditDetailsLink } from "@/components/Hack/Draft/DraftDetails";
import Modal from "@/components/Primitives/Modal";
import { NETWORK_ERROR } from "@/utils/networkError";
export type DraftStage = "draft" | "review" | "listed";
export interface ChecklistItem {
key: string;
label: string;
done: boolean;
/** Sub-route of the hack that fixes it, e.g. "versions". */
href?: string;
}
interface DraftStatusProps {
slug: string;
stage: DraftStage;
submittedAt: string | null;
required: ChecklistItem[];
recommended: ChecklistItem[];
/** Set while the creator is previewing the draft as a player; swaps the preview link for Back to editing. */
preview?: boolean;
/** Verification contact the reviewers see; the submit modal and the review card show it. */
contact: string | null;
/** An admin in someone else's draft: only the creator can submit it. */
notOwner?: boolean;
}
const COPY: Record<DraftStage, { pill: string; tone: string }> = {
draft: { pill: "Draft", tone: "bg-surface-2 text-text" },
review: { pill: "In review", tone: "bg-accent-soft text-accent-text" },
listed: { pill: "Listed", tone: "bg-ready-soft text-text" },
};
/** Strip at the top of a draft: where the hack stands, autosave state, preview, and Submit for review when the checklist is clear. */
export function DraftStatusStrip({ slug, stage, submittedAt, required, contact, preview = false, notOwner = false }: Omit<DraftStatusProps, "recommended">) {
const editing = useDraftEditingOptional();
const [confirming, setConfirming] = React.useState(false);
const manual = editing !== null && !editing.live;
const left = required.filter((r) => !r.done).length;
const canSubmit = left === 0;
const c = COPY[stage];
const text =
stage === "draft" && notOwner ? (
<>Not submitted yet. Only the creator can submit it for review.</>
) : stage === "draft" ? (
<>Only you can see this page. {canSubmit ? "Everything required is in place." : `${left} required ${left === 1 ? "item" : "items"} left before you can submit.`}</>
) : stage === "review" ? (
<>Submitted {submittedAt ? new Date(submittedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }) : ""}.</>
) : (
<>{editing?.dirty ? "Unsaved changes. Nothing publishes until you save." : "Live. Changes publish when you save; new versions go through a quick check."}</>
);
return (
<div className="-mx-6 border-b border-line bg-surface px-6 md:mx-0 md:rounded-card md:border">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 py-3 text-sm md:px-1">
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-[11px] py-1 text-[13px] font-semibold ${c.tone}`}>
{stage === "listed" && <span className="ready-dot" />}
{c.pill}
</span>
<p className="min-w-0 basis-full text-text-2 md:basis-auto md:flex-1">{preview ? "Previewing as a player. This is what the page looks like once listed." : text}</p>
{editing && !manual && editing.status !== "idle" && (
<span className={`inline-flex items-center gap-1 text-xs ${editing.status === "error" ? "text-error" : "text-text-3"}`} aria-live="polite">
{editing.status === "saved" && <FiCheck className="h-3.5 w-3.5 text-ready" />}
{editing.status === "saving" ? "Saving…" : editing.status === "saved" ? "Saved" : "Couldn't save"}
{editing.status === "error" && editing.failed && (
<button type="button" onClick={editing.retry} className="ml-1 font-semibold text-link hover:underline hover:underline-offset-[3px]">
Retry
</button>
)}
</span>
)}
<div className="flex flex-none items-center gap-2 max-md:ml-auto">
{stage === "listed" ? (
<>
{/* DraftEditingProvider's leave guard confirms the discard. */}
<Link
href={`/hack/${slug}`}
className="inline-flex h-[38px] items-center gap-1.5 rounded-control bg-surface-2 px-3 text-sm font-medium text-text transition-colors hover:bg-line"
>
{editing?.dirty ? "Discard" : "Done editing"}
</Link>
{manual && (
<button
type="button"
disabled={!editing.dirty || editing.status === "saving"}
onClick={() => void editing.saveAll()}
className="inline-flex h-[38px] items-center gap-1.5 rounded-control bg-accent-deep px-4 text-sm font-semibold text-white transition-colors hover:enabled:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60"
>
{editing.status === "saving" ? "Saving…" : <><FiCheck className="h-4 w-4" /> Save changes</>}
</button>
)}
</>
) : preview ? (
<Link href={`/hack/${slug}`} className="inline-flex h-[38px] items-center gap-1.5 rounded-control bg-surface-2 px-3 text-sm font-medium text-text transition-colors hover:bg-line">
<FiEdit3 className="h-4 w-4" /> Back to editing
</Link>
) : (
<Link href={`/hack/${slug}?preview=1`} className="inline-flex h-[38px] items-center gap-1.5 rounded-control bg-surface-2 px-3 text-sm font-medium text-text transition-colors hover:bg-line">
<FiEye className="h-4 w-4" /> Preview as a player
</Link>
)}
{stage === "draft" && !notOwner && (
<button
type="button"
disabled={!canSubmit}
onClick={() => setConfirming(true)}
className="inline-flex h-[38px] items-center rounded-control bg-accent-deep px-4 text-sm font-semibold text-white transition-colors hover:enabled:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60"
>
Submit for review
</button>
)}
</div>
</div>
{confirming && <SubmitModal slug={slug} contact={contact} onClose={() => setConfirming(false)} />}
</div>
);
}
const PRIMARY = "inline-flex h-10 items-center justify-center rounded-control bg-accent-deep px-4 text-sm font-semibold text-white transition-colors hover:enabled:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60";
const QUIET = "inline-flex h-10 items-center justify-center rounded-control px-3 text-sm font-medium text-text-2 transition-colors hover:bg-surface-2 hover:text-text disabled:opacity-60";
const CONTACT_HINT = "Add where your post history shows you made this hack, like one of your socials and the communities you’re active in. Or share a public link to where you mention you’re submitting to Hackdex. Only admins see it.";
/**
* Confirms a submission and checks the verification contact on the way:
* shows it for a last look if set, or asks for one (skippable) if not. Turns
* into a what-happens-next screen once the hack is in the queue.
*/
function SubmitModal({ slug, contact, onClose }: { slug: string; contact: string | null; onClose: () => void }) {
const router = useRouter();
const saved = contact?.trim() ?? "";
const [text, setText] = React.useState(saved);
const [editingContact, setEditingContact] = React.useState(!saved);
const [busy, setBusy] = React.useState(false);
const [done, setDone] = React.useState(false);
async function submit(withContact: boolean) {
setBusy(true);
const next = withContact && text.trim() !== saved ? text : undefined;
const res = await submitForReview(slug, next).catch(() => null);
setBusy(false);
if (!res) {
// No answer: it may have gone through anyway, so refresh to show where the hack stands.
toast.error(NETWORK_ERROR);
router.refresh();
return;
}
if (!res.ok) {
toast.error(res.error);
return;
}
setDone(true);
router.refresh();
}
if (done) {
return (
<Modal title="Submitted for review" visible onClose={onClose}>
<div className="flex flex-col items-center text-center">
<span className="mb-3 grid h-14 w-14 place-items-center rounded-full bg-ready-soft">
<svg viewBox="0 0 24 24" className="h-7 w-7 fill-none stroke-ready [stroke-linecap:round] [stroke-linejoin:round] [stroke-width:3]" aria-hidden>
<path d="M5 12.5l4.5 4.5L19 7.5" className="anim-draw" />
</svg>
</span>
<p className="text-sm text-text-2">We&rsquo;ll email you once it&rsquo;s approved.</p>
</div>
<NextSteps />
<button type="button" onClick={onClose} className="mt-5 inline-flex h-10 w-full items-center justify-center rounded-control bg-surface-2 text-sm font-semibold text-text transition-colors hover:bg-line">
Back to my page
</button>
</Modal>
);
}
return (
<Modal title="Ready to submit?" visible onClose={onClose}>
<p className="text-sm text-text-2">A volunteer will review your hack before it&rsquo;s listed.{saved ? " Check your verification contact first. Reviewers use it to confirm the hack is yours." : ""}</p>
<div className={`mt-4 rounded-card border p-3.5 ${saved ? "border-line-strong" : "border-warn/40 bg-warn-soft"}`}>
<h3 className="flex items-baseline justify-between text-[13px] font-semibold">
{saved ? "Verification contact" : "Speed up verification"}
{saved && !editingContact && (
<button type="button" onClick={() => setEditingContact(true)} className="text-[13px] font-medium text-link hover:underline hover:underline-offset-[3px]">
Edit
</button>
)}
</h3>
{!saved && <p className="mt-0.5 text-[12.5px] text-text-2">{CONTACT_HINT}</p>}
{editingContact ? (
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={3}
autoFocus
aria-label="Verification contact"
placeholder={"@yourname, active in RH Hideout\nor a link to your post about it"}
className="mt-2 w-full resize-y rounded-control border border-line-strong bg-surface px-3 py-2 text-sm text-text outline-none placeholder:text-text-3 focus:border-accent focus:shadow-[0_0_0_3px_var(--rose-soft)]"
/>
) : (
<p className="mt-2 whitespace-pre-line rounded-control bg-surface-2 px-3 py-2 text-sm">{saved}</p>
)}
</div>
<div className="mt-5 flex flex-wrap items-center justify-end gap-2">
{saved ? (
<>
<button type="button" onClick={onClose} className={QUIET}>Cancel</button>
<button type="button" disabled={busy || !text.trim()} onClick={() => submit(true)} className={PRIMARY}>
{busy ? "Submitting…" : "Submit for review"}
</button>
</>
) : (
<>
<button type="button" disabled={busy} onClick={() => submit(false)} className={QUIET}>Submit without it</button>
<button type="button" disabled={busy || !text.trim()} onClick={() => submit(true)} className={PRIMARY}>
{busy ? "Submitting…" : "Save and submit"}
</button>
</>
)}
</div>
</Modal>
);
}
/** What happens after submitting; shared by the success screen and the review card. */
function NextSteps() {
const steps = [
["A volunteer reviews your page and patch", null],
["They may reach out to confirm it’s yours", "Using your verification contact."],
["Once approved, it’s listed on Discover", "Edits you make now are included in the review."],
] as const;
return (
<ol className="mt-4 grid gap-2.5 text-left">
{steps.map(([title, sub], i) => (
<li key={title} className="grid grid-cols-[24px_1fr] gap-2.5 text-sm">
<span className="grid h-6 w-6 place-items-center rounded-full bg-surface-2 text-xs font-bold text-text-2">{i + 1}</span>
<span>
{title}
{sub && <small className="block text-[12.5px] text-text-3">{sub}</small>}
</span>
</li>
))}
</ol>
);
}
/** Publishing checklist. Required items gate submission; recommended ones only nudge. In review it gives way to what happens next. */
export function DraftChecklist({ slug, stage, required, recommended, contact }: Omit<DraftStatusProps, "submittedAt">) {
if (stage === "review") {
return (
<section className="flex flex-col gap-3 rounded-card border border-line bg-surface p-4 text-sm shadow-rest">
<h2 className="text-base font-semibold">While you wait</h2>
<p className="text-text-2">A volunteer reviews your page and patch. We&rsquo;ll email you once it&rsquo;s approved.</p>
<div className="rounded-control bg-surface-2 px-3 py-2.5">
<h3 className="flex items-baseline justify-between text-xs font-semibold text-text-2">
Verification contact <EditDetailsLink />
</h3>
{contact?.trim() ? (
<p className="mt-1 whitespace-pre-line">{contact}</p>
) : (
<p className="mt-1 text-text-2">None yet. Adding one speeds up verification.</p>
)}
</div>
<p className="text-text-3">Edits you make now are included in the review.</p>
</section>
);
}
const left = required.filter((r) => !r.done).length;
const openRecommended = recommended.filter((r) => !r.done);
return (
<section className="flex flex-col gap-3.5 rounded-card border border-line bg-surface p-4 shadow-rest">
<div className="flex items-baseline justify-between">
<h2 className="text-base font-semibold">{stage === "draft" ? "Before you submit" : "Listing health"}</h2>
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${left === 0 ? "bg-ready-soft text-ready" : "bg-surface-2 text-text-2"}`}>
{left === 0 ? (stage === "draft" ? "Ready" : "All good") : `${left} to do`}
</span>
</div>
<Group title="Required" items={required} slug={slug} count={`${required.length - left}/${required.length}`} />
{openRecommended.length > 0 && <Group title="Recommended" items={recommended} slug={slug} warn />}
</section>
);
}
function Group({ title, items, slug, count, warn = false }: { title: string; items: ChecklistItem[]; slug: string; count?: string; warn?: boolean }) {
return (
<div>
<h3 className="mb-1.5 flex justify-between text-xs font-semibold text-text-3">
{title} {count && <small className="font-medium tabular-nums">{count}</small>}
</h3>
<ul className="flex flex-col gap-1 text-sm">
{items.map((i) => (
<li key={i.key} className={`flex items-start gap-2 leading-[1.35] ${i.done ? "text-text-3" : ""}`}>
<span
className={`mt-px inline-flex h-[18px] w-[18px] flex-none items-center justify-center rounded-full ${
i.done ? "bg-ready text-on-ready" : warn ? "text-warn" : "border-[1.5px] border-line-strong"
}`}
>
{i.done ? <FiCheck className="h-3.5 w-3.5" /> : warn ? <FiAlertCircle className="h-[13px] w-[13px]" /> : null}
</span>
{!i.done && i.href ? (
<Link href={`/hack/${slug}/${i.href}`} className="underline decoration-line-strong underline-offset-[3px] hover:decoration-accent">
{i.label}
</Link>
) : (
<span className={i.done ? "line-through decoration-line-strong" : ""}>{i.label}</span>
)}
</li>
))}
</ul>
</div>
);
}

View File

@@ -1,235 +0,0 @@
"use client";
import PixelImage from "../PixelImage";
import React from "react";
import useEmblaCarousel from "embla-carousel-react";
import { FiChevronLeft, FiChevronRight, FiGrid, FiX } from "react-icons/fi";
export default function Gallery({ images, title }: { images: string[]; title: string }) {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [lightboxOpen, setLightboxOpen] = React.useState(false);
React.useEffect(() => {
if (!emblaApi) return;
const onSelect = () => setSelectedIndex(emblaApi.selectedScrollSnap());
emblaApi.on("select", onSelect);
onSelect();
return () => {
if (emblaApi) emblaApi.off("select", onSelect);
};
}, [emblaApi]);
return (
<div className="card-simple p-4">
<div className="relative">
<div className="relative aspect-[16/9] w-full overflow-hidden" ref={emblaRef}>
<div className="flex h-full">
{images.map((src, idx) => (
<div key={`${src}-${idx}`} className="relative h-full flex-[0_0_100%]">
<button onClick={() => setLightboxOpen(true)} className="absolute inset-0">
<PixelImage src={src} alt={title} mode="contain" className="absolute inset-0" />
</button>
</div>
))}
</div>
</div>
<button
type="button"
aria-label="Previous image"
disabled={images.length < 2}
onClick={() => emblaApi && emblaApi.scrollPrev()}
className="absolute left-0 top-1/2 z-10 -translate-x-2 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,black_30%,transparent)] p-2 text-white ring-1 ring-white/30 hover:bg-[color-mix(in_oklab,black_50%,transparent)] disabled:pointer-events-none disabled:opacity-40 dark:bg-black/30 dark:hover:bg-black/50"
>
<FiChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
aria-label="Next image"
disabled={images.length < 2}
onClick={() => emblaApi && emblaApi.scrollNext()}
className="absolute right-0 top-1/2 z-10 translate-x-2 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,black_30%,transparent)] p-2 text-white ring-1 ring-white/30 hover:bg-[color-mix(in_oklab,black_50%,transparent)] disabled:pointer-events-none disabled:opacity-40 dark:bg-black/30 dark:hover:bg-black/50"
>
<FiChevronRight className="h-5 w-5" />
</button>
</div>
<div className="mt-3 flex gap-2 overflow-x-auto">
{images.map((src, i) => (
<button
key={`${src}-${i}`}
onClick={() => emblaApi && emblaApi.scrollTo(i)}
className={`relative h-16 w-28 overflow-hidden rounded border-2 ${
i === selectedIndex ? "border-[var(--accent)]" : "border-[var(--border)]"
}`}
aria-label={`Show image ${i + 1}`}
>
<img src={src} alt={`${title} screenshot ${i + 1}`} className="absolute inset-0 w-full h-full object-cover" draggable={false} />
</button>
))}
</div>
{lightboxOpen && (
<Lightbox images={images} startIndex={selectedIndex} title={title} onClose={() => setLightboxOpen(false)} />
)}
</div>
);
}
const DESKTOP_LIGHTBOX = "(min-width: 768px)";
function isDesktopLightbox() {
return typeof window !== "undefined" && window.matchMedia(DESKTOP_LIGHTBOX).matches;
}
function Lightbox({ images, startIndex, title, onClose }: { images: string[]; startIndex: number; title: string; onClose: () => void }) {
const [index, setIndex] = React.useState(startIndex);
const [pixelPerfect, setPixelPerfect] = React.useState(isDesktopLightbox);
const closeRef = React.useRef<HTMLButtonElement | null>(null);
const canCycle = images.length > 1;
const onPrev = React.useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]);
const onNext = React.useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (!canCycle) return;
if (e.key === "ArrowRight") onNext();
if (e.key === "ArrowLeft") onPrev();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [canCycle, onClose, onNext, onPrev]);
React.useEffect(() => {
closeRef.current?.focus();
}, []);
React.useEffect(() => {
const html = document.documentElement;
const body = document.body;
const previousHtmlOverflow = html.style.overflow;
const previousBodyOverflow = body.style.overflow;
const previousBodyPaddingRight = body.style.paddingRight;
const scrollBarWidth = window.innerWidth - html.clientWidth;
html.style.overflow = "hidden";
body.style.overflow = "hidden";
if (scrollBarWidth > 0) {
body.style.paddingRight = `${scrollBarWidth}px`;
}
const preventTouchScroll = (e: TouchEvent) => e.preventDefault();
document.addEventListener("touchmove", preventTouchScroll, { passive: false });
return () => {
html.style.overflow = previousHtmlOverflow;
body.style.overflow = previousBodyOverflow;
body.style.paddingRight = previousBodyPaddingRight;
document.removeEventListener("touchmove", preventTouchScroll);
};
}, []);
React.useEffect(() => {
const mq = window.matchMedia(DESKTOP_LIGHTBOX);
const sync = () => {
if (mq.matches) setPixelPerfect(true);
};
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
const controlClass =
"rounded-full text-white/80 ring-1 ring-white/30 transition-colors hover:bg-white/10 hover:text-white active:bg-white/10 active:text-white focus:outline-none disabled:pointer-events-none disabled:opacity-40";
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={`Screenshots for ${title}`}>
<div className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm" onClick={onClose} />
<div className="relative mx-auto flex h-full max-w-6xl px-0 md:px-4" onClick={(e) => e.stopPropagation()}>
<div className="relative flex min-h-0 w-full flex-col">
<div className="mb-3 flex justify-end pt-[calc(1rem+env(safe-area-inset-top,0px))] pr-[calc(1rem+env(safe-area-inset-right,0px))] pl-[calc(1rem+env(safe-area-inset-left,0px))] md:px-0 md:pt-4">
<button
type="button"
ref={closeRef}
className={`${controlClass} flex h-10 w-10 items-center justify-center`}
onClick={onClose}
aria-label="Close lightbox"
>
<FiX size={24} />
</button>
</div>
<div
className="relative min-h-0 flex-1 overflow-hidden rounded"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<PixelImage
src={images[index]}
alt={`${title} screenshot ${index + 1}`}
mode="contain"
pixelPerfect={pixelPerfect}
className="absolute inset-0"
/>
</div>
<div className="mt-3 flex flex-col items-center gap-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] pl-[calc(1rem+env(safe-area-inset-left,0px))] pr-[calc(1rem+env(safe-area-inset-right,0px))] md:px-0 md:pb-4">
<button
type="button"
role="switch"
aria-checked={pixelPerfect}
onClick={() => setPixelPerfect((enabled) => !enabled)}
className="flex items-center gap-2 rounded-full px-2 py-1 text-xs text-white/80 transition-colors hover:bg-white/10 hover:text-white active:bg-white/10 active:text-white focus:outline-none md:hidden"
>
<FiGrid className="h-4 w-4" aria-hidden="true" />
<span>Pixel-perfect</span>
<span
aria-hidden="true"
className={`flex h-4 w-8 items-center rounded-full p-0.5 ring-1 ring-white/30 transition-colors ${
pixelPerfect ? "bg-white/80" : "bg-white/10"
}`}
>
<span
className={`h-3 w-3 rounded-full transition-transform ${
pixelPerfect ? "translate-x-4 bg-black/70" : "translate-x-0 bg-white/80"
}`}
/>
</span>
</button>
<div className="grid w-full grid-cols-[1fr_auto_1fr] items-center md:flex md:justify-center">
<button
type="button"
aria-label="Previous image"
disabled={!canCycle}
onClick={onPrev}
className={`${controlClass} flex h-14 w-14 items-center justify-center justify-self-start md:hidden`}
>
<FiChevronLeft className="h-7 w-7" />
</button>
<div className="rounded-full bg-black/20 px-3 py-1 text-sm text-white ring-1 ring-white/30 md:text-xs" aria-live="polite">
{index + 1} / {images.length}
</div>
<button
type="button"
aria-label="Next image"
disabled={!canCycle}
onClick={onNext}
className={`${controlClass} flex h-14 w-14 items-center justify-center justify-self-end md:hidden`}
>
<FiChevronRight className="h-7 w-7" />
</button>
</div>
</div>
</div>
<button
type="button"
aria-label="Previous image"
disabled={!canCycle}
onClick={onPrev}
className={`${controlClass} absolute left-4 top-1/2 hidden -translate-y-1/2 p-3 md:block`}
>
<FiChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
aria-label="Next image"
disabled={!canCycle}
onClick={onNext}
className={`${controlClass} absolute right-4 top-1/2 hidden -translate-y-1/2 p-3 md:block`}
>
<FiChevronRight className="h-5 w-5" />
</button>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import React from "react";
import StickyActionBar from "@/components/Hack/StickyActionBar";
import PatchModule from "@/components/Hack/PatchModule";
import BaseRomErrorModal, { type BaseRomErrorModalState } from "@/components/Hack/BaseRomErrorModal";
import HackOnboardingOverlay from "@/components/Hack/Onboarding/HackOnboardingOverlay";
import { useBaseRoms } from "@/contexts/BaseRomContext";
@@ -624,7 +624,7 @@ const HackActions: React.FC<HackActionsProps> = ({
return (
<>
<StickyActionBar
<PatchModule
title={title}
version={selectedVersion}
selectablePatches={patcherSelector.selectablePatches}
@@ -634,6 +634,7 @@ const HackActions: React.FC<HackActionsProps> = ({
onboardingHighlight={onboarding.cardOpen ? onboarding.currentStep : null}
onboardingDimBar={onboarding.cardOpen && onboarding.locked && !onboarding.hideForPicker}
onboardingGateLabel={onboarding.showGate ? onboarding.gateLabel : null}
onboardingGateHidden={onboarding.cardOpen}
onOnboardingGateClick={onboarding.openFromGate}
author={author}
filename={selectedFilename}

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,8 @@ import Select from "@/components/Primitives/Select";
import type { Database } from "@/types/db";
import type { CatalogTagRow } from "@/types/catalogTag";
import { FiExternalLink } from "react-icons/fi";
import { newCoverKey } from "@/utils/format";
import { SUMMARY_MAX, TITLE_MAX } from "@/data/hackLimits";
import { HACK_FORM_DESCRIPTION_PLACEHOLDER } from "./hackFormConstants";
interface HackEditFormProps {
@@ -217,11 +219,11 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
if (item.type === "existing") {
keys.push(item.key);
} else {
const ext = item.file.name.split('.').pop();
const path = `${slug}/${Date.now()}-${i}.${ext}`;
const path = newCoverKey(slug, i, item.file.name);
const presigned = await presignCoverUpload({ slug, objectKey: path });
if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign cover upload');
await fetch(presigned.presignedUrl, { method: 'PUT', body: item.file, headers: { 'Content-Type': item.file.type || 'image/jpeg' } });
const put = await fetch(presigned.presignedUrl, { method: 'PUT', body: item.file, headers: { 'Content-Type': item.file.type || 'image/jpeg' } });
if (!put.ok) throw new Error('Screenshot upload failed');
keys.push(path);
}
}
@@ -239,7 +241,7 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
}
}
const summaryLimit = 120;
const summaryLimit = SUMMARY_MAX;
const summaryTooLong = summary.length > summaryLimit;
const contentHasErrors = summaryTooLong || (!!boxArt && !urlLike(boxArt)) || !completionStatus;
@@ -271,20 +273,20 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
Revert all
</button>
)}
<button onClick={onSaveMeta} disabled={saving || !contentChanged || contentHasErrors} className="shine-wrap btn-premium h-8 min-w-[6rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]">
<button onClick={onSaveMeta} disabled={saving || !contentChanged || contentHasErrors} className="inline-flex items-center justify-center rounded-control bg-accent-deep px-5 text-white transition-colors hover:enabled:bg-accent-hover disabled:opacity-60 h-8 min-w-[6rem] text-sm font-semibold disabled:cursor-not-allowed">
<span>{saving ? "Saving…" : "Save Content/Details"}</span>
</button>
</div>
</div>
{!completionStatus && (
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm text-amber-900 dark:text-amber-100">
<div className="mt-4 rounded-control border border-warn/40 bg-warn-soft p-3 text-sm text-warn">
<div className="flex items-start gap-2">
<div className="flex items-center justify-center w-5 h-5 shrink-0 mt-0.5">
<div className="inline-block h-2 w-2 rounded-full bg-amber-400" />
<div className="inline-block h-2 w-2 rounded-full bg-warn" />
</div>
<div className="flex flex-col gap-1">
<p className="font-semibold">Missing Completion Status</p>
<p className="text-xs text-amber-800 dark:text-amber-200">
<p className="text-xs text-warn">
This is a new required field. Please select a completion status in the Details section to save your changes.
</p>
</div>
@@ -294,23 +296,23 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
<div className="mt-4 grid gap-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Title</label>
<label className="text-sm text-text-2">Title</label>
{titleChanged && (
<div className="flex items-center gap-2 text-[11px] text-foreground/70">
<div className="flex items-center gap-2 text-[11px] text-text-2">
<span>Modified</span>
<button type="button" onClick={() => setTitle(baseline.title)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
</div>
)}
</div>
<input value={title} onChange={(e) => setTitle(e.target.value)} className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${titleChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
<input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={TITLE_MAX} className={`h-11 rounded-control px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${titleChanged ? 'ring-accent bg-surface-2' : 'bg-surface-2 ring-line'}`} />
</div>
<div className="grid gap-1">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Summary</label>
<label className="text-sm text-text-2">Summary</label>
<div className="flex items-center gap-2">
{summaryChanged && (
<>
<span className="text-[11px] text-foreground/70 ml-2">Modified</span>
<span className="text-[11px] text-text-2 ml-2">Modified</span>
<button type="button" onClick={() => setSummary(baseline.summary)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
</>
)}
@@ -320,30 +322,30 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
<input
value={summary}
onChange={(e) => setSummary(e.target.value)}
className={`w-full h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 pr-16 ${summary.length > summaryLimit ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : summaryChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`}
className={`w-full h-11 rounded-control px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 pr-16 ${summary.length > summaryLimit ? "ring-error/40 bg-error-soft dark:ring-error/40 dark:bg-error-soft" : summaryChanged ? 'ring-accent bg-surface-2' : 'bg-surface-2 ring-line'}`}
/>
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-[11px] ${summary.length > summaryLimit ? "text-red-300" : "text-foreground/60"}`}>
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-[11px] ${summary.length > summaryLimit ? "text-error" : "text-text-3"}`}>
{summary.length}/{summaryLimit}
</span>
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Description</label>
<label className="text-sm text-text-2">Description</label>
<div className="flex items-center gap-1 text-xs">
<button type="button" onClick={() => setShowMdPreview(false)} className={`px-2 py-1 rounded ${!showMdPreview ? "bg-[var(--surface-2)] ring-1 ring-[var(--border)]" : "text-foreground/70"}`}>Write</button>
<button type="button" onClick={() => setShowMdPreview(true)} className={`px-2 py-1 rounded ${showMdPreview ? "bg-[var(--surface-2)] ring-1 ring-[var(--border)]" : "text-foreground/70"}`}>Preview</button>
<button type="button" onClick={() => setShowMdPreview(false)} className={`px-2 py-1 rounded ${!showMdPreview ? "bg-surface-2 ring-1 ring-line" : "text-text-2"}`}>Write</button>
<button type="button" onClick={() => setShowMdPreview(true)} className={`px-2 py-1 rounded ${showMdPreview ? "bg-surface-2 ring-1 ring-line" : "text-text-2"}`}>Preview</button>
{descriptionChanged && (
<div className="flex items-center gap-2 ml-2">
<span className="text-[11px] text-foreground/70">Modified</span>
<span className="text-[11px] text-text-2">Modified</span>
<button type="button" onClick={() => setDescription(baseline.description)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
</div>
)}
</div>
</div>
<p className="text-xs text-foreground/60">
<p className="text-xs text-text-3">
A credits section is required.{" "}
<a href="https://www.markdownguide.org/basic-syntax/" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2 hover:text-foreground/80 transition-colors">Supports Markdown <FiExternalLink className="inline-block h-3 w-3" /></a>
<a href="https://www.markdownguide.org/basic-syntax/" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2 hover:text-text-2 transition-colors">Supports Markdown <FiExternalLink className="inline-block h-3 w-3" /></a>
</p>
{!showMdPreview ? (
<textarea
@@ -351,10 +353,10 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={HACK_FORM_DESCRIPTION_PLACEHOLDER}
className={`rounded-md px-3 py-2 min-h-[14rem] text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${descriptionChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`}
className={`rounded-control px-3 py-2 min-h-[14rem] text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${descriptionChanged ? 'ring-accent bg-surface-2' : 'bg-surface-2 ring-line'}`}
/>
) : (
<div className={`prose max-w-none rounded-md min-h-[14rem] px-3 py-2 ring-1 ring-inset ${descriptionChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'} ${description ? "" : "text-foreground/60 text-sm"}`}>
<div className={`prose max-w-none rounded-control min-h-[14rem] px-3 py-2 ring-1 ring-inset ${descriptionChanged ? 'ring-accent bg-surface-2' : 'bg-surface-2 ring-line'} ${description ? "" : "text-text-3 text-sm"}`}>
<Markdown headingLevelOffset={1}>{description || "Nothing to preview yet."}</Markdown>
</div>
)}
@@ -362,15 +364,15 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
<div className="grid gap-2">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Tags</label>
<label className="text-sm text-text-2">Tags</label>
{tagsChanged && (
<div className="flex items-center gap-2 text-[11px] text-foreground/70">
<div className="flex items-center gap-2 text-[11px] text-text-2">
<span>Modified</span>
<button type="button" onClick={() => setTags(baseline.tags.slice())} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
</div>
)}
</div>
<div className={`rounded-md ring-1 ring-inset ${tagsChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'ring-transparent'} p-1`}>
<div className={`rounded-control ring-1 ring-inset ${tagsChanged ? 'ring-accent bg-surface-2' : 'ring-transparent'} p-1`}>
<TagSelector value={tags} onChange={setTags} catalogTags={catalogTags} newTagsCutoff={tagsUpdatedAt} />
</div>
</div>
@@ -382,27 +384,27 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
<h2 className="text-xl font-semibold tracking-tight">Screenshots</h2>
<div className="flex items-center gap-2">
{coversChanged && (
<button type="button" onClick={() => setCoverItems(coversBaseline.keys.map((k, i) => ({ type: 'existing' as const, key: k, url: coversBaseline.urls[i] || '' })))} className="inline-flex h-8 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-[12px] cursor-pointer">
<button type="button" onClick={() => setCoverItems(coversBaseline.keys.map((k, i) => ({ type: 'existing' as const, key: k, url: coversBaseline.urls[i] || '' })))} className="inline-flex h-8 items-center justify-center rounded-control border border-line bg-surface-2 px-3 text-[12px] cursor-pointer">
Revert
</button>
)}
<button onClick={onSaveCovers} disabled={saving || !coversChanged || overLimit} className="inline-flex shine-wrap btn-premium h-8 min-w-[6rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]">
<button onClick={onSaveCovers} disabled={saving || !coversChanged || overLimit} className="inline-flex items-center justify-center rounded-control bg-accent-deep px-5 text-white transition-colors hover:enabled:bg-accent-hover disabled:opacity-60 h-8 min-w-[6rem] text-sm font-semibold disabled:cursor-not-allowed">
<span>{saving ? "Saving…" : "Save images"}</span>
</button>
</div>
</div>
<div className="mt-4 grid gap-4">
{allowedSizes.length > 0 && (
<p className="text-xs text-foreground/60">Allowed sizes: {allowedSizes.map((s) => `${s.w}x${s.h}`).join(", ")}</p>
<p className="text-xs text-text-3">Allowed sizes: {allowedSizes.map((s) => `${s.w}x${s.h}`).join(", ")}</p>
)}
{tutorialInfo && (
<p className="text-xs text-foreground/60">
<p className="text-xs text-text-3">
Need help taking screenshots? Watch our{" "}
<a
href={tutorialInfo.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 underline underline-offset-2 hover:text-foreground/80 transition-colors"
className="inline-flex items-center gap-1 underline underline-offset-2 hover:text-text-2 transition-colors"
>
{tutorialInfo.emulatorName} tutorial
<FiExternalLink className="h-3 w-3" />
@@ -414,11 +416,11 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
multiple
accept="image/*"
onChange={(e) => onAddFiles(Array.from(e.target.files || []))}
className="w-full rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none"
className="w-full rounded-control bg-surface-2 px-3 py-2 text-sm ring-1 ring-inset ring-line focus:outline-none"
/>
{coverItems.length === 0 ? (
<p className="text-sm text-foreground/70">No screenshots yet.</p>
<p className="text-sm text-text-2">No screenshots yet.</p>
) : (
<SortableCovers
items={coverItems.map((item, i) => ({
@@ -432,9 +434,9 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
/>
)}
<div className="text-xs text-foreground/60 flex justify-between">
<p>Images: <span className={overLimit ? "text-red-300 font-bold" : "text-foreground/60"}>{coverItems.length}</span>/{MAX_COVERS}</p>
{overLimit && <p className="text-red-300/80 italic">Remove some to save.</p>}
<div className="text-xs text-text-3 flex justify-between">
<p>Images: <span className={overLimit ? "text-error font-bold" : "text-text-3"}>{coverItems.length}</span>/{MAX_COVERS}</p>
{overLimit && <p className="text-error italic">Remove some to save.</p>}
</div>
</div>
</div>
@@ -445,16 +447,16 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
<h3 className="text-[15px] font-semibold tracking-tight">Details</h3>
<div className="mt-3 grid gap-3 text-sm">
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Base ROM</label>
<p className="flex items-center h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] text-foreground/60 select-none cursor-not-allowed">
<label className="text-sm text-text-2">Base ROM</label>
<p className="flex items-center h-11 rounded-control bg-surface-2 px-3 text-sm ring-1 ring-inset ring-line focus:outline-none focus:ring-2 focus:ring-accent/40 text-text-3 select-none cursor-not-allowed">
{baseRoms.find(r => r.id === baseRom)?.name || baseRom}
</p>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<label className="text-sm text-foreground/80">Language</label>
<label className="text-sm text-text-2">Language</label>
{languageChanged && (
<div className="ml-auto flex items-center gap-2 text-[11px] text-foreground/70">
<div className="ml-auto flex items-center gap-2 text-[11px] text-text-2">
<span>Modified</span>
<button type="button" onClick={() => setLanguage(baseline.language)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
</div>
@@ -463,7 +465,7 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
<Select
value={language}
onChange={setLanguage}
className={languageChanged ? 'ring-[var(--ring)]' : ''}
className={languageChanged ? 'ring-accent' : ''}
options={['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => ({
value: l,
label: l,
@@ -472,12 +474,12 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<label className={`text-sm ${!completionStatus ? 'text-red-500 font-semibold' : 'text-foreground/80'}`}>
Completion Status <span className="text-red-500">*</span>
{!completionStatus && <span className="ml-2 text-xs font-normal text-red-500/80">(Required)</span>}
<label className={`text-sm ${!completionStatus ? 'text-error font-semibold' : 'text-text-2'}`}>
Completion Status <span className="text-error">*</span>
{!completionStatus && <span className="ml-2 text-xs font-normal text-error">(Required)</span>}
</label>
{completionStatusChanged && (
<div className="ml-auto flex items-center gap-2 text-[11px] text-foreground/70">
<div className="ml-auto flex items-center gap-2 text-[11px] text-text-2">
<span>Modified</span>
<button type="button" onClick={() => setCompletionStatus(baseline.completionStatus)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
</div>
@@ -487,55 +489,55 @@ export default function HackEditForm({ slug, initial, catalogTags, tagsUpdatedAt
value={completionStatus || ""}
onChange={(value) => setCompletionStatus(value as Database["public"]["Enums"]["Completion Status"] | null)}
placeholder="Select completion status"
className={!completionStatus ? 'ring-2 ring-red-500/60 bg-red-500/10 dark:ring-red-400/60 dark:bg-red-950/20' : completionStatusChanged ? 'ring-[var(--ring)]' : ''}
className={!completionStatus ? 'ring-2 ring-error/40 bg-error-soft dark:ring-error/40 dark:bg-error-soft' : completionStatusChanged ? 'ring-accent' : ''}
options={['Complete','Demo','Alpha','Beta'].map(s => ({
value: s,
label: s,
}))}
/>
{!completionStatus && (
<p className="text-xs text-red-500/80 dark:text-red-400/80">
<p className="text-xs text-error">
This is a new required field. Please select a completion status to save your changes.
</p>
)}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Current version</label>
<p className="flex items-center h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] text-foreground/60 select-none cursor-not-allowed">
<label className="text-sm text-text-2">Current version</label>
<p className="flex items-center h-11 rounded-control bg-surface-2 px-3 text-sm ring-1 ring-inset ring-line focus:outline-none focus:ring-2 focus:ring-accent/40 text-text-3 select-none cursor-not-allowed">
{version}
</p>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Box art URL</label>
<label className="text-sm text-text-2">Box art URL</label>
<div className="flex items-center justify-between">
<input value={boxArt} onChange={(e) => setBoxArt(e.target.value)} placeholder="https://..." className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${boxArt && !urlLike(boxArt) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : boxArtChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
<input value={boxArt} onChange={(e) => setBoxArt(e.target.value)} placeholder="https://..." className={`flex-1 h-11 rounded-control px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${boxArt && !urlLike(boxArt) ? "ring-error/40 bg-error-soft dark:ring-error/40 dark:bg-error-soft" : boxArtChanged ? 'ring-accent bg-surface-2' : 'bg-surface-2 ring-line'}`} />
{boxArtChanged && (
<button type="button" onClick={() => setBoxArt(baseline.boxArt)} className="ml-3 text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>
)}
</div>
{boxArt && urlLike(boxArt) && (
<div className="relative aspect-square w-full max-h-[300px] overflow-hidden rounded ring-1 ring-[var(--border)]">
<div className="relative aspect-square w-full max-h-[300px] overflow-hidden rounded ring-1 ring-line">
<Image src={boxArt} alt="Box art preview" fill className="object-contain" unoptimized />
</div>
)}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Social links</label>
<label className="text-sm text-text-2">Social links</label>
<div className="grid gap-2">
<div className="flex items-center gap-2">
<input value={discord} onChange={(e) => setDiscord(e.target.value)} placeholder="Discord invite URL" className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${discord && !urlLike(discord) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : discordChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
<input value={discord} onChange={(e) => setDiscord(e.target.value)} placeholder="Discord invite URL" className={`flex-1 h-11 rounded-control px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${discord && !urlLike(discord) ? "ring-error/40 bg-error-soft dark:ring-error/40 dark:bg-error-soft" : discordChanged ? 'ring-accent bg-surface-2' : 'bg-surface-2 ring-line'}`} />
{discordChanged && <button type="button" onClick={() => setDiscord(baseline.discord)} className="text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>}
</div>
<div className="flex items-center gap-2">
<input value={twitter} onChange={(e) => setTwitter(e.target.value)} placeholder="Twitter/X profile URL" className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${twitter && !urlLike(twitter) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : twitterChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
<input value={twitter} onChange={(e) => setTwitter(e.target.value)} placeholder="Twitter/X profile URL" className={`flex-1 h-11 rounded-control px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${twitter && !urlLike(twitter) ? "ring-error/40 bg-error-soft dark:ring-error/40 dark:bg-error-soft" : twitterChanged ? 'ring-accent bg-surface-2' : 'bg-surface-2 ring-line'}`} />
{twitterChanged && <button type="button" onClick={() => setTwitter(baseline.twitter)} className="text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>}
</div>
<div className="flex items-center gap-2">
<input value={pokecommunity} onChange={(e) => setPokecommunity(e.target.value)} placeholder="PokeCommunity thread URL" className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${pokecommunity && !urlLike(pokecommunity) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : pokeChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
<input value={pokecommunity} onChange={(e) => setPokecommunity(e.target.value)} placeholder="PokeCommunity thread URL" className={`flex-1 h-11 rounded-control px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${pokecommunity && !urlLike(pokecommunity) ? "ring-error/40 bg-error-soft dark:ring-error/40 dark:bg-error-soft" : pokeChanged ? 'ring-accent bg-surface-2' : 'bg-surface-2 ring-line'}`} />
{pokeChanged && <button type="button" onClick={() => setPokecommunity(baseline.pokecommunity)} className="text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>}
</div>
<div className="flex items-center gap-2">
<input value={github} onChange={(e) => setGithub(e.target.value)} placeholder="GitHub repository URL" className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${github && !urlLike(github) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : githubChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
<input value={github} onChange={(e) => setGithub(e.target.value)} placeholder="GitHub repository URL" className={`flex-1 h-11 rounded-control px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${github && !urlLike(github) ? "ring-error/40 bg-error-soft dark:ring-error/40 dark:bg-error-soft" : githubChanged ? 'ring-accent bg-surface-2' : 'bg-surface-2 ring-line'}`} />
{githubChanged && <button type="button" onClick={() => setGithub(baseline.github)} className="text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>}
</div>
</div>

View File

@@ -1,47 +0,0 @@
"use client";
import React from "react";
import type { CatalogTagRow } from "@/types/catalogTag";
import HackSubmitForm from "@/components/Hack/HackSubmitForm";
import HackEditForm from "@/components/Hack/HackEditForm";
type Mode = "create" | "edit";
interface HackFormCreateProps {
mode: "create";
dummy?: boolean;
isArchive?: boolean;
permissionFrom?: string;
customCreator?: string;
catalogTags: CatalogTagRow[];
}
interface HackFormEditProps {
mode: "edit";
slug: string;
initial: React.ComponentProps<typeof HackEditForm>["initial"];
catalogTags: CatalogTagRow[];
tagsUpdatedAt: Date;
}
export type HackFormProps = HackFormCreateProps | HackFormEditProps;
export default function HackForm(props: HackFormProps) {
if (props.mode === "create") {
return <HackSubmitForm
dummy={props.dummy}
isArchive={props.isArchive}
permissionFrom={props.permissionFrom}
customCreator={props.customCreator}
catalogTags={props.catalogTags}
/>;
}
return <HackEditForm
slug={props.slug}
initial={props.initial}
catalogTags={props.catalogTags}
tagsUpdatedAt={props.tagsUpdatedAt}
/>;
}

View File

@@ -10,12 +10,15 @@ interface HackOptionsMenuProps {
slug: string;
canEdit: boolean;
canUploadPatch: boolean;
/** Where Edit goes: the in-place editor for regular hacks, the form for archives. */
editHref: string;
children?: React.ReactNode;
}
export default function HackOptionsMenu({
slug,
canEdit,
editHref,
canUploadPatch,
children,
}: HackOptionsMenuProps) {
@@ -31,19 +34,19 @@ export default function HackOptionsMenu({
<MenuButton
aria-label="More options"
title="Options"
className="group inline-flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
className="inline-flex h-9 items-center justify-center gap-2 rounded-control border border-line-strong bg-surface text-sm font-medium text-text-2 transition-colors hover:border-text-3 hover:text-text w-9"
>
<FiMoreVertical size={18} />
</MenuButton>
<MenuItems
<MenuItems modal={false}
transition
className="absolute right-0 z-10 mt-2 w-42 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
className="absolute right-0 z-10 mt-2 w-44 origin-top-right overflow-hidden rounded-card border border-line bg-surface shadow-overlay focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem
as="a"
href={`/hack/${slug}/changelog`}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-surface-2"
>
Changelog
</MenuItem>
@@ -51,35 +54,35 @@ export default function HackOptionsMenu({
<MenuItem
as="a"
href={`/hack/${slug}/versions`}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-surface-2"
>
Version history
</MenuItem>
)}
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
<MenuSeparator className="my-1 h-px bg-line" />
<MenuItem
as="button"
onClick={() => {
setShowReportModal(true);
}}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-surface-2"
>
Report
</MenuItem>
{canEdit && <>
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
<MenuSeparator className="my-1 h-px bg-line" />
<MenuItem
as="a"
href={`/hack/${slug}/stats`}
className="flex items-center gap-2 w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
className="flex items-center gap-2 w-full px-3 py-2 text-left text-sm data-focus:bg-surface-2"
>
<FiBarChart2 className="h-4 w-4" />
Stats
</MenuItem>
<MenuItem
as="a"
href={`/hack/${slug}/edit`}
className="flex items-center gap-2 w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
href={editHref}
className="flex items-center gap-2 w-full px-3 py-2 text-left text-sm data-focus:bg-surface-2"
>
<FiEdit2 className="h-4 w-4" />
Edit
@@ -89,14 +92,14 @@ export default function HackOptionsMenu({
<MenuItem
as="a"
href={`/hack/${slug}/versions`}
className="flex items-center gap-2 w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
className="flex items-center gap-2 w-full px-3 py-2 text-left text-sm data-focus:bg-surface-2"
>
<TbVersions className="h-4 w-4" />
Manage versions
</MenuItem>
)}
{hasRenderableChildren && <>
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
<MenuSeparator className="my-1 h-px bg-line" />
{children}
</>}
</MenuItems>

View File

@@ -1,16 +1,20 @@
"use client";
import React from "react";
import { toast } from "sonner";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import { baseRoms } from "@/data/baseRoms";
import { platformAccept } from "@/utils/idb";
import { sha1Hex } from "@/utils/hash";
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js";
import { presignNewPatchVersion } from "@/app/hack/actions";
import { presignNewPatchVersion, updateHack } from "@/app/hack/actions";
import AiLabel from "@/components/Hack/AiLabel";
import AiDisclosureModal from "@/components/Hack/AiDisclosureModal";
import type { AiDisclosure } from "@/utils/aiDisclosure";
import { confirmPatchUpload } from "@/app/submit/actions";
import { FaInfoCircle } from "react-icons/fa";
import { FiAlertTriangle } from "react-icons/fi";
import { FiAlertTriangle, FiCheck } from "react-icons/fi";
import { patchFormatFromFilename } from "@/utils/patching";
import { encodeXdelta, trialDecodeXdelta, friendlyXdeltaError } from "@/utils/patching/xdelta";
@@ -21,6 +25,10 @@ export interface HackPatchFormProps {
isCustomPatcherActive: boolean;
customVersionName?: string | null;
currentVersion?: string;
/** The hack's AI label. New versions confirm or update it before upload; the first upload skips this (the draft checklist covers it). */
ai: AiDisclosure | null;
/** Archives skip the AI check: archivers may not know how the creator used AI. */
isArchive: boolean;
}
export default function HackPatchForm(props: HackPatchFormProps) {
@@ -35,6 +43,11 @@ export default function HackPatchForm(props: HackPatchFormProps) {
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState<string>("");
const [publishAutomatically, setPublishAutomatically] = React.useState(false);
const [disclosure, setDisclosure] = React.useState(props.ai);
// Either way the upload sends the reviewed label's stamp, and the server re-stamps it once the upload lands.
const [aiCheck, setAiCheck] = React.useState<"pending" | "confirmed" | "updated">("pending");
const [aiFormOpen, setAiFormOpen] = React.useState(false);
const needsAiCheck = !props.isArchive && existingVersions.length > 0;
const versionInputRef = React.useRef<HTMLInputElement | null>(null);
const patchInputRef = React.useRef<HTMLInputElement | null>(null);
@@ -56,8 +69,9 @@ export default function HackPatchForm(props: HackPatchFormProps) {
&& !isVersionTaken
&& !submitting
&& checksumStatus !== "invalid"
&& checksumStatus !== "validating";
}, [version, patchFile, patchMode, genStatus, isVersionTaken, submitting, checksumStatus]);
&& checksumStatus !== "validating"
&& (!needsAiCheck || aiCheck !== "pending");
}, [version, patchFile, patchMode, genStatus, isVersionTaken, submitting, checksumStatus, needsAiCheck, aiCheck]);
React.useEffect(() => {
versionInputRef.current?.focus();
@@ -236,13 +250,18 @@ export default function HackPatchForm(props: HackPatchFormProps) {
setSubmitting(true);
setError("");
try {
const safeVersion = version.trim().replace(/[^a-zA-Z0-9._-]+/g, "-");
const patchExt = patchFormatFromFilename(patchFile?.name) === "xdelta" ? "xdelta" : "bps";
const objectKey = `${slug}-${safeVersion}.${patchExt}`;
const presigned = await presignNewPatchVersion({ slug, version: version.trim(), objectKey });
const presigned = await presignNewPatchVersion({ slug, version: version.trim(), format: patchFormatFromFilename(patchFile?.name) });
if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign');
await fetch(presigned.presignedUrl!, { method: 'PUT', body: patchFile!, headers: { 'Content-Type': 'application/octet-stream' } });
const finalized = await confirmPatchUpload({ slug, objectKey: presigned.objectKey!, version: version.trim(), publishAutomatically });
const put = await fetch(presigned.presignedUrl!, { method: 'PUT', body: patchFile!, headers: { 'Content-Type': 'application/octet-stream' } });
if (!put.ok) throw new Error('Upload failed. Please try again.');
const finalized = await confirmPatchUpload({
slug,
objectKey: presigned.objectKey!,
version: version.trim(),
publishAutomatically,
// The label the uploader just reviewed; the server re-stamps it with the upload.
aiReviewedAt: needsAiCheck ? disclosure?.disclosedAt ?? null : null,
});
if (!finalized.ok) throw new Error(finalized.error || 'Failed to finalize');
window.location.href = finalized.redirectTo!;
} catch (e: any) {
@@ -253,18 +272,18 @@ export default function HackPatchForm(props: HackPatchFormProps) {
};
return (
<div className="grid gap-5">
<div className="grid min-w-0 gap-5">
{currentVersion !== undefined && (
<div className="flex items-center rounded-md border border-[var(--border)]/70 bg-[var(--surface-2)]/20 px-3 py-2">
<div className="flex items-center rounded-control border border-line/70 bg-surface-2/20 px-3 py-2">
<div className="min-w-[24px]">
<FaInfoCircle size={12} className="mr-1 text-foreground/80" />
<FaInfoCircle size={12} className="mr-1 text-text-2" />
</div>
<div className="flex flex-col">
<p data-has-custom-patcher={isCustomPatcherActive} className="text-xs text-foreground/60 data-[has-custom-patcher=true]:text-foreground/90 data-[has-custom-patcher=true]:text-sm">
{isCustomPatcherActive ? 'Public version name:' : 'Current version:'} <span className="text-foreground/90 font-bold">{currentVersion || 'Not set'}</span>
<p data-has-custom-patcher={isCustomPatcherActive} className="text-xs text-text-3 data-[has-custom-patcher=true]:text-text data-[has-custom-patcher=true]:text-sm">
{isCustomPatcherActive ? 'Public version name:' : 'Current version:'} <span className="text-text font-bold">{currentVersion || 'Not set'}</span>
</p>
{isCustomPatcherActive && (
<p className="text-xs text-foreground/60 mt-1">
<p className="text-xs text-text-3 mt-1">
<span className="font-bold">Custom</span> selected for the <span className="font-bold">Patcher Version Settings</span>.
</p>
)}
@@ -272,24 +291,24 @@ export default function HackPatchForm(props: HackPatchFormProps) {
</div>
)}
<div className="grid gap-2">
<label className="text-sm text-foreground/80">New Version <span className="text-red-500">*</span></label>
<label className="text-sm text-text-2">New Version <span className="text-error">*</span></label>
<input
ref={versionInputRef}
value={version}
onChange={(e) => setVersion(e.target.value)}
placeholder="e.g. v1.2.0"
className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ${isVersionTaken ? 'ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20' : 'ring-[var(--border)]'} focus:outline-none focus:ring-2 focus:ring-[var(--ring)]`}
className={`h-11 rounded-control bg-surface-2 px-3 text-sm ring-1 ring-inset ${isVersionTaken ? 'ring-error/40 bg-error-soft dark:ring-error/40 dark:bg-error-soft' : 'ring-line'} focus:outline-none focus:ring-2 focus:ring-accent/40`}
/>
<div className="text-xs text-foreground/60">
<div className="text-xs text-text-3">
{isVersionTaken ? 'Already used by this hack.' : 'Use semantic versions like v1.2.0.'}
</div>
{existingVersions.length > 0 && (
<div className="text-[11px] text-foreground/60">Existing versions: {existingVersions.join(', ')}</div>
<div className="text-[11px] text-text-3">Existing versions: {existingVersions.join(', ')}</div>
)}
</div>
<div className="grid gap-3">
<label className="text-sm text-foreground/80">Provide patch <span className="text-red-500">*</span></label>
<label className="text-sm text-text-2">Provide patch <span className="text-error">*</span></label>
<div className="flex flex-col gap-3">
{patchMode === "bps" && (
<div className="grid gap-2">
@@ -298,20 +317,20 @@ export default function HackPatchForm(props: HackPatchFormProps) {
onChange={onUploadPatch}
type="file"
accept=".bps,.xdelta"
className="cursor-pointer rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm italic text-foreground/50 ring-1 ring-inset ring-[var(--border)] file:bg-black/10 dark:file:bg-[var(--surface-2)] file:text-foreground/80 file:text-sm file:font-medium file:not-italic file:rounded-md file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer"
className="w-full min-w-0 cursor-pointer rounded-control bg-surface-2 px-3 py-2 text-sm italic text-text-3 ring-1 ring-inset ring-line file:bg-black/10 dark:file:bg-surface-2 file:text-text-2 file:text-sm file:font-medium file:not-italic file:rounded-control file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer"
/>
<p className="flex items-center gap-1.5 text-xs text-foreground/60">
<FiAlertTriangle className="h-3 w-3 shrink-0 text-amber-600 dark:text-amber-400" />
<p className="flex items-center gap-1.5 text-xs text-text-3">
<FiAlertTriangle className="h-3 w-3 shrink-0 text-warn" />
<span>Patch file upload is a fallback. Hackdex cannot always guarantee that an uploaded patch is compatible with the chosen base ROM. Auto-generating from a modified ROM is recommended.</span>
</p>
{checksumStatus === "validating" && <div className="text-xs text-foreground/70">Validating checksum…</div>}
{checksumStatus === "valid" && <div className="text-xs text-emerald-400/90">Checksum valid.</div>}
{checksumStatus === "invalid" && !!checksumError && <div className="text-xs text-red-400">{checksumError}</div>}
{checksumStatus === "unknown" && !!checksumError && <div className="text-xs text-amber-400/90">{checksumError}</div>}
{checksumStatus === "validating" && <div className="text-xs text-text-2">Validating checksum…</div>}
{checksumStatus === "valid" && <div className="text-xs text-ready">Checksum valid.</div>}
{checksumStatus === "invalid" && !!checksumError && <div className="text-xs text-error">{checksumError}</div>}
{checksumStatus === "unknown" && !!checksumError && <div className="text-xs text-warn">{checksumError}</div>}
<button
type="button"
onClick={() => setPatchMode("rom")}
className="w-fit cursor-pointer text-xs text-foreground/60 underline underline-offset-2 transition-colors hover:text-foreground/80"
className="w-fit cursor-pointer text-xs text-text-3 underline underline-offset-2 transition-colors hover:text-text-2"
>
Generate from a modified ROM instead (Recommended)
</button>
@@ -320,45 +339,45 @@ export default function HackPatchForm(props: HackPatchFormProps) {
{patchMode === "rom" && (
<div className="grid gap-3">
<div className="rounded-md border border-[var(--border)] p-3 bg-[var(--surface-2)]/50">
<div className="text-xs text-foreground/75">Required base ROM</div>
<div className="rounded-control border border-line p-3 bg-surface-2/50">
<div className="text-xs text-text-2">Required base ROM</div>
<div className="mt-1 text-sm font-medium">{baseRomEntry ? `${baseRomEntry.name} (${baseRomEntry.platform})` : "Select base ROM in main Edit page"}</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className={`rounded-full px-2 py-0.5 ring-1 ${baseRomReady ? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90" : baseRomNeedsPermission ? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90" : "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"}`}>
<span className={`rounded-full px-2 py-0.5 ring-1 ${baseRomReady ? "bg-ready-soft text-ready ring-ready/40" : baseRomNeedsPermission ? "bg-warn-soft text-warn ring-warn/40" : "bg-error-soft text-error ring-error/40"}`}>
{baseRomReady ? "Ready" : baseRomNeedsPermission ? "Permission needed" : "Base ROM needed"}
</span>
{baseRomNeedsPermission && (
<button type="button" onClick={onGrantPermission} disabled={!supported} className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 disabled:opacity-60 disabled:cursor-not-allowed">Grant permission</button>
<button type="button" onClick={onGrantPermission} disabled={!supported} className="rounded-control border border-line bg-surface-2 px-2 py-1 disabled:opacity-60 disabled:cursor-not-allowed">Grant permission</button>
)}
{baseRomMissing && (
<label className="inline-flex items-center gap-2 text-xs text-foreground/80">
<input type="file" onChange={onUploadBaseRom} className="cursor-pointer rounded-md bg-[var(--surface-2)] px-2 py-1 text-xs italic text-foreground/50 ring-1 ring-inset ring-[var(--border)] file:bg-black/10 dark:file:bg-[var(--surface-2)] file:text-foreground/80 file:text-xs file:font-medium file:not-italic file:rounded-md file:border-0 file:px-2 file:py-1 file:mr-2 file:cursor-pointer" />
<label className="flex w-full min-w-0 flex-col items-start gap-1.5 text-xs text-text-2">
<span>Upload base ROM</span>
<input type="file" onChange={onUploadBaseRom} className="w-full min-w-0 cursor-pointer rounded-control bg-surface-2 px-2 py-1 text-xs italic text-text-3 ring-1 ring-inset ring-line file:bg-black/10 dark:file:bg-surface-2 file:text-text-2 file:text-xs file:font-medium file:not-italic file:rounded-control file:border-0 file:px-2 file:py-1 file:mr-2 file:cursor-pointer" />
</label>
)}
</div>
{!!genError && <div className="mt-2 text-xs text-red-400">{genError}</div>}
{!!genError && <div className="mt-2 text-xs text-error">{genError}</div>}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Modified ROM <span className="text-foreground/60">(Recommended)</span></label>
<label className="text-sm text-text-2">Modified ROM <span className="text-text-3">(Recommended)</span></label>
<input
ref={modifiedRomInputRef}
type="file"
accept={baseRomPlatform ? platformAccept(baseRomPlatform) : "*/*"}
disabled={!baseRomEntry || !baseRomReady || !baseRomPlatform}
onChange={onUploadModifiedRom}
className="cursor-pointer rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm italic text-foreground/50 ring-1 ring-inset ring-[var(--border)] file:bg-black/10 dark:file:bg-[var(--surface-2)] file:text-foreground/80 file:text-sm file:font-medium file:not-italic file:rounded-md file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full min-w-0 cursor-pointer rounded-control bg-surface-2 px-3 py-2 text-sm italic text-text-3 ring-1 ring-inset ring-line file:bg-black/10 dark:file:bg-surface-2 file:text-text-2 file:text-sm file:font-medium file:not-italic file:rounded-control file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
/>
<p className="text-xs text-foreground/60">We'll generate a .xdelta patch on-device. No ROMs are uploaded.</p>
{genStatus === "generating" && <div className="text-xs text-foreground/70">Generating patch…</div>}
{genStatus === "ready" && patchFile && <div className="text-xs text-emerald-400/90">Patch ready: {patchFile.name}</div>}
{genStatus === "error" && !!genError && <div className="text-xs text-red-400">{genError}</div>}
<p className="text-xs text-text-3">We'll generate a .xdelta patch on-device. No ROMs are uploaded.</p>
{genStatus === "generating" && <div className="text-xs text-text-2">Generating patch…</div>}
{genStatus === "ready" && patchFile && <div className="text-xs text-ready">Patch ready: {patchFile.name}</div>}
{genStatus === "error" && !!genError && <div className="text-xs text-error">{genError}</div>}
</div>
<button
type="button"
onClick={() => setPatchMode("bps")}
className="w-fit cursor-pointer text-xs text-foreground/60 underline underline-offset-2 transition-colors hover:text-foreground/80"
className="w-fit cursor-pointer text-xs text-text-3 underline underline-offset-2 transition-colors hover:text-text-2"
>
Already have a .bps or .xdelta file?
</button>
@@ -367,9 +386,63 @@ export default function HackPatchForm(props: HackPatchFormProps) {
</div>
</div>
{!!error && <div className="text-sm text-red-400">{error}</div>}
{needsAiCheck && (
<div className="grid gap-2.5 border-t border-line pt-4">
<div>
<div className="text-sm font-medium text-text">Is the AI label still accurate?</div>
<p className="mt-0.5 text-xs text-text-3">Count anything AI-generated you added since the last version.</p>
</div>
{disclosure ? (
<AiLabel disclosure={disclosure} />
) : (
<p className="rounded-card border border-dashed border-line-strong px-3.5 py-3 text-[13px] text-text-3">This hack has no AI label yet. Every hack needs one, even with no AI use.</p>
)}
<div className="flex flex-wrap items-center gap-2">
{disclosure && aiCheck !== "updated" && (
<button
type="button"
aria-pressed={aiCheck === "confirmed"}
onClick={() => setAiCheck(aiCheck === "confirmed" ? "pending" : "confirmed")}
className="inline-flex h-9 items-center gap-1.5 rounded-control border border-line-strong bg-surface px-3 text-sm font-medium transition-colors hover:border-text-3 aria-pressed:border-ready aria-pressed:bg-ready-soft aria-pressed:text-ready"
>
{aiCheck === "confirmed" && <FiCheck className="h-4 w-4" />} Still accurate
</button>
)}
<button
type="button"
onClick={() => setAiFormOpen(true)}
className="inline-flex h-9 items-center rounded-control border border-line-strong bg-surface px-3 text-sm font-medium transition-colors hover:border-text-3"
>
{disclosure ? "Update" : "Add AI label"}
</button>
{aiCheck === "updated" && (
<span className="inline-flex items-center gap-1 text-xs text-ready">
<FiCheck className="h-3.5 w-3.5" /> Saved
</span>
)}
</div>
<AiDisclosureModal
visible={aiFormOpen}
initial={disclosure}
onClose={() => setAiFormOpen(false)}
onSave={async (levels, note) => {
const res = await updateHack({ slug, ai: { levels, note } });
if (!res.ok) {
// A toast, since the inline error sits behind the open modal.
toast.error(res.error);
return false;
}
setDisclosure({ levels, note, disclosedAt: res.aiDisclosedAt ?? new Date().toISOString() });
setAiCheck("updated");
return true;
}}
/>
</div>
)}
<div className="flex items-start gap-3 border-t border-[var(--border)] pt-4 mt-2">
{!!error && <div className="text-sm text-error">{error}</div>}
<div className="flex items-start gap-3 border-t border-line pt-4 mt-2">
<label className="flex items-start gap-2 cursor-pointer has-disabled:cursor-not-allowed">
<input
type="checkbox"
@@ -379,17 +452,17 @@ export default function HackPatchForm(props: HackPatchFormProps) {
if (isCustomPatcherActive) return;
setPublishAutomatically(e.target.checked);
}}
className="mt-0.5 rounded border-[var(--border)] text-emerald-600 focus:ring-emerald-600"
className="mt-0.5 rounded border-line text-ready focus:ring-ready/40"
/>
<div className="text-sm">
<div className="font-medium text-foreground/90">Publish Automatically</div>
<div className="font-medium text-text">Publish Automatically</div>
{isCustomPatcherActive ? (
<div className="italic text-foreground/60 mt-0.5">
<div className="italic text-text-3 mt-0.5">
<p>Because you have "Custom" selected for the Patcher Version Settings, this version <span className="font-bold">cannot</span> be published automatically.</p>
<p className="mt-1">To make this patch available for download, you will need to manually select it after pressing the <span className="font-bold">"Edit patcher versions"</span> button.</p>
</div>
): (
<div className="text-foreground/60 mt-0.5">
<div className="text-text-3 mt-0.5">
If checked, this version will be published and set as the current patch immediately after upload.
</div>
)}
@@ -402,7 +475,7 @@ export default function HackPatchForm(props: HackPatchFormProps) {
type="button"
onClick={onSubmit}
disabled={!canSubmit}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
className="inline-flex items-center justify-center rounded-control bg-accent-deep px-5 text-white transition-colors hover:enabled:bg-accent-hover disabled:opacity-60 h-11 min-w-[7.5rem] text-sm font-semibold disabled:cursor-not-allowed"
>
<span>{submitting ? 'Uploading…' : 'Upload version'}</span>
</button>

View File

@@ -20,10 +20,10 @@ export default function HackShareButton({ title, url, author }: HackShareButtonP
onClick={() => setShowShareModal(true)}
aria-label="Share hack"
title="Share"
className="group inline-flex px-3 md:px-2 h-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
className="inline-flex h-9 items-center justify-center gap-2 rounded-control border border-line-strong bg-surface text-sm font-medium text-text-2 transition-colors hover:border-text-3 hover:text-text px-3"
>
<FiShare2 size={18} />
<span className="ml-2 font-semibold text-sm">Share</span>
<span>Share</span>
</button>
{showShareModal && (
<ShareModal title={title} url={url} author={author} onClose={() => setShowShareModal(false)} />

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,261 @@
"use client";
import React from "react";
import Link from "next/link";
import useEmblaCarousel from "embla-carousel-react";
import { FiMaximize2 } from "react-icons/fi";
import PixelImage from "@/components/PixelImage";
import Lightbox from "@/components/Hack/Lightbox";
import { onTabListKeyDown } from "@/utils/tabKeys";
export interface HackVersionRow {
id: number;
version: string;
createdAt: string;
latest?: boolean;
}
interface HackTabsProps {
slug: string;
title: string;
author: string;
images: string[];
/** Rendered markdown for the description. */
about: React.ReactNode;
/** Latest release notes, when the current patch has any. */
changes?: { version: string; date: string; body: React.ReactNode } | null;
versions: HackVersionRow[];
baseRomName: string | null;
/** Draft editing: replaces the About section (heading included) with the description editor. Stays mounted across tabs. */
aboutPanel?: React.ReactNode;
/** Draft editing: replaces the Gallery panel with the screenshot manager. Stays mounted across tabs. */
gallery?: React.ReactNode;
/** Draft editing: action row under the versions table, e.g. Upload a version. */
versionsAction?: React.ReactNode;
}
const TAB_IDS = ["about", "gallery", "versions"] as const;
type TabId = (typeof TAB_IDS)[number];
/** About / Gallery / Versions for the hack page body. The lightbox is shared by both galleries. */
export default function HackTabs({ slug, title, author, images, about, changes, versions, baseRomName, aboutPanel, gallery, versionsAction }: HackTabsProps) {
const [tab, setTab] = React.useState<TabId>("about");
const [shot, setShot] = React.useState(0);
const [open, setOpen] = React.useState(false);
const tabsRef = React.useRef<HTMLDivElement | null>(null);
const tabs = [
{ id: "about", label: "About" },
{ id: "gallery", label: "Gallery", count: images.length },
{ id: "versions", label: "Versions", count: versions.length },
] as const;
const view = (i: number) => {
setShot(i);
setOpen(true);
};
const showVersions = () => {
setTab("versions");
tabsRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
};
return (
<div className="flex min-w-0 flex-col gap-8">
{/* The tab strip sits 1px over the rule so the active underline replaces it; the scroller is outside so that overhang never becomes a scrollbar. */}
<div ref={tabsRef} className="-mx-6 border-b border-line px-6 scroll-mt-[72px] md:mx-0 md:px-0">
<div
role="tablist"
aria-label="Hack sections"
onKeyDown={(e) => onTabListKeyDown(e, tabs.length, TAB_IDS.indexOf(tab), (i) => setTab(TAB_IDS[i]))}
className="-mb-px flex gap-1 overflow-x-auto overflow-y-hidden [scrollbar-width:none]">
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`tab-${t.id}`}
aria-selected={tab === t.id}
tabIndex={tab === t.id ? 0 : -1}
aria-controls={`panel-${t.id}`}
onClick={() => setTab(t.id)}
className={`inline-flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2.5 text-[15px] font-medium transition-colors ${
tab === t.id ? "border-accent text-text" : "border-transparent text-text-2 hover:text-text"
}`}
>
{t.label} {"count" in t && <small className="text-xs font-normal text-text-3">{t.count}</small>}
</button>
))}
</div>
</div>
{/* Editors stay mounted while hidden: unmounting them dropped staged edits when switching tabs. */}
{(tab === "about" || aboutPanel) && (
<div id="panel-about" role="tabpanel" aria-labelledby="tab-about" hidden={tab !== "about"} className="anim-fade flex flex-col gap-8">
{images.length > 0 && (
<section aria-label="Screenshots">
<figure className="m-0 rounded-card bg-well p-3 md:p-4">
<ScreenshotStage images={images} title={title} shot={shot} onShot={setShot} onOpen={view} />
</figure>
{images.length > 1 && (
<div
role="tablist"
aria-label="Screenshots"
onKeyDown={(e) => onTabListKeyDown(e, images.length, shot, setShot)}
className="-mx-6 mt-1.5 flex gap-2 overflow-x-auto px-6 py-1 [scrollbar-width:none] md:-mx-1 md:flex-wrap md:px-1">
{images.map((src, i) => (
<button
key={`${src}-${i}`}
type="button"
role="tab"
aria-selected={i === shot}
tabIndex={i === shot ? 0 : -1}
aria-label={`Screenshot ${i + 1}`}
onClick={() => setShot(i)}
className={`flex-none overflow-hidden rounded-frame transition-[opacity,box-shadow] duration-[120ms] ${i === shot ? "opacity-100 shadow-[0_0_0_2px_var(--rose)]" : "opacity-55 hover:opacity-90"}`}
>
<img src={src} alt="" width={120} height={80} loading="lazy" className="h-20 w-[120px] object-cover object-top" draggable={false} />
</button>
))}
</div>
)}
</section>
)}
{aboutPanel ?? (
<section>
<h2 className="mb-2.5 text-lg font-semibold leading-tight">About</h2>
<div className="prose prose-sm max-w-[70ch] text-text-2">{about}</div>
</section>
)}
{changes && (
<section>
<h2 className="mb-2.5 text-lg font-semibold leading-tight">
What changed in {changes.version} <span className="ml-2 text-[13px] font-normal text-text-3">{changes.date}</span>
</h2>
<div className="prose prose-sm max-w-[70ch] text-text-2">{changes.body}</div>
{versions.length > 1 && (
<button type="button" className="text-link-hd mt-2 text-sm" onClick={showVersions}>
All {versions.length} versions
</button>
)}
</section>
)}
</div>
)}
{gallery && (
<div id="panel-gallery" role="tabpanel" aria-labelledby="tab-gallery" hidden={tab !== "gallery"} className="anim-fade">
{gallery}
</div>
)}
{tab === "gallery" && !gallery && (
<div id="panel-gallery" role="tabpanel" aria-labelledby="tab-gallery" className="anim-fade flex flex-col gap-5">
<p className="max-w-[70ch] text-sm text-text-2">
{images.length === 0 ? `No screenshots yet from ${author}.` : `${images.length} screenshot${images.length === 1 ? "" : "s"} from ${author}. Open one to see it larger.`}
</p>
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fill,minmax(264px,1fr))]">
{images.map((src, i) => (
<li key={`${src}-${i}`}>
<button
type="button"
onClick={() => view(i)}
aria-label={`Open screenshot ${i + 1} full size`}
className="flex w-full justify-center rounded-card bg-well p-3 transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 hover:shadow-lift"
>
<img src={src} alt="" loading="lazy" className="aspect-[3/2] w-full max-w-[240px] rounded-frame object-cover object-top" draggable={false} />
</button>
</li>
))}
</ul>
</div>
)}
{tab === "versions" && (
<div id="panel-versions" role="tabpanel" aria-labelledby="tab-versions" className="anim-fade flex flex-col gap-5">
<p className="max-w-[70ch] text-sm text-text-2">Every version needs a clean {baseRomName ?? "base"} ROM.</p>
{versions.length > 0 ? (
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th scope="col" className="border-b border-line px-3 pb-2 text-left text-xs font-semibold text-text-3">Version</th>
<th scope="col" className="border-b border-line px-3 pb-2 text-left text-xs font-semibold text-text-3">Released</th>
</tr>
</thead>
<tbody>
{versions.map((v) => (
<tr key={v.id}>
<td className="border-b border-line px-3 py-2.5">
<b className="font-semibold">{v.version}</b>
{v.latest && <span className="ml-2 text-xs font-medium text-link">Latest</span>}
</td>
<td className="border-b border-line px-3 py-2.5 text-text-2">{new Date(v.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-text-3">No versions yet.</p>
)}
{versionsAction}
<p className="flex flex-wrap gap-x-5 gap-y-1 text-sm">
<Link href={`/hack/${slug}/versions`} className="text-link-hd">
Full version history
</Link>
<Link href={`/hack/${slug}/changelog`} className="text-link-hd">
Changelog
</Link>
</p>
</div>
)}
{open && images.length > 0 && <Lightbox images={images} index={shot} title={title} onChange={setShot} onClose={() => setOpen(false)} />}
</div>
);
}
/**
* The About tab's big screenshot: swipe or drag between shots (looping), click to open the lightbox.
* `shot` is shared with the thumbnails and the lightbox, so a pick in either scrolls here too.
*/
function ScreenshotStage({ images, title, shot, onShot, onOpen }: { images: string[]; title: string; shot: number; onShot: (i: number) => void; onOpen: (i: number) => void }) {
const many = images.length > 1;
const [viewportRef, api] = useEmblaCarousel({ loop: true, active: many });
React.useEffect(() => {
if (!api) return;
const onSelect = () => onShot(api.selectedScrollSnap());
api.on("select", onSelect);
return () => {
api.off("select", onSelect);
};
}, [api, onShot]);
React.useEffect(() => {
if (api && api.selectedScrollSnap() !== shot) api.scrollTo(shot);
}, [api, shot]);
return (
<div ref={viewportRef} className={`group/stage relative mx-auto w-full max-w-[720px] overflow-hidden rounded-frame ${many ? "cursor-grab active:cursor-grabbing" : ""}`}>
<div className="flex">
{images.map((src, i) => (
// Embla swallows the click that ends a drag, so a swipe never opens the lightbox.
<button
key={`${src}-${i}`}
type="button"
onClick={() => onOpen(i)}
aria-label={`Open screenshot ${i + 1} full size`}
tabIndex={i === shot ? 0 : -1}
aria-hidden={i !== shot}
className="block min-w-0 flex-[0_0_100%]"
>
<PixelImage src={src} alt={`${title} screenshot ${i + 1} of ${images.length}`} mode="contain" className="aspect-[3/2] w-full" />
</button>
))}
</div>
<span className="pointer-events-none absolute bottom-2.5 right-2.5 inline-flex translate-y-1 items-center gap-1.5 rounded-md bg-[rgba(13,16,23,.78)] px-2.5 py-1 text-xs font-medium text-white opacity-0 transition-[opacity,transform] duration-150 group-hover/stage:translate-y-0 group-hover/stage:opacity-100 group-has-[:focus-visible]/stage:translate-y-0 group-has-[:focus-visible]/stage:opacity-100">
<FiMaximize2 className="h-4 w-4" /> {shot + 1} of {images.length}
</span>
</div>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import PixelImage from "../PixelImage";
import React from "react";
import { FiChevronLeft, FiChevronRight, FiGrid, FiX } from "react-icons/fi";
import { useDialog } from "@/hooks/useDialog";
import { onTabListKeyDown } from "@/utils/tabKeys";
const DESKTOP_LIGHTBOX = "(min-width: 768px)";
function isDesktopLightbox() {
return typeof window !== "undefined" && window.matchMedia(DESKTOP_LIGHTBOX).matches;
}
interface LightboxProps {
images: string[];
index: number;
title: string;
onChange: (index: number) => void;
onClose: () => void;
}
/**
* Full-window screenshot viewer. Desktop shows the image at the largest whole
* multiple of its native size so pixels stay square; phones can toggle that.
* Arrow keys and the filmstrip move between shots. Escape, clicking outside the
* image, and Close all close it; focus stays inside and returns to the opener.
*/
export default function Lightbox({ images, index, title, onChange, onClose }: LightboxProps) {
const [pixelPerfect, setPixelPerfect] = React.useState(isDesktopLightbox);
const panelRef = React.useRef<HTMLDivElement | null>(null);
const closeRef = React.useRef<HTMLButtonElement | null>(null);
const stripRef = React.useRef<HTMLDivElement | null>(null);
const canCycle = images.length > 1;
const onPrev = React.useCallback(() => onChange((index - 1 + images.length) % images.length), [images.length, index, onChange]);
const onNext = React.useCallback(() => onChange((index + 1) % images.length), [images.length, index, onChange]);
// Keep the layout from shifting when the scrollbar goes, and stop touch scrolling behind the viewer
// (except in the thumbnail strip, which scrolls sideways; touch-pan-x keeps it from panning the page).
// Declared before useDialog so it measures the scrollbar before useDialog locks <html>; each owns
// different styles, since cleanups run in declaration order and nested save/restore of one style
// left the page stuck on overflow: hidden.
React.useEffect(() => {
const body = document.body;
const previousBodyPaddingRight = body.style.paddingRight;
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
if (scrollBarWidth > 0) body.style.paddingRight = `${scrollBarWidth}px`;
const preventTouchScroll = (e: TouchEvent) => {
if (!(e.target instanceof Node && stripRef.current?.contains(e.target))) e.preventDefault();
};
document.addEventListener("touchmove", preventTouchScroll, { passive: false });
return () => {
body.style.paddingRight = previousBodyPaddingRight;
document.removeEventListener("touchmove", preventTouchScroll);
};
}, []);
// Scroll lock on <html>, focus trap, Escape, and handing focus back to the opener; the arrow keys stay ours.
useDialog(panelRef, onClose);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!canCycle) return;
if (e.key === "ArrowRight") onNext();
if (e.key === "ArrowLeft") onPrev();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [canCycle, onNext, onPrev]);
React.useEffect(() => {
closeRef.current?.focus();
}, []);
React.useEffect(() => {
const mq = window.matchMedia(DESKTOP_LIGHTBOX);
const sync = () => {
if (mq.matches) setPixelPerfect(true);
};
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
// Arrowing past the edge of the filmstrip drags the strip along, so the highlight never leaves view.
React.useEffect(() => {
const strip = stripRef.current;
const thumb = strip?.children[index] as HTMLElement | undefined;
if (!strip || !thumb) return;
const left = thumb.offsetLeft - (strip.clientWidth - thumb.offsetWidth) / 2;
strip.scrollTo({ left, behavior: "smooth" });
}, [index]);
const control = "inline-flex items-center justify-center rounded-control text-white/80 transition-colors hover:bg-white/10 hover:text-white focus:outline-none disabled:pointer-events-none disabled:opacity-40";
return (
<div ref={panelRef} tabIndex={-1} className="anim-fade fixed inset-0 z-50 grid grid-rows-[auto_minmax(0,1fr)_auto] bg-[rgba(13,16,23,.97)] text-white outline-none" role="dialog" aria-modal="true" aria-label={`Screenshots for ${title}`}>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 pt-[calc(12px+env(safe-area-inset-top,0px))] pb-3 text-sm">
<span className="tabular-nums" aria-live="polite">
{index + 1} <span className="text-white/55">of {images.length}</span>
</span>
<span className="truncate text-center font-semibold">{title}</span>
<button type="button" ref={closeRef} onClick={onClose} aria-label="Close" className={`${control} h-[38px] w-[38px] justify-self-end`}>
<FiX size={22} />
</button>
</div>
{/* The image's wrapper fills the stage (and the img ignores pointers), so close on clicks outside the picture's box. */}
<div className="relative flex min-h-0 items-center justify-center px-4 py-2 md:px-[72px]" onClick={(e) => !overImage(e) && onClose()}>
<PixelImage
src={images[index]}
alt={`${title} screenshot ${index + 1} of ${images.length}`}
mode="contain"
pixelPerfect={pixelPerfect}
className="h-full w-full"
imgClassName="rounded-frame"
/>
{canCycle && (
<>
<button
type="button"
aria-label="Previous image"
onClick={(e) => {
e.stopPropagation();
onPrev();
}}
className="absolute left-4 top-1/2 hidden h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/[.08] text-white/80 transition-colors hover:bg-white/[.18] hover:text-white md:inline-flex"
>
<FiChevronLeft className="h-6 w-6" />
</button>
<button
type="button"
aria-label="Next image"
onClick={(e) => {
e.stopPropagation();
onNext();
}}
className="absolute right-4 top-1/2 hidden h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/[.08] text-white/80 transition-colors hover:bg-white/[.18] hover:text-white md:inline-flex"
>
<FiChevronRight className="h-6 w-6" />
</button>
</>
)}
</div>
<div className="flex flex-col items-center gap-3 pb-[calc(12px+env(safe-area-inset-bottom,0px))] pt-2">
<button
type="button"
role="switch"
aria-checked={pixelPerfect}
onClick={() => setPixelPerfect((v) => !v)}
className="flex items-center gap-2 rounded-full px-2 py-1 text-xs text-white/80 transition-colors hover:bg-white/10 hover:text-white md:hidden"
>
<FiGrid className="h-4 w-4" aria-hidden="true" />
<span>Pixel-perfect</span>
<span aria-hidden="true" className={`flex h-4 w-8 items-center rounded-full p-0.5 ring-1 ring-white/30 transition-colors ${pixelPerfect ? "bg-white/80" : "bg-white/10"}`}>
<span className={`h-3 w-3 rounded-full transition-transform ${pixelPerfect ? "translate-x-4 bg-black/70" : "translate-x-0 bg-white/80"}`} />
</span>
</button>
{canCycle && (
<div className="flex w-full items-center gap-2 px-4 md:justify-center">
<button type="button" aria-label="Previous image" onClick={onPrev} className={`${control} h-12 w-12 flex-none md:hidden`}>
<FiChevronLeft className="h-7 w-7" />
</button>
{/* p-1 leaves room for the selected thumb's 2px ring, which the scroll container would clip.
relative makes the thumbs' offsetLeft measure from the strip, which the centering below relies on. */}
<div
ref={stripRef}
role="tablist"
aria-label="Screenshots"
onKeyDown={(e) => onTabListKeyDown(e, images.length, index, onChange)}
className="relative flex min-w-0 flex-1 touch-pan-x gap-2 overflow-x-auto overscroll-x-contain p-1 [scrollbar-width:none] md:flex-none md:max-w-full">
{images.map((src, i) => (
<button
key={`${src}-${i}`}
type="button"
role="tab"
aria-selected={i === index}
tabIndex={i === index ? 0 : -1}
aria-label={`Screenshot ${i + 1}`}
onClick={() => onChange(i)}
className={`flex-none overflow-hidden rounded-frame transition-[opacity,box-shadow] duration-[120ms] ${i === index ? "opacity-100 shadow-[0_0_0_2px_var(--rose)]" : "opacity-45 hover:opacity-85"}`}
>
<img src={src} alt="" width={120} height={80} className="h-20 w-[120px] object-cover object-top" draggable={false} />
</button>
))}
</div>
<button type="button" aria-label="Next image" onClick={onNext} className={`${control} h-12 w-12 flex-none md:hidden`}>
<FiChevronRight className="h-7 w-7" />
</button>
</div>
)}
</div>
</div>
);
}
function overImage(e: React.MouseEvent<HTMLElement>) {
const box = e.currentTarget.querySelector("img")?.getBoundingClientRect();
return !!box && e.clientX >= box.left && e.clientX <= box.right && e.clientY >= box.top && e.clientY <= box.bottom;
}

View File

@@ -1,37 +1,74 @@
"use client";
import React from "react";
import { FiChevronDown, FiHelpCircle } from "react-icons/fi";
import { FiChevronRight, FiHelpCircle } from "react-icons/fi";
interface HackOnboardingGateProps {
label: string;
onClick: () => void;
/** Rose dot on the icon while the player is most likely stuck (no ROM yet). */
beacon?: boolean;
/**
* row: full-width line under the primary action inside the patch module.
* icon: round button beside the compact action in the site header (desktop).
* pill: floating chip above the phone tab bar once the module scrolls away.
*/
variant: "row" | "icon" | "pill";
/** Row only: the tour is open. The row stays put (so the module doesn't jump) but ignores clicks and focus. */
inactive?: boolean;
}
/**
* Opt-in entry point for the hack page onboarding tour.
*
* Positions itself against the StickyActionBar root, so it must be rendered
* inside that root: an attached tab under the desktop bar (1px overlap, no top
* border) and a detached frost pill floating above the mobile sheet.
*/
export default function HackOnboardingGate({ label, onClick }: HackOnboardingGateProps) {
/** Opt-in entry point for the hack page onboarding tour, in three shapes for three homes. */
export default function HackOnboardingGate({ label, onClick, beacon = false, variant, inactive = false }: HackOnboardingGateProps) {
const icon = (
<span className="relative inline-flex flex-none">
<FiHelpCircle size={variant === "row" ? 16 : 18} aria-hidden className="text-accent" />
{beacon && <span aria-hidden className="onboarding-beacon -right-[3px] -top-[3px] h-2! w-2!" />}
</span>
);
if (variant === "icon") {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className="inline-flex h-9 w-9 flex-none cursor-pointer items-center justify-center rounded-full border border-line-strong bg-surface text-text-2 transition-colors hover:border-text-3 hover:text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{icon}
</button>
);
}
if (variant === "pill") {
return (
<button
type="button"
onClick={onClick}
className="anim-fade fixed bottom-[calc(72px+env(safe-area-inset-bottom))] right-4 z-40 inline-flex cursor-pointer items-center gap-2 rounded-full border border-line-strong bg-surface py-2.5 pl-3.5 pr-3 text-sm font-semibold text-text shadow-lift transition-colors hover:border-text-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent md:hidden"
>
{icon}
{label}
<FiChevronRight size={15} aria-hidden className="text-text-3" />
</button>
);
}
return (
<button
type="button"
onClick={onClick}
className="group/gate absolute bottom-full left-1/2 z-10 mb-3 -translate-x-1/2 inline-flex cursor-pointer items-center gap-2 whitespace-nowrap rounded-full border border-[var(--border)] bg-[var(--surface-2)]/80 px-5 py-2.5 text-sm font-semibold text-foreground/80 shadow-sm backdrop-blur transition-colors supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_90%,transparent)] hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] after:absolute after:-inset-x-5 after:-top-4 after:-bottom-3 after:content-[''] md:after:hidden md:bottom-auto md:top-full md:mb-0 md:-mt-px md:rounded-t-none md:rounded-b-xl md:border-t-0 md:px-6 md:pt-4 md:pb-3 md:shadow-none md:supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)]"
aria-hidden={inactive || undefined}
tabIndex={inactive ? -1 : undefined}
className={`${inactive ? "pointer-events-none" : ""} group/gate -mt-0.5 flex w-full cursor-pointer items-center gap-2 rounded-control border-t border-line pt-3 text-[13px] font-medium text-text-2 transition-colors hover:text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
>
<FiHelpCircle
size={16}
aria-hidden
className="shrink-0 text-[var(--accent)]"
/>
{icon}
{label}
<FiChevronDown
<FiChevronRight
size={15}
aria-hidden
className="shrink-0 text-foreground/60 transition-transform duration-200 group-hover/gate:translate-y-0.5"
className="ml-auto text-text-3 transition-transform duration-200 group-hover/gate:translate-x-0.5"
/>
</button>
);

View File

@@ -39,7 +39,7 @@ const STEP_COPY: Record<HackOnboardingStep, { title: string; body: React.ReactNo
href="/terms"
target="_blank"
rel="noopener noreferrer"
className="underline text-foreground/85 hover:text-foreground"
className="underline text-text-2 hover:text-text"
>
the Terms
</Link>{" "}
@@ -55,7 +55,6 @@ interface Anchor {
barLeft: number;
barRight: number;
viewportWidth: number;
viewportHeight: number;
desktop: boolean;
}
@@ -66,14 +65,13 @@ function sameAnchor(a: Anchor, b: Anchor) {
a.barLeft === b.barLeft &&
a.barRight === b.barRight &&
a.viewportWidth === b.viewportWidth &&
a.viewportHeight === b.viewportHeight &&
a.desktop === b.desktop
);
}
/**
* Tracks the live geometry of the sticky action bar so the coach card can sit
* under it on desktop and above the sheet on mobile, whatever height it has.
* Tracks the live geometry of the patch module so the coach card can sit
* under it, whatever height it has.
*/
function useBarAnchor(active: boolean): Anchor | null {
const [anchor, setAnchor] = React.useState<Anchor | null>(null);
@@ -91,7 +89,6 @@ function useBarAnchor(active: boolean): Anchor | null {
barLeft: rect.left,
barRight: rect.right,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
desktop: window.matchMedia("(min-width: 768px)").matches,
};
setAnchor((previous) => (previous && sameAnchor(previous, next) ? previous : next));
@@ -114,7 +111,7 @@ function useBarAnchor(active: boolean): Anchor | null {
function cardPosition(anchor: Anchor, step: HackOnboardingStep): React.CSSProperties {
if (!anchor.desktop) {
return { left: 16, right: 16, bottom: anchor.viewportHeight - anchor.barTop + 12 };
return { left: 16, right: 16, top: anchor.barBottom + 12 };
}
return {
top: anchor.barBottom + 14,
@@ -128,7 +125,7 @@ function cardPosition(anchor: Anchor, step: HackOnboardingStep): React.CSSProper
/**
* Coach mark for the hack page onboarding. The dimmed bar, lit control and rose
* beacon are drawn by StickyActionBar; this owns the scrim and the card.
* beacon are drawn by PatchModule; this owns the scrim and the card.
*/
export default function HackOnboardingOverlay({
step,
@@ -215,14 +212,14 @@ export default function HackOnboardingOverlay({
aria-labelledby={titleId}
tabIndex={-1}
style={cardPosition(anchor, step)}
className="anim-pop elevate fixed z-[80] rounded-xl border border-[var(--border)] bg-white p-3.5 outline-none dark:bg-[#141414]"
className="anim-pop elevate fixed z-[80] rounded-card border border-line bg-white p-3.5 outline-none dark:bg-[#141414]"
>
{(step === "selectRom" || !isLastStep) && (
<button
type="button"
onClick={onDismiss}
aria-label="Close"
className="absolute right-2 top-2 cursor-pointer rounded-md p-1 text-foreground/50 transition-colors hover:bg-[var(--surface-2)] hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
className="absolute right-2 top-2 cursor-pointer rounded-control p-1 text-text-3 transition-colors hover:bg-surface-2 hover:text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<FiX size={14} aria-hidden />
</button>
@@ -230,9 +227,9 @@ export default function HackOnboardingOverlay({
<h4 id={titleId} className={`text-[13px] font-semibold ${step === "selectRom" || !isLastStep ? "pr-7" : ""}`}>
{copy.title}
</h4>
<p className="mt-1 text-xs leading-relaxed text-foreground/70">{copy.body}</p>
<p className="mt-1 text-xs leading-relaxed text-text-2">{copy.body}</p>
{step === "selectRom" ? (
<p className="mt-3 text-xs font-semibold text-foreground/75">
<p className="mt-3 text-xs font-semibold text-text-2">
Select a clean {baseRomName || "base"} ROM to continue.
</p>
) : (
@@ -240,7 +237,7 @@ export default function HackOnboardingOverlay({
<button
type="button"
onClick={isLastStep ? onGotIt : onNext}
className="inline-flex h-7 cursor-pointer items-center rounded-full bg-[var(--accent)] px-3 text-xs font-bold text-[var(--accent-foreground)] transition-[background-color,transform] hover:bg-[var(--accent-700)] active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]"
className="inline-flex h-7 cursor-pointer items-center rounded-full bg-accent-deep px-3 text-xs font-bold text-white transition-[background-color,transform] hover:bg-accent-hover active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]"
>
{isLastStep ? "Got it" : "Next"}
</button>

View File

@@ -0,0 +1,542 @@
"use client";
import React from "react";
import Link from "next/link";
import { createPortal } from "react-dom";
import { FiCheck, FiChevronDown, FiDownload, FiX } from "react-icons/fi";
import { platformAccept } from "@/utils/idb";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import { baseGameLabel, type Platform } from "@/data/baseRoms";
import HackOnboardingGate from "@/components/Hack/Onboarding/HackOnboardingGate";
import { HEADER_COMPACT_ID } from "@/components/Header";
type OnboardingTarget = "version" | "selectRom" | "agree";
type Status = "idle" | "ready" | "patching" | "done" | "downloading";
interface PatchModuleProps {
title: string;
version?: string;
selectablePatches?: { id: number; version: string }[];
selectedPatchId?: number | null;
onVersionChange?: (patchId: number) => void;
author: string;
filename: string | null;
baseRomName?: string | null;
baseRomPlatform?: Platform;
onPatch: () => void;
status: Status;
error: string | null;
isLinked: boolean;
romReady: boolean;
onClickLink: () => void;
supported: boolean;
onUploadChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
termsAgreed: boolean;
isVerifyingRom?: boolean;
patchProgress?: number | null;
onVersionPickerOpenChange?: (open: boolean) => void;
/** Control the onboarding is pointing at. Dims the module around it and lights it up. */
onboardingHighlight?: OnboardingTarget | null;
onboardingDimBar?: boolean;
onboardingBeacon?: boolean;
onboardingGateLabel?: string | null;
/** The tour is open: the in-module entry point stays (inactive) so nothing shifts; the floating ones go. */
onboardingGateHidden?: boolean;
onOnboardingGateClick?: () => void;
}
/** Patch file format from its extension, for the facts list. */
function patchFormat(filename: string | null) {
const ext = filename?.split(".").pop()?.toLowerCase();
return ext && ["bps", "ips", "ups", "xdelta"].includes(ext) ? ext.toUpperCase() : null;
}
/** True once `el` has scrolled up behind the sticky site header. */
function useScrolledBehindHeader(ref: React.RefObject<HTMLElement | null>) {
const [out, setOut] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => {
const rootTop = entry.rootBounds?.top ?? 0;
setOut(!entry.isIntersecting && entry.boundingClientRect.bottom <= rootTop);
},
{ threshold: 0, rootMargin: "-60px 0px 0px 0px" },
);
io.observe(el);
return () => io.disconnect();
}, [ref]);
return out;
}
const SCROLL_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]);
/** Swallows wheel, touch, and key scrolling until the returned release is called. */
function holdUserScroll() {
const block = (event: Event) => event.preventDefault();
const blockKeys = (event: KeyboardEvent) => {
if (SCROLL_KEYS.has(event.key)) event.preventDefault();
};
window.addEventListener("wheel", block, { passive: false });
window.addEventListener("touchmove", block, { passive: false });
window.addEventListener("keydown", blockKeys);
return () => {
window.removeEventListener("wheel", block);
window.removeEventListener("touchmove", block);
window.removeEventListener("keydown", blockKeys);
};
}
/**
* Patch module: status line, version, the one action, and the facts a player
* checks before trusting a download. Lives at the top of the hack page rail.
* Once the action button scrolls behind the site header the title, version
* picker, and the same action reappear in a compact bar there, so patching is
* never far away.
*/
export default function PatchModule({
title,
version,
selectablePatches = [],
selectedPatchId,
onVersionChange,
author,
filename,
baseRomName,
baseRomPlatform,
onPatch,
status,
error,
isLinked,
romReady,
onClickLink,
supported,
onUploadChange,
termsAgreed,
isVerifyingRom = false,
patchProgress = null,
onVersionPickerOpenChange,
onboardingHighlight = null,
onboardingDimBar = false,
onboardingBeacon = true,
onboardingGateLabel = null,
onboardingGateHidden = false,
onOnboardingGateClick,
}: PatchModuleProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
const { loading: baseRomsLoading } = useBaseRoms();
const uploadInputRef = React.useRef<HTMLInputElement | null>(null);
const moduleRef = React.useRef<HTMLDivElement | null>(null);
const actionRef = React.useRef<HTMLDivElement | null>(null);
const scrolledOut = useScrolledBehindHeader(actionRef);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [patchAgainReady, setPatchAgainReady] = React.useState(true);
// Which copy of the picker is open: the module's or the compact bar's. Sharing one flag opened both,
// and the hidden one's outside-click handler closed the menu before an option could be picked.
const [versionPickerOpenIn, setVersionPickerOpenIn] = React.useState<"module" | "compact" | null>(null);
const versionPickerOpen = versionPickerOpenIn !== null;
const hasVersionPicker = selectablePatches.length > 1 && !!onVersionChange;
const base = baseRomName ? baseGameLabel(baseRomName) : baseRomPlatform ?? "base";
const format = patchFormat(filename);
const versionPickerOpenChange = React.useRef(onVersionPickerOpenChange);
React.useEffect(() => {
versionPickerOpenChange.current = onVersionPickerOpenChange;
}, [onVersionPickerOpenChange]);
React.useEffect(() => {
versionPickerOpenChange.current?.(versionPickerOpen);
}, [versionPickerOpen]);
React.useEffect(() => {
let timeoutId: number | undefined;
if (error) setErrorMessage(error);
else if (errorMessage !== null) timeoutId = window.setTimeout(() => setErrorMessage(null), 300);
return () => {
if (timeoutId) window.clearTimeout(timeoutId);
};
}, [error, errorMessage]);
React.useEffect(() => {
if (status !== "done") {
setPatchAgainReady(true);
return;
}
setPatchAgainReady(false);
const t = window.setTimeout(() => setPatchAgainReady(true), 3000);
return () => window.clearTimeout(t);
}, [status]);
const isSpotlight = (target: OnboardingTarget) => onboardingHighlight === target;
const spotlight = (target: OnboardingTarget) =>
isSpotlight(target) ? " relative z-[2] outline-[3px] outline-offset-2 outline-[color-mix(in_oklab,var(--rose)_45%,transparent)]" : "";
const spotlightAttr = (target: OnboardingTarget) => isSpotlight(target) || undefined;
const onboardingActive = onboardingDimBar || onboardingHighlight !== null;
// ---- the state the player sees ----
const kind: "loading" | "needs-rom" | "permission" | "ready" | "patching" | "downloading" | "done" = baseRomsLoading
? "loading"
: !romReady
? isLinked
? "permission"
: "needs-rom"
: status === "patching"
? "patching"
: status === "downloading"
? "downloading"
: status === "done"
? "done"
: "ready";
const tone = errorMessage ? "error" : kind === "ready" || kind === "done" || kind === "patching" || kind === "downloading" ? "ready" : "neutral";
const busy = kind === "patching" || kind === "downloading";
const actionLabel = (() => {
switch (kind) {
case "loading":
return "Checking your ROMs…";
case "needs-rom":
return isVerifyingRom ? "Verifying…" : `Select ${base} ROM`;
case "permission":
return "Grant permission";
case "patching":
return patchProgress != null && patchProgress > 0 ? `Patching… (${(patchProgress / (1024 * 1024)).toFixed(0)} MB)` : "Patching…";
case "downloading":
return "Downloading…";
case "done":
return patchAgainReady ? "Patch again" : "Patched";
case "ready":
return termsAgreed ? "Retry patching" : "Agree and patch";
}
})();
// Called as a function, not rendered as <ActionButton />: a component declared in render
// remounts every status tick, which drops keyboard focus and restarts the sweep.
function actionButton(compact = false) {
const shape = compact ? "h-10 w-full px-4 text-sm md:w-auto" : "h-12 w-full px-5 text-[15px]";
const paint =
kind === "done" && !patchAgainReady
? "bg-ready text-on-ready"
: busy
? "bg-accent-hover text-white"
: "bg-accent-deep text-white hover:enabled:bg-accent-hover";
const cls = `relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-control font-semibold transition-[background-color,transform] duration-[120ms] active:enabled:not-aria-disabled:scale-[.98] disabled:cursor-default aria-disabled:cursor-default ${shape} ${paint}`;
if (kind === "needs-rom") {
return (
<span className={`relative inline-flex w-full ${compact ? "md:w-auto" : ""}`}>
<button
type="button"
onClick={() => uploadInputRef.current?.click()}
disabled={isVerifyingRom}
data-onboarding-spotlight={compact ? undefined : spotlightAttr("selectRom")}
className={`${cls} disabled:opacity-70${compact ? "" : spotlight("selectRom")}`}
>
{actionLabel}
</button>
{!compact && onboardingBeacon && isSpotlight("selectRom") && <OnboardingBeacon large className="-top-[3px] right-[2px]" />}
</span>
);
}
if (kind === "permission") {
return (
<span className={`relative inline-flex w-full ${compact ? "md:w-auto" : ""}`}>
<button
type="button"
onClick={onClickLink}
disabled={!supported}
data-onboarding-spotlight={compact ? undefined : spotlightAttr("selectRom")}
className={`${cls} disabled:opacity-60${compact ? "" : spotlight("selectRom")}`}
>
{actionLabel}
</button>
{!compact && onboardingBeacon && isSpotlight("selectRom") && <OnboardingBeacon large className="-top-[3px] -right-[3px]" />}
</span>
);
}
// Busy and just-patched only read as disabled: a real `disabled` would blur the focused button mid-patch.
const blocked = busy || !patchAgainReady;
return (
<span className={`relative inline-flex w-full ${compact ? "md:w-auto" : ""}`}>
<button
type="button"
onClick={blocked ? undefined : onPatch}
disabled={!mounted || kind === "loading"}
aria-disabled={blocked || undefined}
aria-live="polite"
data-onboarding-spotlight={compact ? undefined : spotlightAttr("agree")}
className={`${cls}${kind === "loading" ? " opacity-70" : ""}${compact ? "" : spotlight("agree")}`}
>
{busy && <span className="absolute inset-y-0 left-0 w-1/3 animate-[progressSweep_1.2s_ease-in-out_infinite] bg-white/15" aria-hidden />}
<span className="relative inline-flex items-center gap-2">
{kind === "done" && !patchAgainReady ? <FiCheck className="h-5 w-5" /> : kind === "ready" ? <FiDownload className="h-5 w-5" /> : null}
{actionLabel}
</span>
</button>
{!compact && onboardingBeacon && isSpotlight("agree") && <OnboardingBeacon large className="-top-[3px] right-[2px]" />}
</span>
);
}
const picker = (where: "module" | "compact") => hasVersionPicker && (
<VersionPicker
version={version}
patches={selectablePatches}
selectedPatchId={selectedPatchId}
open={versionPickerOpenIn === where}
onOpenChange={(open) => setVersionPickerOpenIn(open ? where : null)}
onSelect={(id) => {
onVersionChange?.(id);
setVersionPickerOpenIn(null);
}}
spotlight={spotlight("version")}
spotlightAttr={spotlightAttr("version")}
beacon={onboardingBeacon && isSpotlight("version")}
/>
);
const termsLine = kind === "ready" || kind === "done" ? (
<>
Patching means you agree to the{" "}
<Link href="/terms" prefetch={false} target="_blank" className="underline underline-offset-2">
terms
</Link>
.
</>
) : (
"Your ROM stays in your browser. Hackdex never receives it."
);
const compactTarget = mounted && scrolledOut ? document.getElementById(HEADER_COMPACT_ID) : null;
// The help entry point shows wherever the action is: in the module, beside the
// compact action on desktop, and as a floating pill on phones. Every home first
// brings the module to a fixed spot under the header, since the tour spotlights
// the full-size controls and anchors its card to the module.
const gate = onboardingGateLabel && onOnboardingGateClick && errorMessage === null && !busy
? {
label: onboardingGateLabel,
beacon: kind === "needs-rom",
open: () => {
const el = moduleRef.current;
if (!el) return onOnboardingGateClick();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const top = Math.max(0, window.scrollY + el.getBoundingClientRect().top - 80);
window.scrollTo({ top, behavior: reduce ? "auto" : "smooth" });
// The tour locks body scroll once open, which would freeze a smooth
// scroll midway, so hold the user's own scrolling until the page has
// settled at the target, then open.
const release = holdUserScroll();
const started = performance.now();
const settle = () => {
if (Math.abs(window.scrollY - top) < 2 || performance.now() - started > 1000) {
release();
onOnboardingGateClick();
} else requestAnimationFrame(settle);
};
requestAnimationFrame(settle);
},
}
: null;
return (
<div ref={moduleRef} className={`relative ${onboardingActive ? "z-[60]" : ""}`}>
<input ref={uploadInputRef} type="file" accept={platformAccept(baseRomPlatform)} onChange={onUploadChange} disabled={isVerifyingRom} className="hidden" />
<div data-hack-action-bar className="relative flex flex-col gap-3 rounded-card border border-line bg-surface p-4 shadow-rest">
{onboardingDimBar && <div aria-hidden className="absolute inset-0 z-[1] rounded-card bg-[#171717]/25 dark:bg-black/55" />}
<div
className={`rounded-control px-3.5 py-3 text-sm transition-colors duration-[220ms] ${
tone === "error" ? "bg-error-soft" : tone === "ready" ? "bg-ready-soft" : "bg-surface-2"
}`}
role={tone === "error" ? "alert" : undefined}
aria-live="polite"
>
{errorMessage ? (
<p>
<b className="font-semibold text-error">{errorMessage}</b> If the issue persists, try clearing your browser cache or using a different browser.
</p>
) : kind === "loading" ? (
<p>
<b className="font-semibold">Checking this device for your {base} ROM.</b>
</p>
) : kind === "needs-rom" ? (
<p>
<b className="font-semibold">Needs your {base} ROM.</b> You bring the ROM; we apply {author}&rsquo;s patch to it in your browser. Nothing gets downloaded that {author} didn&rsquo;t make.
</p>
) : kind === "permission" ? (
<p>
<b className="font-semibold">{base} ROM linked.</b> Grant permission so the patcher can read it. It never leaves your browser.
</p>
) : kind === "patching" ? (
<p>
<b className="font-semibold">Patching {base}.</b> Applying the patch in your browser. Keep this tab open.
</p>
) : kind === "downloading" ? (
<p>
<b className="font-semibold">Almost done.</b> The patched ROM is on its way to your downloads.
</p>
) : kind === "done" ? (
<p>
<b className="font-semibold">Done.</b> The patched ROM is in your downloads. Load it in any {baseRomPlatform ?? ""} emulator.
</p>
) : (
<p>
<b className="font-semibold">{base} ROM ready.</b> Linked on this device; it never leaves your browser.
</p>
)}
</div>
<div ref={actionRef}>
{actionButton()}
</div>
{gate && <HackOnboardingGate variant="row" label={gate.label} onClick={gate.open} beacon={gate.beacon && !onboardingGateHidden} inactive={onboardingGateHidden} />}
<dl className="mt-0.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[13px] leading-[1.45]">
<dt className={`text-text-3 ${hasVersionPicker ? "self-center" : ""}`}>Version</dt>
<dd className="flex min-w-0 flex-wrap items-center gap-2 text-text-2">{hasVersionPicker ? picker("module") : <span className="font-medium text-text">{version}</span>}</dd>
<dt className="text-text-3">Patch</dt>
<dd className="text-text-2">{format ? `${format} file` : "Patch file"}, applied locally</dd>
<dt className="text-text-3">Needs</dt>
<dd className="text-text-2">A clean {baseRomName ?? base} ROM. We verify the checksum first.</dd>
</dl>
<p className="text-xs text-text-3">{termsLine}</p>
</div>
{compactTarget &&
createPortal(
<div className="anim-fade flex items-center gap-3 bg-surface px-6 max-md:flex-wrap max-md:gap-x-3 max-md:gap-y-2 max-md:border-b max-md:border-line max-md:py-2 max-md:shadow-lift md:h-full md:gap-4 md:border-l md:border-line md:px-5">
<strong className="min-w-0 flex-1 text-[15px] font-semibold leading-tight max-md:line-clamp-2 md:truncate md:text-base">{title}</strong>
<div className="flex items-center gap-3 max-md:flex-[1_1_100%] max-md:flex-col-reverse max-md:items-stretch max-md:gap-1.5">
{kind === "ready" && (
<small className="text-xs text-text-3 max-md:text-center md:max-w-[220px] md:text-right md:max-lg:hidden">
By patching, you agree to the{" "}
<Link href="/terms" prefetch={false} target="_blank" className="underline underline-offset-2">
terms
</Link>
.
</small>
)}
<div className="flex items-center gap-2 max-md:w-full">
{gate && !onboardingGateHidden && (
<div className="hidden md:block">
<HackOnboardingGate variant="icon" label={gate.label} onClick={gate.open} beacon={gate.beacon} />
</div>
)}
{hasVersionPicker && (
<div className="max-md:flex-none">
{picker("compact")}
</div>
)}
{actionButton(true)}
</div>
</div>
</div>,
compactTarget,
)}
{compactTarget && gate && !onboardingGateHidden && createPortal(<HackOnboardingGate variant="pill" label={gate.label} onClick={gate.open} beacon={gate.beacon} />, document.body)}
</div>
);
}
/** Rose onboarding beacon with a reduced-motion-safe halo. */
function OnboardingBeacon({ className, large = false }: { className: string; large?: boolean }) {
return <span aria-hidden className={`onboarding-beacon ${large ? "onboarding-beacon-large" : ""} pointer-events-none absolute! block overflow-visible ${className}`} />;
}
/** Version chip that opens a list of the patches a player may choose from. Closes on outside click or Escape. */
function VersionPicker({
version,
patches,
selectedPatchId,
open,
onOpenChange,
onSelect,
spotlight,
spotlightAttr,
beacon,
}: {
version?: string;
patches: { id: number; version: string }[];
selectedPatchId?: number | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (id: number) => void;
spotlight: string;
spotlightAttr: true | undefined;
beacon: boolean;
}) {
const rootRef = React.useRef<HTMLDivElement | null>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
// Opens upward when the phone tab bar or the viewport edge would cover it and there is more room above.
const [up, setUp] = React.useState(false);
React.useLayoutEffect(() => {
if (!open || !rootRef.current || !listRef.current) return setUp(false);
const chip = rootRef.current.getBoundingClientRect();
const tabs = document.getElementById("mobile-tabs")?.offsetHeight ?? 0;
const below = window.innerHeight - tabs - chip.bottom;
setUp(below < listRef.current.offsetHeight + 8 && chip.top > below);
}, [open]);
React.useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) onOpenChange(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onOpenChange(false);
};
document.addEventListener("pointerdown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open, onOpenChange]);
return (
<div ref={rootRef} className="relative inline-flex">
<button
type="button"
aria-label="Patch version"
aria-haspopup="listbox"
aria-expanded={open}
data-onboarding-spotlight={spotlightAttr}
onClick={() => onOpenChange(!open)}
className={`tap-target inline-flex h-8 max-w-44 items-center gap-1.5 rounded-full border border-line-strong bg-surface px-2.5 text-[12px] font-semibold text-text transition-colors hover:border-text-3${spotlight}`}
>
<span className="truncate">{version}</span>
{open ? <FiX size={13} className="shrink-0 text-text-3" aria-hidden /> : <FiChevronDown size={13} className="shrink-0 text-text-3" aria-hidden />}
</button>
{beacon && <OnboardingBeacon className="-top-[3px] -right-[3px]" />}
{open && (
<div
ref={listRef}
role="listbox"
aria-label="Patch version"
className={`anim-pop absolute left-0 z-30 w-60 overflow-hidden rounded-card border border-line bg-surface shadow-overlay ${up ? "bottom-full mb-2" : "top-full mt-2"}`}
>
{patches.map((patch, index) => {
const selected = selectedPatchId === patch.id;
return (
<button
key={patch.id}
type="button"
role="option"
aria-selected={selected}
onClick={() => onSelect(patch.id)}
className={`flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors ${
selected ? "bg-accent-soft/60 text-text" : "text-text-2 hover:bg-surface-2"
} ${index > 0 ? "border-t border-line" : ""}`}
>
<span className="font-medium">{patch.version}</span>
{selected && <FiCheck className="h-4 w-4 text-accent-text" aria-hidden />}
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -315,17 +315,17 @@ export default function PatcherVersionManager({
visible={showPublishModal}
onClose={() => !saving && setShowPublishModal(false)}
>
<p className="text-foreground/80 mb-3">
<p className="text-text-2 mb-3">
These patches will be available to choose from in the downloader on your hack&apos;s homepage:
</p>
{draftVersionLabels.length > 0 ? (
<ul className="mb-4 space-y-1 text-sm text-foreground/75">
<ul className="mb-4 space-y-1 text-sm text-text-2">
{draftVersionLabels.map((label, index) => (
<li key={label} className="flex items-center gap-2">
<span aria-hidden>-</span>
<span>{label}</span>
{draftOption === "custom" && index === 0 && (
<span className="inline-flex items-center rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
<span className="inline-flex items-center rounded-full bg-ready-soft px-2 py-0.5 text-[10px] font-medium text-ready">
Default
</span>
)}
@@ -333,20 +333,20 @@ export default function PatcherVersionManager({
))}
</ul>
) : (
<p className="mb-4 text-sm text-foreground/60">No current patch is set.</p>
<p className="mb-4 text-sm text-text-3">No current patch is set.</p>
)}
{draftOption === "custom" && (
<p className="mb-4 text-sm text-foreground/70">
Public version name: <strong className="text-foreground">{draftCustomVersionName.trim()}</strong>
<p className="mb-4 text-sm text-text-2">
Public version name: <strong className="text-text">{draftCustomVersionName.trim()}</strong>
</p>
)}
{selectedUnpublishedVersionLabels.length > 0 && (
<p className="mb-4 text-sm text-amber-600 dark:text-amber-400">
<p className="mb-4 text-sm text-warn">
Selected unpublished versions will be published when these changes are saved.
</p>
)}
{publishError && (
<p className="mb-4 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
<p className="mb-4 rounded-control border border-error/40 bg-error-soft px-3 py-2 text-sm text-error">
{publishError}
</p>
)}
@@ -355,7 +355,7 @@ export default function PatcherVersionManager({
type="button"
onClick={confirmPublish}
disabled={saving}
className="flex-1 inline-flex items-center justify-center rounded-md bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 inline-flex items-center justify-center rounded-control bg-accent-deep px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "Publishing..." : "Publish Changes"}
</button>
@@ -366,7 +366,7 @@ export default function PatcherVersionManager({
setPublishError(null);
}}
disabled={saving}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
className="flex-1 rounded-control border border-line bg-surface-2 px-4 py-2 text-sm font-medium hover:bg-surface-3 disabled:opacity-50"
>
Cancel
</button>

View File

@@ -95,12 +95,12 @@ export default function PatcherVersionSettings({
})();
const summary = (
<>
<span className="text-foreground/45">Live: </span>
<span className="text-foreground/80 font-medium">{liveOptionLabel}</span>
<span className="text-text-3">Live: </span>
<span className="text-text-2 font-medium">{liveOptionLabel}</span>
{summaryStatus && (
<>
<span className="text-foreground/40">{summaryStatus.prefix}</span>
<span className="text-foreground/70 font-medium">{summaryStatus.label}</span>
<span className="text-text-3">{summaryStatus.prefix}</span>
<span className="text-text-2 font-medium">{summaryStatus.label}</span>
</>
)}
</>
@@ -112,10 +112,10 @@ export default function PatcherVersionSettings({
titleId="patcher-version-settings-heading"
leading={<FaCode size={20} />}
summary={summary}
className="mb-6 rounded-lg border border-[var(--border)]/70 border-l-[3px] border-l-[var(--accent)]/40 bg-[var(--surface-2)]"
className="mb-6 rounded-card border border-line/70 border-l-[3px] border-l-[var(--accent)]/40 bg-surface-2"
>
<div>
<p className="text-xs sm:text-sm text-foreground/60 leading-snug md:-mt-4 mb-6">
<p className="text-xs sm:text-sm text-text-3 leading-snug md:-mt-4 mb-6">
Choose which patch versions players can use from the downloader on your hack&apos;s homepage.
</p>
<div className="grid gap-3" role="radiogroup" aria-label="Patcher version source">
@@ -139,14 +139,14 @@ export default function PatcherVersionSettings({
/>
</div>
{draftOption === "custom" && selectionMode && (
<div className="mt-4 rounded-md border border-[var(--border)]/70 bg-[var(--surface-1)]/70 px-3 py-2 text-xs text-foreground/65">
<div className="font-medium text-foreground/80 mb-1">Custom draft</div>
<div className="mt-4 rounded-control border border-line/70 bg-surface/70 px-3 py-2 text-xs text-text-3">
<div className="font-medium text-text-2 mb-1">Custom draft</div>
<div>
{customSelectionCount > 0
? versionSummary(draftVersionLabels, "No versions selected.")
: "No versions selected. Choose at least one version to publish Custom."}
</div>
<label htmlFor="custom-version-name" className="mt-3 block font-medium text-foreground/80">
<label htmlFor="custom-version-name" className="mt-3 block font-medium text-text-2">
Public version name
</label>
<input
@@ -155,20 +155,20 @@ export default function PatcherVersionSettings({
value={customVersionName}
onChange={(event) => onCustomVersionNameChange(event.target.value)}
maxLength={CUSTOM_VERSION_NAME_MAX_LENGTH}
className="mt-1 block h-9 w-full rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-sm text-foreground outline-none transition-colors placeholder:text-foreground/35 focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent)]/20"
className="mt-1 block h-9 w-full rounded-control border border-line bg-surface-2 px-3 text-sm text-text outline-none transition-colors placeholder:text-text-3 focus:border-accent focus:ring-2 focus:ring-[var(--accent)]/20"
placeholder="e.g. 2.1.2"
/>
<p className="mt-1 text-foreground/55">
<p className="mt-1 text-text-3">
Shown on the hack page and discover cards. Max {CUSTOM_VERSION_NAME_MAX_LENGTH} characters.
</p>
{showCustomVersionNameHint && (
<p className="mt-1 text-red-400">Custom version name is required.</p>
<p className="mt-1 text-error">Custom version name is required.</p>
)}
{showSuggestedNameButton && (
<button
type="button"
onClick={onApplySuggestedCustomVersionName}
className="mt-2 inline-flex items-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1.5 text-xs font-semibold text-foreground/80 transition-colors hover:bg-[var(--surface-3)]"
className="mt-2 inline-flex items-center rounded-control border border-line bg-surface-2 px-2.5 py-1.5 text-xs font-semibold text-text-2 transition-colors hover:bg-surface-3"
>
Use &quot;{suggestedCustomVersionName}&quot;
</button>
@@ -181,7 +181,7 @@ export default function PatcherVersionSettings({
<button
type="button"
onClick={onClearSelections}
className="inline-flex items-center justify-center h-10 sm:h-8 px-3 text-xs font-semibold rounded-md border border-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] transition-colors sm:w-auto"
className="inline-flex items-center justify-center h-10 sm:h-8 px-3 text-xs font-semibold rounded-control border border-line bg-surface-2 hover:bg-surface-3 transition-colors sm:w-auto"
>
Clear selections
</button>
@@ -190,7 +190,7 @@ export default function PatcherVersionSettings({
type="button"
onClick={onCancel}
disabled={saving}
className="inline-flex flex-1 items-center justify-center h-10 sm:h-8 px-3 text-xs font-semibold rounded-md border border-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] transition-colors disabled:opacity-50 sm:flex-none"
className="inline-flex flex-1 items-center justify-center h-10 sm:h-8 px-3 text-xs font-semibold rounded-control border border-line bg-surface-2 hover:bg-surface-3 transition-colors disabled:opacity-50 sm:flex-none"
>
Cancel
</button>
@@ -198,7 +198,7 @@ export default function PatcherVersionSettings({
type="button"
onClick={onPublish}
disabled={publishDisabled}
className="inline-flex flex-1 items-center justify-center min-w-0 h-10 sm:h-8 px-3 text-xs font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-[var(--accent)] sm:flex-none sm:min-w-32"
className="inline-flex flex-1 items-center justify-center min-w-0 h-10 sm:h-8 px-3 text-xs font-semibold rounded-control bg-accent-deep text-white hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-accent-deep sm:flex-none sm:min-w-32"
>
{saving ? "Publishing..." : "Publish Changes"}
</button>
@@ -211,7 +211,7 @@ export default function PatcherVersionSettings({
<button
type="button"
onClick={onEnterSelectionMode}
className="inline-flex items-center justify-center h-10 sm:h-8 px-3 text-xs font-semibold rounded-md border border-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] transition-colors"
className="inline-flex items-center justify-center h-10 sm:h-8 px-3 text-xs font-semibold rounded-control border border-line bg-surface-2 hover:bg-surface-3 transition-colors"
>
Edit patcher versions
</button>
@@ -223,7 +223,7 @@ export default function PatcherVersionSettings({
type="button"
onClick={onCancel}
disabled={saving}
className="inline-flex items-center justify-center h-10 sm:h-8 px-3 text-xs font-semibold rounded-md border border-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] transition-colors disabled:opacity-50"
className="inline-flex items-center justify-center h-10 sm:h-8 px-3 text-xs font-semibold rounded-control border border-line bg-surface-2 hover:bg-surface-3 transition-colors disabled:opacity-50"
>
Cancel
</button>
@@ -232,7 +232,7 @@ export default function PatcherVersionSettings({
type="button"
onClick={onPublish}
disabled={publishDisabled}
className="inline-flex items-center justify-center min-w-32 h-10 sm:h-8 px-3 text-xs font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-[var(--accent)]"
className="inline-flex items-center justify-center min-w-32 h-10 sm:h-8 px-3 text-xs font-semibold rounded-control bg-accent-deep text-white hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-accent-deep"
>
{saving ? "Publishing..." : "Publish Changes"}
</button>
@@ -246,14 +246,14 @@ export default function PatcherVersionSettings({
>
{showSaved ? (
<span
className={`text-emerald-600 dark:text-emerald-400 font-medium transition-opacity duration-[450ms] ease-out ${
className={`text-ready dark:text-ready font-medium transition-opacity duration-[450ms] ease-out ${
savedFadeOut ? "opacity-0" : "opacity-100"
}`}
>
Changes published.
</span>
) : (
<span className="text-red-400">{error}</span>
<span className="text-error">{error}</span>
)}
</div>
)}
@@ -286,31 +286,31 @@ function OptionCard({
role="radio"
aria-checked={selected}
onClick={() => onSelect(option)}
className={`w-full text-left rounded-md border-2 px-3 py-3 transition-colors flex gap-3 items-start touch-manipulation ${
className={`w-full text-left rounded-control border-2 px-3 py-3 transition-colors flex gap-3 items-start touch-manipulation ${
selected
? "border-[var(--accent)] bg-[var(--surface-2)]"
: "border-[var(--border)] bg-[var(--surface-2)]/50 hover:bg-[var(--surface-2)]"
? "border-accent bg-surface-2"
: "border-line bg-surface-2/50 hover:bg-surface-2"
}`}
>
<span
className={`mt-0.5 shrink-0 h-3.5 w-3.5 rounded-full border-2 flex items-center justify-center ${
selected ? "border-[var(--accent)]" : "border-[var(--border)]"
selected ? "border-accent" : "border-line"
}`}
aria-hidden
>
{selected && <span className="h-1.5 w-1.5 rounded-full bg-[var(--accent)]" />}
{selected && <span className="h-1.5 w-1.5 rounded-full bg-accent-deep" />}
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{label}</span>
{saved && (
<span className="inline-flex items-center rounded px-1 py-px text-[10px] font-medium uppercase tracking-wide text-foreground/60 bg-foreground/5 ring-1 ring-[var(--border)]">
<span className="inline-flex items-center rounded px-1 py-px text-[10px] font-medium uppercase tracking-wide text-text-3 bg-foreground/5 ring-1 ring-line">
Published
</span>
)}
</span>
<span className="mt-1 block text-xs text-foreground/60">{description}</span>
<span className="mt-2 block text-xs font-medium text-foreground/75">{detail}</span>
<span className="mt-1 block text-xs text-text-3">{description}</span>
<span className="mt-2 block text-xs font-medium text-text-2">{detail}</span>
</span>
</button>
);

View File

@@ -1,8 +1,8 @@
"use client";
import React, { useEffect, useState } from "react";
import { FiX } from "react-icons/fi";
import React, { useState } from "react";
import { FaCircleCheck } from "react-icons/fa6";
import Modal from "@/components/Primitives/Modal";
import { submitHackReport } from "@/app/hack/[slug]/actions";
type ReportType = "hateful" | "harassment" | "misleading" | "stolen";
@@ -21,27 +21,6 @@ const ReportModal: React.FC<ReportModalProps> = ({ slug, onClose }) => {
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const html = document.documentElement;
const body = document.body;
const previousHtmlOverflow = html.style.overflow;
const previousBodyOverflow = body.style.overflow;
const previousBodyPaddingRight = body.style.paddingRight;
const scrollBarWidth = window.innerWidth - html.clientWidth;
html.style.overflow = "hidden";
body.style.overflow = "hidden";
if (scrollBarWidth > 0) {
body.style.paddingRight = `${scrollBarWidth}px`;
}
return () => {
html.style.overflow = previousHtmlOverflow;
body.style.overflow = previousBodyOverflow;
body.style.paddingRight = previousBodyPaddingRight;
};
}, []);
const canSubmit = () => {
if (!reportType) return false;
@@ -84,84 +63,48 @@ const ReportModal: React.FC<ReportModalProps> = ({ slug, onClose }) => {
}
};
const reset = () => {
setCurrentPage("select");
setReportType(null);
setDetails("");
setEmail("");
setIsImpersonating(false);
setError(null);
};
const field = "w-full rounded-control border border-line bg-surface-2 px-3 text-sm text-text outline-none transition-[border-color,box-shadow] placeholder:text-text-3 focus:border-line-strong focus:ring-2 focus:ring-accent/40";
const primary = "inline-flex h-11 w-full items-center justify-center rounded-control bg-accent-deep px-4 text-sm font-semibold text-white transition-colors hover:enabled:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60";
const secondary = "inline-flex h-11 w-full items-center justify-center rounded-control border border-line-strong px-4 text-sm font-medium text-text-2 transition-colors hover:enabled:border-text-3 hover:enabled:text-text disabled:cursor-not-allowed disabled:opacity-50";
const renderSelectPage = () => (
<div className="flex flex-col gap-8 sm:gap-4">
<div className="mb-2">
<div className="text-xl font-semibold">Report Hack</div>
<p className="mt-1 text-sm text-foreground/80">
Please select the reason for reporting this hack.
</p>
</div>
<div className="flex flex-col gap-3">
<button
type="button"
onClick={() => {
setReportType("hateful");
setCurrentPage("details");
}}
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
>
Hateful content
</button>
<button
type="button"
onClick={() => {
setReportType("harassment");
setCurrentPage("details");
}}
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
>
Harassment
</button>
<button
type="button"
onClick={() => {
setReportType("misleading");
setCurrentPage("details");
}}
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
>
Misleading
</button>
<button
type="button"
onClick={() => {
setReportType("stolen");
setCurrentPage("details");
}}
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
>
My hack was stolen
</button>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-2">What is wrong with this hack?</p>
<div className="flex flex-col gap-2">
{REASONS.map(([type, label]) => (
<button
key={type}
type="button"
onClick={() => {
setReportType(type);
setCurrentPage("details");
}}
className="flex h-12 w-full items-center rounded-control border border-line px-4 text-left text-sm font-medium transition-colors hover:border-line-strong hover:bg-surface-2"
>
{label}
</button>
))}
</div>
</div>
);
const renderSuccessPage = () => (
<div className="flex flex-col gap-6 sm:gap-4">
<div className="mb-2">
<div className="text-xl font-semibold">Report Submitted</div>
<p className="mt-1 text-sm text-foreground/80">
Thank you for your report. We will review it and take appropriate action.
</p>
</div>
<div className="p-4 rounded-md bg-green-500/10 border border-green-500/20">
<p className="text-green-500 font-semibold flex items-center justify-center gap-2">
<FaCircleCheck size={18} />
Report successfully sent!
</p>
</div>
<div className="flex flex-col gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center h-14 sm:h-11 w-full text-sm font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] enabled:hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
Done
</button>
</div>
<div className="flex flex-col gap-5">
<p className="flex items-center gap-2 rounded-control bg-ready-soft px-3 py-2.5 text-sm font-medium text-ready">
<FaCircleCheck size={16} /> Thanks. We will review your report.
</p>
<button type="button" onClick={onClose} className={primary}>
Done
</button>
</div>
);
@@ -169,157 +112,71 @@ const ReportModal: React.FC<ReportModalProps> = ({ slug, onClose }) => {
const isStolen = reportType === "stolen";
return (
<div className="flex flex-col gap-6 sm:gap-4">
<div className="mb-2">
<div className="text-xl font-semibold">
{reportType === "hateful" && "Hateful Content"}
{reportType === "harassment" && "Harassment"}
{reportType === "misleading" && "Misleading"}
{reportType === "stolen" && "My Hack Was Stolen"}
</div>
<p className="mt-1 text-sm text-foreground/80">
{isStolen
? "Please provide your contact information and details about the stolen hack."
: "Please provide additional details."}
</p>
</div>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-2">
{isStolen ? "Tell us how to reach you and how we can confirm the hack is yours." : "Tell us what you found."}
</p>
{isStolen && (
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isImpersonating}
onChange={(e) => setIsImpersonating(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm font-semibold">Is the uploader impersonating you?</span>
</label>
</div>
<label className="flex cursor-pointer items-center gap-2.5 text-sm font-medium">
<input type="checkbox" checked={isImpersonating} onChange={(e) => setIsImpersonating(e.target.checked)} className="h-4 w-4 accent-[var(--rose-deep)]" />
The uploader is pretending to be me
</label>
)}
<div>
<label className="text-sm font-semibold mb-2 block">
Contact Email
{isStolen ? (
<span className="text-red-500"> *</span>
) : (
<span className="text-foreground/60 text-xs ml-1">(optional)</span>
)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required={isStolen}
className="w-full px-3 py-2 rounded-md border border-[var(--border)] bg-[var(--surface-1)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
/>
</div>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
Contact email {isStolen ? <span className="text-error">*</span> : <span className="text-xs font-normal text-text-3">(optional)</span>}
</span>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="your@email.com" required={isStolen} className={`${field} h-10`} />
</label>
<div>
<label className="text-sm font-semibold mb-2 block">
Additional Details <span className="text-red-500">*</span>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
Details <span className="text-error">*</span>
</span>
<textarea
value={details}
onChange={(e) => setDetails(e.target.value)}
placeholder={
isStolen
? "Please provide important context, proof of ownership, and any other relevant information..."
: "Please provide additional context..."
}
rows={6}
placeholder={isStolen ? "Proof that you made it, links, anything that helps…" : "What did you see, and where?"}
rows={5}
required
className="w-full px-3 py-2 rounded-md border border-[var(--border)] bg-[var(--surface-1)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)] resize-none"
className={`${field} resize-none py-2`}
/>
</div>
</label>
{isStolen && (
<div className="p-4 rounded-md bg-[var(--surface-2)] border border-[var(--border)]">
<p className="text-sm text-foreground/90">
<strong>Note:</strong> We will reach out to you via email. Please ensure you have sufficient proof that you are the original creator of this hack.
</p>
</div>
)}
<p className="text-xs text-text-3">
{isStolen ? "We will email you. Have proof ready that you made this hack." : "If we have questions, we will email you."}
</p>
{!isStolen && (
<div className="p-4 rounded-md bg-[var(--surface-2)] border border-[var(--border)]">
<p className="text-sm text-foreground/90">
<strong>Note:</strong> If we have questions, we will reach out via email for clarification.
</p>
</div>
)}
{error && <p className="rounded-control bg-error-soft px-3 py-2 text-sm text-error">{error}</p>}
{error && (
<div className="p-4 rounded-md bg-red-500/10 border border-red-500/20">
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<div className="flex flex-col gap-3 pt-2">
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit() || isSubmitting}
className="inline-flex items-center justify-center h-14 sm:h-11 w-full text-sm font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] enabled:hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSubmitting ? "Submitting..." : "Submit Report"}
</button>
<button
type="button"
onClick={() => {
setCurrentPage("select");
setReportType(null);
setDetails("");
setEmail("");
setIsImpersonating(false);
setError(null);
}}
disabled={isSubmitting}
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)] disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex flex-col-reverse gap-2 pt-1 sm:flex-row">
<button type="button" onClick={reset} disabled={isSubmitting} className={secondary}>
Back
</button>
<button type="button" onClick={handleSubmit} disabled={!canSubmit() || isSubmitting} className={primary}>
{isSubmitting ? "Sending…" : "Send report"}
</button>
</div>
</div>
);
};
const title = currentPage === "success" ? "Report sent" : currentPage === "details" ? REASONS.find(([t]) => t === reportType)?.[1] ?? "Report" : "Report this hack";
return (
<div className="fixed left-0 right-0 top-0 bottom-0 z-[100] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label={
currentPage === "select"
? "Select report type"
: currentPage === "success"
? "Report submitted"
: "Report details"
}
className="relative z-[101] mb-16 card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-md max-h-[85vh] overflow-y-auto w-full rounded-lg"
>
<button
type="button"
onClick={onClose}
aria-label="Close report modal"
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
<FiX size={20} />
</button>
{currentPage === "select"
? renderSelectPage()
: currentPage === "success"
? renderSuccessPage()
: renderDetailsPage()}
</div>
</div>
<Modal visible title={title} onClose={onClose}>
{currentPage === "select" ? renderSelectPage() : currentPage === "success" ? renderSuccessPage() : renderDetailsPage()}
</Modal>
);
};
const REASONS: [ReportType, string][] = [
["hateful", "Hateful content"],
["harassment", "Harassment"],
["misleading", "Misleading"],
["stolen", "My hack was stolen"],
];
export default ReportModal;

View File

@@ -1,10 +1,10 @@
"use client";
import React, { useEffect, useState } from "react";
import { FiX, FiCheck, FiCopy, FiMail, FiShare2, FiArrowLeft } from "react-icons/fi";
import React, { useState } from "react";
import { FiCheck, FiCopy, FiMail, FiShare2, FiArrowLeft } from "react-icons/fi";
import { FaXTwitter, FaReddit, FaFacebook } from "react-icons/fa6";
import { FaInfoCircle } from "react-icons/fa";
import { PiBracketsSquareBold, PiBracketsAngleBold } from "react-icons/pi";
import Modal from "@/components/Primitives/Modal";
const BANNER_IMAGE_URL = "/img/badge-dark.png";
const BANNER_IMAGE_WIDTH = 190;
@@ -24,27 +24,6 @@ const ShareModal: React.FC<ShareModalProps> = ({ title, url, author, onClose })
const [codeCopied, setCodeCopied] = useState(false);
const hasNavigatorShare = typeof navigator !== "undefined" && navigator.share;
useEffect(() => {
const html = document.documentElement;
const body = document.body;
const previousHtmlOverflow = html.style.overflow;
const previousBodyOverflow = body.style.overflow;
const previousBodyPaddingRight = body.style.paddingRight;
const scrollBarWidth = window.innerWidth - html.clientWidth;
html.style.overflow = "hidden";
body.style.overflow = "hidden";
if (scrollBarWidth > 0) {
body.style.paddingRight = `${scrollBarWidth}px`;
}
return () => {
html.style.overflow = previousHtmlOverflow;
body.style.overflow = previousBodyOverflow;
body.style.paddingRight = previousBodyPaddingRight;
};
}, []);
const copyUrl = async () => {
try {
await navigator.clipboard.writeText(url);
@@ -136,262 +115,100 @@ const ShareModal: React.FC<ShareModalProps> = ({ title, url, author, onClose })
type: string;
icon: React.ComponentType<{ size?: number; className?: string }>;
label: string;
}) => {
return (
<button
type="button"
onClick={() => handleShare(type)}
className="flex flex-col items-center gap-1.5 min-w-18 p-3 rounded-lg ring-1 ring-[var(--border)] bg-[var(--surface-2)] hover:bg-black/5 dark:hover:bg-white/10"
title={label}
>
<div className="w-12 h-12 rounded-full flex items-center justify-center bg-[var(--surface-1)] text-foreground/80">
<Icon size={26} />
</div>
<span className="text-xs font-medium text-foreground/70">
{label}
</span>
</button>
);
};
}) => (
<button
type="button"
onClick={() => handleShare(type)}
className="group flex min-w-[72px] flex-col items-center gap-1.5 rounded-card p-2 text-text-2 transition-colors hover:bg-surface-2 hover:text-text"
>
<span className="flex h-12 w-12 items-center justify-center rounded-full border border-line bg-surface-2 transition-colors group-hover:border-line-strong">
<Icon size={22} />
</span>
<span className="text-xs font-medium">{label}</span>
</button>
);
const badgePreview = (
<div className="flex justify-center rounded-card bg-well p-3">
<img src={BANNER_IMAGE_URL} width={BANNER_IMAGE_WIDTH} height={BANNER_IMAGE_HEIGHT} alt="Download now at hackdex.app" className="h-auto max-w-full rounded" />
</div>
);
const copied = (
<>
<FiCheck size={16} className="text-ready" />
<span className="text-ready">Copied</span>
</>
);
// Show code preview modal if codePreview is set
if (codePreview) {
return (
<div className="fixed left-0 right-0 top-0 bottom-0 z-[100] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label={`${codePreview.label} code preview`}
className="relative z-[101] card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-lg max-h-[90vh] overflow-y-auto w-full rounded-lg"
>
<button
type="button"
onClick={onClose}
aria-label="Close code preview"
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
<FiX size={20} />
<Modal visible title={`${codePreview.label} code`} onClose={onClose} className="max-w-lg">
<div className="flex flex-col gap-5">
<button type="button" onClick={() => setCodePreview(null)} className="-mt-2 inline-flex items-center gap-1.5 self-start text-sm font-medium text-link hover:underline hover:underline-offset-[3px]">
<FiArrowLeft size={15} /> Back
</button>
<div className="flex flex-col gap-6">
{/* Header */}
<div>
<button
type="button"
onClick={() => setCodePreview(null)}
className="inline-flex items-center gap-2 text-sm text-foreground/70 hover:text-foreground mb-3"
aria-label="Back to share options"
>
<FiArrowLeft size={16} />
<span>Back</span>
</button>
<h2 className="text-xl font-semibold">{codePreview.label} Code</h2>
<p className="mt-1 text-sm text-foreground/70">
Copy the code below to share this hack with the badge.
</p>
</div>
{/* Badge Preview */}
<div className="flex flex-col gap-2">
<p className="text-xs text-foreground/60 font-medium">Preview:</p>
<div className="flex justify-center p-3 rounded-md bg-[var(--surface-1)] border border-[var(--border)]">
<img
src={BANNER_IMAGE_URL}
width={BANNER_IMAGE_WIDTH}
height={BANNER_IMAGE_HEIGHT}
alt="Download now at hackdex.app"
className="max-w-full h-auto rounded"
/>
</div>
</div>
{/* Code Section */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-foreground/90">Code</p>
</div>
<div className="flex flex-col gap-2">
<textarea
readOnly
value={codePreview.code}
rows={3}
className="flex-1 px-3 py-2.5 rounded-md border border-[var(--border)] bg-[var(--surface-1)] text-sm text-foreground/80 font-mono resize-none focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
/>
<button
type="button"
onClick={copyCode}
className="inline-flex items-center gap-1.5 self-end mr-1 text-sm text-foreground/70 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-[var(--accent)] rounded"
title="Copy to clipboard"
aria-label="Copy code to clipboard"
>
{codeCopied ? (
<>
<FiCheck size={16} className="text-green-500" />
<span className="text-green-500">Copied</span>
</>
) : (
<>
<FiCopy size={16} />
<span>Copy</span>
</>
)}
</button>
</div>
</div>
<p className="text-sm text-text-2">Paste this where you post about the hack to show the badge.</p>
{badgePreview}
<div className="flex flex-col gap-2">
<textarea
readOnly
value={codePreview.code}
rows={3}
className="w-full resize-none rounded-control border border-line bg-surface-2 px-3 py-2.5 font-mono text-[13px] text-text-2 outline-none focus:border-line-strong focus:ring-2 focus:ring-accent/40"
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
/>
<button type="button" onClick={copyCode} className="inline-flex h-9 items-center gap-1.5 self-end rounded-control px-2.5 text-sm font-medium text-text-2 hover:bg-surface-2 hover:text-text" aria-label="Copy code to clipboard">
{codeCopied ? copied : <><FiCopy size={16} /> Copy</>}
</button>
</div>
</div>
</div>
</Modal>
);
}
return (
<div className="fixed left-0 right-0 top-0 bottom-0 z-[100] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label="Share hack"
className="relative z-[101] card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-lg max-h-[90vh] overflow-y-auto w-full rounded-lg"
>
<button
type="button"
onClick={onClose}
aria-label="Close share modal"
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
<FiX size={20} />
</button>
<div className="flex flex-col gap-6">
{/* Header */}
<div>
<h2 className="text-xl font-semibold">Share Hack</h2>
<p className="mt-1 text-sm text-foreground/70">
Choose how you want to share this hack.
</p>
</div>
{/* Share Directly Section */}
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-foreground/90">Share Directly</h3>
{/* Share Icons Row */}
<div className="flex items-center gap-3 overflow-x-auto p-1 pb-2 scroll-smooth scrollbar-thin scrollbar-color-muted"
style={{ scrollbarWidth: 'thin' }}
>
<SocialIconButton
type="facebook"
icon={FaFacebook}
label="Facebook"
/>
<SocialIconButton
type="reddit"
icon={FaReddit}
label="Reddit"
/>
<SocialIconButton
type="twitter"
icon={FaXTwitter}
label="Twitter/X"
/>
<SocialIconButton
type="email"
icon={FiMail}
label="Email"
/>
{hasNavigatorShare && (
<SocialIconButton
type="other"
icon={FiShare2}
label="Other"
/>
)}
</div>
</div>
{/* Share as Badge Section */}
<div className="flex flex-col gap-4 pt-4 border-t border-[var(--border)]">
<h3 className="text-sm font-semibold text-foreground/90">Share as Badge</h3>
{/* Badge Preview */}
<div className="flex flex-col gap-2">
<p className="text-xs text-foreground/60 font-medium">Preview:</p>
<div className="flex justify-center p-3 rounded-md bg-[var(--surface-1)] border border-[var(--border)]">
<img
src={BANNER_IMAGE_URL}
width={BANNER_IMAGE_WIDTH}
height={BANNER_IMAGE_HEIGHT}
alt="Download now at hackdex.app"
className="max-w-full h-auto rounded"
/>
</div>
</div>
{/* SEO Tip */}
<div className="flex items-start justify-center gap-2 rounded-md border border-[var(--border)]/70 bg-[var(--surface-2)]/20 px-3 py-2.5">
<FaInfoCircle size={13} className="text-foreground/80 shrink-0 self-center" />
<p className="text-xs text-foreground/60 leading-relaxed">
<span className="font-semibold text-foreground/80">Creator Tip:</span> Sharing your hack on other platforms helps boost this page&apos;s SEO to potentially outrank unauthorized mirror sites.
</p>
</div>
{/* Badge Share Buttons */}
<div className="flex items-center gap-3 overflow-x-auto p-1 pb-2 scroll-smooth scrollbar-thin scrollbar-color-muted"
style={{ scrollbarWidth: 'thin' }}
>
<SocialIconButton
type="bbcode"
icon={PiBracketsSquareBold}
label="BBCode"
/>
<SocialIconButton
type="html"
icon={PiBracketsAngleBold}
label="HTML"
/>
</div>
</div>
{/* URL Copy Section */}
<div className="pt-4 border-t border-[var(--border)]">
<div className="flex flex-col gap-2">
<p className="text-xs text-foreground/60 font-medium mb-1">Or share with link</p>
<div className="flex gap-2">
<input
type="text"
readOnly
value={url}
className="flex-1 px-3 py-2.5 rounded-md border border-[var(--border)] bg-[var(--surface-1)] text-sm text-foreground/80 focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
onClick={copyUrl}
className="inline-flex items-center justify-center w-10 h-10 rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
title="Copy to clipboard"
aria-label="Copy URL to clipboard"
>
{urlCopied ? (
<FiCheck size={18} className="text-green-500" />
) : (
<FiCopy size={18} className="text-foreground/80" />
)}
</button>
</div>
</div>
</div>
<Modal visible title="Share this hack" onClose={onClose} className="max-w-lg">
<div className="flex flex-col gap-5">
<div className="flex gap-2">
<input
type="text"
readOnly
value={url}
aria-label="Link to this hack"
className="h-10 min-w-0 flex-1 rounded-control border border-line bg-surface-2 px-3 text-sm text-text-2 outline-none focus:border-line-strong focus:ring-2 focus:ring-accent/40"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
onClick={copyUrl}
className="inline-flex h-10 flex-none items-center gap-1.5 rounded-control bg-accent-deep px-3.5 text-sm font-semibold text-white transition-colors hover:bg-accent-hover"
>
{urlCopied ? <><FiCheck size={16} /> Copied</> : <><FiCopy size={16} /> Copy link</>}
</button>
</div>
<div className="-mx-2 flex gap-1 overflow-x-auto [scrollbar-width:none]">
<SocialIconButton type="facebook" icon={FaFacebook} label="Facebook" />
<SocialIconButton type="reddit" icon={FaReddit} label="Reddit" />
<SocialIconButton type="twitter" icon={FaXTwitter} label="Twitter/X" />
<SocialIconButton type="email" icon={FiMail} label="Email" />
{hasNavigatorShare && <SocialIconButton type="other" icon={FiShare2} label="More" />}
</div>
<section className="flex flex-col gap-3 border-t border-line pt-5">
<h3 className="text-sm font-semibold">Share as a badge</h3>
{badgePreview}
<p className="text-xs text-text-3">
<span className="font-semibold text-text-2">Creator tip:</span> Linking to your hack from other sites helps this page outrank unauthorized mirrors in search.
</p>
<div className="-mx-2 flex gap-1">
<SocialIconButton type="bbcode" icon={PiBracketsSquareBold} label="BBCode" />
<SocialIconButton type="html" icon={PiBracketsAngleBold} label="HTML" />
</div>
</section>
</div>
</div>
</Modal>
);
};

View File

@@ -34,26 +34,26 @@ function Row({ id, index, url, filename, isNew, onRemove }: { id: string; index:
onRemove();
}
return (
<div ref={setNodeRef} style={style} className="rounded-md">
<div className={`h-16 flex items-center justify-between gap-3 p-2 bg-[var(--surface-2)] ring-1 ring-inset ring-[var(--border)] ${isDragging ? "opacity-60" : ""}`}>
<div ref={setNodeRef} style={style} className="rounded-control">
<div className={`h-16 flex items-center justify-between gap-3 p-2 bg-surface-2 ring-1 ring-inset ring-line ${isDragging ? "opacity-60" : ""}`}>
<div className="flex items-center gap-3">
<div className="cursor-grab select-none pr-1 text-foreground/60" title="Drag to reorder" {...attributes} {...listeners}>
<div className="cursor-grab select-none pr-1 text-text-3" title="Drag to reorder" {...attributes} {...listeners}>
<RxDragHandleDots2 size={24} />
</div>
<div className="relative h-12 w-20 overflow-hidden rounded">
<Image src={url} alt={`Cover ${index + 1}`} fill className="object-cover" unoptimized />
</div>
<div className="min-w-0">
<div className="truncate max-w-[260px] text-xs text-foreground/80">{filename}</div>
{index === 0 && <div className="text-[10px] text-emerald-400/90">Primary</div>}
{isNew && <div className="text-[10px] text-amber-400/90">New</div>}
<div className="truncate max-w-[260px] text-xs text-text-2">{filename}</div>
{index === 0 && <div className="text-[10px] text-ready">Primary</div>}
{isNew && <div className="text-[10px] text-warn">New</div>}
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleRemove}
className="inline-flex h-8 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 text-xs text-red-600 transition-colors hover:bg-black/5 dark:text-red-300 dark:hover:bg-white/10"
className="inline-flex h-8 items-center justify-center rounded-control border border-line bg-surface-2 px-2 text-xs text-error transition-colors hover:bg-surface-2"
>
{isNew ? 'Remove' : 'Delete'}
</button>

View File

@@ -60,11 +60,11 @@ export default function HackStatsCharts({ series, insights, activeTab }: HackSta
return (
<div className="space-y-6 md:space-y-0 md:grid md:grid-cols-3 md:gap-6">
{activeTab === "overview" ? (
<div className="md:col-span-3 rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="md:col-span-3 rounded-card border border-line bg-surface-2 p-3">
<div className="mb-2 text-sm font-medium">Downloads over time (last 30 days, UTC)</div>
<div className="h-[60vh] sm:h-72 max-w-full overflow-hidden">
{series.datasets.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/70">No data yet.</div>
<div className="flex h-full items-center justify-center text-sm text-text-2">No data yet.</div>
) : (
<Line data={lineData} options={lineOptions} className="!w-full !h-full block" style={{ width: "100%", height: "100%" }} />
)}
@@ -72,22 +72,22 @@ export default function HackStatsCharts({ series, insights, activeTab }: HackSta
</div>
) : (
<>
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="rounded-card border border-line bg-surface-2 p-3">
<div className="mb-2 text-sm font-medium">Latest-version adoption</div>
{insights.isNewToday ? (
<div className="text-sm text-foreground/70">New upload today—please check back tomorrow (UTC) for analytics.</div>
<div className="text-sm text-text-2">New upload today—please check back tomorrow (UTC) for analytics.</div>
) : (
<div>
<div className="text-4xl font-semibold">{Math.round(insights.adoptionRate * 100)}%</div>
<div className="mt-1 text-xs text-foreground/70">{insights.latestUniqueDevices} of {insights.totalUniqueDevices} unique devices on latest</div>
<div className="mt-1 text-xs text-text-2">{insights.latestUniqueDevices} of {insights.totalUniqueDevices} unique devices on latest</div>
</div>
)}
</div>
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="rounded-card border border-line bg-surface-2 p-3">
<div className="mb-2 text-sm font-medium">Downloads per version (all-time)</div>
<div className="h-[50vh] sm:h-64">
{insights.versionCounts.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/70">No downloads yet.</div>
<div className="flex h-full items-center justify-center text-sm text-text-2">No downloads yet.</div>
) : (
<Bar data={barData} options={{ responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }} />
)}
@@ -101,32 +101,32 @@ export default function HackStatsCharts({ series, insights, activeTab }: HackSta
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="lg:col-span-2 rounded-card border border-line bg-surface-2 p-3">
<div className="mb-2 text-sm font-medium">Downloads over time (last 30 days, UTC)</div>
<div className="h-72 max-w-full overflow-hidden">
{series.datasets.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/70">No data yet.</div>
<div className="flex h-full items-center justify-center text-sm text-text-2">No data yet.</div>
) : (
<Line data={lineData} options={lineOptions} className="!w-full !h-full block" style={{ width: "100%", height: "100%" }} />
)}
</div>
</div>
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="rounded-card border border-line bg-surface-2 p-3">
<div className="mb-2 text-sm font-medium">Latest-version adoption</div>
{insights.isNewToday ? (
<div className="text-sm text-foreground/70">New upload today—please check back tomorrow (UTC) for analytics.</div>
<div className="text-sm text-text-2">New upload today—please check back tomorrow (UTC) for analytics.</div>
) : (
<div>
<div className="text-4xl font-semibold">{Math.round(insights.adoptionRate * 100)}%</div>
<div className="mt-1 text-xs text-foreground/70">{insights.latestUniqueDevices} of {insights.totalUniqueDevices} unique devices on latest</div>
<div className="mt-1 text-xs text-text-2">{insights.latestUniqueDevices} of {insights.totalUniqueDevices} unique devices on latest</div>
</div>
)}
</div>
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3 lg:col-span-3">
<div className="rounded-card border border-line bg-surface-2 p-3 lg:col-span-3">
<div className="mb-2 text-sm font-medium">Downloads per version (all-time)</div>
<div className="h-64">
{insights.versionCounts.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/70">No downloads yet.</div>
<div className="flex h-full items-center justify-center text-sm text-text-2">No downloads yet.</div>
) : (
<Bar data={barData} options={{ responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }} />
)}

View File

@@ -27,21 +27,21 @@ export default function HackStatsClient({
<div className="mx-auto max-w-screen-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Stats: {title}</h1>
<p className="mt-2 text-[15px] text-foreground/80">Analytics update daily at 00:00 UTC. Today&apos;s data appears tomorrow.</p>
<h1 className="font-display text-[28px] leading-[1.1] md:text-[32px]">Stats: {title}</h1>
<p className="mt-2 text-[15px] text-text-2">Analytics update daily at 00:00 UTC. Today&apos;s data appears tomorrow.</p>
</div>
<Link href={`/hack/${slug}`} className="inline-flex h-10 items-center rounded-md px-4 text-sm ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]">Back to hack</Link>
<Link href={`/hack/${slug}`} className="inline-flex h-10 items-center rounded-control px-4 text-sm ring-1 ring-line hover:bg-surface-2">Back to hack</Link>
</div>
{/* Mobile segmented control */}
{isMobile && (
<div className="mt-4" role="tablist" aria-label="Stats tabs">
<div className="inline-flex rounded-md ring-1 ring-[var(--border)] p-0.5">
<div className="inline-flex rounded-control ring-1 ring-line p-0.5">
<button
role="tab"
aria-selected={activeTab === "overview"}
onClick={() => setActiveTab("overview")}
className={`px-3 py-1.5 text-sm rounded ${activeTab === "overview" ? "bg-[var(--surface-2)]" : "text-foreground/70"}`}
className={`px-3 py-1.5 text-sm rounded ${activeTab === "overview" ? "bg-surface-2" : "text-text-2"}`}
>
Overview
</button>
@@ -49,7 +49,7 @@ export default function HackStatsClient({
role="tab"
aria-selected={activeTab === "versions"}
onClick={() => setActiveTab("versions")}
className={`px-3 py-1.5 text-sm rounded ${activeTab === "versions" ? "bg-[var(--surface-2)]" : "text-foreground/70"}`}
className={`px-3 py-1.5 text-sm rounded ${activeTab === "versions" ? "bg-surface-2" : "text-text-2"}`}
>
Versions
</button>

View File

@@ -1,407 +0,0 @@
"use client";
import React from "react";
import Link from "next/link";
import { FiChevronDown, FiX } from "react-icons/fi";
import { platformAccept } from "@/utils/idb";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import type { Platform } from "@/data/baseRoms";
import HackOnboardingGate from "@/components/Hack/Onboarding/HackOnboardingGate";
type OnboardingTarget = "version" | "selectRom" | "agree";
interface StickyActionBarProps {
title: string;
version?: string;
selectablePatches?: { id: number; version: string }[];
selectedPatchId?: number | null;
onVersionChange?: (patchId: number) => void;
author: string;
filename: string | null;
baseRomName?: string | null;
baseRomPlatform?: Platform;
onPatch: () => void;
status: "idle" | "ready" | "patching" | "done" | "downloading";
error: string | null;
isLinked: boolean;
romReady: boolean;
onClickLink: () => void;
supported: boolean;
onUploadChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
termsAgreed: boolean;
isVerifyingRom?: boolean;
patchProgress?: number | null;
onVersionPickerOpenChange?: (open: boolean) => void;
/** Control the onboarding is pointing at. Dims the bar around it and lights it up. */
onboardingHighlight?: OnboardingTarget | null;
onboardingDimBar?: boolean;
/** The beacon rides along with `onboardingHighlight`; set false to suppress it. */
onboardingBeacon?: boolean;
/** Renders the onboarding opt-in gate when both gate props are supplied. */
onboardingGateLabel?: string | null;
onOnboardingGateClick?: () => void;
}
export default function StickyActionBar({
title,
version,
selectablePatches = [],
selectedPatchId,
onVersionChange,
author,
filename,
baseRomName,
baseRomPlatform,
onPatch,
status,
error,
isLinked,
romReady,
onClickLink,
supported,
onUploadChange,
termsAgreed,
isVerifyingRom = false,
patchProgress = null,
onVersionPickerOpenChange,
onboardingHighlight = null,
onboardingDimBar = false,
onboardingBeacon = true,
onboardingGateLabel = null,
onOnboardingGateClick,
}: StickyActionBarProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
const { loading: baseRomsLoading } = useBaseRoms();
const uploadInputRef = React.useRef<HTMLInputElement | null>(null);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [showError, setShowError] = React.useState(false);
const [patchAgainReady, setPatchAgainReady] = React.useState(true);
const [versionPickerOpen, setVersionPickerOpen] = React.useState(false);
const hasVersionPicker = selectablePatches.length > 1 && !!onVersionChange;
const onboardingActive = onboardingDimBar || onboardingHighlight !== null;
// Reported through a ref so an inline parent callback cannot loop the effect.
const versionPickerOpenChange = React.useRef(onVersionPickerOpenChange);
React.useEffect(() => {
versionPickerOpenChange.current = onVersionPickerOpenChange;
}, [onVersionPickerOpenChange]);
React.useEffect(() => {
versionPickerOpenChange.current?.(versionPickerOpen);
}, [versionPickerOpen]);
// Lifts the target above the dim layer and rings it with the spotlight.
const isSpotlight = (target: OnboardingTarget) => onboardingHighlight === target;
const spotlight = (target: OnboardingTarget) =>
isSpotlight(target)
? " relative z-[2] outline-[3px] outline-offset-2 outline-[color-mix(in_oklab,var(--accent)_45%,transparent)]"
: "";
const spotlightAttr = (target: OnboardingTarget) =>
isSpotlight(target) || undefined;
// Keep error mounted to allow fade-out when error becomes null
React.useEffect(() => {
let timeoutId: number | undefined;
if (error) {
setErrorMessage(error);
// next frame to ensure transition runs
requestAnimationFrame(() => setShowError(true));
} else if (errorMessage !== null) {
setShowError(false);
timeoutId = window.setTimeout(() => setErrorMessage(null), 300);
} else {
setShowError(false);
}
return () => {
if (timeoutId) window.clearTimeout(timeoutId);
};
}, [error, errorMessage]);
React.useEffect(() => {
if (status === "done") {
setPatchAgainReady(false);
setTimeout(() => {
setPatchAgainReady(true);
}, 3000);
} else {
setPatchAgainReady(true);
}
}, [status]);
const handleVersionSelect = (patchId: number, closeAfterSelect: boolean) => {
onVersionChange?.(patchId);
if (closeAfterSelect) {
setVersionPickerOpen(false);
}
};
// Mystery Dungeon-length titles (e.g. "Explorers of Sky") stay readable at text-sm.
const compactSelectRomLabel = (baseRomName?.length ?? 0) >= 36;
return (
<div
className={`fixed inset-x-0 bottom-0 md:sticky md:top-18 flex flex-col gap-2 pb-safe ${
// Raised above the coach scrim so the lit control stays reachable.
onboardingActive ? "z-[60] md:z-[60]" : "z-40 md:z-30"
}`}
>
{/* The error banner occupies the same edge as the gate, so it wins. */}
{onboardingGateLabel && onOnboardingGateClick && errorMessage === null && (
<HackOnboardingGate label={onboardingGateLabel} onClick={onOnboardingGateClick} />
)}
<div data-hack-action-bar className="relative mx-auto w-full lg:max-w-screen-lg flex flex-col md:flex-row md:items-center md:justify-between md:gap-4 rounded-t-xl md:rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 px-4 py-3 pb-[env(safe-area-inset-bottom)] md:pb-3 shadow-[0_-6px_24px_rgba(0,0,0,0.2)] md:shadow-none backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_90%,transparent)] md:supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)]">
{onboardingDimBar && (
<div
aria-hidden
className="absolute inset-0 z-[1] rounded-t-xl md:rounded-md bg-[#171717]/25 dark:bg-black/55"
/>
)}
<div className="md:w-fit md:max-w-[40%] lg:max-w-[45%]">
<div className="flex items-center gap-2">
<div className="truncate text-xl font-bold md:text-sm md:font-medium">{title}</div>
{hasVersionPicker ? (
<button
type="button"
aria-label="Patch version"
aria-haspopup="dialog"
aria-expanded={versionPickerOpen}
data-onboarding-spotlight={spotlightAttr("version")}
onClick={() => setVersionPickerOpen((open) => !open)}
className={`relative shrink-0 max-w-44 ml-auto md:ml-0 inline-flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1 text-[11px] font-semibold text-foreground/90 shadow-sm cursor-pointer focus:outline-none md:hover:bg-[var(--surface-3)] md:focus:ring-2 md:focus:ring-[var(--accent)]${spotlight("version")}`}
>
<span className="truncate">{version}</span>
{versionPickerOpen ? (
<FiX size={13} className="shrink-0 text-foreground/65" aria-hidden />
) : (
<FiChevronDown size={13} className="shrink-0 text-foreground/65" aria-hidden />
)}
{onboardingBeacon && onboardingHighlight === "version" && (
<OnboardingBeacon className="-top-[3px] -right-[3px]" />
)}
</button>
) : version && (
<span className="shrink-0 rounded-full bg-[var(--surface-2)] ml-auto md:ml-0 px-2 py-0.5 text-[11px] font-medium text-foreground/85 ring-1 ring-[var(--border)]">{version}</span>
)}
</div>
<div className="truncate text-sm md:text-xs text-foreground/60">By {author}</div>
</div>
{hasVersionPicker && versionPickerOpen && (
<div className="relative z-[2] md:hidden mt-3 mb-4 animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="mb-2">
<div className="text-xs font-semibold uppercase tracking-wide text-foreground/55">Select version</div>
</div>
<VersionRadioList
patches={selectablePatches}
selectedPatchId={selectedPatchId}
onSelect={(patchId) => handleVersionSelect(patchId, true)}
/>
</div>
)}
<div className={`${hasVersionPicker && versionPickerOpen ? "hidden md:flex" : "flex"} w-full min-w-0 md:flex-1 md:justify-end flex-col md:flex-row items-stretch md:items-center gap-2 mb-4 md:mb-0`}>
{!termsAgreed || status === "downloading" ? (
baseRomsLoading ? (
<p className="rounded-full mx-auto md:mx-0 px-2 py-6 md:py-0.5 text-base text-center md:text-right md:mr-1 md:text-balance font-bold">
Loading base ROMs…
</p>
) : !romReady ? (
<p className="rounded-full mx-auto md:mx-0 px-2 py-0.5 text-xs text-center md:text-right md:text-balance">
To patch this hack, you must select a <span className="font-bold">clean ROM</span> for the patcher to use.
</p>
) : (
<p className="rounded-full mx-auto md:mx-0 px-2 py-0.5 text-xs text-center md:text-right md:text-balance">
By patching, you agree to the <Link href="/terms" prefetch={false} target="_blank" className="underline">Terms of Service</Link>.
</p>
)
) : (
<span className={`rounded-full mx-auto md:mx-0 px-2 py-0.5 text-xs ring-1 transition-opacity duration-300 ${
romReady
? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90"
: isLinked
? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90"
: "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"
}`}>
{romReady ? (filename ?? "patch file ready") : isLinked ? "Permission needed" : "Base ROM needed"}
</span>
)}
{!baseRomsLoading && !romReady && !isLinked && (
<label className="flex w-full min-w-0 max-w-full md:w-auto items-center justify-center md:justify-end">
<input
ref={uploadInputRef}
type="file"
accept={platformAccept(baseRomPlatform)}
onChange={onUploadChange}
disabled={isVerifyingRom}
className="hidden"
/>
{/* The button clips its own overflow for the shine sweep, so the
beacon rides on this wrapper instead. */}
<span className="relative inline-flex w-5/6 max-w-full min-w-0 mx-auto md:w-auto md:mx-0">
<button
type="button"
onClick={() => uploadInputRef.current?.click()}
disabled={isVerifyingRom}
data-onboarding-spotlight={spotlightAttr("selectRom")}
className={`shine-wrap btn-premium min-h-11 md:min-h-9 h-auto py-2 w-full max-w-full md:w-auto md:min-w-34 text-balance text-center font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70${spotlight("selectRom")} ${
compactSelectRomLabel ? "text-sm" : "text-base md:text-sm"
}`}
>
{isVerifyingRom ? (
<span>Verifying…</span>
) : baseRomName ? (
<span className="min-w-0 w-full">Select <span className="font-bold">{baseRomName}</span> ROM</span>
) : baseRomPlatform ? (
<span className="min-w-0 w-full">Select <span className="font-bold">{baseRomPlatform}</span> ROM</span>
) : (
<span>Select Base ROM</span>
)}
</button>
{onboardingBeacon && onboardingHighlight === "selectRom" && (
<OnboardingBeacon large className="-top-[3px] right-[2px]" />
)}
</span>
</label>
)}
{!baseRomsLoading && !romReady && isLinked && (
<button
type="button"
onClick={onClickLink}
disabled={!supported}
data-onboarding-spotlight={spotlightAttr("selectRom")}
className={`relative w-5/6 md:w-auto rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm md:text-xs cursor-pointer hover:bg-[var(--surface-3)] hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60${spotlight("selectRom")}`}
>
Grant permission
{onboardingBeacon && onboardingHighlight === "selectRom" && (
<OnboardingBeacon large className="-top-[3px] -right-[3px]" />
)}
</button>
)}
{/* Wrapper owns the layout slot so the beacon can escape the button's
overflow clip. Hiding it here keeps the flex gap collapsed. */}
<span
data-ready={romReady}
className={`relative inline-flex data-[ready=false]:hidden! w-full md:w-auto ${romReady && status !== 'downloading' && status !== 'ready' && termsAgreed ? "mt-6 md:mt-0" : ""}`}
>
<button
onClick={onPatch}
data-onboarding-spotlight={spotlightAttr("agree")}
disabled={!mounted || !romReady || (status !== "ready" && status !== "done" && status !== "idle") || !patchAgainReady}
className={`shine-wrap btn-premium h-11 md:h-9 w-full md:min-w-46 ${!termsAgreed || status === 'downloading' ? "md:w-32" : "md:w-auto"} text-base md:text-sm font-semibold cursor-pointer disabled:cursor-not-allowed disabled:opacity-70${spotlight("agree")}`}
>
<span>{
status === "patching" ? (
patchProgress != null && patchProgress > 0
? `Patching… (${(patchProgress / (1024 * 1024)).toFixed(0)} MB)`
: "Patching…"
) :
status === "downloading" ? "Downloading…" :
status === "done" ? (
patchAgainReady ? "Patch Again" : "Patched"
) : termsAgreed ? "Retry Patching" : "Agree and Patch"
}</span>
</button>
{onboardingBeacon && onboardingHighlight === "agree" && (
<OnboardingBeacon large className="-top-[3px] right-[2px]" />
)}
</span>
</div>
</div>
{hasVersionPicker && versionPickerOpen && (
<div className="fixed left-0 right-0 top-0 bottom-0 z-[100] hidden md:flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm"
onClick={() => setVersionPickerOpen(false)}
/>
<div
role="dialog"
aria-modal="true"
aria-label="Select which version to download"
className="relative z-[101] card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-md w-full rounded-lg"
>
<button
type="button"
onClick={() => setVersionPickerOpen(false)}
aria-label="Close modal"
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
×
</button>
<h2 className="text-xl font-semibold mb-4 pr-8">Select which version to download</h2>
<VersionRadioList
patches={selectablePatches}
selectedPatchId={selectedPatchId}
onSelect={(patchId) => handleVersionSelect(patchId, false)}
/>
</div>
</div>
)}
{errorMessage !== null && (
<div
className={`absolute inset-x-0 md:left-1/2 md:-translate-x-1/2 md:mt-4 mb-2 md:mx-auto flex flex-col w-full md:w-auto lg:max-w-screen-lg rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)] text-sm text-red-400 transition-all duration-300 ${showError ? "opacity-100 -translate-y-full md:translate-y-full" : "opacity-0 translate-y-0 md:-translate-y-1/2 pointer-events-none"}`}
role="alert"
aria-live="polite"
>
<p className="mb-2 text-center font-bold">{errorMessage}</p>
<p className="text-xs">If the issue persists, try clearing your browser cache or using a different browser.</p>
</div>
)}
</div>
);
}
/** Rose onboarding beacon with a reduced-motion-safe halo. */
function OnboardingBeacon({ className, large = false }: { className: string; large?: boolean }) {
return (
<span
aria-hidden
className={`onboarding-beacon ${large ? "onboarding-beacon-large" : ""} pointer-events-none absolute! block overflow-visible ${className}`}
/>
);
}
function VersionRadioList({
patches,
selectedPatchId,
onSelect,
}: {
patches: { id: number; version: string }[];
selectedPatchId?: number | null;
onSelect: (patchId: number) => void;
}) {
return (
<div
role="radiogroup"
aria-label="Patch version"
className="overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface-2)]"
>
{patches.map((patch, index) => {
const selected = selectedPatchId === patch.id;
return (
<button
key={patch.id}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onSelect(patch.id)}
className={`flex w-full items-center justify-between gap-3 px-3 py-3 text-left text-sm transition-colors md:py-2.5 ${
selected
? "bg-[var(--accent)]/10 text-foreground"
: "text-foreground/75 hover:bg-[var(--surface-3)]"
} ${index > 0 ? "border-t border-[var(--border)]" : ""}`}
>
<span className="font-medium">{patch.version}</span>
<span
className={`h-3.5 w-3.5 rounded-full border-2 flex items-center justify-center ${
selected ? "border-[var(--accent)]" : "border-[var(--border)]"
}`}
aria-hidden
>
{selected && <span className="h-1.5 w-1.5 rounded-full bg-[var(--accent)]" />}
</span>
</button>
);
})}
</div>
);
}

View File

@@ -29,6 +29,10 @@ import { useBaseRoms } from "@/contexts/BaseRomContext";
import { patchFormatFromFilename } from "@/utils/patching";
import { encodeXdelta, trialDecodeXdelta, friendlyXdeltaError } from "@/utils/patching/xdelta";
/** Solid status buttons: the paired on-color keeps text readable in both themes; hover mixes toward the text color. */
export const READY_BUTTON = "bg-ready text-on-ready transition-colors hover:enabled:bg-[color-mix(in_srgb,var(--ready)_85%,var(--text))]";
const ERROR_BUTTON = "bg-error text-on-error transition-colors hover:enabled:bg-[color-mix(in_srgb,var(--error)_85%,var(--text))]";
interface Patch {
id: number;
version: string;
@@ -372,11 +376,7 @@ export default function VersionActions({
setActionLoading(true);
setReuploadError(null);
try {
const safeVersion = patch.version.replace(/[^a-zA-Z0-9._-]+/g, "-");
const patchExt = patchFormatFromFilename(reuploadFile.name) === "xdelta" ? "xdelta" : "bps";
const objectKey = `${hackSlug}-${safeVersion}-reupload-${Date.now()}.${patchExt}`;
const presignResult = await reuploadPatchVersion(hackSlug, patch.id, objectKey);
const presignResult = await reuploadPatchVersion(hackSlug, patch.id, patchFormatFromFilename(reuploadFile.name));
if (!presignResult.ok) {
throw new Error(presignResult.error || "Failed to get upload URL");
}
@@ -393,7 +393,7 @@ export default function VersionActions({
}
// Confirm upload
const confirmResult = await confirmReuploadPatchVersion(hackSlug, patch.id, objectKey);
const confirmResult = await confirmReuploadPatchVersion(hackSlug, patch.id, presignResult.objectKey);
if (confirmResult.ok) {
setShowReuploadModal(false);
setReuploadFile(null);
@@ -418,7 +418,7 @@ export default function VersionActions({
<div className="hidden sm:flex flex-wrap gap-1.5">
<button
onClick={handleDownload}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs font-medium hover:bg-[var(--surface-3)] transition-colors"
className="inline-flex items-center gap-1.5 rounded-control border border-line bg-surface-2 px-2 py-1 text-xs font-medium hover:bg-surface-3 transition-colors"
title="Download"
>
<FaDownload size={12} />
@@ -427,7 +427,7 @@ export default function VersionActions({
<button
onClick={() => setShowRestoreModal(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-emerald-600/50 bg-emerald-600/10 px-2 py-1 text-xs font-medium text-emerald-600 hover:bg-emerald-600/20 transition-colors"
className="inline-flex items-center gap-1.5 rounded-control border border-ready/40 bg-ready-soft px-2 py-1 text-xs font-medium text-ready hover:border-ready transition-colors"
title="Restore version"
>
<FaRotateLeft size={12} />
@@ -440,19 +440,19 @@ export default function VersionActions({
<MenuButton
id={`version-actions-menu-archived-${patch.id}`}
aria-label="Version actions"
className="inline-flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
className="inline-flex h-8 w-8 items-center justify-center rounded-control ring-1 ring-line bg-surface-2 text-text-2 hover:bg-surface-3 hover:text-text transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-line"
>
<FiMoreVertical size={16} />
</MenuButton>
<MenuItems
<MenuItems modal={false}
transition
className="absolute right-0 z-10 mt-2 w-48 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
className="absolute right-0 z-10 mt-2 w-48 origin-top-right overflow-hidden rounded-control border border-line bg-surface shadow-overlay focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem
as="button"
onClick={handleDownload}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-surface-2"
>
<FaDownload size={14} />
Download
@@ -461,7 +461,7 @@ export default function VersionActions({
<MenuItem
as="button"
onClick={() => setShowRestoreModal(true)}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-emerald-600 data-focus:bg-emerald-600/10"
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-ready data-focus:bg-ready-soft"
>
<FaRotateLeft size={14} />
Restore
@@ -475,21 +475,21 @@ export default function VersionActions({
visible={showRestoreModal}
onClose={() => !actionLoading && setShowRestoreModal(false)}
>
<p className="text-foreground/80 mb-4">
<p className="text-text-2 mb-4">
Restore version <strong>{patch.version}</strong>? This will make it visible again.
</p>
<div className="flex gap-2">
<button
onClick={handleRestore}
disabled={actionLoading}
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
className={`flex-1 rounded-control px-4 py-2 text-sm font-medium ${READY_BUTTON} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{actionLoading ? "Restoring..." : "Restore"}
</button>
<button
onClick={() => setShowRestoreModal(false)}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
className="flex-1 rounded-control border border-line bg-surface-2 px-4 py-2 text-sm font-medium hover:bg-surface-3 disabled:opacity-50"
>
Cancel
</button>
@@ -507,7 +507,7 @@ export default function VersionActions({
{!patch.published && (
<button
onClick={() => setShowPublishModal(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-emerald-600/50 bg-emerald-600/10 px-2 py-1 text-xs font-medium text-emerald-600 hover:bg-emerald-600/20 transition-colors"
className="inline-flex items-center gap-1.5 rounded-control border border-ready/40 bg-ready-soft px-2 py-1 text-xs font-medium text-ready hover:border-ready transition-colors"
title="Publish"
>
<FaCheck size={12} />
@@ -517,7 +517,7 @@ export default function VersionActions({
<button
onClick={handleDownload}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs font-medium hover:bg-[var(--surface-3)] transition-colors"
className="inline-flex items-center gap-1.5 rounded-control border border-line bg-surface-2 px-2 py-1 text-xs font-medium hover:bg-surface-3 transition-colors"
title="Download"
>
<FaDownload size={12} />
@@ -526,7 +526,7 @@ export default function VersionActions({
<button
onClick={() => setShowReuploadModal(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs font-medium hover:bg-[var(--surface-3)] transition-colors"
className="inline-flex items-center gap-1.5 rounded-control border border-line bg-surface-2 px-2 py-1 text-xs font-medium hover:bg-surface-3 transition-colors"
title="Re-upload patch file"
>
<FaUpload size={12} />
@@ -536,7 +536,7 @@ export default function VersionActions({
{shouldShowRollback && (
<button
onClick={() => setShowRollbackModal(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs font-medium hover:bg-[var(--surface-3)] transition-colors"
className="inline-flex items-center gap-1.5 rounded-control border border-line bg-surface-2 px-2 py-1 text-xs font-medium hover:bg-surface-3 transition-colors"
title="Rollback to this version"
>
<FaRotateLeft size={12} />
@@ -547,7 +547,7 @@ export default function VersionActions({
{!isCurrent && (
<button
onClick={() => setShowDeleteModal(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-red-600/50 bg-red-600/10 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-600/20 transition-colors"
className="inline-flex items-center gap-1.5 rounded-control border border-error/40 bg-error-soft px-2 py-1 text-xs font-medium text-error hover:border-error transition-colors"
title="Archive version"
>
<FaTrash size={12} />
@@ -561,20 +561,20 @@ export default function VersionActions({
<MenuButton
id={`version-actions-menu-active-${patch.id}`}
aria-label="Version actions"
className="inline-flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
className="inline-flex h-8 w-8 items-center justify-center rounded-control ring-1 ring-line bg-surface-2 text-text-2 hover:bg-surface-3 hover:text-text transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-line"
>
<FiMoreVertical size={16} />
</MenuButton>
<MenuItems
<MenuItems modal={false}
transition
className="absolute right-0 z-10 mt-2 w-48 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
className="absolute right-0 z-10 mt-2 w-48 origin-top-right overflow-hidden rounded-control border border-line bg-surface shadow-overlay focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
{!patch.published && (
<MenuItem
as="button"
onClick={() => setShowPublishModal(true)}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-emerald-600 data-focus:bg-emerald-600/10"
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-ready data-focus:bg-ready-soft"
>
<FaCheck size={14} />
Publish
@@ -584,7 +584,7 @@ export default function VersionActions({
<MenuItem
as="button"
onClick={handleDownload}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-surface-2"
>
<FaDownload size={14} />
Download
@@ -593,7 +593,7 @@ export default function VersionActions({
<MenuItem
as="button"
onClick={() => setShowReuploadModal(true)}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-surface-2"
>
<FaUpload size={14} />
Re-upload
@@ -606,7 +606,7 @@ export default function VersionActions({
<MenuItem
as="button"
onClick={() => setShowRollbackModal(true)}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-surface-2"
>
<FaRotateLeft size={14} />
Rollback
@@ -617,7 +617,7 @@ export default function VersionActions({
<MenuItem
as="button"
onClick={() => setShowDeleteModal(true)}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-red-600 data-focus:bg-red-600/10"
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-error data-focus:bg-error-soft"
>
<FaTrash size={14} />
Archive
@@ -635,16 +635,16 @@ export default function VersionActions({
onClose={() => !actionLoading && setShowDeleteModal(false)}
>
{archiveWouldRemoveLastCustomPatch ? (
<p className="text-foreground/80 mb-4">
<p className="text-text-2 mb-4">
Version <strong>{patch.version}</strong> is one of the last 2 versions in the <strong>Custom</strong> patcher list. Switch to <strong>Latest published patch</strong> or add another Custom version before archiving it.
</p>
) : (
<>
<p className="text-foreground/80 mb-4">
<p className="text-text-2 mb-4">
Are you sure you want to archive version <strong>{patch.version}</strong>? This will hide it from public view, but it can be restored later.
</p>
{isInCustomPatcherList && (
<p className="text-sm text-foreground/60 mb-4">
<p className="text-sm text-text-3 mb-4">
This version will also be removed from the Custom patcher list.
</p>
)}
@@ -655,7 +655,7 @@ export default function VersionActions({
<button
onClick={handleDelete}
disabled={actionLoading}
className="flex-1 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
className={`flex-1 rounded-control px-4 py-2 text-sm font-medium ${ERROR_BUTTON} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{actionLoading ? "Archiving..." : "Archive"}
</button>
@@ -663,7 +663,7 @@ export default function VersionActions({
<button
onClick={() => setShowDeleteModal(false)}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
className="flex-1 rounded-control border border-line bg-surface-2 px-4 py-2 text-sm font-medium hover:bg-surface-3 disabled:opacity-50"
>
Cancel
</button>
@@ -676,21 +676,21 @@ export default function VersionActions({
visible={showRollbackModal}
onClose={() => !actionLoading && setShowRollbackModal(false)}
>
<p className="text-foreground/80 mb-4">
<p className="text-text-2 mb-4">
Rollback to version <strong>{patch.version}</strong>? This will set this version as the current patch and unpublish all newer versions.
</p>
<div className="flex gap-2">
<button
onClick={handleRollback}
disabled={actionLoading}
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
className={`flex-1 rounded-control px-4 py-2 text-sm font-medium ${READY_BUTTON} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{actionLoading ? "Rolling back..." : "Rollback"}
</button>
<button
onClick={() => setShowRollbackModal(false)}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
className="flex-1 rounded-control border border-line bg-surface-2 px-4 py-2 text-sm font-medium hover:bg-surface-3 disabled:opacity-50"
>
Cancel
</button>
@@ -703,10 +703,10 @@ export default function VersionActions({
visible={showPublishModal}
onClose={() => !actionLoading && setShowPublishModal(false)}
>
<p className="text-foreground/80 mb-4">
<p className="text-text-2 mb-4">
Publish version <strong>{patch.version}</strong>? This will make it viewable to the public along with its changelog.
</p>
<p className="text-sm text-foreground/60 mb-4">
<p className="text-sm text-text-3 mb-4">
{isCustomPatcherActive
? "This will not add the version to the Custom patcher list. Add it through Patcher Version Settings if you want it available in the homepage downloader."
: "If this version is newer than the current patch, it will become the primary download used for all users."}
@@ -715,14 +715,14 @@ export default function VersionActions({
<button
onClick={handlePublish}
disabled={actionLoading}
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
className={`flex-1 rounded-control px-4 py-2 text-sm font-medium ${READY_BUTTON} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{actionLoading ? "Publishing..." : "Publish"}
</button>
<button
onClick={() => setShowPublishModal(false)}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
className="flex-1 rounded-control border border-line bg-surface-2 px-4 py-2 text-sm font-medium hover:bg-surface-3 disabled:opacity-50"
>
Cancel
</button>
@@ -749,7 +749,7 @@ export default function VersionActions({
>
<div className="mb-4">
<label className="block text-sm font-medium mb-2">
Provide patch <span className="text-red-500">*</span>
Provide patch <span className="text-error">*</span>
</label>
<div className="flex flex-col gap-3">
{patchMode === "bps" && (
@@ -759,20 +759,20 @@ export default function VersionActions({
onChange={onUploadPatch}
type="file"
accept=".bps,.xdelta"
className="cursor-pointer rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm italic text-foreground/50 ring-1 ring-inset ring-[var(--border)] file:bg-black/10 dark:file:bg-[var(--surface-2)] file:text-foreground/80 file:text-sm file:font-medium file:not-italic file:rounded-md file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer"
className="cursor-pointer rounded-control bg-surface-2 px-3 py-2 text-sm italic text-text-3 ring-1 ring-inset ring-line file:bg-black/10 dark:file:bg-surface-2 file:text-text-2 file:text-sm file:font-medium file:not-italic file:rounded-control file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer"
/>
<p className="flex items-center gap-1.5 text-xs text-foreground/60">
<FiAlertTriangle className="h-3 w-3 shrink-0 text-amber-600 dark:text-amber-400" />
<p className="flex items-center gap-1.5 text-xs text-text-3">
<FiAlertTriangle className="h-3 w-3 shrink-0 text-warn" />
<span>Patch file upload is a fallback. Hackdex cannot always guarantee that an uploaded patch is compatible with the chosen base ROM. Auto-generating from a modified ROM is recommended.</span>
</p>
{checksumStatus === "validating" && <div className="text-xs text-foreground/70">Validating checksum…</div>}
{checksumStatus === "valid" && <div className="text-xs text-emerald-400/90">Checksum valid.</div>}
{checksumStatus === "invalid" && !!checksumError && <div className="text-xs text-red-400">{checksumError}</div>}
{checksumStatus === "unknown" && !!checksumError && <div className="text-xs text-amber-400/90">{checksumError}</div>}
{checksumStatus === "validating" && <div className="text-xs text-text-2">Validating checksum…</div>}
{checksumStatus === "valid" && <div className="text-xs text-ready">Checksum valid.</div>}
{checksumStatus === "invalid" && !!checksumError && <div className="text-xs text-error">{checksumError}</div>}
{checksumStatus === "unknown" && !!checksumError && <div className="text-xs text-warn">{checksumError}</div>}
<button
type="button"
onClick={() => setPatchMode("rom")}
className="w-fit cursor-pointer text-xs text-foreground/60 underline underline-offset-2 transition-colors hover:text-foreground/80"
className="w-fit cursor-pointer text-xs text-text-3 underline underline-offset-2 transition-colors hover:text-text-2"
>
Generate from a modified ROM instead (Recommended)
</button>
@@ -780,52 +780,52 @@ export default function VersionActions({
)}
{patchMode === "rom" && (
<div className="grid gap-3">
<div className="rounded-md border border-[var(--border)] p-3 bg-[var(--surface-2)]/50">
<div className="text-xs text-foreground/75">Required base ROM</div>
<div className="grid min-w-0 gap-3">
<div className="rounded-control border border-line p-3 bg-surface-2/50">
<div className="text-xs text-text-2">Required base ROM</div>
<div className="mt-1 text-sm font-medium">{baseRomEntry ? `${baseRomEntry.name} (${baseRomEntry.platform})` : "Unknown base ROM"}</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className={`rounded-full px-2 py-0.5 ring-1 ${baseRomReady ? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90" : baseRomNeedsPermission ? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90" : "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"}`}>
<span className={`rounded-full px-2 py-0.5 ring-1 ${baseRomReady ? "bg-ready-soft text-ready ring-ready/40" : baseRomNeedsPermission ? "bg-warn-soft text-warn ring-warn/40" : "bg-error-soft text-error ring-error/40"}`}>
{baseRomReady ? "Ready" : baseRomNeedsPermission ? "Permission needed" : "Base ROM needed"}
</span>
{baseRomNeedsPermission && (
<button type="button" onClick={onGrantPermission} disabled={!supported} className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 disabled:opacity-60 disabled:cursor-not-allowed">Grant permission</button>
<button type="button" onClick={onGrantPermission} disabled={!supported} className="rounded-control border border-line bg-surface-2 px-2 py-1 disabled:opacity-60 disabled:cursor-not-allowed">Grant permission</button>
)}
{baseRomMissing && (
<label className="inline-flex items-center gap-2 text-xs text-foreground/80">
<label className="flex basis-full flex-col gap-1.5 text-xs text-text-2">
<span>Upload base ROM</span>
<input
ref={baseRomInputRef}
type="file"
onChange={onUploadBaseRom}
accept={baseRomPlatform ? platformAccept(baseRomPlatform) : "*/*"}
className="cursor-pointer rounded-md bg-[var(--surface-2)] px-2 py-1 text-xs italic text-foreground/50 ring-1 ring-inset ring-[var(--border)] file:bg-black/10 dark:file:bg-[var(--surface-2)] file:text-foreground/80 file:text-xs file:font-medium file:not-italic file:rounded-md file:border-0 file:px-2 file:py-1 file:mr-2 file:cursor-pointer"
className="w-full min-w-0 cursor-pointer rounded-control bg-surface-2 px-2 py-1 text-xs italic text-text-3 ring-1 ring-inset ring-line file:bg-black/10 dark:file:bg-surface-2 file:text-text-2 file:text-xs file:font-medium file:not-italic file:rounded-control file:border-0 file:px-2 file:py-1 file:mr-2 file:cursor-pointer"
/>
<span>Upload base ROM</span>
</label>
)}
</div>
{!!genError && <div className="mt-2 text-xs text-red-400">{genError}</div>}
{!!genError && <div className="mt-2 text-xs text-error">{genError}</div>}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Modified ROM <span className="text-foreground/60">(Recommended)</span></label>
<div className="grid min-w-0 gap-2">
<label className="text-sm text-text-2">Modified ROM <span className="text-text-3">(Recommended)</span></label>
<input
ref={modifiedRomInputRef}
type="file"
accept={baseRomPlatform ? platformAccept(baseRomPlatform) : "*/*"}
disabled={!baseRomEntry || !baseRomReady || !baseRomPlatform}
onChange={onUploadModifiedRom}
className="cursor-pointer rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm italic text-foreground/50 ring-1 ring-inset ring-[var(--border)] file:bg-black/10 dark:file:bg-[var(--surface-2)] file:text-foreground/80 file:text-sm file:font-medium file:not-italic file:rounded-md file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full min-w-0 cursor-pointer rounded-control bg-surface-2 px-3 py-2 text-sm italic text-text-3 ring-1 ring-inset ring-line file:bg-black/10 dark:file:bg-surface-2 file:text-text-2 file:text-sm file:font-medium file:not-italic file:rounded-control file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
/>
<p className="text-xs text-foreground/60">We'll generate a .xdelta patch on-device. No ROMs are uploaded.</p>
{genStatus === "generating" && <div className="text-xs text-foreground/70">Generating patch…</div>}
{genStatus === "ready" && reuploadFile && <div className="text-xs text-emerald-400/90">Patch ready: {reuploadFile.name}</div>}
{genStatus === "error" && !!genError && <div className="text-xs text-red-400">{genError}</div>}
<p className="text-xs text-text-3">We'll generate a .xdelta patch on-device. No ROMs are uploaded.</p>
{genStatus === "generating" && <div className="text-xs text-text-2">Generating patch…</div>}
{genStatus === "ready" && reuploadFile && <div className="text-xs text-ready">Patch ready: {reuploadFile.name}</div>}
{genStatus === "error" && !!genError && <div className="text-xs text-error">{genError}</div>}
</div>
<button
type="button"
onClick={() => setPatchMode("bps")}
className="w-fit cursor-pointer text-xs text-foreground/60 underline underline-offset-2 transition-colors hover:text-foreground/80"
className="w-fit cursor-pointer text-xs text-text-3 underline underline-offset-2 transition-colors hover:text-text-2"
>
Already have a .bps or .xdelta file?
</button>
@@ -833,14 +833,14 @@ export default function VersionActions({
)}
</div>
{reuploadError && (
<p className="mt-2 text-sm text-red-400">{reuploadError}</p>
<p className="mt-2 text-sm text-error">{reuploadError}</p>
)}
</div>
<div className="flex gap-2">
<button
onClick={handleReupload}
disabled={actionLoading || !reuploadFile || checksumStatus === "invalid" || checksumStatus === "validating"}
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
className={`flex-1 rounded-control px-4 py-2 text-sm font-medium ${READY_BUTTON} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{actionLoading ? "Uploading..." : "Upload"}
</button>
@@ -857,7 +857,7 @@ export default function VersionActions({
setPatchMode("rom");
}}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
className="flex-1 rounded-control border border-line bg-surface-2 px-4 py-2 text-sm font-medium hover:bg-surface-3 disabled:opacity-50"
>
Cancel
</button>

Some files were not shown because too many files have changed in this diff Show More