Import/export story
This commit is contained in:
parent
662b903bb4
commit
01888131b5
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue