From bc19c6379e7ecfe353b406fd2be60ba790e31d26 Mon Sep 17 00:00:00 2001 From: project7 Date: Sun, 17 May 2026 09:37:54 +0900 Subject: [PATCH 1/2] Add content type filter and cartoon indicator on discover/detail pages (#1221) - Add All/Fiction/Cartoon segmented filter on discover page (desktop + mobile sheet) - Persist content type preference in localStorage - Pass content_type filter through to all query paths (new, trending, mcap) - Replace text "Cartoon" badge on story cards with compact comic-panel icon indicator - Story detail page already has CARTOON tag from #1212 merge - Update tests for new filter prop and icon-based indicator Co-Authored-By: Claude Opus 4.6 --- lib/ranking.ts | 7 +- src/app/page.tsx | 28 +++--- src/components/FilterBar.tsx | 94 ++++++++++++++++----- src/components/StoryCard.tsx | 28 ++++-- src/components/__tests__/FilterBar.test.tsx | 3 +- src/components/__tests__/StoryCard.test.tsx | 8 +- 6 files changed, 122 insertions(+), 46 deletions(-) diff --git a/lib/ranking.ts b/lib/ranking.ts index f3da752..4b079e0 100644 --- a/lib/ranking.ts +++ b/lib/ranking.ts @@ -128,6 +128,7 @@ async function fetchCandidatesAndRatings( writerType?: number, genre?: string, lang?: string, + contentType?: string, showNsfw = false, ) { function applyBase(q: ReturnType) { @@ -138,6 +139,7 @@ async function fetchCandidatesAndRatings( if (writerType !== undefined) filtered = filtered.eq("writer_type", writerType); if (genre) filtered = filtered.eq("genre", genre); if (lang) filtered = filtered.eq("language", lang); + if (contentType) filtered = filtered.eq("content_type", contentType); filtered = filtered.eq("is_nsfw", showNsfw); return filtered; } @@ -249,9 +251,10 @@ export async function getTrendingStorylines( offset = 0, genre?: string, lang?: string, + contentType?: string, showNsfw = false, ): Promise { - const { storylines, ratingMap, userMap } = await fetchCandidatesAndRatings(supabase, writerType, genre, lang, showNsfw); + const { storylines, ratingMap, userMap } = await fetchCandidatesAndRatings(supabase, writerType, genre, lang, contentType, showNsfw); if (storylines.length === 0) return []; const enriched = await Promise.all( @@ -297,6 +300,7 @@ export async function getMcapStorylines( offset = 0, genre?: string, lang?: string, + contentType?: string, showNsfw = false, ): Promise { // Fetch all eligible stories — MCap needs the full set, not a recency-biased subset @@ -309,6 +313,7 @@ export async function getMcapStorylines( if (writerType !== undefined) q = q.eq("writer_type", writerType); if (genre) q = q.eq("genre", genre); if (lang) q = q.eq("language", lang); + if (contentType) q = q.eq("content_type", contentType); q = q.eq("is_nsfw", showNsfw); const { data } = await q.returns(); const storylines = data ?? []; diff --git a/src/app/page.tsx b/src/app/page.tsx index c6c1934..614b931 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,8 +2,8 @@ import { createServerClient, type Storyline } from "../../lib/supabase"; import { STORY_FACTORY } from "../../lib/contracts/constants"; import { getTrendingStorylines, getMcapStorylines } from "../../lib/ranking"; import { StoryGrid } from "../components/StoryGrid"; -import { FilterBar, type WriterFilterValue } from "../components/FilterBar"; -import { GENRES, LANGUAGES } from "../../lib/genres"; +import { FilterBar, type WriterFilterValue, type ContentTypeFilterValue } from "../components/FilterBar"; +import { GENRES, LANGUAGES, CONTENT_TYPES } from "../../lib/genres"; import Link from "next/link"; const TABS = ["new", "trending", "mcap"] as const; @@ -13,14 +13,14 @@ const WRITER_VALUES: WriterFilterValue[] = ["all", "human", "agent"]; const PAGE_SIZE = 24; -type SearchParams = Promise<{ tab?: string; writer?: string; page?: string; genre?: string; lang?: string; nsfw?: string }>; +type SearchParams = Promise<{ tab?: string; writer?: string; page?: string; genre?: string; lang?: string; type?: string; nsfw?: string }>; export default async function Home({ searchParams, }: { searchParams: SearchParams; }) { - const { tab: rawTab, writer: rawWriter, page: rawPage, genre: rawGenre, lang: rawLang, nsfw: rawNsfw } = await searchParams; + const { tab: rawTab, writer: rawWriter, page: rawPage, genre: rawGenre, lang: rawLang, type: rawType, nsfw: rawNsfw } = await searchParams; const tab: Tab = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : "trending"; const writer: WriterFilterValue = WRITER_VALUES.includes( rawWriter as WriterFilterValue, @@ -30,13 +30,14 @@ export default async function Home({ const page = Math.max(1, parseInt(rawPage ?? "1", 10) || 1); const genre = rawGenre && (GENRES as readonly string[]).includes(rawGenre) ? rawGenre : "all"; const lang = rawLang && (LANGUAGES as readonly string[]).includes(rawLang) ? rawLang : "all"; + const contentType: ContentTypeFilterValue = rawType && (CONTENT_TYPES as readonly string[]).includes(rawType) ? (rawType as ContentTypeFilterValue) : "all"; const showNsfw = rawNsfw === "1"; const supabase = createServerClient(); let storylines: Storyline[] = []; if (supabase) { - storylines = await queryTab(supabase, tab, writer, page, genre, lang, showNsfw); + storylines = await queryTab(supabase, tab, writer, page, genre, lang, contentType, showNsfw); } return ( @@ -51,7 +52,7 @@ export default async function Home({

- + {/* Section label */}

@@ -66,7 +67,7 @@ export default async function Home({
{page > 1 && ( ← Previous @@ -75,7 +76,7 @@ export default async function Home({ Page {page} {storylines.length === PAGE_SIZE && ( Next → @@ -106,11 +107,12 @@ export default async function Home({ ); } -function buildPageHref(tab: string, writer: string, page: number, genre: string, lang: string, showNsfw?: boolean): string { +function buildPageHref(tab: string, writer: string, page: number, genre: string, lang: string, contentType: string, showNsfw?: boolean): string { const params = new URLSearchParams({ tab }); if (writer !== "all") params.set("writer", writer); if (genre !== "all") params.set("genre", genre); if (lang !== "all") params.set("lang", lang); + if (contentType !== "all") params.set("type", contentType); if (showNsfw) params.set("nsfw", "1"); if (page > 1) params.set("page", String(page)); return `/?${params.toString()}`; @@ -123,6 +125,7 @@ async function queryTab( page: number, genre: string, lang: string, + contentType: string, showNsfw: boolean, ): Promise { const from = (page - 1) * PAGE_SIZE; @@ -134,6 +137,7 @@ async function queryTab( if (writer === "agent") filtered = filtered.eq("writer_type", 1); if (genre !== "all") filtered = filtered.eq("genre", genre); if (lang !== "all") filtered = filtered.eq("language", lang); + if (contentType !== "all") filtered = filtered.eq("content_type", contentType); filtered = filtered.eq("is_nsfw", showNsfw); return filtered; } @@ -158,14 +162,16 @@ async function queryTab( const wt = writer === "human" ? 0 : writer === "agent" ? 1 : undefined; const g = genre !== "all" ? genre : undefined; const l = lang !== "all" ? lang : undefined; - return getTrendingStorylines(supabase, PAGE_SIZE, wt, from, g, l, showNsfw); + const ct = contentType !== "all" ? contentType : undefined; + return getTrendingStorylines(supabase, PAGE_SIZE, wt, from, g, l, ct, showNsfw); } case "mcap": { const wt = writer === "human" ? 0 : writer === "agent" ? 1 : undefined; const g = genre !== "all" ? genre : undefined; const l = lang !== "all" ? lang : undefined; - return getMcapStorylines(supabase, PAGE_SIZE, wt, from, g, l, showNsfw); + const ct = contentType !== "all" ? contentType : undefined; + return getMcapStorylines(supabase, PAGE_SIZE, wt, from, g, l, ct, showNsfw); } } } diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index 7189560..c7a92cb 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from "react"; import { useRouter } from "next/navigation"; -import { GENRES, LANGUAGES } from "../../lib/genres"; +import { GENRES, LANGUAGES, CONTENT_TYPES } from "../../lib/genres"; const SORT_OPTIONS = [ { value: "new", label: "New" }, @@ -18,20 +18,30 @@ const WRITER_OPTIONS = [ export type WriterFilterValue = "all" | "human" | "agent"; +const CONTENT_TYPE_OPTIONS = [ + { value: "all", label: "All" }, + { value: "fiction", label: "Fiction" }, + { value: "cartoon", label: "Cartoon" }, +] as const; + +export type ContentTypeFilterValue = "all" | "fiction" | "cartoon"; + interface FilterBarProps { writer: string; genre: string; lang: string; + contentType: string; tab: string; totalCount?: number; showNsfw?: boolean; } -function buildHref(params: { tab: string; writer: string; genre: string; lang: string; nsfw?: boolean }) { +function buildHref(params: { tab: string; writer: string; genre: string; lang: string; contentType: string; nsfw?: boolean }) { const sp = new URLSearchParams({ tab: params.tab }); if (params.writer !== "all") sp.set("writer", params.writer); if (params.genre !== "all") sp.set("genre", params.genre); if (params.lang !== "all") sp.set("lang", params.lang); + if (params.contentType !== "all") sp.set("type", params.contentType); if (params.nsfw) sp.set("nsfw", "1"); return `/?${sp.toString()}`; } @@ -41,6 +51,7 @@ function FilterSheetContent({ writer, genre, lang, + contentType, nsfw, onApply, }: { @@ -48,12 +59,14 @@ function FilterSheetContent({ writer: string; genre: string; lang: string; + contentType: string; nsfw: boolean; - onApply: (w: string, g: string, l: string, nsfw: boolean) => void; + onApply: (w: string, g: string, l: string, ct: string, nsfw: boolean) => void; }) { const [localWriter, setLocalWriter] = useState(writer); const [localGenre, setLocalGenre] = useState(genre); const [localLang, setLocalLang] = useState(lang); + const [localContentType, setLocalContentType] = useState(contentType); const [localNsfw, setLocalNsfw] = useState(nsfw); useEffect(() => { @@ -89,6 +102,26 @@ function FilterSheetContent({
+ {/* Content Type */} +
+
Type
+
+ {CONTENT_TYPE_OPTIONS.map(({ value, label }) => ( + + ))} +
+
+ {/* Genre */}
Genre
@@ -134,7 +167,7 @@ function FilterSheetContent({ {/* Apply */}
+ {/* Content type pills */} +
+ {CONTENT_TYPE_OPTIONS.map(({ value, label }) => ( + + ))} +
+ {/* Genre pill */}
navigate({ tab, writer, genre, lang: e.target.value, nsfw })} + onChange={(e) => navigate({ tab, writer, genre, lang: e.target.value, contentType, nsfw })} className={`rounded-full border px-3 py-1.5 text-[12px] font-medium transition-colors focus:border-[var(--accent)] focus:outline-none ${ lang !== "all" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" @@ -322,6 +375,7 @@ export function FilterBar({ writer, genre, lang, tab, totalCount, showNsfw = fal writer={writer} genre={genre} lang={lang} + contentType={contentType} nsfw={nsfw} onApply={handleApplyFilters} /> diff --git a/src/components/StoryCard.tsx b/src/components/StoryCard.tsx index a5cf6c3..32da614 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, contentType }: { genre?: string | null; writerType: number | null; status: string; contentType?: string }) { +function Badges({ genre, writerType, status }: { genre?: string | null; writerType: number | null; status: string }) { const isActive = status === "active"; return (
@@ -43,11 +43,19 @@ function Badges({ genre, writerType, status, contentType }: { genre?: string | n Ongoing )} - {contentType === "cartoon" && ( - - Cartoon - - )} +
+ ); +} + +function CartoonIndicator({ contentType }: { contentType?: string }) { + if (contentType !== "cartoon") return null; + return ( +
+ + + + +
); } @@ -86,8 +94,9 @@ export function StoryCard({ />
- + +

@@ -112,8 +121,9 @@ export function StoryCard({
- - + + + {/* Centered title with accent line */}
diff --git a/src/components/__tests__/FilterBar.test.tsx b/src/components/__tests__/FilterBar.test.tsx index dcf02d6..1b8c93c 100644 --- a/src/components/__tests__/FilterBar.test.tsx +++ b/src/components/__tests__/FilterBar.test.tsx @@ -14,6 +14,7 @@ const defaultProps = { writer: "all", genre: "all", lang: "all", + contentType: "all", tab: "new", }; @@ -31,7 +32,7 @@ describe("FilterBar", () => { it("renders writer pill buttons", () => { render(); - expect(screen.getByText("All")).toBeInTheDocument(); + expect(screen.getAllByText("All").length).toBeGreaterThan(0); expect(screen.getByText("Human")).toBeInTheDocument(); expect(screen.getByText("Agent")).toBeInTheDocument(); }); diff --git a/src/components/__tests__/StoryCard.test.tsx b/src/components/__tests__/StoryCard.test.tsx index 6fa1ee8..7d45f04 100644 --- a/src/components/__tests__/StoryCard.test.tsx +++ b/src/components/__tests__/StoryCard.test.tsx @@ -91,13 +91,13 @@ describe("StoryCard", () => { expect(screen.getByText("AI Writer")).toBeInTheDocument(); }); - it("shows Cartoon badge when content_type is cartoon", () => { + it("shows Cartoon indicator when content_type is cartoon", () => { render(); - expect(screen.getAllByText("Cartoon").length).toBeGreaterThan(0); + expect(screen.getAllByTitle("Cartoon").length).toBeGreaterThan(0); }); - it("does not show Cartoon badge for fiction stories", () => { + it("does not show Cartoon indicator for fiction stories", () => { render(); - expect(screen.queryByText("Cartoon")).not.toBeInTheDocument(); + expect(screen.queryByTitle("Cartoon")).not.toBeInTheDocument(); }); }); From 50489f31cf043383535447284880744c5a5d23b2 Mon Sep 17 00:00:00 2001 From: project7 Date: Sun, 17 May 2026 09:40:09 +0900 Subject: [PATCH 2/2] Respect explicit ?type= URL param over saved localStorage preference (#1221) Skip localStorage restore when URL already has a type param, preventing saved content type from overriding shared links. Also validates saved value before using it. Co-Authored-By: Claude Opus 4.6 --- src/components/FilterBar.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index c7a92cb..fd94f5f 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -190,13 +190,14 @@ export function FilterBar({ writer, genre, lang, contentType, tab, totalCount, s useEffect(() => { const urlParams = new URLSearchParams(window.location.search); - if (urlParams.has("lang") || urlParams.has("nsfw")) return; + if (urlParams.has("lang") || urlParams.has("nsfw") || urlParams.has("type")) return; try { const savedLang = localStorage.getItem("plotlink_lang"); const savedContentType = localStorage.getItem("plotlink_content_type") || "all"; + const validContentType = savedContentType !== "all" && (CONTENT_TYPES as readonly string[]).includes(savedContentType) ? savedContentType : "all"; const savedNsfw = localStorage.getItem("plotlink_nsfw") === "1"; - if ((savedLang && savedLang !== "all" && (LANGUAGES as readonly string[]).includes(savedLang)) || savedNsfw || (savedContentType !== "all" && (CONTENT_TYPES as readonly string[]).includes(savedContentType))) { - router.replace(buildHref({ tab, writer, genre, lang: savedLang && savedLang !== "all" ? savedLang : lang, contentType: savedContentType !== "all" ? savedContentType : contentType, nsfw: savedNsfw })); + if ((savedLang && savedLang !== "all" && (LANGUAGES as readonly string[]).includes(savedLang)) || savedNsfw || validContentType !== "all") { + router.replace(buildHref({ tab, writer, genre, lang: savedLang && savedLang !== "all" ? savedLang : lang, contentType: validContentType !== "all" ? validContentType : contentType, nsfw: savedNsfw })); } } catch {} }, []); // eslint-disable-line react-hooks/exhaustive-deps