Add nsfw handling. Add zustand for global store

This commit is contained in:
2026-02-04 01:12:00 +01:00
parent c4107718d0
commit e907de47a4
16 changed files with 319 additions and 13 deletions

View File

@ -1,6 +1,9 @@
"use client";
import NsfwBadge from "@/components/nsfw/NsfwBadge";
import NsfwConsentDialog from "@/components/nsfw/NsfwConsentDialog";
import { cn } from "@/lib/utils";
import { useNsfwStore } from "@/stores/nsfw-store";
import Image from "next/image";
import Link from "next/link";
import {
@ -24,6 +27,9 @@ export type JustifiedGalleryItem = {
/** Optional: dominant color for hover ring. */
dominantHex?: string | null;
/** Optional: NSFW flag */
nsfw?: boolean | null;
};
type Props = {
@ -94,12 +100,18 @@ export default function JustifiedGallery({
debug = false,
className,
}: Props) {
const consent = useNsfwStore((s) => s.consent);
const allowNsfw = consent === "allow";
const hasNsfw = useMemo(() => items.some((i) => i.nsfw), [items]);
const containerRef = useRef<HTMLDivElement | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const effectiveGap = (() => {
if (gapBreakpoints && containerWidth > 0) {
const sorted = [...gapBreakpoints].sort((a, b) => a.maxWidth - b.maxWidth);
const sorted = [...gapBreakpoints].sort(
(a, b) => a.maxWidth - b.maxWidth,
);
for (const bp of sorted) {
if (containerWidth <= bp.maxWidth) return bp.gap;
}
@ -273,6 +285,7 @@ export default function JustifiedGallery({
ref={containerRef}
className={cn("mx-auto w-full max-w-6xl", className)}
>
<NsfwConsentDialog hasNsfw={hasNsfw} />
<div className="space-y-3">
{rows.map((row, idx) => (
<div key={getRowKey(row)}>
@ -294,6 +307,7 @@ export default function JustifiedGallery({
hrefBase={hrefBase}
hrefFrom={hrefFrom}
showCaption={showCaption}
allowNsfw={allowNsfw}
/>
))}
</div>
@ -328,16 +342,20 @@ function GalleryTile({
hrefBase,
hrefFrom,
showCaption,
allowNsfw,
}: {
tile: RowTile;
hrefBase: string;
hrefFrom: string;
showCaption: boolean;
allowNsfw: boolean;
}) {
const { item, w, h } = tile;
const href = `${hrefBase}/${item.id}?from=${encodeURIComponent(hrefFrom)}`;
const src = `/api/image/gallery/${item.fileKey}.webp`;
const isNsfw = Boolean(item.nsfw);
const shouldBlur = isNsfw && !allowNsfw;
const style: CSSProperties & { "--dom"?: string } = {};
const dom = normalizeColor(item.dominantHex);
@ -372,11 +390,26 @@ function GalleryTile({
alt={item.altText ?? item.name ?? "Artwork"}
width={w}
height={h}
className="h-full w-full object-cover"
className={cn(
"h-full w-full object-cover transition-[filter,transform] duration-200",
shouldBlur ? "blur-2xl scale-[1.02]" : "blur-0",
)}
// Tiles are thumbnail-ish; bias Next toward small resources.
sizes="(max-width: 640px) 90vw, (max-width: 1024px) 50vw, 320px"
/>
{isNsfw ? (
shouldBlur ? null : (
<NsfwBadge className="absolute left-2 bottom-2 z-10" />
)
) : null}
{shouldBlur ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/45">
<NsfwBadge className="scale-110 shadow-md" />
</div>
) : null}
{showCaption ? (
<div className="pointer-events-none absolute inset-x-0 top-0 bg-black/60 p-3">
<div className="text-sm font-medium text-white line-clamp-1">

View File

@ -1,4 +1,5 @@
import ModeToggle from "./ModeToggle";
import NsfwModeToggle from "./NsfwModeToggle";
import TopNav from "./TopNav";
export default function Header() {
@ -7,8 +8,11 @@ export default function Header() {
<div className="w-full">
<div className="flex items-center justify-between px-4 md:px-8 py-2">
<TopNav />
<ModeToggle />
<div className="flex items-center gap-2">
<NsfwModeToggle />
<ModeToggle />
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,42 @@
"use client";
import { Eye, EyeOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useNsfwStore } from "@/stores/nsfw-store";
export default function NsfwModeToggle() {
const consent = useNsfwStore((s) => s.consent);
const setConsent = useNsfwStore((s) => s.setConsent);
const enabled = consent === "allow";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
{enabled ? (
<Eye className="h-[1.2rem] w-[1.2rem]" />
) : (
<EyeOff className="h-[1.2rem] w-[1.2rem]" />
)}
<span className="sr-only">Toggle NSFW mode</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setConsent("allow")}>
Allow NSFW Content
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setConsent("deny")}>
Hide NSFW Content
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,19 @@
"use client";
import { EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
export default function NsfwBadge({ className }: { className?: string }) {
return (
<div
className={cn(
"inline-flex items-center gap-1 rounded-full bg-black/70 px-2 py-1 text-xs font-semibold text-white shadow-sm",
className,
)}
>
<EyeOff className="h-3 w-3" />
NSFW
</div>
);
}

View File

@ -0,0 +1,75 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { EyeOff } from "lucide-react";
import { useEffect, useState } from "react";
import { useNsfwStore } from "@/stores/nsfw-store";
export default function NsfwConsentDialog({ hasNsfw }: { hasNsfw: boolean }) {
const consent = useNsfwStore((s) => s.consent);
const setConsent = useNsfwStore((s) => s.setConsent);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!hasNsfw) {
setOpen(false);
return;
}
if (consent === "unset") setOpen(true);
else setOpen(false);
}, [hasNsfw, consent]);
if (!hasNsfw) return null;
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next && consent === "unset") return;
setOpen(next);
}}
>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<EyeOff className="h-5 w-5 text-destructive" />
Sensitive Content
</DialogTitle>
<DialogDescription>
Sensitive artworks ahead. Confirm youre 18+ to view it. You can
change this anytime with the NSFW toggle in the header.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setConsent("deny");
setOpen(false);
}}
>
Keep blurred
</Button>
<Button
onClick={() => {
setConsent("allow");
setOpen(false);
}}
>
Im 18+ · Show
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import Image, { type ImageProps } from "next/image";
import { cn } from "@/lib/utils";
import { useNsfwStore } from "@/stores/nsfw-store";
import NsfwBadge from "./NsfwBadge";
type NsfwImageProps = ImageProps & {
nsfw?: boolean;
wrapperClassName?: string;
};
export default function NsfwImage({
nsfw = false,
wrapperClassName,
className,
...props
}: NsfwImageProps) {
const consent = useNsfwStore((s) => s.consent);
const allowNsfw = consent === "allow";
const shouldBlur = nsfw && !allowNsfw;
return (
<div>
<Image
{...props}
className={cn(
"transition-[filter,transform] duration-200",
shouldBlur ? "blur-2xl scale-[1.02]" : "blur-0",
className,
)}
/>
{nsfw ? shouldBlur ? null : (
<NsfwBadge className="absolute left-2 bottom-2 z-10" />
) : null}
{shouldBlur ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/45">
<NsfwBadge className="scale-110 shadow-md" />
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { useNsfwStore } from "@/stores/nsfw-store";
export default function NsfwLink({
href,
nsfw = false,
className,
children,
}: {
href: string;
nsfw?: boolean;
className?: string;
children: React.ReactNode;
}) {
const consent = useNsfwStore((s) => s.consent);
const allowNsfw = consent === "allow";
const blocked = nsfw && !allowNsfw;
if (blocked) {
return (
<div
className={cn("block cursor-not-allowed", className)}
aria-disabled="true"
>
{children}
</div>
);
}
return (
<Link href={href} className={cn("block", className)}>
{children}
</Link>
);
}

View File

@ -110,6 +110,7 @@ export default function PortfolioGallery({
id: it.id,
name: it.name,
altText: it.altText,
nsfw: it.nsfw,
fileKey: it.fileKey,
width: it.thumbW,
height: it.thumbH,

View File

@ -30,6 +30,7 @@ export default function TaggedGallery({ tagSlugs }: { tagSlugs: string[] }) {
const cursorRef = useRef<Cursor>(null);
useEffect(() => {
void resetKey;
setItems([]);
setDone(false);
doneRef.current = false;
@ -75,6 +76,7 @@ export default function TaggedGallery({ tagSlugs }: { tagSlugs: string[] }) {
id: it.id,
name: it.name,
altText: it.altText,
nsfw: it.nsfw,
fileKey: it.fileKey,
width: it.thumbW,
height: it.thumbH,