diff --git a/src/games/storywriter/components/menu.tsx b/src/games/storywriter/components/menu.tsx
index 8dcb01f..958e7ae 100644
--- a/src/games/storywriter/components/menu.tsx
+++ b/src/games/storywriter/components/menu.tsx
@@ -4,7 +4,7 @@ import clsx from "clsx";
import { ChevronDown, ChevronRight, Copy, Download, Globe, MessageSquarePlus, MessagesSquare, Pencil, Plus, Settings, Upload, X } from "lucide-preact";
import styles from '../assets/menu.module.css';
import type { Story, World } from "../contexts/state";
-import { isWorld, useAppState } from "../contexts/state";
+import { isStory, isWorld, useAppState } from "../contexts/state";
import CharacterCard from "../utils/character-card";
import { SettingsModal } from "./settings-modal";
@@ -51,9 +51,10 @@ interface StoryItemProps {
onRename: (newTitle: string) => void;
onDelete: () => void;
onDuplicate: () => void;
+ onExport: () => void;
}
-const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate }: StoryItemProps) => {
+const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate, onExport }: StoryItemProps) => {
const isEditing = useBool(false);
if (isEditing.value) {
@@ -81,6 +82,9 @@ const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate }:
+
@@ -107,12 +111,14 @@ interface WorldItemProps {
onRenameStory: (storyId: string, title: string) => void;
onDeleteStory: (storyId: string) => void;
onDuplicateStory: (storyId: string) => void;
+ onExportStory: (storyId: string) => void;
+ onImportStory: () => void;
}
const WorldItem = ({
world, activeWorldId, activeStoryId,
onSelectWorld, onRenameWorld, onDeleteWorld, onExportWorld, onCreateStory,
- onSelectStory, onRenameStory, onDeleteStory, onDuplicateStory,
+ onSelectStory, onRenameStory, onDeleteStory, onDuplicateStory, onExportStory, onImportStory,
}: WorldItemProps) => {
const isRenaming = useBool(false);
const isExpanded = useBool(activeWorldId === world.id);
@@ -151,6 +157,9 @@ const WorldItem = ({
+
@@ -174,6 +183,7 @@ const WorldItem = ({
onRename={(title) => onRenameStory(story.id, title)}
onDelete={() => onDeleteStory(story.id)}
onDuplicate={() => onDuplicateStory(story.id)}
+ onExport={() => onExportStory(story.id)}
/>
))}
{world.stories.length === 0 && (
@@ -254,6 +264,45 @@ export const Menu = ({ visible }: { visible: boolean }) => {
URL.revokeObjectURL(url);
};
+ const handleExportStory = (worldId: string, storyId: string) => {
+ const world = worlds.find(w => w.id === worldId);
+ const story = world?.stories.find(s => s.id === storyId);
+ if (!story) return;
+ const json = JSON.stringify(story, null, 2);
+ const blob = new Blob([json], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${story.title}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const handleImportStory = (worldId: string) => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json,application/json';
+ input.onchange = () => {
+ const file = input.files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ try {
+ const parsed = JSON.parse(reader.result as string);
+ if (isStory(parsed)) {
+ dispatch({ type: 'IMPORT_STORY', worldId, story: parsed });
+ } else {
+ alert('Invalid story file.');
+ }
+ } catch {
+ alert('Invalid file.');
+ }
+ };
+ reader.readAsText(file);
+ };
+ input.click();
+ };
+
const handleImportWorld = () => {
const input = document.createElement('input');
input.type = 'file';
@@ -274,6 +323,9 @@ export const Menu = ({ visible }: { visible: boolean }) => {
dispatch({ type: 'IMPORT_WORLD', world: CharacterCard.toWorld(parsed) });
} else if (isWorld(parsed)) {
dispatch({ type: 'IMPORT_WORLD', world: parsed });
+ } else if (isStory(parsed)) {
+ if (!currentWorld) { alert('Select a world first to import a story.'); return; }
+ dispatch({ type: 'IMPORT_STORY', worldId: currentWorld.id, story: parsed });
} else {
alert('Invalid file.');
}
@@ -314,6 +366,8 @@ export const Menu = ({ visible }: { visible: boolean }) => {
onRenameStory={(storyId, title) => handleRenameStory(world.id, storyId, title)}
onDeleteStory={(storyId) => handleDeleteStory(world.id, storyId)}
onDuplicateStory={(storyId) => handleDuplicateStory(world.id, storyId)}
+ onExportStory={(storyId) => handleExportStory(world.id, storyId)}
+ onImportStory={() => handleImportStory(world.id)}
/>
))}
diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx
index e15bac0..31144ca 100644
--- a/src/games/storywriter/contexts/state.tsx
+++ b/src/games/storywriter/contexts/state.tsx
@@ -122,6 +122,20 @@ export function isWorld(obj: unknown): obj is World {
);
}
+export function isStory(obj: unknown): obj is Story {
+ if (typeof obj !== 'object' || obj === null) return false;
+ const s = obj as Record;
+ return (
+ typeof s.id === 'string' &&
+ typeof s.title === 'string' &&
+ typeof s.text === 'string' &&
+ Array.isArray(s.lore) &&
+ Array.isArray(s.characters) &&
+ Array.isArray(s.locations) &&
+ Array.isArray(s.chatMessages)
+ );
+}
+
// ─── State ───────────────────────────────────────────────────────────────────
interface IState {
@@ -197,7 +211,8 @@ type Action =
| { type: 'STORE_CHAPTER_SUMMARY'; worldId: string; storyId: string; header: string; hash: Chapters.Hash; summary: string }
| { type: 'CLEAN_CHAPTER_SUMMARIES'; worldId: string; storyId: string; validHashes: Record }
// Import/Export
- | { type: 'IMPORT_WORLD'; world: World };
+ | { type: 'IMPORT_WORLD'; world: World }
+ | { type: 'IMPORT_STORY'; worldId: string; story: Story };
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -605,6 +620,17 @@ function reducer(state: IState, action: Action): IState {
currentStoryId: null,
};
}
+ case 'IMPORT_STORY': {
+ const targetWorld = state.worlds.find(w => w.id === action.worldId);
+ if (!targetWorld) return state;
+ const idExists = targetWorld.stories.some(s => s.id === action.story.id);
+ const story = idExists ? { ...action.story, id: crypto.randomUUID() } : action.story;
+ return {
+ ...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })),
+ currentWorldId: action.worldId,
+ currentStoryId: story.id,
+ };
+ }
}
}