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.
This commit is contained in:
2026-09-17 13:47:51 -05:00
parent 09405d1a0f
commit a0d36911e7
4 changed files with 109 additions and 1 deletions
+2
View File
@@ -85,6 +85,8 @@ Files now stage first and are resolved before entering the library.
## 5. Shell & polish ## 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 - [ ] Toasts instead of inline messages / confirm dialogs
- [ ] Mobile drawer polish for metadata panels (design spec §layout) - [ ] Mobile drawer polish for metadata panels (design spec §layout)
- [x] Profile pictures: staff Users page sets avatars from library J-IDs - [x] Profile pictures: staff Users page sets avatars from library J-IDs
+15 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { import {
Navigate, Navigate,
Route, Route,
@@ -10,6 +10,7 @@ import {
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { FullPageSpinner } from "@/components/ui"; import { FullPageSpinner } from "@/components/ui";
import AccountPage from "@/features/account/AccountPage"; import AccountPage from "@/features/account/AccountPage";
import { AgeGate } from "@/features/age/AgeGate";
import LoginPage from "@/features/auth/LoginPage"; import LoginPage from "@/features/auth/LoginPage";
import RegisterPage from "@/features/auth/RegisterPage"; import RegisterPage from "@/features/auth/RegisterPage";
import DetailPage from "@/features/detail/DetailPage"; import DetailPage from "@/features/detail/DetailPage";
@@ -20,6 +21,7 @@ import Md5Redirect from "@/features/library/Md5Redirect";
import OnlinePage from "@/features/online/OnlinePage"; import OnlinePage from "@/features/online/OnlinePage";
import UploadPage from "@/features/upload/UploadPage"; import UploadPage from "@/features/upload/UploadPage";
import UsersPage from "@/features/users/UsersPage"; import UsersPage from "@/features/users/UsersPage";
import { isAgeVerified, markAgeVerified } from "@/lib/age";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
function RequireAuth({ children }: { children: ReactNode }) { function RequireAuth({ children }: { children: ReactNode }) {
@@ -39,11 +41,23 @@ function PostRedirect() {
export default function App() { export default function App() {
const ready = useAuth((state) => state.ready); const ready = useAuth((state) => state.ready);
const loadUser = useAuth((state) => state.loadUser); const loadUser = useAuth((state) => state.loadUser);
const [ageVerified, setAgeVerified] = useState(isAgeVerified);
useEffect(() => { useEffect(() => {
void loadUser(); void loadUser();
}, [loadUser]); }, [loadUser]);
if (!ageVerified) {
return (
<AgeGate
onEnter={() => {
markAgeVerified();
setAgeVerified(true);
}}
/>
);
}
if (!ready) { if (!ready) {
return <FullPageSpinner />; return <FullPageSpinner />;
} }
+75
View File
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-ctp-crust px-4">
<div className="w-full max-w-md rounded-lg border border-ctp-red/40 bg-ctp-base p-6 text-center">
<Ban className="mx-auto h-7 w-7 text-ctp-red" />
<h1 className="mt-3 text-lg font-semibold text-ctp-text">
Access restricted
</h1>
<p className="mt-2 text-sm leading-relaxed text-ctp-subtext0">
You have to be 18 years or older to view this archive. Come back
when that applies.
</p>
<Button
variant="secondary"
className="mt-5"
onClick={() => setDeclined(false)}
>
Go back
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-ctp-crust px-4">
<div className="w-full max-w-lg">
<div className="flex items-center gap-3">
<span className="rounded bg-ctp-mauve px-2.5 py-1.5 font-mono text-sm font-bold tracking-wide text-ctp-crust">
J621
</span>
<span className="font-mono text-[11px] uppercase tracking-widest text-ctp-overlay0">
Self-hosted media archive
</span>
</div>
<div className="mt-6 rounded-lg border border-ctp-surface0 bg-ctp-base p-6">
<div className="flex items-start gap-3">
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-ctp-peach" />
<h1 className="text-lg font-semibold leading-snug text-ctp-text">
You have to be 18+ to access this site.
</h1>
</div>
<p className="mt-3 text-sm leading-relaxed text-ctp-subtext0">
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.
</p>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<Button className="flex-1" autoFocus onClick={onEnter}>
I am 18 or older — Enter
</Button>
<Button variant="secondary" onClick={() => setDeclined(true)}>
Leave
</Button>
</div>
</div>
<p className="mt-4 text-center font-mono text-[11px] text-ctp-overlay0">
Local-first library · e621 integration · nothing leaves this server
</p>
</div>
</div>
);
}
+17
View File
@@ -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.
}
}