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..9cdfee3 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,16 @@ export async function publishStoryline( content: string, genre: string | undefined, onProgress: (progress: PublishProgress) => void, + 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); + const contentCid = await uploadToIPFS(content, title, genre, normalizedLanguage); // Step 2: Compute content hash + get creation fee const contentHash = keccak256(toBytes(content)); @@ -334,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 }, + { txHash, content, genre, language: normalizedLanguage, isNsfw: normalizedIsNsfw }, onProgress, txHash, contentCid, @@ -361,10 +367,13 @@ export async function publishPlot( content: string, genre: string | undefined, 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); + const contentCid = await uploadToIPFS(content, title, genre, normalizedLanguage); // 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) {