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
|
## 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
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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