1
0
Fork 0

Import/export story

This commit is contained in:
Pabloader 2026-04-14 06:45:17 +00:00
parent 662b903bb4
commit 01888131b5
2 changed files with 84 additions and 4 deletions

View File

@ -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 { ChevronDown, ChevronRight, Copy, Download, Globe, MessageSquarePlus, MessagesSquare, Pencil, Plus, Settings, Upload, X } from "lucide-preact";
import styles from '../assets/menu.module.css'; import styles from '../assets/menu.module.css';
import type { Story, World } from "../contexts/state"; 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 CharacterCard from "../utils/character-card";
import { SettingsModal } from "./settings-modal"; import { SettingsModal } from "./settings-modal";
@ -51,9 +51,10 @@ interface StoryItemProps {
onRename: (newTitle: string) => void; onRename: (newTitle: string) => void;
onDelete: () => void; onDelete: () => void;
onDuplicate: () => 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); const isEditing = useBool(false);
if (isEditing.value) { if (isEditing.value) {
@ -81,6 +82,9 @@ const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate }:
<button class={styles.actionButton} onClick={isEditing.setTrue} title="Rename"> <button class={styles.actionButton} onClick={isEditing.setTrue} title="Rename">
<Pencil size={14} /> <Pencil size={14} />
</button> </button>
<button class={styles.actionButton} onClick={onExport} title="Export Story">
<Download size={14} />
</button>
<button class={styles.actionButton} onClick={onDuplicate} title="Duplicate"> <button class={styles.actionButton} onClick={onDuplicate} title="Duplicate">
<Copy size={14} /> <Copy size={14} />
</button> </button>
@ -107,12 +111,14 @@ interface WorldItemProps {
onRenameStory: (storyId: string, title: string) => void; onRenameStory: (storyId: string, title: string) => void;
onDeleteStory: (storyId: string) => void; onDeleteStory: (storyId: string) => void;
onDuplicateStory: (storyId: string) => void; onDuplicateStory: (storyId: string) => void;
onExportStory: (storyId: string) => void;
onImportStory: () => void;
} }
const WorldItem = ({ const WorldItem = ({
world, activeWorldId, activeStoryId, world, activeWorldId, activeStoryId,
onSelectWorld, onRenameWorld, onDeleteWorld, onExportWorld, onCreateStory, onSelectWorld, onRenameWorld, onDeleteWorld, onExportWorld, onCreateStory,
onSelectStory, onRenameStory, onDeleteStory, onDuplicateStory, onSelectStory, onRenameStory, onDeleteStory, onDuplicateStory, onExportStory, onImportStory,
}: WorldItemProps) => { }: WorldItemProps) => {
const isRenaming = useBool(false); const isRenaming = useBool(false);
const isExpanded = useBool(activeWorldId === world.id); const isExpanded = useBool(activeWorldId === world.id);
@ -151,6 +157,9 @@ const WorldItem = ({
<button class={styles.actionButton} onClick={onCreateStory} title={world.chatOnly ? "New Chat" : "New Story"}> <button class={styles.actionButton} onClick={onCreateStory} title={world.chatOnly ? "New Chat" : "New Story"}>
{world.chatOnly ? <MessageSquarePlus size={14} /> : <Plus size={14} />} {world.chatOnly ? <MessageSquarePlus size={14} /> : <Plus size={14} />}
</button> </button>
<button class={styles.actionButton} onClick={onImportStory} title="Import Story">
<Upload size={14} />
</button>
<button class={styles.actionButton} onClick={onExportWorld} title="Export World"> <button class={styles.actionButton} onClick={onExportWorld} title="Export World">
<Download size={14} /> <Download size={14} />
</button> </button>
@ -174,6 +183,7 @@ const WorldItem = ({
onRename={(title) => onRenameStory(story.id, title)} onRename={(title) => onRenameStory(story.id, title)}
onDelete={() => onDeleteStory(story.id)} onDelete={() => onDeleteStory(story.id)}
onDuplicate={() => onDuplicateStory(story.id)} onDuplicate={() => onDuplicateStory(story.id)}
onExport={() => onExportStory(story.id)}
/> />
))} ))}
{world.stories.length === 0 && ( {world.stories.length === 0 && (
@ -254,6 +264,45 @@ export const Menu = ({ visible }: { visible: boolean }) => {
URL.revokeObjectURL(url); 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 handleImportWorld = () => {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
@ -274,6 +323,9 @@ export const Menu = ({ visible }: { visible: boolean }) => {
dispatch({ type: 'IMPORT_WORLD', world: CharacterCard.toWorld(parsed) }); dispatch({ type: 'IMPORT_WORLD', world: CharacterCard.toWorld(parsed) });
} else if (isWorld(parsed)) { } else if (isWorld(parsed)) {
dispatch({ type: 'IMPORT_WORLD', world: 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 { } else {
alert('Invalid file.'); alert('Invalid file.');
} }
@ -314,6 +366,8 @@ export const Menu = ({ visible }: { visible: boolean }) => {
onRenameStory={(storyId, title) => handleRenameStory(world.id, storyId, title)} onRenameStory={(storyId, title) => handleRenameStory(world.id, storyId, title)}
onDeleteStory={(storyId) => handleDeleteStory(world.id, storyId)} onDeleteStory={(storyId) => handleDeleteStory(world.id, storyId)}
onDuplicateStory={(storyId) => handleDuplicateStory(world.id, storyId)} onDuplicateStory={(storyId) => handleDuplicateStory(world.id, storyId)}
onExportStory={(storyId) => handleExportStory(world.id, storyId)}
onImportStory={() => handleImportStory(world.id)}
/> />
))} ))}
</div> </div>

View File

@ -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<string, unknown>;
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 ─────────────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────────────
interface IState { interface IState {
@ -197,7 +211,8 @@ type Action =
| { type: 'STORE_CHAPTER_SUMMARY'; worldId: string; storyId: string; header: string; hash: Chapters.Hash; summary: string } | { 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<string, Chapters.Hash[]> } | { type: 'CLEAN_CHAPTER_SUMMARIES'; worldId: string; storyId: string; validHashes: Record<string, Chapters.Hash[]> }
// Import/Export // Import/Export
| { type: 'IMPORT_WORLD'; world: World }; | { type: 'IMPORT_WORLD'; world: World }
| { type: 'IMPORT_STORY'; worldId: string; story: Story };
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@ -605,6 +620,17 @@ function reducer(state: IState, action: Action): IState {
currentStoryId: null, 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,
};
}
} }
} }