From 3f853ef48f8ef16587e546931f7746861baa62c4 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 17 Mar 2026 16:01:20 +0000 Subject: [PATCH 1/3] [#265] Add genre and language metadata to storylines - Migration 00014: add genre (nullable) and language (default English) columns with indexes - Types: add genre/language to Storyline Row/Insert/Update - Constants: lib/genres.ts with GENRES and LANGUAGES arrays - Create form: genre (required) and language (default English) dropdowns - usePublish: forward optional metadata to indexer POST body - Storyline indexer: accept and store genre/language - Discovery page: genre and language filter dropdowns (all four tabs) - StoryCard: display genre badge + non-English language badge - Story detail: display genre + language in header metadata Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/genres.ts | 40 +++++++++++++++++ lib/supabase.ts | 6 +++ src/app/api/index/storyline/route.ts | 4 ++ src/app/create/page.tsx | 38 ++++++++++++++++- src/app/page.tsx | 33 ++++++++++---- src/app/story/[storylineId]/page.tsx | 10 +++++ src/components/GenreLanguageFilter.tsx | 45 ++++++++++++++++++++ src/components/StoryCard.tsx | 9 +++- src/hooks/usePublish.ts | 3 +- supabase/migrations/00014_genre_language.sql | 10 +++++ 10 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 lib/genres.ts create mode 100644 src/components/GenreLanguageFilter.tsx create mode 100644 supabase/migrations/00014_genre_language.sql diff --git a/lib/genres.ts b/lib/genres.ts new file mode 100644 index 00000000..e55ab0a8 --- /dev/null +++ b/lib/genres.ts @@ -0,0 +1,40 @@ +export const GENRES = [ + "Romance", + "Fantasy", + "Science Fiction", + "Mystery", + "Thriller", + "Horror", + "Adventure", + "Historical Fiction", + "Contemporary Lit", + "Humor", + "Poetry", + "Non-Fiction", + "Fanfiction", + "Short Story", + "Paranormal", + "Werewolf", + "LGBTQ+", + "New Adult", + "Teen Fiction", + "Diverse Lit", + "Others", +] as const; + +export const LANGUAGES = [ + "English", + "Chinese", + "Korean", + "Japanese", + "Spanish", + "French", + "Hindi", + "Arabic", + "Portuguese", + "Russian", + "Others", +] as const; + +export type Genre = (typeof GENRES)[number]; +export type Language = (typeof LANGUAGES)[number]; diff --git a/lib/supabase.ts b/lib/supabase.ts index aa1650d0..cae91704 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -62,6 +62,8 @@ export interface Database { indexed_at: string; view_count: number; contract_address: string; + genre: string | null; + language: string; }; Insert: { id?: never; @@ -81,6 +83,8 @@ export interface Database { indexed_at?: string; view_count?: number; contract_address: string; + genre?: string | null; + language?: string; }; Update: { id?: never; @@ -100,6 +104,8 @@ export interface Database { indexed_at?: string; view_count?: number; contract_address?: string; + genre?: string | null; + language?: string; }; Relationships: []; }; diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index a14eb3cd..4ef106d2 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -28,6 +28,8 @@ export async function POST(req: Request) { const body = await req.json(); const txHash = body.txHash as Hex | undefined; const fallbackContent = body.content as string | undefined; + const genre = (body.genre as string | undefined) || null; + const language = (body.language as string | undefined) || "English"; if (!txHash || !/^0x[0-9a-fA-F]{64}$/.test(txHash)) { return error("Missing or invalid txHash"); @@ -139,6 +141,8 @@ export async function POST(req: Request) { tx_hash: txHash.toLowerCase(), log_index: storylineLog.logIndex!, contract_address: STORY_FACTORY.toLowerCase(), + genre, + language, }; const { error: dbError } = await supabase.from("storylines").upsert( diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index 0ff2ffc6..e84868ce 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -13,6 +13,7 @@ import { STORY_FACTORY } from "../../../lib/contracts/constants"; import { decodeEventLog, encodeEventTopics } from "viem"; import Link from "next/link"; import { ConnectWallet } from "../../components/ConnectWallet"; +import { GENRES, LANGUAGES } from "../../../lib/genres"; const STORYLINE_CREATED_TOPIC = encodeEventTopics({ abi: [storylineCreatedEvent], @@ -32,15 +33,18 @@ const STATE_LABELS: Record = { export default function CreateStorylinePage() { const { isConnected } = useAccount(); const [title, setTitle] = useState(""); + const [genre, setGenre] = useState(""); + const [language, setLanguage] = useState("English"); const [content, setContent] = useState(""); const hasDeadline = true; // mandatory 7-day deadline for all storylines const { state, error, receipt, execute, reset } = usePublish(); const { valid, charCount } = validateContentLength(content); const titleValid = title.trim().length > 0; + const genreValid = genre.length > 0; const canSubmit = state === "idle" || state === "error" - ? titleValid && valid + ? titleValid && genreValid && valid : false; if (!isConnected) { @@ -119,6 +123,7 @@ export default function CreateStorylinePage() { args: [title.trim(), cid, contentHash, hasDeadline], gas: BigInt(16_000_000), }), + metadata: { genre, language }, }); }} className="mt-8 space-y-6" @@ -136,6 +141,37 @@ export default function CreateStorylinePage() { /> + {/* Genre + Language */} +
+
+ + +
+
+ + +
+
+ {/* Content */}
diff --git a/src/components/GenreLanguageFilter.tsx b/src/components/GenreLanguageFilter.tsx new file mode 100644 index 00000000..bb533f70 --- /dev/null +++ b/src/components/GenreLanguageFilter.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { GENRES, LANGUAGES } from "../../lib/genres"; + +export function GenreFilter({ active, tab, writer, lang }: { active: string; tab: string; writer: string; lang: string }) { + return ( + + ); +} + +export function LanguageFilter({ active, tab, writer, genre }: { active: string; tab: string; writer: string; genre: string }) { + return ( + + ); +} diff --git a/src/components/StoryCard.tsx b/src/components/StoryCard.tsx index 13ec5bf1..92ffcd93 100644 --- a/src/components/StoryCard.tsx +++ b/src/components/StoryCard.tsx @@ -47,9 +47,14 @@ export function StoryCard({ {dateStr && {dateStr}} - {genre && ( + {(genre || storyline.genre) && ( - {genre} + {genre || storyline.genre} + + )} + {storyline.language && storyline.language !== "English" && ( + + {storyline.language} )} {storyline.writer_type === 1 && } diff --git a/src/hooks/usePublish.ts b/src/hooks/usePublish.ts index 07c0c951..11af6646 100644 --- a/src/hooks/usePublish.ts +++ b/src/hooks/usePublish.ts @@ -28,6 +28,7 @@ interface PublishOptions { uploadKeyPrefix: string; indexerRoute: string; buildWriteCall: (cid: string, contentHash: Hex) => WriteCall; + metadata?: Record; } /** @@ -93,7 +94,7 @@ export function usePublish() { await fetch(opts.indexerRoute, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ txHash: hash, content: opts.content }), + body: JSON.stringify({ txHash: hash, content: opts.content, ...opts.metadata }), }); // 5. Done diff --git a/supabase/migrations/00014_genre_language.sql b/supabase/migrations/00014_genre_language.sql new file mode 100644 index 00000000..b1436389 --- /dev/null +++ b/supabase/migrations/00014_genre_language.sql @@ -0,0 +1,10 @@ +-- Add genre and language metadata to storylines. +-- genre: nullable (existing storylines = uncategorized) +-- language: defaults to 'English' + +ALTER TABLE storylines + ADD COLUMN genre text, + ADD COLUMN language text NOT NULL DEFAULT 'English'; + +CREATE INDEX idx_storylines_genre ON storylines (genre); +CREATE INDEX idx_storylines_language ON storylines (language); From f610ce1242716f29bdc3d3fa0d5af39547316599 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 17 Mar 2026 16:03:54 +0000 Subject: [PATCH 2/3] [#265] Apply genre/language filters to trending and rising tabs Add genre/lang params to fetchCandidatesAndRatings, getTrendingStorylines, and getRisingStorylines. Discovery page now passes filters to all four tabs. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/ranking.ts | 17 ++++++++++++++--- src/app/page.tsx | 8 ++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/lib/ranking.ts b/lib/ranking.ts index 2c550be6..24e3d9cc 100644 --- a/lib/ranking.ts +++ b/lib/ranking.ts @@ -57,7 +57,12 @@ function computeTrendScore( } /** Shared: fetch storyline candidates + batch ratings */ -async function fetchCandidatesAndRatings(supabase: SupabaseClient, writerType?: number) { +async function fetchCandidatesAndRatings( + supabase: SupabaseClient, + writerType?: number, + genre?: string, + lang?: string, +) { let q = supabase.from("storylines") .select("*") .eq("hidden", false) @@ -65,6 +70,8 @@ async function fetchCandidatesAndRatings(supabase: SupabaseClient, wri .neq("token_address", "") .eq("contract_address", STORY_FACTORY.toLowerCase()); if (writerType !== undefined) q = q.eq("writer_type", writerType); + if (genre) q = q.eq("genre", genre); + if (lang) q = q.eq("language", lang); const { data } = await q .order("block_timestamp", { ascending: false }) .limit(50); @@ -120,8 +127,10 @@ export async function getTrendingStorylines( limit = 20, writerType?: number, offset = 0, + genre?: string, + lang?: string, ): Promise { - const { storylines, ratingMap } = await fetchCandidatesAndRatings(supabase, writerType); + const { storylines, ratingMap } = await fetchCandidatesAndRatings(supabase, writerType, genre, lang); if (storylines.length === 0) return []; const enriched = await Promise.all( @@ -160,8 +169,10 @@ export async function getRisingStorylines( limit = 20, writerType?: number, offset = 0, + genre?: string, + lang?: string, ): Promise { - const { storylines } = await fetchCandidatesAndRatings(supabase, writerType); + const { storylines } = await fetchCandidatesAndRatings(supabase, writerType, genre, lang); if (storylines.length === 0) return []; const now = new Date(); diff --git a/src/app/page.tsx b/src/app/page.tsx index d17188f1..5662e58b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -190,12 +190,16 @@ async function queryTab( case "trending": { const wt = writer === "human" ? 0 : writer === "agent" ? 1 : undefined; - return getTrendingStorylines(supabase, PAGE_SIZE, wt, from); + const g = genre !== "all" ? genre : undefined; + const l = lang !== "all" ? lang : undefined; + return getTrendingStorylines(supabase, PAGE_SIZE, wt, from, g, l); } case "rising": { const wt = writer === "human" ? 0 : writer === "agent" ? 1 : undefined; - return getRisingStorylines(supabase, PAGE_SIZE, wt, from); + const g = genre !== "all" ? genre : undefined; + const l = lang !== "all" ? lang : undefined; + return getRisingStorylines(supabase, PAGE_SIZE, wt, from, g, l); } } } From 9344f9e14f73ce2096071d3ec22ed1e5fe222dc8 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 17 Mar 2026 16:05:17 +0000 Subject: [PATCH 3/3] [#265] Validate genre/language against allowed values on server Coerce unknown genre to null, unknown language to English. Prevents arbitrary metadata via direct POST. Uses shared GENRES/LANGUAGES arrays. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/index/storyline/route.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index 4ef106d2..97b90eb6 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -9,6 +9,7 @@ import { import { STORY_FACTORY } from "../../../../../lib/contracts/constants"; import { detectWriterType } from "../../../../../lib/contracts/erc8004"; import { hashContent } from "../../../../../lib/content"; +import { GENRES, LANGUAGES } from "../../../../../lib/genres"; import type { Database } from "../../../../../lib/supabase"; const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; @@ -28,8 +29,10 @@ export async function POST(req: Request) { const body = await req.json(); const txHash = body.txHash as Hex | undefined; const fallbackContent = body.content as string | undefined; - const genre = (body.genre as string | undefined) || null; - const language = (body.language as string | undefined) || "English"; + const rawGenre = body.genre as string | undefined; + const rawLanguage = body.language as string | undefined; + const genre = rawGenre && (GENRES as readonly string[]).includes(rawGenre) ? rawGenre : null; + const language = rawLanguage && (LANGUAGES as readonly string[]).includes(rawLanguage) ? rawLanguage : "English"; if (!txHash || !/^0x[0-9a-fA-F]{64}$/.test(txHash)) { return error("Missing or invalid txHash");