From a0d36911e75fdb11352f24a73237286adf250b44 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 13:47:51 -0500 Subject: [PATCH] Add an 18+ entry screen before the app renders A homescreen-style age gate shown before auth or any route: J621 brand mark, the explicit 18+ check, an Enter action remembered per browser in localStorage (j621.age-verified), and a blocked state if the visitor chooses Leave. --- ROADMAP.md | 2 + frontend/src/App.tsx | 16 +++++- frontend/src/features/age/AgeGate.tsx | 75 +++++++++++++++++++++++++++ frontend/src/lib/age.ts | 17 ++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/age/AgeGate.tsx create mode 100644 frontend/src/lib/age.ts diff --git a/ROADMAP.md b/ROADMAP.md index 31f184e..77c7b34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -85,6 +85,8 @@ Files now stage first and are resolved before entering the library. ## 5. Shell & polish +- [x] 18+ entry screen: an age check gates the app before anything renders + (remembered per browser in `localStorage`) - [ ] Toasts instead of inline messages / confirm dialogs - [ ] Mobile drawer polish for metadata panels (design spec §layout) - [x] Profile pictures: staff Users page sets avatars from library J-IDs diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index acd5442..d8fc416 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { Navigate, Route, @@ -10,6 +10,7 @@ import { import { AppShell } from "@/components/AppShell"; import { FullPageSpinner } from "@/components/ui"; import AccountPage from "@/features/account/AccountPage"; +import { AgeGate } from "@/features/age/AgeGate"; import LoginPage from "@/features/auth/LoginPage"; import RegisterPage from "@/features/auth/RegisterPage"; import DetailPage from "@/features/detail/DetailPage"; @@ -20,6 +21,7 @@ import Md5Redirect from "@/features/library/Md5Redirect"; import OnlinePage from "@/features/online/OnlinePage"; import UploadPage from "@/features/upload/UploadPage"; import UsersPage from "@/features/users/UsersPage"; +import { isAgeVerified, markAgeVerified } from "@/lib/age"; import { useAuth } from "@/store/auth"; function RequireAuth({ children }: { children: ReactNode }) { @@ -39,11 +41,23 @@ function PostRedirect() { export default function App() { const ready = useAuth((state) => state.ready); const loadUser = useAuth((state) => state.loadUser); + const [ageVerified, setAgeVerified] = useState(isAgeVerified); useEffect(() => { void loadUser(); }, [loadUser]); + if (!ageVerified) { + return ( + { + markAgeVerified(); + setAgeVerified(true); + }} + /> + ); + } + if (!ready) { return ; } diff --git a/frontend/src/features/age/AgeGate.tsx b/frontend/src/features/age/AgeGate.tsx new file mode 100644 index 0000000..407154a --- /dev/null +++ b/frontend/src/features/age/AgeGate.tsx @@ -0,0 +1,75 @@ +import { Ban, ShieldAlert } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui"; + +export function AgeGate({ onEnter }: { onEnter: () => void }) { + const [declined, setDeclined] = useState(false); + + if (declined) { + return ( +
+
+ +

+ Access restricted +

+

+ You have to be 18 years or older to view this archive. Come back + when that applies. +

+ +
+
+ ); + } + + return ( +
+
+
+ + J621 + + + Self-hosted media archive + +
+ +
+
+ +

+ You have to be 18+ to access this site. +

+
+ +

+ This archive contains adult material. By entering you confirm that + you are at least 18 years old and that viewing adult content is + legal where you live. +

+ +
+ + +
+
+ +

+ Local-first library · e621 integration · nothing leaves this server +

+
+
+ ); +} diff --git a/frontend/src/lib/age.ts b/frontend/src/lib/age.ts new file mode 100644 index 0000000..f5d431b --- /dev/null +++ b/frontend/src/lib/age.ts @@ -0,0 +1,17 @@ +const AGE_KEY = "j621.age-verified"; + +export function isAgeVerified(): boolean { + try { + return window.localStorage.getItem(AGE_KEY) === "1"; + } catch { + return false; + } +} + +export function markAgeVerified(): void { + try { + window.localStorage.setItem(AGE_KEY, "1"); + } catch { + // Private mode with storage disabled: the gate simply shows again. + } +}