Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,7 @@ function CreatePage() {
disabled={newBusy}
className="h-4 w-4 rounded border-border accent-accent"
/>
<span className="text-foreground text-sm">This story contains adult content (18+)</span>
<span className="text-foreground text-sm">This story contains adult content (19+)</span>
</label>
{isNsfw && (
<p className="text-muted mt-1.5 ml-6 text-[11px]">
Expand Down
2 changes: 1 addition & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export default async function Home({
{storylines.length === 0 && (
<section className="flex flex-col items-center gap-4 py-16 text-center">
<div className="border-border text-muted rounded border px-4 py-3 text-xs">
<span className="text-accent-dim">$</span> {showNsfw ? "no 18+ stories found" : "no storylines found"}
<span className="text-accent-dim">$</span> {showNsfw ? "no 19+ stories found" : "no storylines found"}
</div>
<p className="text-muted text-sm">
{showNsfw ? "No mature content has been published yet." : "Be the first to start a story on PlotLink."}
Expand Down
11 changes: 7 additions & 4 deletions src/app/profile/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@
import { getFullUserProfile, getFarcasterProfile } from "../../../../lib/actions";
import { truncateAddress } from "../../../../lib/utils";
import { formatPrice, formatSupply } from "../../../../lib/format";
import { getTokenPrice, mcv2BondAbi, erc20Abi, type TokenPriceInfo, get24hPriceChange, getTokenTVL } from "../../../../lib/price";

Check warning on line 14 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'TokenPriceInfo' is defined but never used
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";
import { DeadlineCountdown, DEADLINE_MS } from "../../../components/DeadlineCountdown";

Check warning on line 23 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'DeadlineCountdown' is defined but never used
import { ClaimRoyalties } from "../../../components/ClaimRoyalties";
import { WriterTradingStats } from "../../../components/WriterTradingStats";
import { DropdownSelect } from "../../../components/DropdownSelect";
Expand Down Expand Up @@ -709,9 +710,10 @@
connectedAddress: string | null;
totalRoyalties?: bigint;
}) {
const [showNsfw] = useNsfwPreference();
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
Expand All @@ -720,7 +722,7 @@
.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<Storyline[]>();
Expand Down Expand Up @@ -836,7 +838,7 @@
});

// Claimable royalties (own profile only)
const { data: royaltyInfo } = useQuery({

Check warning on line 841 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'royaltyInfo' is assigned a value but never used
queryKey: ["profile-royalties", address],
queryFn: async () => {
const [balance, claimed] = await browserClient.readContract({
Expand Down Expand Up @@ -1429,11 +1431,12 @@
}

function PortfolioTab({ address, isOwnProfile }: { address: string; isOwnProfile: boolean }) {
const [showNsfw] = useNsfwPreference();
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<PortfolioHolding[]> => {
if (!supabase) return [];

Expand All @@ -1443,7 +1446,7 @@
.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<Storyline[]>();
if (!storylines || storylines.length === 0) return [];

Expand Down
3 changes: 2 additions & 1 deletion src/app/story/[storylineId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -387,6 +387,7 @@ function StoryHeader({
</div>
</>
)}
<NsfwBadge isNsfw={storyline.is_nsfw} />
</div>
</div>

Expand Down
2 changes: 1 addition & 1 deletion src/app/terms/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default function TermsPage() {
<p>Published content is permanent and cannot be deleted from IPFS or the blockchain by PlotLink.</p>

<h2>6. Adult Content Policy</h2>
<p>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.</p>
<p>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.</p>
<ul>
<li>Authors are responsible for accurately marking their content as adult-only when it contains material not suitable for general audiences</li>
<li>PlotLink reserves the right to flag or reclassify any story as adult-only if it determines the content is not suitable for general audiences</li>
Expand Down
19 changes: 2 additions & 17 deletions src/components/FilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ function FilterSheetContent({
onChange={(e) => setLocalNsfw(e.target.checked)}
className="h-4 w-4 rounded border-[var(--border)] accent-[var(--accent)]"
/>
<span className="text-sm text-[var(--fg)]">Show 18+ content</span>
<span className="text-sm text-[var(--fg)]">Show 19+ content</span>
</label>
</div>

Expand Down Expand Up @@ -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 (
<>
Expand Down Expand Up @@ -268,21 +268,6 @@ export function FilterBar({ writer, genre, lang, tab, totalCount, showNsfw = fal
</select>
</div>

{/* NSFW toggle */}
<label className={`flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12px] font-medium transition-colors ${
nsfw
? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]"
: "border-[var(--border)] text-[var(--muted)] hover:border-[var(--muted)] hover:text-[var(--fg)]"
}`}>
<input
type="checkbox"
checked={nsfw}
onChange={(e) => navigate({ tab, writer, genre, lang, nsfw: e.target.checked })}
className="h-3 w-3 rounded accent-[var(--accent)]"
/>
18+
</label>

{/* Result count */}
{totalCount !== undefined && (
<span className="ml-1 text-[12px] tabular-nums text-[var(--muted)]">
Expand Down
31 changes: 30 additions & 1 deletion src/components/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,31 @@

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";
import { useNsfwPreference } from "../hooks/useNsfwPreference";

export function NavBar() {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const { address, isConnected } = useAccount();

const [showNsfw, setNsfw] = useNsfwPreference();

const toggleNsfw = () => {
const next = !showNsfw;
setNsfw(next);
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";
Expand Down Expand Up @@ -51,6 +66,20 @@ export function NavBar() {
</span>
</Link>

{/* 19+ NSFW toggle pill */}
<button
type="button"
onClick={toggleNsfw}
className={`ml-2 rounded-full px-2 py-0.5 text-[10px] font-bold transition-colors ${
showNsfw
? "bg-[oklch(45%_0.18_25)] text-white"
: "border border-border text-muted hover:text-foreground"
}`}
title={showNsfw ? "Hide 19+ content" : "Show 19+ content"}
>
19+
</button>

{/* Desktop nav links */}
<div className="hidden items-center gap-1 md:flex">
{navLinks.map(({ href, label }) => {
Expand Down
22 changes: 14 additions & 8 deletions src/components/StoryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const FALLBACK_STYLES: Record<FallbackVariant, React.CSSProperties> = {
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 (
<div className="absolute top-2 left-2 z-[2] flex flex-wrap items-center gap-1">
Expand Down Expand Up @@ -48,11 +48,15 @@ function Badges({ genre, writerType, status, isNsfw, contentType }: { genre?: st
Cartoon
</span>
)}
{isNsfw && (
<span className="rounded-[3px] bg-[oklch(45%_0.18_25_/_0.7)] px-[7px] py-[2px] text-[10px] font-medium uppercase tracking-wider leading-[1.4] text-white/90 backdrop-blur-[2px]">
18+
</span>
)}
</div>
);
}

export function NsfwBadge({ isNsfw }: { isNsfw?: boolean }) {
if (!isNsfw) return null;
return (
<div className="absolute top-2 right-2 z-[2] flex h-6 w-6 items-center justify-center rounded-full border-2 border-white/80 bg-[oklch(45%_0.18_25)] text-[9px] font-bold text-white shadow-sm">
19
</div>
);
}
Expand Down Expand Up @@ -82,7 +86,8 @@ export function StoryCard({
/>
<div className="absolute inset-0 bg-[linear-gradient(to_bottom,transparent_40%,oklch(0%_0_0_/_0.15)_60%,oklch(0%_0_0_/_0.55)_80%,oklch(0%_0_0_/_0.78)_100%)]" />

<Badges genre={displayGenre} writerType={storyline.writer_type} status={status} isNsfw={storyline.is_nsfw} contentType={storyline.content_type} />
<Badges genre={displayGenre} writerType={storyline.writer_type} status={status} contentType={storyline.content_type} />
<NsfwBadge isNsfw={storyline.is_nsfw} />

<div className="absolute right-0 bottom-0 left-0 z-[2] px-2.5 pt-3 pb-2.5">
<h3 className="font-heading text-[13px] font-semibold leading-[1.25] text-white drop-shadow-[0_1px_2px_oklch(0%_0_0_/_0.6)] line-clamp-2 sm:text-[15px]">
Expand All @@ -107,7 +112,8 @@ export function StoryCard({
<Link href={`/story/${storyline.storyline_id}`} className={cardClass}>
<div className="absolute inset-0" style={FALLBACK_STYLES[variant]} />

<Badges genre={displayGenre} writerType={storyline.writer_type} status={status} isNsfw={storyline.is_nsfw} contentType={storyline.content_type} />
<Badges genre={displayGenre} writerType={storyline.writer_type} status={status} contentType={storyline.content_type} />
<NsfwBadge isNsfw={storyline.is_nsfw} />

{/* Centered title with accent line */}
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center px-4 text-center">
Expand Down
2 changes: 1 addition & 1 deletion src/components/StoryEditPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export function StoryEditPanel({
disabled={saving}
className="h-3.5 w-3.5 rounded border-border accent-accent"
/>
<span className="text-foreground text-xs">This story contains adult content (18+)</span>
<span className="text-foreground text-xs">This story contains adult content (19+)</span>
</label>
{isNsfw && (
<p className="text-muted mt-1 ml-5.5 text-[10px]">
Expand Down
2 changes: 2 additions & 0 deletions src/components/__tests__/NavBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
32 changes: 32 additions & 0 deletions src/hooks/useNsfwPreference.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading