Import/Export
This commit is contained in:
parent
d93e3c812e
commit
62c71a02c3
|
|
@ -6,7 +6,7 @@ import { useBool } from "@common/hooks/useBool";
|
||||||
import { useInputState } from "@common/hooks/useInputState";
|
import { useInputState } from "@common/hooks/useInputState";
|
||||||
import type { World, Story } from "../contexts/state";
|
import type { World, Story } from "../contexts/state";
|
||||||
import styles from '../assets/menu.module.css';
|
import styles from '../assets/menu.module.css';
|
||||||
import { Pencil, X, Plus, Plug, Settings, Copy, ChevronRight, ChevronDown, Globe } from "lucide-preact";
|
import { Pencil, X, Plus, Plug, Settings, Copy, ChevronRight, ChevronDown, Globe, Download, Upload } from "lucide-preact";
|
||||||
|
|
||||||
// ─── Inline Rename Input ──────────────────────────────────────────────────────
|
// ─── Inline Rename Input ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -101,6 +101,7 @@ interface WorldItemProps {
|
||||||
onSelectWorld: () => void;
|
onSelectWorld: () => void;
|
||||||
onRenameWorld: (title: string) => void;
|
onRenameWorld: (title: string) => void;
|
||||||
onDeleteWorld: () => void;
|
onDeleteWorld: () => void;
|
||||||
|
onExportWorld: () => void;
|
||||||
onCreateStory: () => void;
|
onCreateStory: () => void;
|
||||||
onSelectStory: (storyId: string) => void;
|
onSelectStory: (storyId: string) => void;
|
||||||
onRenameStory: (storyId: string, title: string) => void;
|
onRenameStory: (storyId: string, title: string) => void;
|
||||||
|
|
@ -110,7 +111,7 @@ interface WorldItemProps {
|
||||||
|
|
||||||
const WorldItem = ({
|
const WorldItem = ({
|
||||||
world, activeWorldId, activeStoryId,
|
world, activeWorldId, activeStoryId,
|
||||||
onSelectWorld, onRenameWorld, onDeleteWorld, onCreateStory,
|
onSelectWorld, onRenameWorld, onDeleteWorld, onExportWorld, onCreateStory,
|
||||||
onSelectStory, onRenameStory, onDeleteStory, onDuplicateStory,
|
onSelectStory, onRenameStory, onDeleteStory, onDuplicateStory,
|
||||||
}: WorldItemProps) => {
|
}: WorldItemProps) => {
|
||||||
const isRenaming = useBool(false);
|
const isRenaming = useBool(false);
|
||||||
|
|
@ -150,6 +151,9 @@ const WorldItem = ({
|
||||||
<button class={styles.actionButton} onClick={onCreateStory} title="New Story">
|
<button class={styles.actionButton} onClick={onCreateStory} title="New Story">
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
<button class={styles.actionButton} onClick={onExportWorld} title="Export World">
|
||||||
|
<Download size={14} />
|
||||||
|
</button>
|
||||||
<button class={styles.actionButton} onClick={isRenaming.setTrue} title="Rename">
|
<button class={styles.actionButton} onClick={isRenaming.setTrue} title="Rename">
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -233,11 +237,48 @@ export const Menu = () => {
|
||||||
dispatch({ type: 'DUPLICATE_STORY', worldId, id: storyId });
|
dispatch({ type: 'DUPLICATE_STORY', worldId, id: storyId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExportWorld = (worldId: string) => {
|
||||||
|
const world = worlds.find(w => w.id === worldId);
|
||||||
|
if (!world) return;
|
||||||
|
const json = JSON.stringify(world, 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 = `${world.title}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportWorld = () => {
|
||||||
|
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 world = JSON.parse(reader.result as string);
|
||||||
|
dispatch({ type: 'IMPORT_WORLD', world });
|
||||||
|
} catch {
|
||||||
|
alert('Invalid world file.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.menu}>
|
<div class={styles.menu}>
|
||||||
<button class={styles.newButton} onClick={handleCreateWorld}>
|
<button class={styles.newButton} onClick={handleCreateWorld}>
|
||||||
<Plus size={16} /> New World
|
<Plus size={16} /> New World
|
||||||
</button>
|
</button>
|
||||||
|
<button class={styles.newButton} onClick={handleImportWorld}>
|
||||||
|
<Upload size={16} /> Import World
|
||||||
|
</button>
|
||||||
<div class={styles.list}>
|
<div class={styles.list}>
|
||||||
{worlds.map(world => (
|
{worlds.map(world => (
|
||||||
<WorldItem
|
<WorldItem
|
||||||
|
|
@ -248,6 +289,7 @@ export const Menu = () => {
|
||||||
onSelectWorld={() => handleSelectWorld(world.id)}
|
onSelectWorld={() => handleSelectWorld(world.id)}
|
||||||
onRenameWorld={(title) => handleRenameWorld(world.id, title)}
|
onRenameWorld={(title) => handleRenameWorld(world.id, title)}
|
||||||
onDeleteWorld={() => handleDeleteWorld(world.id)}
|
onDeleteWorld={() => handleDeleteWorld(world.id)}
|
||||||
|
onExportWorld={() => handleExportWorld(world.id)}
|
||||||
onCreateStory={() => handleCreateStory(world.id)}
|
onCreateStory={() => handleCreateStory(world.id)}
|
||||||
onSelectStory={(storyId) => handleSelectStory(world.id, storyId)}
|
onSelectStory={(storyId) => handleSelectStory(world.id, storyId)}
|
||||||
onRenameStory={(storyId, title) => handleRenameStory(world.id, storyId, title)}
|
onRenameStory={(storyId, title) => handleRenameStory(world.id, storyId, title)}
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,9 @@ type Action =
|
||||||
| { type: 'DELETE_LOCATION'; worldId: string; storyId: string | null; locationId: string }
|
| { type: 'DELETE_LOCATION'; worldId: string; storyId: string | null; locationId: string }
|
||||||
// Chapters
|
// Chapters
|
||||||
| { 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
|
||||||
|
| { type: 'IMPORT_WORLD'; world: World };
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -498,6 +500,17 @@ function reducer(state: IState, action: Action): IState {
|
||||||
return { ...s, chapters };
|
return { ...s, chapters };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
case 'IMPORT_WORLD': {
|
||||||
|
const exists = state.worlds.some(w => w.id === action.world.id);
|
||||||
|
const world = exists ? { ...action.world, id: crypto.randomUUID() } : action.world;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
worlds: [...state.worlds, world],
|
||||||
|
currentWorldId: world.id,
|
||||||
|
currentStoryId: null,
|
||||||
|
currentTab: 'lore',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue