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:
@@ -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
|
||||
|
||||
+15
-1
@@ -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 (
|
||||
<AgeGate
|
||||
onEnter={() => {
|
||||
markAgeVerified();
|
||||
setAgeVerified(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return <FullPageSpinner />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user