From 6baefef631a3eb059a7a561f4d530728dc82e326 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 9 May 2026 07:40:32 +0900 Subject: [PATCH 1/3] [#174] Add NSFW flag and language selector to publish flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add language dropdown and NSFW checkbox to PreviewPanel genesis UI - Auto-detect language from structure.md (same pattern as genre) - Pass language and isNsfw through StoriesPage → route → publish lib → indexer - Include language in IPFS metadata - Update AGENTS.md with NSFW content guidance and language field docs Fixes #174 Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 14 +++++ app/lib/publish.ts | 13 +++-- app/routes/publish.ts | 5 ++ app/web/components/PreviewPanel.tsx | 83 +++++++++++++++++++++-------- app/web/components/StoriesPage.tsx | 4 +- 5 files changed, 89 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4df5c2b..0c8cf47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,3 +107,17 @@ When the human is ready to publish, they use the PlotLink OWS app to upload stor - Earns the author 5% royalties on every trade You focus on the writing. The human handles publishing. + +## Content Flags + +### NSFW Content + +When writing content that includes explicit sexual themes, graphic violence, or other adult material: + +- **Inform the user** that the story should be marked as NSFW (18+) when publishing +- NSFW stories are hidden from the default browse view on PlotLink +- The NSFW checkbox is available in the publish UI for genesis files + +### Language + +Include a `**language**: English` (or appropriate language) line in `structure.md` so the publish UI auto-detects it. Supported languages: English, Chinese, Korean, Japanese, Spanish, French, Hindi, Arabic, Portuguese, Russian, Others. diff --git a/app/lib/publish.ts b/app/lib/publish.ts index 70fdb3f..77e2fee 100644 --- a/app/lib/publish.ts +++ b/app/lib/publish.ts @@ -142,9 +142,9 @@ async function indexWithDelayAndRetry( * Upload story content to IPFS via PlotLink's API (plotlink.xyz/api/upload). * PlotLink handles Filebase credentials server-side. */ -export async function uploadToIPFS(content: string, title: string, genre?: string): Promise { +export async function uploadToIPFS(content: string, title: string, genre?: string, language?: string): Promise { const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz"; - const metadata = JSON.stringify({ title, genre, content }); + const metadata = JSON.stringify({ title, genre, language, content }); const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40); const key = `plotlink/storylines/${Date.now()}-${slug}.json`; @@ -293,10 +293,12 @@ export async function publishStoryline( content: string, genre: string | undefined, onProgress: (progress: PublishProgress) => void, + language?: string, + isNsfw?: boolean, ): Promise { // Step 1: Upload to IPFS onProgress({ step: "uploading", message: "Uploading story to IPFS..." }); - const contentCid = await uploadToIPFS(content, title, genre); + const contentCid = await uploadToIPFS(content, title, genre, language); // Step 2: Compute content hash + get creation fee const contentHash = keccak256(toBytes(content)); @@ -334,7 +336,7 @@ export async function publishStoryline( // Streams "Indexing…" progress so the user does not escalate to Retry Publish. const indexError = await indexWithDelayAndRetry( "storyline", - { txHash, content, genre }, + { txHash, content, genre, language, isNsfw: isNsfw != null ? String(isNsfw) : undefined }, onProgress, txHash, contentCid, @@ -361,10 +363,11 @@ export async function publishPlot( content: string, genre: string | undefined, onProgress: (progress: PublishProgress) => void, + language?: string, ): Promise { // Step 1: Upload to IPFS onProgress({ step: "uploading", message: "Uploading plot to IPFS..." }); - const contentCid = await uploadToIPFS(content, title, genre); + const contentCid = await uploadToIPFS(content, title, genre, language); // Step 2: Compute content hash const contentHash = keccak256(toBytes(content)); diff --git a/app/routes/publish.ts b/app/routes/publish.ts index 75697e4..9cc4031 100644 --- a/app/routes/publish.ts +++ b/app/routes/publish.ts @@ -71,6 +71,8 @@ publish.post("/file", async (c) => { title: string; content: string; genre?: string; + language?: string; + isNsfw?: boolean; storylineId?: number; }>(); @@ -118,6 +120,7 @@ publish.post("/file", async (c) => { async (progress) => { await stream.writeSSE({ data: JSON.stringify(progress) }); }, + body.language, ); } else { // Create new storyline (genesis or first file) @@ -129,6 +132,8 @@ publish.post("/file", async (c) => { async (progress) => { await stream.writeSSE({ data: JSON.stringify(progress) }); }, + body.language, + body.isNsfw, ); } diff --git a/app/web/components/PreviewPanel.tsx b/app/web/components/PreviewPanel.tsx index da89d40..0a2b3cd 100644 --- a/app/web/components/PreviewPanel.tsx +++ b/app/web/components/PreviewPanel.tsx @@ -3,13 +3,13 @@ import ReactMarkdown from "react-markdown"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import rehypeSanitize from "rehype-sanitize"; -import { GENRES } from "../../../lib/genres"; +import { GENRES, LANGUAGES } from "../../../lib/genres"; interface PreviewPanelProps { storyName: string | null; fileName: string | null; authFetch: (url: string, opts?: RequestInit) => Promise; - onPublish?: (storyName: string, fileName: string, genre: string) => void; + onPublish?: (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean) => void; publishingFile?: string | null; } @@ -36,6 +36,8 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis const [retrying, setRetrying] = useState(false); const [indexTimeLeft, setIndexTimeLeft] = useState(null); const [selectedGenre, setSelectedGenre] = useState(GENRES[0]); + const [selectedLanguage, setSelectedLanguage] = useState(LANGUAGES[0]); + const [isNsfw, setIsNsfw] = useState(false); const textareaRef = useRef(null); const dirtyRef = useRef(false); @@ -91,6 +93,12 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis const found = GENRES.find((g) => g.toLowerCase() === detected.toLowerCase()); if (found) setSelectedGenre(found); } + const langMatch = data.content.match(/\*{0,2}language\*{0,2}[:\s]+(.+)/i); + if (langMatch) { + const detected = langMatch[1].replace(/\*+/g, "").trim(); + const found = LANGUAGES.find((l) => l.toLowerCase() === detected.toLowerCase()); + if (found) setSelectedLanguage(found); + } }) .catch(() => {}); return () => { cancelled = true; }; @@ -324,7 +332,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis )} {isPlot && ( - {overLimit && ( - Reduce content to publish + {publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"} + + {overLimit && ( + Reduce content to publish + )} + + {(isGenesis) && ( +
+ + {isNsfw && ( + Adult content will be hidden from the default browse view. + )} +
)} )} diff --git a/app/web/components/StoriesPage.tsx b/app/web/components/StoriesPage.tsx index 07b40b4..8129f83 100644 --- a/app/web/components/StoriesPage.tsx +++ b/app/web/components/StoriesPage.tsx @@ -177,7 +177,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { window.addEventListener("mouseup", onMouseUp); }, []); - const handlePublish = useCallback(async (storyName: string, fileName: string, genre: string) => { + const handlePublish = useCallback(async (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean) => { setPublishingFile(fileName); setPublishProgress("Reading file..."); @@ -215,7 +215,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { const publishRes = await authFetch("/api/publish/file", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ storyName, fileName, title, content: fileData.content, genre, storylineId }), + body: JSON.stringify({ storyName, fileName, title, content: fileData.content, genre, language, isNsfw, storylineId }), }); if (!publishRes.ok) { From b83ecf072b1faaac0701fe201a6f831d33eaad8a Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 9 May 2026 07:42:45 +0900 Subject: [PATCH 2/3] [#174] Normalize language/isNsfw defaults at publish boundary Apply backwards-compatible defaults (English / false) at the start of publishStoryline and publishPlot so callers that omit the new optional fields still send valid values to IPFS metadata and the PlotLink indexer. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/lib/publish.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/lib/publish.ts b/app/lib/publish.ts index 77e2fee..c1ad638 100644 --- a/app/lib/publish.ts +++ b/app/lib/publish.ts @@ -296,9 +296,13 @@ export async function publishStoryline( language?: string, isNsfw?: boolean, ): Promise { + // Normalize optional fields to backwards-compatible defaults + const normalizedLanguage = language || "English"; + const normalizedIsNsfw = isNsfw ?? false; + // Step 1: Upload to IPFS onProgress({ step: "uploading", message: "Uploading story to IPFS..." }); - const contentCid = await uploadToIPFS(content, title, genre, language); + const contentCid = await uploadToIPFS(content, title, genre, normalizedLanguage); // Step 2: Compute content hash + get creation fee const contentHash = keccak256(toBytes(content)); @@ -336,7 +340,7 @@ export async function publishStoryline( // Streams "Indexing…" progress so the user does not escalate to Retry Publish. const indexError = await indexWithDelayAndRetry( "storyline", - { txHash, content, genre, language, isNsfw: isNsfw != null ? String(isNsfw) : undefined }, + { txHash, content, genre, language: normalizedLanguage, isNsfw: String(normalizedIsNsfw) }, onProgress, txHash, contentCid, @@ -365,9 +369,11 @@ export async function publishPlot( onProgress: (progress: PublishProgress) => void, language?: string, ): Promise { + const normalizedLanguage = language || "English"; + // Step 1: Upload to IPFS onProgress({ step: "uploading", message: "Uploading plot to IPFS..." }); - const contentCid = await uploadToIPFS(content, title, genre, language); + const contentCid = await uploadToIPFS(content, title, genre, normalizedLanguage); // Step 2: Compute content hash const contentHash = keccak256(toBytes(content)); From a80da1b1c8f15d4b6824aef0f96cad8744abfda2 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 9 May 2026 07:43:23 +0900 Subject: [PATCH 3/3] [#174] Pass isNsfw as boolean, not string, to indexer String(false) produces "false" which is truthy. The indexer body goes through JSON.stringify so the boolean serializes correctly as-is. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/lib/publish.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/publish.ts b/app/lib/publish.ts index c1ad638..9cdfee3 100644 --- a/app/lib/publish.ts +++ b/app/lib/publish.ts @@ -340,7 +340,7 @@ export async function publishStoryline( // Streams "Indexing…" progress so the user does not escalate to Retry Publish. const indexError = await indexWithDelayAndRetry( "storyline", - { txHash, content, genre, language: normalizedLanguage, isNsfw: String(normalizedIsNsfw) }, + { txHash, content, genre, language: normalizedLanguage, isNsfw: normalizedIsNsfw }, onProgress, txHash, contentCid,