For Agents
The new txarchive-frontend — a fresh modern React SPA replacing the stock-shadcn one, talking Supabase directly (see txArchive Supabase Contract). Source: §7–§8 of
txarchive_2025/docs/txarchive-2025-unified-design.md; the as-built reference + parity checklist is the frontend spec (txarchive-frontend/docs/frontend-modernization-spec.md) — but that spec’s §8 “backend API contract” is OBSOLETE (superseded by the contract note). Parent: txArchive 2025 Rewrite.
Stack
| Concern | Choice | Replaces |
|---|---|---|
| Build | Vite 5 | the old Vite + the manual index.html SW registration |
| Framework | React 18 + TypeScript (strict) | React (loose TS) |
| Routing | TanStack Router — typed routes, nested layouts, type-safe params/search, route guards, modal routes | wouter (+ history.pushState, the ?reload=1 dance, no real 404) |
| Server state | TanStack Query v5 — cache, useInfiniteQuery, optimistic mutations | SWR + the Recoil async selectors + all three fetch wrappers (TxArchiveApiClient, useAuthFetcher, noAuthFetcher) |
| Data client | @supabase/supabase-js v2 — the one typed client | TxArchiveApiClient + the fetchers + react-use-websocket |
| UI kit | Tailwind CSS 3 + shadcn/ui (kept — modern; cn(), Radix primitives) | the stock shadcn “default” + slate scaffold (now with a real design language) |
| UI-only state | Zustand — theme, mobile-drawer open, active modal | the Recoil atoms (Recoil was a DI hack — the REST client lived in an atom) |
| PWA | vite-plugin-pwa (registerType:'autoUpdate') — manifest + SW | the old plugin + the manual SW registration |
| Drag-drop | react-dropzone (kept, for /upload) | — |
| Icons | lucide-react (one set) | react-icons (dropped) |
| Skeletons | shadcn <Skeleton> / react-loading-skeleton (actually used this time) | the imported-but-unused skeletons |
| Tooling | Prettier + ESLint + prettier-plugin-tailwindcss; Vitest (unit) + Playwright (E2E) | — |
| Package manager | pnpm or yarn (yarn-classic today; pnpm is fine) | — |
Gone from the frontend: Recoil + the Recoil-as-DI pattern · wouter · SWR + the three fetch wrappers · react-use-websocket + the custom WS interest protocol · the two comment subsystems (one dead) · the per-page <Nav> · the dangling /users link (now a real /members) · the ?reload=1 dance · setAuth-during-render · the JS-readable auth cookie · RecoilizeDebugger in prod builds · the 150-day JWT · the JSON-blob-in-redirect-query handshake · the hard-coded 800 KB react-dropzone cap (→ the env) · the conflicting brand-colour metadata (#bbf7d0 theme-color vs #ffffff PWA vs #da532c tile vs dark-slate --primary → one brand colour) · the ~21 MB of uncompressed PNGs (re-export optimized).
Directory structure (new src/)
src/
main.tsx # createRoot → QueryClientProvider > ThemeProvider > RouterProvider; <Toaster/>
routes/ # TanStack Router route tree
__root.tsx # the doc-level shell (head, Toaster)
_public/ # chrome-less: index (Landing), auth.callback, error, $404
_app/ # the authenticated layout (Nav + main + MobileDrawer)
timeline.tsx · post.$id.tsx · upload.tsx · my-uploads.tsx · approvals.tsx
members.tsx · enroll.tsx · search.tsx
yorick.tsx # its own dark shell, outside _app
lib/
supabase.ts # the client singleton + generated Database types
auth.ts # useSession(), useScopes(), requireScope() guard, the complete-login flow
cdn.ts # fileUrl(file_key), avatarUrl(profile)
queries/ # useTimeline, usePost, useComments, useMyUploads, useApprovals, useApprovalCount, useMembers, useEnrollments, useSearch
mutations/ # useToggleLike, usePostComment, useDeleteComment, useUpload, useDeleteUpload, useApprove, useDeny, useEnroll, useSetScopes, useCancelEnrollment
realtime/ # useRealtimeTimeline, useRealtimePostDetail, useRealtimeApprovals (subscribe a channel; patch the query cache)
format.ts # relative/absolute dates (user locale), formatBytes (binary)
components/
layout/ # AppShell, Nav, MobileDrawer, UserMenu, ThemeToggle, NavSearch
post/ # PostCard, PostDetail, Lightbox, ShareButton
comments/ # CommentList, CommentItem, CommentComposer
upload/ # UploadDropzone, FilePreview, UploadGrid, UploadCard, StatusPill
common/ # EmptyState, ErrorState, PageSkeleton, ScopeGate, ConfirmDialog
ui/ # shadcn primitives (only the ones used)
features/yorick/ # YorickPage, VideoModal, yorick-catalog.json
styles/index.css # Tailwind + the design tokens (:root light, .dark dark)
pwa/ # manifest source, icons (incl. the SVG favicon)
types/ # re-exports of Database row types + the view-row types from the contract
Routing
/ (logged-out → Landing; logged-in → redirect /timeline) · /auth/callback (runs complete-login, then → /timeline or → /error for not_in_guild) · /error (the friendly no_access/not_in_guild screen — the cdn.txarchive.org/no_access.png meme optional) · /timeline · /post/:id (post detail — a modal over the timeline when navigated from the feed, a full page on direct load / refresh; deep-linkable; shareable; replaces /timeline?postId=) · /upload (gate ADD) · /my-uploads (the user’s gallery) · /approvals (gate APPROVE) · /members (gate ENROLL ∨ DELETE — NEW, implements the dangling /users link) · /enroll (gate ENROLL) · /search (gate VIEW — NEW) · /yorick (any logged-in user; its own dark shell) · * → a real 404 page (not a silent redirect). Auth gating = TanStack Router beforeLoad guards: no session → redirect /; missing required scope → a “you don’t have access to this page” screen (not a redirect). The Nav also shows/hides links by scope (cosmetic) — but the routes are guarded regardless.
App shell
ONE <AppShell> rendered by the _app layout route: <Nav> (rendered once — its state lives in TanStack Query / Zustand, so it survives navigation; fixes today’s state-resets-on-navigation) + <main> + <Toaster> + <MobileDrawer>. Chrome-less routes (/, /auth/callback, /error, the 404) use a bare layout; /yorick uses its own dark layout. The <Nav>: the SVG logo/wordmark (→ /timeline); the scope-gated links (active-route highlighted properly — an underline/pill, not just font-bold); the Approvals link with a live count <Badge> when ≠ 0; a <NavSearch> (input → /search?q=); a <ThemeToggle> (light/dark/system); a <UserMenu> (Radix DropdownMenu — avatar + username → “Members” (if scoped) · “Theme” · “Log out”; click-outside + Esc close — fixing the old hand-rolled dropdown). Below lg the links collapse to a hamburger → <MobileDrawer> (Radix Dialog/Sheet — a scrim; close on backdrop / Esc / navigate; body-scroll-lock; the same links + the user-menu items inside).
Data layer
Query keys: ['timeline', { excludeOwn }] · ['post', id] · ['comments', uploadId] · ['my-uploads'] · ['approvals'] · ['approval-count'] · ['members'] · ['enrollments'] · ['search', q]. useInfiniteQuery for timeline / comments / search (page param = the .range() offset; page sizes: timeline 25, comments 50, my-uploads 10, approvals 20). Mutations are optimistic where it matters: useToggleLike (flip liked_by_me and ±1 like_count in every cached page that has the post; roll back on error); usePostComment (prepend a temp CommentRow with a client id to ['comments', uploadId] page 0; on success replace it with the returned row; the real row also arrives via Realtime — dedupe by id); useApprove/useDeny/useDeleteUpload (optimistically remove from the list; invalidate ['approval-count']; useApprove also invalidates ['timeline']). Errors → a toast (+ inline for forms). Session lost (onAuthStateChange → SIGNED_OUT, or a query error indicating an expired/invalid JWT) → clear the query cache + route to /.
Realtime integration
A hook per surface (lib/realtime/): on mount, supabase.channel(...).on('postgres_changes', <filters>, handler); the handler patches the TanStack Query cache — timeline: APPROVED INSERT → prepend to ['timeline'] page 0; DELETE or UPDATE away-from-APPROVED → remove; comments: INSERT → add to ['comments', id] with dedupe-by-id, DELETE → remove; likes: INSERT/DELETE → ±1 like_count, set liked_by_me if it’s the current user. On unmount, supabase.removeChannel(...). This replaces addInterest / clearInterest / the interest map entirely — Realtime authenticates via the user’s JWT and RLS-filters automatically, so the old “no token on the WS” bug is gone.
Theming
CSS variables (:root light + .dark) for the shadcn token set, with a real brand colour (decided in Phase 0) on --primary. <ThemeProvider> sets document.documentElement.classList to light/dark from the Zustand theme state (system → matchMedia('(prefers-color-scheme: dark)') + a listener); persisted in localStorage. One <meta name="theme-color"> updated to match the active theme. Typography: a web font picked in Phase 0 (e.g. Inter via @fontsource) — or the system stack; Phase 0 decides.
Error / loading / empty conventions
Every async surface: (a) a skeleton while loading (real shimmer, never <p>Loading…</p>); (b) an empty state (“No posts yet” / “No comments yet” / “Nothing awaiting approval 🎉” / “No results for ‘{q}’”) — icon + one-liner + (where relevant) a CTA; (c) an error state — a card with a friendly message + a Retry button (never a raw JSON.stringify). A top-level error boundary catches the rest. Document titles: useDocumentTitle(t) → "{t} · txArchive", reactive, on every route. OG/meta: sensible defaults in index.html; per-route overrides where useful.
PWA
vite-plugin-pwa (registerType:'autoUpdate'): manifest name/short_name txArchive, the tagline as description, display:standalone, start_url:/, scope:/, one theme_color/background_color (the brand colour), the icon set (android-chrome-192 maskable + android-chrome-512 + apple-touch-icon + favicon.ico + favicon.svg (finally provide it — everything references it) + mstile-150x150 (provide it or drop browserconfig.xml)). Drop the manual index.html SW registration (the plugin owns it). Precache the app shell + the logo SVG; runtime-cache cdn.txarchive.org images (CacheFirst or StaleWhileRevalidate + an entry/age cap); do not precache the Yorick posters (re-export them as optimized WebP/AVIF, loading="lazy").
Rebuilt feature set (§8 of the design)
Each: (parity) what must still be true · (rebuild) how it’s done now · (drops) what debt disappears.
- 8.1 Auth & onboarding —
/Landing: the SVG logo, one “Continue with Discord” button →signInWithOAuth({provider:'discord', options:{redirectTo:'<origin>/auth/callback', scopes:'identify guilds guilds.join'}}), the legal disclaimer (verbatim), the tagline. → Discord →/auth/callback: a spinner → callcomplete-login(bearer = the new session JWT; body{provider_token}) → on success briefly show “Welcome, {username}” + the granted-scopes list (icons: VIEW→eye · ADD→upload-cloud · DELETE/APPROVE→shield-check · ENROLL→user-plus · ESCALATE→shield-alert) → auto-route/timeline; on403 not_in_guild→signOut()→/error. (drops) the?reload=1dance;setAuth-during-render; the JS-readable cookie; the 150-day JWT; the JSON-blob-in-query handshake. - 8.2 Timeline (
/timeline) — an infinite-scrolling responsive card grid of<PostCard>(page 25,useInfiniteQueryoverfrom('timeline').range()); a small toolbar with an “Exclude my own posts”<Switch>(toggles.neq('uploader', uid)→ its own query key);useRealtimeTimeline()patches the cache; click a card → navigate/post/:id(modal-over-timeline from here; full page on direct load); each card lazy-loads its image (loading="lazy"+ a skeleton/blur placeholder + analtof the filename or OCR description). Default post-login page. - 8.3 PostCard — image (lazy,
object-contain, theme-aware bg, click →/post/:id); a metadata row (relative date · size · uploader avatar+name); an action row — Like (optimistic heart,aria-pressed, count) · Comments (count →/post/:id) · Share (copieshttps://txarchive.org/post/<id>+ a toast). (drops) the inlineCommentFormon the card — composing moves into/post/:id. - 8.4 Post detail (
/post/:id) (merges the oldCommentsViewer) — a route, modal over the timeline from the feed / full page on direct load: the big image (a<Lightbox>-style zoom/pan view;alttext), a comments panel — author header + Like + Share; a<CommentComposer>(textarea, 1–2000 chars, char counter, Enter to send / Shift+Enter newline, optimistic prepend, error toast, “Sending…” state); the comment list (useInfiniteQuery, page 50, newest-first, “Load more”; each<CommentItem>: avatar + name + relative timestamp + body + a Delete button whenauthor===me || hasScope('DELETE')→useDeleteCommentwith a confirm); a “No comments yet” empty state;useRealtimePostDetail(id)subscribescomments+likesand patches the cache (dedupe the optimistic comment byid). Modal mode: focus trap + Esc + focus-restore + body-scroll-lock. Works for owner /APPROVE/DELETEon PENDING/DENIED (thepost_detailview). - 8.5 Image lightbox — a
<Lightbox>(RadixDialog-based) for My Uploads / Approvals / the upload preview / (zoom in) post detail: full-screen;object-contain; the original filename in a corner; X + Esc + backdrop to close; optional Prev/Next across the current dataset (arrow keys too; bounds-checked, no wrap, disabled at ends); zoom/pan (scroll/pinch, drag, double-click resets); focus-trapped; body-scroll-locked. - 8.6 Upload (
/upload, gateADD) — a centered card: areact-dropzonearea (idle / drag-accept / drag-reject states) plus awindowpastelistener (⌘/Ctrl-V a screenshot → selected); on select → a blob-URL preview (click →<Lightbox>); constraints from the env (mirrored:ALLOWED_MIME_TYPES,UPLOAD_SIZE_LIMIT— display them); Upload →useUpload→POST create-upload(multipart, fieldfile) → on success: clear + a “Queued for approval” toast + invalidate['my-uploads']+['approval-count']; on error map the code → a friendly message. A Reset button. (parity) keep clipboard paste. (drops) the quad-stateuploadState; the hard-coded 800 KB. - 8.7 My uploads (
/my-uploads) — a shared<UploadGrid mode="mine">(responsive card grid, page 10, “Load more”): each<UploadCard>= the image (click →<Lightbox>over the dataset) · a<StatusPill>(Approved=green / Denied=red / Pending=amber — semantic tokens) · filename + relative date + size · a Delete button (useDeleteUpload→POST delete-upload?id=→ optimistic removal + a toast + invalidate['approval-count']if it was pending; a<ConfirmDialog>first); per-row mutation state. A real skeleton; a “You haven’t uploaded anything yet” empty state + a “Go to Upload” CTA. (drops) theImages.tsx/Approvals.tsx~80% dup (one<UploadGrid>/<UploadCard>parameterized by mode); the shared-loading-spins-all-buttons bug. - 8.8 Approvals (
/approvals, gateAPPROVE) —<UploadGrid mode="approvals">(page 20, oldest-first, “Load more”): each card adds “Uploaded by {avatar + username}” · an Approve button (from('uploads').update({approved:'APPROVED'}).eq('id',id)) and a Deny button — per-row state; on success → optimistic removal + invalidate['approval-count']+['timeline'](Realtime also delivers the approved post);useRealtimeApprovals()keeps the queue live. The count =useApprovalCount(from('pending_uploads').select('*',{count:'exact',head:true})), shown as the nav badge, also kept live. A “Nothing awaiting approval 🎉” empty state. (drops) the dup; theapproveRefetchAtominteger-bump hack. - 8.9 Invite (
/enroll, gateENROLL) — a card: a Discord-ID<Input>(snowflake-validated^\d{17,19}$; button disabled until valid) plus a scope picker (checkboxes for the scopes the inviter may grant — their own scopes, minusESCALATEunless they hold it — this is the real improvement: the old flow silently “inherited the inviter’s scopes”; now it’s explicit); Invite →rpc('enroll_user', {p_discord_id, p_scopes})→'ENROLLED'→ “Scopes granted to {id}”;'ENROLLED_PENDING'→ “Invite saved — they’ll get these scopes when they first log in”. (drops) the overloadedenrollState; the silent scope-inheritance. - 8.10 Members / Admin (
/members, gateENROLL∨DELETE) — NEW (implements the dangling/userslink) — a table of members (from('profiles').select('*, scopes:user_scopes(scope)').order('username')): avatar · username ·discord_id· scopes (badges) · (withENROLL) Edit scopes → a dialog with scope checkboxes →rpc('set_user_scopes', {p_user_id, p_scopes})(can’t grantESCALATEunless you hold it) → optimistic update · (withDELETE) View uploads → a drawer showing that member’s uploads (from('uploads').eq('uploader', targetId)) with an admin-delete per row (delete-upload?id=→notify-discord {post_deleted_admin}fires). A Pending invites section (from('enrollments').select('*, inviter:profiles(username)')—ENROLL-visible): targetdiscord_id· scopes · invited by · a Cancel (rpc('cancel_enrollment', {p_invited_discord_id})). - 8.11 Search (
/search, gateVIEW) — NEW (the payoff of doing OCR for real) — a search input (also in the nav) →/search?q=...→useSearch(q)(rpc('search_archive', {q}), debounced) → a responsive grid of matching approved uploads (timeline-shaped) with the matched-textheadlinesnippet under each; click →/post/:id. Emptyq→ a hint (“Search the archive — every screenshot is OCR’d”); no results → “No matches for ‘{q}’“. - 8.12 Yorick & Wowjesus stories (
/yorick) — a deliberately-distinct dark “streaming service” page, dark regardless of the app theme, not using the shared<Nav>: its own dark header (a film icon + “Yorick And Wowjesus Stories” + a “Back to txArchive” link →/timeline); a hero (/hero.pngre-exported optimized; the overlaid “Insane stories” / “Brought to you by wowjesus.”); a responsive poster grid driven bysrc/features/yorick/yorick-catalog.json([{title, description, comingSoon, poster, video}]) — click an entry → if notcomingSoon, playvideo(acdn.txarchive.org/*.mp4URL) in an in-app<video>modal (fall back to a new tab). Posters re-exported as optimized WebP,loading="lazy", with Reactkeys. Migrate the 7 current entries verbatim (verify the...wh...MP4 filename in R2 — keep if real, else fix). Adding an episode = append one JSON object + drop a poster. (drops) the missingkeys; the hard-coded array;window.open. - 8.13 App shell — Nav, mobile drawer, user menu, theme toggle — see “App shell” above. (drops) the per-page Nav; the
bg-whiteliteral; the no-click-outside dropdown; the no-scrim drawer; the/usersvs/enrollroute mismatch; the hard-coded ”© 2023”. - 8.14 Cross-cutting UX — toasts (used for real); skeletons everywhere; empty states everywhere; visible error states + a top-level error boundary; per-route document titles (
"{Page} · txArchive", reactive); date formatting (relative, absolute on hover, user locale); size formatting (binary unitsKiB/MiB); deep-links (/post/:id); image handling (lazy-load,alt, blur placeholder, optimized Yorick posters); responsiveness (Tailwind + the mobile affordances); a11y (labels on all controls, focus traps + Esc + focus-restore in modals/drawers, full keyboard nav,alttext,prefers-reduced-motion, visible focus rings, AA contrast). Identity preserved: txArchive (lowercase tx), the tagline, the login disclaimer,txarchive.org.
Related
- txArchive 2025 Rewrite — the unified rewrite overview + key decisions.
- txArchive Supabase Contract — the backend the SPA consumes (PostgREST + RPCs + Realtime + 3 Edge Functions).
- LOG · TOPICS