From 5d15afb8a1fd24cba47c507139a1327eccc72974 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 18 Mar 2026 12:07:56 +0000 Subject: [PATCH 1/2] [#308] GitHub-style inline filter bar with clickable params New FilterBar component replaces separate Writer/Genre/Language/Sort controls with a single bordered box of label:value tokens. Values in accent green, clicking opens positioned dropdown below. Only one dropdown open at a time, outside click closes. Mobile: sort collapses to icon only so everything fits one line. Fixes #308 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/page.tsx | 14 +-- src/components/FilterBar.tsx | 230 +++++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 src/components/FilterBar.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 64d7c4c5..aa0cf9cd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,9 +2,8 @@ import { createServerClient, type Storyline } from "../../lib/supabase"; import { STORY_FACTORY } from "../../lib/contracts/constants"; import { getTrendingStorylines, getRisingStorylines } from "../../lib/ranking"; import { StoryCard } from "../components/StoryCard"; -import { SortDropdown } from "../components/SortDropdown"; -import { WriterFilter, type WriterFilterValue } from "../components/WriterFilter"; -import { GenreFilter, LanguageFilter } from "../components/GenreLanguageFilter"; +import { FilterBar } from "../components/FilterBar"; +import { type WriterFilterValue } from "../components/WriterFilter"; import { GENRES, LANGUAGES } from "../../lib/genres"; import Link from "next/link"; @@ -42,8 +41,6 @@ export default async function Home({ storylines = await queryTab(supabase, tab, writer, page, genre, lang); } - const extraParams = writer !== "all" ? { writer } : undefined; - return (
{/* Compact hero */} @@ -57,12 +54,7 @@ export default async function Home({ {/* Filter bar */} -
- - - - -
+ {/* Story grid */}
diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx new file mode 100644 index 00000000..e64afd84 --- /dev/null +++ b/src/components/FilterBar.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { GENRES, LANGUAGES } from "../../lib/genres"; + +const WRITER_OPTIONS = ["All", "Human", "AI"] as const; +const SORT_OPTIONS = [ + { value: "new", label: "Recent" }, + { value: "trending", label: "Trending" }, + { value: "rising", label: "Rising" }, + { value: "completed", label: "Completed" }, +] as const; + +type FilterKey = "writer" | "genre" | "lang" | "sort"; + +interface FilterBarProps { + writer: string; + genre: string; + lang: string; + tab: string; +} + +function buildHref(params: { tab: string; writer: string; genre: string; lang: string }) { + 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); + return `/?${sp.toString()}`; +} + +function writerDisplay(v: string) { + if (v === "agent") return "AI"; + return v.charAt(0).toUpperCase() + v.slice(1); +} + +function sortLabel(tab: string) { + return SORT_OPTIONS.find((o) => o.value === tab)?.label ?? "Recent"; +} + +export function FilterBar({ writer, genre, lang, tab }: FilterBarProps) { + const [open, setOpen] = useState(null); + const barRef = useRef(null); + const router = useRouter(); + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (barRef.current && !barRef.current.contains(e.target as Node)) { + setOpen(null); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + function toggle(key: FilterKey) { + setOpen(open === key ? null : key); + } + + function navigate(params: { tab: string; writer: string; genre: string; lang: string }) { + setOpen(null); + router.push(buildHref(params)); + } + + return ( +
+
+ {/* Writer token */} + toggle("writer")} + /> + + {/* Genre token */} + toggle("genre")} + /> + + {/* Language token */} + toggle("lang")} + /> + + {/* Spacer */} +
+ + {/* Sort — icon on mobile, full label on sm+ */} + +
+ + {/* Dropdowns */} + {open === "writer" && ( + + {WRITER_OPTIONS.map((opt) => { + const val = opt.toLowerCase() === "ai" ? "agent" : opt.toLowerCase(); + return ( + navigate({ tab, writer: val, genre, lang })} + /> + ); + })} + + )} + + {open === "genre" && ( + + navigate({ tab, writer, genre: "all", lang })} + /> + {GENRES.map((g) => ( + navigate({ tab, writer, genre: g, lang })} + /> + ))} + + )} + + {open === "lang" && ( + + navigate({ tab, writer, genre, lang: "all" })} + /> + {LANGUAGES.map((l) => ( + navigate({ tab, writer, genre, lang: l })} + /> + ))} + + )} + + {open === "sort" && ( + + {SORT_OPTIONS.map(({ value, label }) => ( + navigate({ tab: value, writer, genre, lang })} + /> + ))} + + )} +
+ ); +} + +function Token({ + label, + value, + active, + onClick, +}: { + label: string; + value: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function Dropdown({ children, align }: { children: React.ReactNode; align?: "right" }) { + return ( +
+ {children} +
+ ); +} + +function DropdownItem({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} From 8a8154eecf7e0bbbc40eca971bcc004da2e2839d Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 18 Mar 2026 12:10:39 +0000 Subject: [PATCH 2/2] [#308] Position dropdowns below each token, not bar edges Each token now wrapped in relative container so its dropdown opens directly below the clicked token. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/FilterBar.tsx | 205 ++++++++++++++++++----------------- 1 file changed, 104 insertions(+), 101 deletions(-) diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index e64afd84..e3d521d3 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -63,113 +63,116 @@ export function FilterBar({ writer, genre, lang, tab }: FilterBarProps) { } return ( -
+
- {/* Writer token */} - toggle("writer")} - /> - - {/* Genre token */} - toggle("genre")} - /> - - {/* Language token */} - toggle("lang")} - /> + {/* Writer token + dropdown */} +
+ toggle("writer")} + /> + {open === "writer" && ( + + {WRITER_OPTIONS.map((opt) => { + const val = opt.toLowerCase() === "ai" ? "agent" : opt.toLowerCase(); + return ( + navigate({ tab, writer: val, genre, lang })} + /> + ); + })} + + )} +
+ + {/* Genre token + dropdown */} +
+ toggle("genre")} + /> + {open === "genre" && ( + + navigate({ tab, writer, genre: "all", lang })} + /> + {GENRES.map((g) => ( + navigate({ tab, writer, genre: g, lang })} + /> + ))} + + )} +
+ + {/* Language token + dropdown */} +
+ toggle("lang")} + /> + {open === "lang" && ( + + navigate({ tab, writer, genre, lang: "all" })} + /> + {LANGUAGES.map((l) => ( + navigate({ tab, writer, genre, lang: l })} + /> + ))} + + )} +
{/* Spacer */}
- {/* Sort — icon on mobile, full label on sm+ */} - + {/* Sort token + dropdown */} +
+ + {open === "sort" && ( + + {SORT_OPTIONS.map(({ value, label }) => ( + navigate({ tab: value, writer, genre, lang })} + /> + ))} + + )} +
- - {/* Dropdowns */} - {open === "writer" && ( - - {WRITER_OPTIONS.map((opt) => { - const val = opt.toLowerCase() === "ai" ? "agent" : opt.toLowerCase(); - return ( - navigate({ tab, writer: val, genre, lang })} - /> - ); - })} - - )} - - {open === "genre" && ( - - navigate({ tab, writer, genre: "all", lang })} - /> - {GENRES.map((g) => ( - navigate({ tab, writer, genre: g, lang })} - /> - ))} - - )} - - {open === "lang" && ( - - navigate({ tab, writer, genre, lang: "all" })} - /> - {LANGUAGES.map((l) => ( - navigate({ tab, writer, genre, lang: l })} - /> - ))} - - )} - - {open === "sort" && ( - - {SORT_OPTIONS.map(({ value, label }) => ( - navigate({ tab: value, writer, genre, lang })} - /> - ))} - - )}
); }