From 9794c4fc7a054d5b59367cf609080d7739376e9a Mon Sep 17 00:00:00 2001 From: project7 Date: Sun, 17 May 2026 08:34:22 +0900 Subject: [PATCH] Add cartoon content creation flow on /create page (#1215) - Fiction/Cartoon selector at top of create form (defaults to Fiction) - Fiction mode unchanged: text area with markdown support - Cartoon mode: multi-image upload via /api/upload-plot-images with drag-and-drop reordering, optional alt text and scene labels per panel - Vertical webtoon preview of uploaded panels - Generates markdown image sequence for on-chain storage, passes contentType: "cartoon" through publish/index metadata - Shows and enforces 10K character budget for generated markdown - Blocks invalid image types/sizes before upload - Tests for cartoon markdown generation (ordering, labels, budget) Co-Authored-By: Claude Opus 4.6 --- lib/cartoon-markdown.test.ts | 64 +++++++++ lib/cartoon-markdown.ts | 16 +++ src/app/create/page.tsx | 121 ++++++++++++---- src/components/CartoonPreview.tsx | 35 +++++ src/components/CartoonUploader.tsx | 218 +++++++++++++++++++++++++++++ 5 files changed, 423 insertions(+), 31 deletions(-) create mode 100644 lib/cartoon-markdown.test.ts create mode 100644 lib/cartoon-markdown.ts create mode 100644 src/components/CartoonPreview.tsx create mode 100644 src/components/CartoonUploader.tsx diff --git a/lib/cartoon-markdown.test.ts b/lib/cartoon-markdown.test.ts new file mode 100644 index 0000000..7e6d631 --- /dev/null +++ b/lib/cartoon-markdown.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { generateCartoonMarkdown, type CartoonPanel } from "./cartoon-markdown"; + +describe("generateCartoonMarkdown", () => { + it("generates markdown for a single panel", () => { + const panels: CartoonPanel[] = [ + { cid: "Qm123", url: "https://ipfs.filebase.io/ipfs/Qm123", alt: "hero shot", label: "" }, + ]; + const md = generateCartoonMarkdown(panels); + expect(md).toBe("![hero shot](https://ipfs.filebase.io/ipfs/Qm123)"); + }); + + it("generates markdown for multiple panels with separators", () => { + const panels: CartoonPanel[] = [ + { cid: "Qm1", url: "https://ipfs.filebase.io/ipfs/Qm1", alt: "panel 1", label: "" }, + { cid: "Qm2", url: "https://ipfs.filebase.io/ipfs/Qm2", alt: "panel 2", label: "" }, + ]; + const md = generateCartoonMarkdown(panels); + expect(md).toContain("---"); + expect(md.split("---")).toHaveLength(2); + }); + + it("includes scene labels as bold text before image", () => { + const panels: CartoonPanel[] = [ + { cid: "Qm1", url: "https://ipfs.filebase.io/ipfs/Qm1", alt: "scene", label: "Opening" }, + ]; + const md = generateCartoonMarkdown(panels); + expect(md).toContain("**Opening**"); + expect(md.indexOf("**Opening**")).toBeLessThan(md.indexOf("![")); + }); + + it("uses default alt text when empty", () => { + const panels: CartoonPanel[] = [ + { cid: "Qm1", url: "https://ipfs.filebase.io/ipfs/Qm1", alt: "", label: "" }, + ]; + const md = generateCartoonMarkdown(panels); + expect(md).toBe("![Panel 1](https://ipfs.filebase.io/ipfs/Qm1)"); + }); + + it("preserves panel ordering", () => { + const panels: CartoonPanel[] = [ + { cid: "Qm1", url: "https://ipfs.filebase.io/ipfs/Qm1", alt: "first", label: "" }, + { cid: "Qm2", url: "https://ipfs.filebase.io/ipfs/Qm2", alt: "second", label: "" }, + { cid: "Qm3", url: "https://ipfs.filebase.io/ipfs/Qm3", alt: "third", label: "" }, + ]; + const md = generateCartoonMarkdown(panels); + const firstIdx = md.indexOf("first"); + const secondIdx = md.indexOf("second"); + const thirdIdx = md.indexOf("third"); + expect(firstIdx).toBeLessThan(secondIdx); + expect(secondIdx).toBeLessThan(thirdIdx); + }); + + it("stays within 10K character budget for 20 panels", () => { + const panels: CartoonPanel[] = Array.from({ length: 20 }, (_, i) => ({ + cid: `QmFakeCid${i.toString().padStart(40, "0")}`, + url: `https://ipfs.filebase.io/ipfs/QmFakeCid${i.toString().padStart(40, "0")}`, + alt: `Panel ${i + 1}`, + label: "", + })); + const md = generateCartoonMarkdown(panels); + expect(md.length).toBeLessThan(10000); + }); +}); diff --git a/lib/cartoon-markdown.ts b/lib/cartoon-markdown.ts new file mode 100644 index 0000000..8792963 --- /dev/null +++ b/lib/cartoon-markdown.ts @@ -0,0 +1,16 @@ +export interface CartoonPanel { + cid: string; + url: string; + alt: string; + label: string; +} + +export function generateCartoonMarkdown(panels: CartoonPanel[]): string { + return panels + .map((p, i) => { + const alt = p.alt || `Panel ${i + 1}`; + const label = p.label ? `**${p.label}**\n\n` : ""; + return `${label}![${alt}](${p.url})`; + }) + .join("\n\n---\n\n"); +} diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index e10689c..802643d 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -24,9 +24,12 @@ import Link from "next/link"; import { ConnectWallet } from "../../components/ConnectWallet"; import { DropdownSelect } from "../../components/DropdownSelect"; import { Select } from "../../components/Select"; -import { GENRES, LANGUAGES } from "../../../lib/genres"; +import { GENRES, LANGUAGES, CONTENT_TYPES } from "../../../lib/genres"; import { WritePreviewToggle, ContentPreview } from "../../components/StoryContent"; import { PlotImageUpload } from "../../components/PlotImageUpload"; +import { CartoonUploader } from "../../components/CartoonUploader"; +import { CartoonPreview } from "../../components/CartoonPreview"; +import { generateCartoonMarkdown, type CartoonPanel } from "../../../lib/cartoon-markdown"; import { FALLBACK_STYLES } from "../../components/StoryCard"; import { getCoverUrl } from "../../../lib/cover"; @@ -138,10 +141,12 @@ function CreatePage() { const [tab, setTab] = useState(initialTab); // ---- New Storyline state ---- + const [contentType, setContentType] = useState<"fiction" | "cartoon">("fiction"); const [newTitle, setNewTitle] = useState(""); const [genre, setGenre] = useState(""); const [language, setLanguage] = useState("English"); const [newContent, setNewContent] = useState(""); + const [cartoonPanels, setCartoonPanels] = useState([]); const [newPreviewTab, setNewPreviewTab] = useState<"write" | "preview">("write"); const [coverCid, setCoverCid] = useState(null); const [coverUploading, setCoverUploading] = useState(false); @@ -151,6 +156,9 @@ function CreatePage() { const coverInputRef = useRef(null); const hasDeadline = true; + const cartoonMarkdown = useMemo(() => generateCartoonMarkdown(cartoonPanels), [cartoonPanels]); + const effectiveContent = contentType === "cartoon" ? cartoonMarkdown : newContent; + const handleCoverSelect = useCallback(async (file: File) => { setCoverError(null); if (!isConnected) { @@ -214,13 +222,14 @@ function CreatePage() { clearIntent: newClearIntent, attemptRetry: newAttemptRetry, } = usePublishIntent(); - const { valid: newValid, charCount: newCharCount } = validateContentLength(newContent); + const { valid: newValid, charCount: newCharCount } = validateContentLength(effectiveContent); const MAX_TITLE_LENGTH = 60; const newTitleValid = newTitle.trim().length > 0 && newTitle.length <= MAX_TITLE_LENGTH; const newGenreValid = genre.length > 0; + const cartoonHasImages = contentType === "cartoon" ? cartoonPanels.length > 0 : true; const newCanSubmit = newState === "idle" || newState === "error" - ? newTitleValid && newGenreValid && newValid && !coverUploading + ? newTitleValid && newGenreValid && newValid && !coverUploading && cartoonHasImages : false; const newBusy = newState !== "idle" && newState !== "error"; @@ -438,7 +447,7 @@ function CreatePage() { e.preventDefault(); if (newCanSubmit) execute({ - content: newContent, + content: effectiveContent, uploadKeyPrefix: "plotlink/genesis", indexerRoute: "/api/index/storyline", buildWriteCall: (cid, contentHash) => ({ @@ -449,7 +458,7 @@ function CreatePage() { gas: BigInt(16_000_000), value: creationFee, }), - metadata: { genre, language, ...(coverCid ? { coverCid } : {}), isNsfw: String(isNsfw) }, + metadata: { genre, language, contentType, ...(coverCid ? { coverCid } : {}), isNsfw: String(isNsfw) }, onIntentSave: newSaveIntent, onTxConfirmed: newPersistTxHash, onIndexed: newClearIntent, @@ -584,6 +593,28 @@ function CreatePage() { + {/* Content Type Selector */} +
+ +
+ {CONTENT_TYPES.map((ct) => ( + + ))} +
+
+ {/* NSFW Toggle */}
-
- -

- The opening of your storyline — write a synopsis or introduction, or jump straight into the story. Markdown supported. -

- - {newPreviewTab === "write" ? ( -