diff --git a/bun.lock b/bun.lock index b1043fe..fcd5b58 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "app.gaertan.art", "dependencies": { - "@aws-sdk/client-s3": "^3.974.0", - "@aws-sdk/s3-request-presigner": "^3.974.0", + "@aws-sdk/client-s3": "^3.980.0", + "@aws-sdk/s3-request-presigner": "^3.980.0", "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.3.0", "@prisma/client": "^7.3.0", @@ -27,15 +27,16 @@ "lucide-react": "^0.561.0", "next": "16.1.6", "next-themes": "^0.4.6", - "pg": "^8.17.2", + "pg": "^8.18.0", "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", - "simple-icons": "^16.6.0", + "simple-icons": "^16.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zod": "^4.3.6", + "zustand": "^5.0.6", }, "devDependencies": { "@biomejs/biome": "2.2.0", @@ -944,6 +945,8 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.953.0", "", { "dependencies": { "@smithy/types": "^4.10.0", "tslib": "^2.6.2" } }, "sha512-M9Iwg9kTyqTErI0vOTVVpcnTHWzS3VplQppy8MuL02EE+mJ0BIwpWfsaAPQW+/XnVpdNpWZTsHcNE29f1+hR8g=="], diff --git a/package.json b/package.json index 17e56c1..bdaa39f 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "simple-icons": "^16.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", + "zustand": "^5.0.6", "zod": "^4.3.6" }, "devDependencies": { diff --git a/src/actions/animalStudies/getAnimalStudiesPage.ts b/src/actions/animalStudies/getAnimalStudiesPage.ts index c3a49ed..a334029 100644 --- a/src/actions/animalStudies/getAnimalStudiesPage.ts +++ b/src/actions/animalStudies/getAnimalStudiesPage.ts @@ -51,6 +51,7 @@ export async function getAnimalStudiesPage(input: unknown): Promise +
@@ -69,16 +71,17 @@ export default async function SingleArtworkPage({ className="relative w-full bg-muted items-center justify-center" style={{ aspectRatio: "4 / 3" }} > - - + - +
{artwork.timelapse?.enabled ? ( diff --git a/src/components/gallery/JustifiedGallery.tsx b/src/components/gallery/JustifiedGallery.tsx index 5e2f3f0..3ef52d1 100644 --- a/src/components/gallery/JustifiedGallery.tsx +++ b/src/components/gallery/JustifiedGallery.tsx @@ -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(null); const sentinelRef = useRef(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)} > +
{rows.map((row, idx) => (
@@ -294,6 +307,7 @@ export default function JustifiedGallery({ hrefBase={hrefBase} hrefFrom={hrefFrom} showCaption={showCaption} + allowNsfw={allowNsfw} /> ))}
@@ -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 : ( + + ) + ) : null} + + {shouldBlur ? ( +
+ +
+ ) : null} + {showCaption ? (
diff --git a/src/components/global/Header.tsx b/src/components/global/Header.tsx index 987179f..e6b8a18 100644 --- a/src/components/global/Header.tsx +++ b/src/components/global/Header.tsx @@ -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() {
- +
+ + +
); -} \ No newline at end of file +} diff --git a/src/components/global/NsfwModeToggle.tsx b/src/components/global/NsfwModeToggle.tsx new file mode 100644 index 0000000..6f8c2ba --- /dev/null +++ b/src/components/global/NsfwModeToggle.tsx @@ -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 ( + + + + + + setConsent("allow")}> + Allow NSFW Content + + setConsent("deny")}> + Hide NSFW Content + + + + ); +} diff --git a/src/components/nsfw/NsfwBadge.tsx b/src/components/nsfw/NsfwBadge.tsx new file mode 100644 index 0000000..a021f67 --- /dev/null +++ b/src/components/nsfw/NsfwBadge.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { EyeOff } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export default function NsfwBadge({ className }: { className?: string }) { + return ( +
+ + NSFW +
+ ); +} diff --git a/src/components/nsfw/NsfwConsentDialog.tsx b/src/components/nsfw/NsfwConsentDialog.tsx new file mode 100644 index 0000000..a3c37ed --- /dev/null +++ b/src/components/nsfw/NsfwConsentDialog.tsx @@ -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 ( + { + if (!next && consent === "unset") return; + setOpen(next); + }} + > + + + + + Sensitive Content + + + Sensitive artworks ahead. Confirm you’re 18+ to view it. You can + change this anytime with the NSFW toggle in the header. + + + + + + + + + ); +} diff --git a/src/components/nsfw/NsfwImage.tsx b/src/components/nsfw/NsfwImage.tsx new file mode 100644 index 0000000..b4af03f --- /dev/null +++ b/src/components/nsfw/NsfwImage.tsx @@ -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 ( +
+ + + {nsfw ? shouldBlur ? null : ( + + ) : null} + + {shouldBlur ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/nsfw/NsfwLink.tsx b/src/components/nsfw/NsfwLink.tsx new file mode 100644 index 0000000..174fdae --- /dev/null +++ b/src/components/nsfw/NsfwLink.tsx @@ -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 ( +
+ {children} +
+ ); + } + + return ( + + {children} + + ); +} diff --git a/src/components/portfolio/PortfolioGallery.tsx b/src/components/portfolio/PortfolioGallery.tsx index 1f929c6..7871b92 100644 --- a/src/components/portfolio/PortfolioGallery.tsx +++ b/src/components/portfolio/PortfolioGallery.tsx @@ -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, diff --git a/src/components/portfolio/TaggedGallery.tsx b/src/components/portfolio/TaggedGallery.tsx index deff121..c93bf4f 100644 --- a/src/components/portfolio/TaggedGallery.tsx +++ b/src/components/portfolio/TaggedGallery.tsx @@ -30,6 +30,7 @@ export default function TaggedGallery({ tagSlugs }: { tagSlugs: string[] }) { const cursorRef = useRef(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, diff --git a/src/stores/nsfw-store.ts b/src/stores/nsfw-store.ts new file mode 100644 index 0000000..d347420 --- /dev/null +++ b/src/stores/nsfw-store.ts @@ -0,0 +1,30 @@ +"use client"; + +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export type NsfwConsent = "unset" | "allow" | "deny"; + +type NsfwStore = { + consent: NsfwConsent; + setConsent: (consent: NsfwConsent) => void; + allow: () => void; + deny: () => void; + reset: () => void; +}; + +export const useNsfwStore = create()( + persist( + (set) => ({ + consent: "unset", + setConsent: (consent) => set({ consent }), + allow: () => set({ consent: "allow" }), + deny: () => set({ consent: "deny" }), + reset: () => set({ consent: "unset" }), + }), + { + name: "gaertan-nsfw-consent", + storage: createJSONStorage(() => localStorage), + }, + ), +);