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, + }; + } } }