276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
import clsx from "clsx";
|
|
import { ConnectionSettingsModal } from "./connection-settings-modal";
|
|
import { SettingsModal } from "./settings-modal";
|
|
import { useAppState } from "../contexts/state";
|
|
import { useBool } from "@common/hooks/useBool";
|
|
import { useInputState } from "@common/hooks/useInputState";
|
|
import type { World, Story } from "../contexts/state";
|
|
import styles from '../assets/menu.module.css';
|
|
import { Pencil, X, Plus, Plug, Settings, Copy, ChevronRight, ChevronDown, Globe } from "lucide-preact";
|
|
|
|
// ─── Inline Rename Input ──────────────────────────────────────────────────────
|
|
|
|
interface RenameInputProps {
|
|
value: string;
|
|
onSubmit: (title: string) => void;
|
|
onCancel: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
const RenameInput = ({ value, onSubmit, onCancel, className }: RenameInputProps) => {
|
|
const [editTitle, setEditTitle] = useInputState(value);
|
|
|
|
const handleSubmit = () => {
|
|
if (editTitle.trim()) onSubmit(editTitle.trim());
|
|
else onCancel();
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Enter') handleSubmit();
|
|
else if (e.key === 'Escape') onCancel();
|
|
};
|
|
|
|
return (
|
|
<input
|
|
class={clsx(styles.input, className)}
|
|
value={editTitle}
|
|
onInput={setEditTitle}
|
|
onKeyDown={handleKeyDown}
|
|
onBlur={handleSubmit}
|
|
autoFocus
|
|
/>
|
|
);
|
|
};
|
|
|
|
// ─── Story Item ───────────────────────────────────────────────────────────────
|
|
|
|
interface StoryItemProps {
|
|
story: Story;
|
|
active: boolean;
|
|
onSelect: () => void;
|
|
onRename: (newTitle: string) => void;
|
|
onDelete: () => void;
|
|
onDuplicate: () => void;
|
|
}
|
|
|
|
const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate }: StoryItemProps) => {
|
|
const isEditing = useBool(false);
|
|
|
|
if (isEditing.value) {
|
|
return (
|
|
<div class={clsx(styles.itemWrapper, styles.storyItem, active && styles.active)}>
|
|
<RenameInput
|
|
value={story.title}
|
|
onSubmit={(t) => { onRename(t); isEditing.setFalse(); }}
|
|
onCancel={isEditing.setFalse}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div class={clsx(styles.itemWrapper, styles.storyItem, active && styles.active)}>
|
|
<button
|
|
class={clsx(styles.item, active && styles.active)}
|
|
onClick={onSelect}
|
|
onDblClick={isEditing.setTrue}
|
|
>
|
|
{story.title}
|
|
</button>
|
|
<div class={styles.actions}>
|
|
<button class={styles.actionButton} onClick={isEditing.setTrue} title="Rename">
|
|
<Pencil size={14} />
|
|
</button>
|
|
<button class={styles.actionButton} onClick={onDuplicate} title="Duplicate">
|
|
<Copy size={14} />
|
|
</button>
|
|
<button class={styles.actionButton} onClick={onDelete} title="Delete">
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── World Item ───────────────────────────────────────────────────────────────
|
|
|
|
interface WorldItemProps {
|
|
world: World;
|
|
activeWorldId: string | null;
|
|
activeStoryId: string | null;
|
|
onSelectWorld: () => void;
|
|
onRenameWorld: (title: string) => void;
|
|
onDeleteWorld: () => void;
|
|
onCreateStory: () => void;
|
|
onSelectStory: (storyId: string) => void;
|
|
onRenameStory: (storyId: string, title: string) => void;
|
|
onDeleteStory: (storyId: string) => void;
|
|
onDuplicateStory: (storyId: string) => void;
|
|
}
|
|
|
|
const WorldItem = ({
|
|
world, activeWorldId, activeStoryId,
|
|
onSelectWorld, onRenameWorld, onDeleteWorld, onCreateStory,
|
|
onSelectStory, onRenameStory, onDeleteStory, onDuplicateStory,
|
|
}: WorldItemProps) => {
|
|
const isRenaming = useBool(false);
|
|
const isExpanded = useBool(activeWorldId === world.id);
|
|
|
|
const isWorldActive = activeWorldId === world.id && !activeStoryId;
|
|
|
|
const toggleExpand = (e: MouseEvent) => {
|
|
e.stopPropagation();
|
|
isExpanded.toggle();
|
|
};
|
|
|
|
return (
|
|
<div class={styles.worldGroup}>
|
|
{isRenaming.value ? (
|
|
<div class={clsx(styles.itemWrapper, isWorldActive && styles.active)}>
|
|
<RenameInput
|
|
value={world.title}
|
|
onSubmit={(t) => { onRenameWorld(t); isRenaming.setFalse(); }}
|
|
onCancel={isRenaming.setFalse}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div class={clsx(styles.itemWrapper, isWorldActive && styles.active)}>
|
|
<button class={styles.expandButton} onClick={toggleExpand} title={isExpanded.value ? 'Collapse' : 'Expand'}>
|
|
{isExpanded.value ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
</button>
|
|
<button
|
|
class={clsx(styles.item, styles.worldTitle, isWorldActive && styles.active)}
|
|
onClick={onSelectWorld}
|
|
onDblClick={isRenaming.setTrue}
|
|
>
|
|
<Globe size={13} />
|
|
{world.title}
|
|
</button>
|
|
<div class={styles.actions}>
|
|
<button class={styles.actionButton} onClick={onCreateStory} title="New Story">
|
|
<Plus size={14} />
|
|
</button>
|
|
<button class={styles.actionButton} onClick={isRenaming.setTrue} title="Rename">
|
|
<Pencil size={14} />
|
|
</button>
|
|
<button class={styles.actionButton} onClick={onDeleteWorld} title="Delete World">
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{isExpanded.value && (
|
|
<div class={styles.storiesList}>
|
|
{world.stories.map(story => (
|
|
<StoryItem
|
|
key={story.id}
|
|
story={story}
|
|
active={activeStoryId === story.id && activeWorldId === world.id}
|
|
onSelect={() => onSelectStory(story.id)}
|
|
onRename={(title) => onRenameStory(story.id, title)}
|
|
onDelete={() => onDeleteStory(story.id)}
|
|
onDuplicate={() => onDuplicateStory(story.id)}
|
|
/>
|
|
))}
|
|
{world.stories.length === 0 && (
|
|
<div class={styles.emptyStories}>No stories yet</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Menu Sidebar ─────────────────────────────────────────────────────────────
|
|
|
|
export const Menu = () => {
|
|
const { worlds, currentWorld, currentStory, dispatch } = useAppState();
|
|
const isConnectionSettingsOpen = useBool(false);
|
|
const isSettingsOpen = useBool(false);
|
|
|
|
const handleCreateWorld = () => {
|
|
dispatch({ type: 'CREATE_WORLD', title: 'New World' });
|
|
};
|
|
|
|
const handleSelectWorld = (worldId: string) => {
|
|
dispatch({ type: 'SELECT_WORLD', worldId });
|
|
};
|
|
|
|
const handleRenameWorld = (worldId: string, title: string) => {
|
|
dispatch({ type: 'RENAME_WORLD', worldId, title });
|
|
};
|
|
|
|
const handleDeleteWorld = (worldId: string) => {
|
|
const world = worlds.find(w => w.id === worldId);
|
|
if (!world) return;
|
|
if (confirm(`Delete world "${world.title}" and all its stories?`)) {
|
|
dispatch({ type: 'DELETE_WORLD', worldId });
|
|
}
|
|
};
|
|
|
|
const handleCreateStory = (worldId: string) => {
|
|
dispatch({ type: 'CREATE_STORY', worldId, title: 'New Story' });
|
|
};
|
|
|
|
const handleSelectStory = (worldId: string, storyId: string) => {
|
|
dispatch({ type: 'SELECT_STORY', worldId, id: storyId });
|
|
};
|
|
|
|
const handleRenameStory = (worldId: string, storyId: string, title: string) => {
|
|
dispatch({ type: 'RENAME_STORY', worldId, id: storyId, title });
|
|
};
|
|
|
|
const handleDeleteStory = (worldId: string, storyId: string) => {
|
|
const world = worlds.find(w => w.id === worldId);
|
|
const story = world?.stories.find(s => s.id === storyId);
|
|
if (!story) return;
|
|
if (confirm(`Delete "${story.title}"?`)) {
|
|
dispatch({ type: 'DELETE_STORY', worldId, id: storyId });
|
|
}
|
|
};
|
|
|
|
const handleDuplicateStory = (worldId: string, storyId: string) => {
|
|
dispatch({ type: 'DUPLICATE_STORY', worldId, id: storyId });
|
|
};
|
|
|
|
return (
|
|
<div class={styles.menu}>
|
|
<button class={styles.newButton} onClick={handleCreateWorld}>
|
|
<Plus size={16} /> New World
|
|
</button>
|
|
<div class={styles.list}>
|
|
{worlds.map(world => (
|
|
<WorldItem
|
|
key={world.id}
|
|
world={world}
|
|
activeWorldId={currentWorld?.id ?? null}
|
|
activeStoryId={currentStory?.id ?? null}
|
|
onSelectWorld={() => handleSelectWorld(world.id)}
|
|
onRenameWorld={(title) => handleRenameWorld(world.id, title)}
|
|
onDeleteWorld={() => handleDeleteWorld(world.id)}
|
|
onCreateStory={() => handleCreateStory(world.id)}
|
|
onSelectStory={(storyId) => handleSelectStory(world.id, storyId)}
|
|
onRenameStory={(storyId, title) => handleRenameStory(world.id, storyId, title)}
|
|
onDeleteStory={(storyId) => handleDeleteStory(world.id, storyId)}
|
|
onDuplicateStory={(storyId) => handleDuplicateStory(world.id, storyId)}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div class={styles.bottomButtons}>
|
|
<button class={styles.settingsButton} onClick={isSettingsOpen.toggle}>
|
|
<Settings size={16} /> Settings
|
|
</button>
|
|
<button class={styles.settingsButton} onClick={isConnectionSettingsOpen.toggle}>
|
|
<Plug size={16} /> Connection Settings
|
|
</button>
|
|
</div>
|
|
{isSettingsOpen.value && (
|
|
<SettingsModal onClose={isSettingsOpen.toggle} />
|
|
)}
|
|
{isConnectionSettingsOpen.value && (
|
|
<ConnectionSettingsModal onClose={isConnectionSettingsOpen.toggle} />
|
|
)}
|
|
</div>
|
|
);
|
|
};
|