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 }) => (
+ navigate({ tab, writer, genre, lang, contentType: value, nsfw })}
+ className={`rounded-full px-2.5 py-1 text-[12px] font-medium transition-colors ${
+ contentType === value
+ ? "bg-[var(--accent)] text-white"
+ : "text-[var(--muted)] hover:text-[var(--fg)]"
+ }`}
+ >
+ {label}
+
+ ))}
+
+
{/* Genre pill */}