diff --git a/src/app/api/storyline/[storylineId]/metadata/route.ts b/src/app/api/storyline/[storylineId]/metadata/route.ts new file mode 100644 index 00000000..78f0b2ad --- /dev/null +++ b/src/app/api/storyline/[storylineId]/metadata/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from "next/server"; +import { type Address } from "viem"; +import { publicClient } from "../../../../../../lib/rpc"; +import { createServerClient } from "../../../../../../lib/supabase"; +import { STORY_FACTORY } from "../../../../../../lib/contracts/constants"; +import { GENRES, LANGUAGES } from "../../../../../../lib/genres"; + +function error(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +interface PatchBody { + genre?: string; + language?: string; + address: string; + signature: string; + message: string; +} + +// --------------------------------------------------------------------------- +// PATCH /api/storyline/[storylineId]/metadata +// --------------------------------------------------------------------------- + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ storylineId: string }> }, +) { + const { storylineId: storylineIdParam } = await params; + const storylineId = Number(storylineIdParam); + if (!storylineId || !Number.isInteger(storylineId)) { + return error("Invalid storylineId"); + } + + let body: PatchBody; + try { + body = await req.json(); + } catch { + return error("Invalid JSON body"); + } + + const { genre, language, address, signature, message } = body; + + if (!address || !signature || !message) { + return error("Missing address, signature, or message"); + } + + if (!genre && !language) { + return error("Must provide genre or language"); + } + + if (genre && !(GENRES as readonly string[]).includes(genre)) { + return error("Invalid genre"); + } + + if (language && !(LANGUAGES as readonly string[]).includes(language)) { + return error("Invalid language"); + } + + // Build expected message to prevent replay / cross-action attacks + const expectedMessage = `Update storyline ${storylineId} metadata genre:${genre || ""} language:${language || ""}`; + if (message !== expectedMessage) { + return error(`Signed message must be exactly: "${expectedMessage}"`); + } + + // Verify signature (supports both EOA and EIP-1271 contract wallets) + const callerAddress = address as Address; + try { + const valid = await publicClient.verifyMessage({ + address: callerAddress, + message, + signature: signature as `0x${string}`, + }); + if (!valid) { + return error("Invalid signature"); + } + } catch { + return error("Failed to verify signature"); + } + + const db = createServerClient(); + if (!db) { + return error("Supabase not configured", 500); + } + + // Validate caller is the storyline writer + const { data: storyline, error: fetchErr } = await db + .from("storylines") + .select("writer_address") + .eq("storyline_id", storylineId) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .single(); + + if (fetchErr || !storyline) { + return error("Storyline not found", 404); + } + + if (storyline.writer_address.toLowerCase() !== callerAddress.toLowerCase()) { + return error("Not the storyline writer", 403); + } + + // Build update payload + const update: Record = {}; + if (genre) update.genre = genre; + if (language) update.language = language; + + const { data: updated, error: updateErr } = await db + .from("storylines") + .update(update) + .eq("storyline_id", storylineId) + .eq("contract_address", STORY_FACTORY.toLowerCase()) + .select() + .single(); + + if (updateErr) { + return error(`Database error: ${updateErr.message}`, 500); + } + + return NextResponse.json({ storyline: updated }); +} diff --git a/src/app/dashboard/writer/page.tsx b/src/app/dashboard/writer/page.tsx index 1cbdf2ec..27aadab3 100644 --- a/src/app/dashboard/writer/page.tsx +++ b/src/app/dashboard/writer/page.tsx @@ -1,16 +1,18 @@ "use client"; import { useState } from "react"; -import { useAccount } from "wagmi"; -import { useQuery } from "@tanstack/react-query"; +import { useAccount, useSignMessage } from "wagmi"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { formatUnits } from "viem"; import { supabase, type Storyline } from "../../../../lib/supabase"; import { getTokenTVL } from "../../../../lib/price"; import { RESERVE_LABEL, STORY_FACTORY } from "../../../../lib/contracts/constants"; +import { GENRES, LANGUAGES } from "../../../../lib/genres"; import { DeadlineCountdown } from "../../../components/DeadlineCountdown"; import { ClaimRoyalties } from "../../../components/ClaimRoyalties"; import { WriterTradingStats } from "../../../components/WriterTradingStats"; import { WriterIdentityClient } from "../../../components/WriterIdentityClient"; +import { DropdownSelect } from "../../../components/DropdownSelect"; import Link from "next/link"; import { ConnectWallet } from "../../../components/ConnectWallet"; import { type Address } from "viem"; @@ -38,6 +40,12 @@ async function fetchWriterStorylines( return data ?? []; } +const genreOptions = [ + { value: "", label: "Select genre..." }, + ...GENRES.map((g) => ({ value: g, label: g })), +]; +const languageOptions = LANGUAGES.map((l) => ({ value: l, label: l })); + export default function WriterDashboard() { const { address, isConnected } = useAccount(); @@ -109,6 +117,14 @@ function StorylineDetail({ storyline, writerAddress }: { storyline: Storyline; w )} + {!storyline.genre && ( + + )} +
@@ -166,6 +182,95 @@ function StorylineDetail({ storyline, writerAddress }: { storyline: Storyline; w ); } +function GenrePrompt({ + storylineId, + language, + writerAddress, +}: { + storylineId: number; + language: string; + writerAddress: string; +}) { + const [genre, setGenre] = useState(""); + const [lang, setLang] = useState(language || "English"); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(null); + const queryClient = useQueryClient(); + const { signMessageAsync } = useSignMessage(); + + async function handleSave() { + if (!genre) return; + setSaving(true); + setErr(null); + try { + const langValue = language ? "" : lang; + const message = `Update storyline ${storylineId} metadata genre:${genre} language:${langValue}`; + const signature = await signMessageAsync({ message }); + + const res = await fetch(`/api/storyline/${storylineId}/metadata`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + genre, + ...(language ? {} : { language: lang }), + address: writerAddress, + signature, + message, + }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `Error (${res.status})`); + } + queryClient.invalidateQueries({ queryKey: ["writer-storylines"] }); + } catch (e) { + setErr(e instanceof Error ? e.message : "Failed to save"); + } finally { + setSaving(false); + } + } + + return ( +
+

+ Set your genre + + {" — "}improve discoverability by categorizing your story. + +

+
+
+ +
+ {!language && ( +
+ +
+ )} + +
+ {err &&

{err}

} +
+ ); +} + function DonationCount({ storylineId, tokenAddress }: { storylineId: number; tokenAddress: string }) { const { data } = useQuery({ queryKey: ["donation-count", storylineId, tokenAddress], diff --git a/src/components/StoryCard.tsx b/src/components/StoryCard.tsx index 92ffcd93..3bd53a57 100644 --- a/src/components/StoryCard.tsx +++ b/src/components/StoryCard.tsx @@ -47,11 +47,9 @@ export function StoryCard({
{dateStr && {dateStr}} - {(genre || storyline.genre) && ( - - {genre || storyline.genre} - - )} + + {genre || storyline.genre || Uncategorized} + {storyline.language && storyline.language !== "English" && ( {storyline.language}