From 5468c5b5a6103a4be141e334cd71fde68b58db80 Mon Sep 17 00:00:00 2001 From: project7 Date: Sun, 17 May 2026 09:17:52 +0900 Subject: [PATCH 1/3] Rename 18+ to 19+ and redesign NSFW UI components (#1209) - Add circled "19" badge on story cards (top-right position) - Add 19+ toggle pill in NavBar next to logo - Remove inline NSFW toggle from desktop FilterBar - Rename all user-facing "18+" text to "19+" - Fix NavBar test mock for useSearchParams/useRouter Co-Authored-By: Claude Opus 4.6 --- src/app/create/page.tsx | 2 +- src/app/page.tsx | 2 +- src/app/terms/page.tsx | 2 +- src/components/FilterBar.tsx | 19 ++----------- src/components/NavBar.tsx | 34 +++++++++++++++++++++++- src/components/StoryCard.tsx | 22 +++++++++------ src/components/StoryEditPanel.tsx | 2 +- src/components/__tests__/NavBar.test.tsx | 2 ++ 8 files changed, 55 insertions(+), 30 deletions(-) diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index 802643de..14233cd7 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -625,7 +625,7 @@ function CreatePage() { disabled={newBusy} className="h-4 w-4 rounded border-border accent-accent" /> - This story contains adult content (18+) + This story contains adult content (19+) {isNsfw && (

diff --git a/src/app/page.tsx b/src/app/page.tsx index 2c15f155..c6c19346 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -87,7 +87,7 @@ export default async function Home({ {storylines.length === 0 && (

- $ {showNsfw ? "no 18+ stories found" : "no storylines found"} + $ {showNsfw ? "no 19+ stories found" : "no storylines found"}

{showNsfw ? "No mature content has been published yet." : "Be the first to start a story on PlotLink."} diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx index 7e827275..733c6b80 100644 --- a/src/app/terms/page.tsx +++ b/src/app/terms/page.tsx @@ -41,7 +41,7 @@ export default function TermsPage() {

Published content is permanent and cannot be deleted from IPFS or the blockchain by PlotLink.

6. Adult Content Policy

-

Stories may be marked as containing adult content (18+/NSFW) by the author at the time of publication. Adult content is hidden by default on the browse page and requires readers to opt-in to view it.

+

Stories may be marked as containing adult content (19+/NSFW) by the author at the time of publication. Adult content is hidden by default on the browse page and requires readers to opt-in to view it.

  • Authors are responsible for accurately marking their content as adult-only when it contains material not suitable for general audiences
  • PlotLink reserves the right to flag or reclassify any story as adult-only if it determines the content is not suitable for general audiences
  • diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index d931be6c..71895606 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -128,7 +128,7 @@ function FilterSheetContent({ onChange={(e) => setLocalNsfw(e.target.checked)} className="h-4 w-4 rounded border-[var(--border)] accent-[var(--accent)]" /> - Show 18+ content + Show 19+ content @@ -184,7 +184,7 @@ export function FilterBar({ writer, genre, lang, tab, totalCount, showNsfw = fal if (writer !== "all") activeChips.push({ label: `Writer: ${writer}`, clear: () => navigate({ tab, writer: "all", genre, lang, nsfw }) }); if (genre !== "all") activeChips.push({ label: genre, clear: () => navigate({ tab, writer, genre: "all", lang, nsfw }) }); if (lang !== "all") activeChips.push({ label: lang, clear: () => navigate({ tab, writer, genre, lang: "all", nsfw }) }); - if (nsfw) activeChips.push({ label: "18+", clear: () => navigate({ tab, writer, genre, lang, nsfw: false }) }); + if (nsfw) activeChips.push({ label: "19+", clear: () => navigate({ tab, writer, genre, lang, nsfw: false }) }); return ( <> @@ -268,21 +268,6 @@ export function FilterBar({ writer, genre, lang, tab, totalCount, showNsfw = fal - {/* NSFW toggle */} - - {/* Result count */} {totalCount !== undefined && ( diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index a2302ba9..40f0e1e3 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -2,16 +2,34 @@ import { useState } from "react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useSearchParams, useRouter } from "next/navigation"; import { useAccount } from "wagmi"; import Image from "next/image"; import { ConnectWallet } from "./ConnectWallet"; export function NavBar() { const pathname = usePathname(); + const searchParams = useSearchParams(); + const router = useRouter(); const [mobileOpen, setMobileOpen] = useState(false); const { address, isConnected } = useAccount(); + const [showNsfw, setShowNsfw] = useState(() => { + if (typeof window === "undefined") return false; + return localStorage.getItem("plotlink_nsfw") === "1"; + }); + + const toggleNsfw = () => { + const next = !showNsfw; + setShowNsfw(next); + localStorage.setItem("plotlink_nsfw", next ? "1" : "0"); + if (pathname === "/") { + const sp = new URLSearchParams(searchParams.toString()); + if (next) sp.set("nsfw", "1"); else sp.delete("nsfw"); + router.replace(`/?${sp.toString()}`); + } + }; + const dashboardHref = isConnected && address ? `/profile/${address}` : "/dashboard/writer"; @@ -51,6 +69,20 @@ export function NavBar() { + {/* 19+ NSFW toggle pill */} + + {/* Desktop nav links */}
    {navLinks.map(({ href, label }) => { diff --git a/src/components/StoryCard.tsx b/src/components/StoryCard.tsx index 9a11c266..4d3e3d6b 100644 --- a/src/components/StoryCard.tsx +++ b/src/components/StoryCard.tsx @@ -19,7 +19,7 @@ export const FALLBACK_STYLES: Record = { D: { background: "linear-gradient(175deg, oklch(94% 0.015 50) 0%, oklch(90% 0.02 40) 100%)" }, }; -function Badges({ genre, writerType, status, isNsfw, contentType }: { genre?: string | null; writerType: number | null; status: string; isNsfw?: boolean; contentType?: string }) { +function Badges({ genre, writerType, status, contentType }: { genre?: string | null; writerType: number | null; status: string; contentType?: string }) { const isActive = status === "active"; return (
    @@ -48,11 +48,15 @@ function Badges({ genre, writerType, status, isNsfw, contentType }: { genre?: st Cartoon )} - {isNsfw && ( - - 18+ - - )} +
    + ); +} + +function NsfwBadge({ isNsfw }: { isNsfw?: boolean }) { + if (!isNsfw) return null; + return ( +
    + 19
    ); } @@ -82,7 +86,8 @@ export function StoryCard({ />
    - + +

    @@ -107,7 +112,8 @@ export function StoryCard({
    - + + {/* Centered title with accent line */}
    diff --git a/src/components/StoryEditPanel.tsx b/src/components/StoryEditPanel.tsx index ba4efbb1..8cb710a2 100644 --- a/src/components/StoryEditPanel.tsx +++ b/src/components/StoryEditPanel.tsx @@ -247,7 +247,7 @@ export function StoryEditPanel({ disabled={saving} className="h-3.5 w-3.5 rounded border-border accent-accent" /> - This story contains adult content (18+) + This story contains adult content (19+) {isNsfw && (

    diff --git a/src/components/__tests__/NavBar.test.tsx b/src/components/__tests__/NavBar.test.tsx index 64bed8d5..207f1e10 100644 --- a/src/components/__tests__/NavBar.test.tsx +++ b/src/components/__tests__/NavBar.test.tsx @@ -12,6 +12,8 @@ vi.mock("next/link", () => ({ vi.mock("next/navigation", () => ({ usePathname: () => "/", + useSearchParams: () => ({ toString: () => "" }), + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), })); vi.mock("../ConnectWallet", () => ({ From e4f4d4e01d709d0db06f4c55e9ec864c9bef4ceb Mon Sep 17 00:00:00 2001 From: project7 Date: Sun, 17 May 2026 09:22:57 +0900 Subject: [PATCH 2/3] Add NsfwBadge to story detail cover and respect 19+ toggle on profiles (#1209) - Export NsfwBadge and overlay on story detail cover (both uploaded and fallback) - Profile pages now respect global 19+ localStorage preference for non-owner views - Include showNsfw in query keys for proper cache invalidation Co-Authored-By: Claude Opus 4.6 --- src/app/profile/[address]/page.tsx | 10 ++++++---- src/app/story/[storylineId]/page.tsx | 3 ++- src/components/StoryCard.tsx | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index c85d17cf..d7a4abc9 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -709,9 +709,10 @@ function StoriesTab({ connectedAddress: string | null; totalRoyalties?: bigint; }) { + const showNsfw = typeof window !== "undefined" && localStorage.getItem("plotlink_nsfw") === "1"; const { data: plotUsd } = usePlotUsdPrice(); const { data: storylines = [], isLoading, error } = useQuery({ - queryKey: ["profile-storylines", address, isOwnProfile], + queryKey: ["profile-storylines", address, isOwnProfile, showNsfw], queryFn: async () => { if (!supabase) return []; let q = supabase @@ -720,7 +721,7 @@ function StoriesTab({ .eq("writer_address", address) .eq("hidden", false) .eq("contract_address", STORY_FACTORY.toLowerCase()); - if (!isOwnProfile) q = q.eq("is_nsfw", false); + if (!isOwnProfile && !showNsfw) q = q.eq("is_nsfw", false); const { data, error } = await q .order("block_timestamp", { ascending: false }) .returns(); @@ -1429,11 +1430,12 @@ interface PortfolioHolding { } function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile: boolean }) { + const showNsfw = typeof window !== "undefined" && localStorage.getItem("plotlink_nsfw") === "1"; const { data: plotUsd } = usePlotUsdPrice(); // Fetch on-chain token holdings const { data: holdings, isLoading: holdingsLoading } = useQuery({ - queryKey: ["profile-holdings", address, isOwnProfile], + queryKey: ["profile-holdings", address, isOwnProfile, showNsfw], queryFn: async (): Promise => { if (!supabase) return []; @@ -1443,7 +1445,7 @@ function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile .eq("hidden", false) .neq("token_address", "") .eq("contract_address", STORY_FACTORY.toLowerCase()); - if (!isOwnProfile) q = q.eq("is_nsfw", false); + if (!isOwnProfile && !showNsfw) q = q.eq("is_nsfw", false); const { data: storylines } = await q.returns(); if (!storylines || storylines.length === 0) return []; diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 633c96c7..498c14c1 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -24,7 +24,7 @@ import { CommentSection } from "../../../components/CommentSection"; import { MobileActionBar } from "../../../components/MobileActionBar"; import { MarketCapBox } from "../../../components/MarketCapBox"; import { TokenPriceBox } from "../../../components/TokenPriceBox"; -import { FALLBACK_STYLES, hashToVariant } from "../../../components/StoryCard"; +import { FALLBACK_STYLES, hashToVariant, NsfwBadge } from "../../../components/StoryCard"; import { CoverLightbox } from "../../../components/CoverLightbox"; import { getCoverUrl } from "../../../../lib/cover"; import { StoryEditPanel } from "../../../components/StoryEditPanel"; @@ -387,6 +387,7 @@ function StoryHeader({

    )} +

    diff --git a/src/components/StoryCard.tsx b/src/components/StoryCard.tsx index 4d3e3d6b..a5cf6c3d 100644 --- a/src/components/StoryCard.tsx +++ b/src/components/StoryCard.tsx @@ -52,7 +52,7 @@ function Badges({ genre, writerType, status, contentType }: { genre?: string | n ); } -function NsfwBadge({ isNsfw }: { isNsfw?: boolean }) { +export function NsfwBadge({ isNsfw }: { isNsfw?: boolean }) { if (!isNsfw) return null; return (
    From 79f4aea7fe96f6075c679ec5483f07d33119c81a Mon Sep 17 00:00:00 2001 From: project7 Date: Sun, 17 May 2026 09:25:17 +0900 Subject: [PATCH 3/3] Make 19+ toggle reactive across pages via custom event (#1209) - Extract useNsfwPreference hook with custom event dispatch - NavBar toggle dispatches 'plotlink:nsfw-change' event on state change - Profile StoriesTab/PortfolioTab subscribe to the event and re-query - Query keys include showNsfw for proper cache invalidation Co-Authored-By: Claude Opus 4.6 --- src/app/profile/[address]/page.tsx | 5 +++-- src/components/NavBar.tsx | 9 +++------ src/hooks/useNsfwPreference.ts | 32 ++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 src/hooks/useNsfwPreference.ts diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index d7a4abc9..dd2d9f8a 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -16,6 +16,7 @@ import { browserClient } from "../../../../lib/rpc"; import type { FarcasterProfile } from "../../../../lib/farcaster"; import type { AgentMetadata } from "../../../../lib/contracts/erc8004"; import { usePlotUsdPrice } from "../../../hooks/usePlotUsdPrice"; +import { useNsfwPreference } from "../../../hooks/useNsfwPreference"; import { formatUsdValue } from "../../../../lib/usd-price"; import { DisconnectButton } from "../../../components/ConnectWallet"; import { GENRES, LANGUAGES } from "../../../../lib/genres"; @@ -709,7 +710,7 @@ function StoriesTab({ connectedAddress: string | null; totalRoyalties?: bigint; }) { - const showNsfw = typeof window !== "undefined" && localStorage.getItem("plotlink_nsfw") === "1"; + const [showNsfw] = useNsfwPreference(); const { data: plotUsd } = usePlotUsdPrice(); const { data: storylines = [], isLoading, error } = useQuery({ queryKey: ["profile-storylines", address, isOwnProfile, showNsfw], @@ -1430,7 +1431,7 @@ interface PortfolioHolding { } function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile: boolean }) { - const showNsfw = typeof window !== "undefined" && localStorage.getItem("plotlink_nsfw") === "1"; + const [showNsfw] = useNsfwPreference(); const { data: plotUsd } = usePlotUsdPrice(); // Fetch on-chain token holdings diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 40f0e1e3..e8aec583 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -6,6 +6,7 @@ import { usePathname, useSearchParams, useRouter } from "next/navigation"; import { useAccount } from "wagmi"; import Image from "next/image"; import { ConnectWallet } from "./ConnectWallet"; +import { useNsfwPreference } from "../hooks/useNsfwPreference"; export function NavBar() { const pathname = usePathname(); @@ -14,15 +15,11 @@ export function NavBar() { const [mobileOpen, setMobileOpen] = useState(false); const { address, isConnected } = useAccount(); - const [showNsfw, setShowNsfw] = useState(() => { - if (typeof window === "undefined") return false; - return localStorage.getItem("plotlink_nsfw") === "1"; - }); + const [showNsfw, setNsfw] = useNsfwPreference(); const toggleNsfw = () => { const next = !showNsfw; - setShowNsfw(next); - localStorage.setItem("plotlink_nsfw", next ? "1" : "0"); + setNsfw(next); if (pathname === "/") { const sp = new URLSearchParams(searchParams.toString()); if (next) sp.set("nsfw", "1"); else sp.delete("nsfw"); diff --git a/src/hooks/useNsfwPreference.ts b/src/hooks/useNsfwPreference.ts new file mode 100644 index 00000000..7277de03 --- /dev/null +++ b/src/hooks/useNsfwPreference.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +const NSFW_KEY = "plotlink_nsfw"; +const NSFW_EVENT = "plotlink:nsfw-change"; + +export function useNsfwPreference() { + const [showNsfw, setShowNsfw] = useState(() => { + if (typeof window === "undefined") return false; + return localStorage.getItem(NSFW_KEY) === "1"; + }); + + useEffect(() => { + const handler = () => { + setShowNsfw(localStorage.getItem(NSFW_KEY) === "1"); + }; + window.addEventListener(NSFW_EVENT, handler); + window.addEventListener("storage", handler); + return () => { + window.removeEventListener(NSFW_EVENT, handler); + window.removeEventListener("storage", handler); + }; + }, []); + + const setNsfw = useCallback((value: boolean) => { + localStorage.setItem(NSFW_KEY, value ? "1" : "0"); + window.dispatchEvent(new Event(NSFW_EVENT)); + }, []); + + return [showNsfw, setNsfw] as const; +}