Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/app/api/storyline/[storylineId]/metadata/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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 });
}
109 changes: 107 additions & 2 deletions src/app/dashboard/writer/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -109,6 +117,14 @@ function StorylineDetail({ storyline, writerAddress }: { storyline: Storyline; w
)}
</div>

{!storyline.genre && (
<GenrePrompt
storylineId={storyline.storyline_id}
language={storyline.language}
writerAddress={writerAddress}
/>
)}

<div className="text-muted mt-3 grid grid-cols-4 gap-2 text-xs">
<div>
<span className="block text-[10px] uppercase tracking-wider">
Expand Down Expand Up @@ -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<string | null>(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 (
<div className="border-accent/30 bg-surface mt-2 rounded border px-3 py-2.5">
<p className="text-foreground text-xs font-medium">
Set your genre
<span className="text-muted font-normal">
{" — "}improve discoverability by categorizing your story.
</span>
</p>
<div className="mt-2 flex items-end gap-2">
<div className="min-w-0 flex-1">
<DropdownSelect
value={genre}
onChange={setGenre}
options={genreOptions}
placeholder="Select genre..."
disabled={saving}
/>
</div>
{!language && (
<div className="min-w-0 flex-1">
<DropdownSelect
value={lang}
onChange={setLang}
options={languageOptions}
disabled={saving}
/>
</div>
)}
<button
onClick={handleSave}
disabled={!genre || saving}
className="border-accent text-accent hover:bg-accent hover:text-background shrink-0 rounded border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
{err && <p className="text-error mt-1 text-[10px]">{err}</p>}
</div>
);
}

function DonationCount({ storylineId, tokenAddress }: { storylineId: number; tokenAddress: string }) {
const { data } = useQuery({
queryKey: ["donation-count", storylineId, tokenAddress],
Expand Down
8 changes: 3 additions & 5 deletions src/components/StoryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,9 @@ export function StoryCard({
</span>
<ViewCount storylineId={storyline.storyline_id} initialCount={storyline.view_count} />
{dateStr && <span>{dateStr}</span>}
{(genre || storyline.genre) && (
<span className="border-border rounded border px-1.5 py-0.5 text-[10px]">
{genre || storyline.genre}
</span>
)}
<span className="border-border rounded border px-1.5 py-0.5 text-[10px]">
{genre || storyline.genre || <span className="text-muted italic">Uncategorized</span>}
</span>
{storyline.language && storyline.language !== "English" && (
<span className="border-border rounded border px-1.5 py-0.5 text-[10px]">
{storyline.language}
Expand Down
Loading