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
64 changes: 64 additions & 0 deletions lib/cartoon-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
16 changes: 16 additions & 0 deletions lib/cartoon-markdown.ts
Original file line number Diff line number Diff line change
@@ -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");
}
121 changes: 90 additions & 31 deletions src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -138,10 +141,12 @@ function CreatePage() {
const [tab, setTab] = useState<Tab>(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<CartoonPanel[]>([]);
const [newPreviewTab, setNewPreviewTab] = useState<"write" | "preview">("write");
const [coverCid, setCoverCid] = useState<string | null>(null);
const [coverUploading, setCoverUploading] = useState(false);
Expand All @@ -151,6 +156,9 @@ function CreatePage() {
const coverInputRef = useRef<HTMLInputElement>(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) {
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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) => ({
Expand All @@ -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,
Expand Down Expand Up @@ -584,6 +593,28 @@ function CreatePage() {
</div>
</div>

{/* Content Type Selector */}
<div>
<label className="text-foreground mb-2 block text-sm">Content Type</label>
<div className="flex gap-1.5">
{CONTENT_TYPES.map((ct) => (
<button
key={ct}
type="button"
onClick={() => setContentType(ct)}
disabled={newBusy}
className={`rounded-full px-4 py-1.5 text-xs font-medium capitalize transition-colors ${
contentType === ct
? "bg-accent text-white"
: "bg-surface border border-border text-muted hover:text-foreground"
} disabled:opacity-50`}
>
{ct}
</button>
))}
</div>
</div>

{/* NSFW Toggle */}
<div>
<label className="flex items-center gap-2 cursor-pointer">
Expand All @@ -603,35 +634,63 @@ function CreatePage() {
)}
</div>

<div>
<label className="text-foreground mb-1 block text-sm">Opening Chapter</label>
<p className="text-muted mb-2 text-[11px]">
The opening of your storyline — write a synopsis or introduction, or jump straight into the story. Markdown supported.
</p>
<WritePreviewToggle
activeTab={newPreviewTab}
onTabChange={setNewPreviewTab}
/>
{newPreviewTab === "write" ? (
<textarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
{/* Content: Fiction (text) or Cartoon (images) */}
{contentType === "fiction" ? (
<div>
<label className="text-foreground mb-1 block text-sm">Opening Chapter</label>
<p className="text-muted mb-2 text-[11px]">
The opening of your storyline — write a synopsis or introduction, or jump straight into the story. Markdown supported.
</p>
<WritePreviewToggle
activeTab={newPreviewTab}
onTabChange={setNewPreviewTab}
/>
{newPreviewTab === "write" ? (
<textarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
disabled={newBusy}
rows={12}
placeholder="Write the genesis plot (500–10,000 characters)"
className="ruled-paper border-border text-foreground placeholder:text-muted w-full resize-y rounded border focus:border-accent focus:outline-none disabled:opacity-50"
/>
) : (
<ContentPreview content={newContent} />
)}
<div className="mt-1 flex justify-between text-xs">
<span className={newContent.length > 0 && !newValid ? "text-error" : "text-muted"}>
{newCharCount.toLocaleString()} / {MIN_CONTENT_LENGTH.toLocaleString()}&ndash;
{MAX_CONTENT_LENGTH.toLocaleString()} chars
</span>
</div>
<PlotImageUpload disabled={newBusy} />
</div>
) : (
<div>
<label className="text-foreground mb-1 block text-sm">Cartoon Panels</label>
<p className="text-muted mb-2 text-[11px]">
Upload images in reading order. Drag to reorder. Max 20 images, WebP/JPEG only, 1MB each.
</p>
<CartoonUploader
panels={cartoonPanels}
onChange={setCartoonPanels}
disabled={newBusy}
rows={12}
placeholder="Write the genesis plot (500–10,000 characters)"
className="ruled-paper border-border text-foreground placeholder:text-muted w-full resize-y rounded border focus:border-accent focus:outline-none disabled:opacity-50"
/>
) : (
<ContentPreview content={newContent} />
)}
<div className="mt-1 flex justify-between text-xs">
<span className={newContent.length > 0 && !newValid ? "text-error" : "text-muted"}>
{newCharCount.toLocaleString()} / {MIN_CONTENT_LENGTH.toLocaleString()}&ndash;
{MAX_CONTENT_LENGTH.toLocaleString()} chars
</span>
<div className="mt-3">
<p className="text-muted mb-1 text-[11px] font-medium">Preview</p>
<CartoonPreview panels={cartoonPanels} />
</div>
<div className="mt-2 flex justify-between text-xs">
<span className={cartoonPanels.length > 0 && !newValid ? "text-error" : "text-muted"}>
{newCharCount.toLocaleString()} / {MIN_CONTENT_LENGTH.toLocaleString()}&ndash;
{MAX_CONTENT_LENGTH.toLocaleString()} chars (generated markdown)
</span>
{cartoonPanels.length > 0 && !cartoonHasImages && (
<span className="text-error">At least one image is required</span>
)}
</div>
</div>
<PlotImageUpload disabled={newBusy} />
</div>
)}

<p className="text-muted text-xs">
All storylines have a 7-day deadline &mdash; the story sunsets if no new plot is added within 7 days.
Expand Down
35 changes: 35 additions & 0 deletions src/components/CartoonPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";

import type { CartoonPanel } from "../../lib/cartoon-markdown";

interface CartoonPreviewProps {
panels: CartoonPanel[];
}

export function CartoonPreview({ panels }: CartoonPreviewProps) {
if (panels.length === 0) {
return (
<div className="flex items-center justify-center rounded border border-border px-4 py-12">
<span className="text-muted text-xs">Upload images to preview your cartoon</span>
</div>
);
}

return (
<div className="space-y-1 rounded border border-border bg-surface p-2 max-h-[500px] overflow-y-auto">
{panels.map((panel, i) => (
<div key={`${panel.cid}-${i}`}>
{panel.label && (
<p className="text-foreground text-xs font-semibold px-1 pt-2 pb-1">{panel.label}</p>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={panel.url}
alt={panel.alt || `Panel ${i + 1}`}
className="w-full rounded"
/>
</div>
))}
</div>
);
}
Loading
Loading