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
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 14 additions & 5 deletions app/lib/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
export async function uploadToIPFS(content: string, title: string, genre?: string, language?: string): Promise<string> {
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`;

Expand Down Expand Up @@ -293,10 +293,16 @@ export async function publishStoryline(
content: string,
genre: string | undefined,
onProgress: (progress: PublishProgress) => void,
language?: string,
isNsfw?: boolean,
): Promise<PublishResult> {
// 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));
Expand Down Expand Up @@ -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,
Expand All @@ -361,10 +367,13 @@ export async function publishPlot(
content: string,
genre: string | undefined,
onProgress: (progress: PublishProgress) => void,
language?: string,
): Promise<PublishResult> {
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));
Expand Down
5 changes: 5 additions & 0 deletions app/routes/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ publish.post("/file", async (c) => {
title: string;
content: string;
genre?: string;
language?: string;
isNsfw?: boolean;
storylineId?: number;
}>();

Expand Down Expand Up @@ -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)
Expand All @@ -129,6 +132,8 @@ publish.post("/file", async (c) => {
async (progress) => {
await stream.writeSSE({ data: JSON.stringify(progress) });
},
body.language,
body.isNsfw,
);
}

Expand Down
83 changes: 60 additions & 23 deletions app/web/components/PreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>;
onPublish?: (storyName: string, fileName: string, genre: string) => void;
onPublish?: (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean) => void;
publishingFile?: string | null;
}

Expand All @@ -36,6 +36,8 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
const [retrying, setRetrying] = useState(false);
const [indexTimeLeft, setIndexTimeLeft] = useState<number | null>(null);
const [selectedGenre, setSelectedGenre] = useState(GENRES[0]);
const [selectedLanguage, setSelectedLanguage] = useState(LANGUAGES[0]);
const [isNsfw, setIsNsfw] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dirtyRef = useRef(false);

Expand Down Expand Up @@ -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; };
Expand Down Expand Up @@ -324,7 +332,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
)}
{isPlot && (
<button
onClick={() => storyName && fileName && onPublish?.(storyName, fileName, selectedGenre)}
onClick={() => storyName && fileName && onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw)}
disabled={!!publishingFile}
className="px-3 py-1 border border-border text-xs rounded hover:bg-surface disabled:opacity-50"
>
Expand Down Expand Up @@ -390,27 +398,56 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
)}
</div>
) : (
<div className="flex items-center gap-2">
{(isGenesis) && (
<select
value={selectedGenre}
onChange={(e) => setSelectedGenre(e.target.value)}
className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
{(isGenesis) && (
<>
<select
value={selectedGenre}
onChange={(e) => setSelectedGenre(e.target.value)}
className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
>
{GENRES.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
<select
value={selectedLanguage}
onChange={(e) => setSelectedLanguage(e.target.value)}
className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
>
{LANGUAGES.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
</>
)}
<button
onClick={() => storyName && fileName && onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw)}
disabled={!!publishingFile || overLimit}
className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
>
{GENRES.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
)}
<button
onClick={() => storyName && fileName && onPublish?.(storyName, fileName, selectedGenre)}
disabled={!!publishingFile || overLimit}
className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
>
{publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
</button>
{overLimit && (
<span className="text-error text-xs">Reduce content to publish</span>
{publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
</button>
{overLimit && (
<span className="text-error text-xs">Reduce content to publish</span>
)}
</div>
{(isGenesis) && (
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
<input
type="checkbox"
checked={isNsfw}
onChange={(e) => setIsNsfw(e.target.checked)}
className="rounded border-border"
/>
This story contains adult content (18+)
</label>
{isNsfw && (
<span className="text-xs text-amber-600">Adult content will be hidden from the default browse view.</span>
)}
</div>
)}
</div>
)}
Expand Down
4 changes: 2 additions & 2 deletions app/web/components/StoriesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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...");

Expand Down Expand Up @@ -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) {
Expand Down
Loading