1
0
Fork 0
tsgames/src/games/storywriter/components/menu.tsx

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>
);
};