204 lines
8.3 KiB
TypeScript
204 lines
8.3 KiB
TypeScript
import { ContentEditable } from "@common/components/ContentEditable";
|
||
import { useState } from "preact/hooks";
|
||
import styles from '../../assets/lore-editor.module.css';
|
||
import { useAppState, type LoreEntry } from "../../contexts/state";
|
||
|
||
export const LoreEditor = ({ visible }: { visible: boolean }) => {
|
||
const { currentWorld, currentStory, dispatch } = useAppState();
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [newTitle, setNewTitle] = useState('');
|
||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||
|
||
if (!currentWorld || !visible) {
|
||
return null;
|
||
}
|
||
|
||
// When a story is selected, edit story-level lore; otherwise world-level lore
|
||
const storyId = currentStory?.id ?? null;
|
||
const worldId = currentWorld.id;
|
||
const lore = currentStory ? currentStory.lore : currentWorld.lore;
|
||
|
||
const handleAddEntry = () => {
|
||
if (!newTitle.trim()) return;
|
||
|
||
dispatch({
|
||
type: 'ADD_LORE_ENTRY',
|
||
worldId,
|
||
storyId,
|
||
entry: {
|
||
id: crypto.randomUUID(),
|
||
title: newTitle.trim(),
|
||
text: '',
|
||
},
|
||
});
|
||
setNewTitle('');
|
||
setEditingId(null);
|
||
};
|
||
|
||
const handleEditEntry = (entryId: string, field: keyof LoreEntry, value: string) => {
|
||
dispatch({
|
||
type: 'EDIT_LORE_ENTRY',
|
||
worldId,
|
||
storyId,
|
||
entryId,
|
||
updates: { [field]: value },
|
||
});
|
||
};
|
||
|
||
const handleDeleteEntry = (entryId: string) => {
|
||
dispatch({
|
||
type: 'DELETE_LORE_ENTRY',
|
||
worldId,
|
||
storyId,
|
||
entryId,
|
||
});
|
||
};
|
||
|
||
const handleMoveUp = (index: number) => {
|
||
if (index === 0) return;
|
||
const entryIds = lore.map(e => e.id);
|
||
[entryIds[index - 1], entryIds[index]] = [entryIds[index], entryIds[index - 1]];
|
||
dispatch({
|
||
type: 'REORDER_LORE_ENTRIES',
|
||
worldId,
|
||
storyId,
|
||
entryIds,
|
||
});
|
||
};
|
||
|
||
const handleMoveDown = (index: number) => {
|
||
if (index === lore.length - 1) return;
|
||
const entryIds = lore.map(e => e.id);
|
||
[entryIds[index], entryIds[index + 1]] = [entryIds[index + 1], entryIds[index]];
|
||
dispatch({
|
||
type: 'REORDER_LORE_ENTRIES',
|
||
worldId,
|
||
storyId,
|
||
entryIds,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div class={styles.loreEditor}>
|
||
<div class={styles.header}>
|
||
<h2>{currentStory ? 'Story Lore' : 'World Lore'}</h2>
|
||
<div class={styles.addEntry}>
|
||
<input
|
||
type="text"
|
||
class={styles.titleInput}
|
||
value={newTitle}
|
||
onInput={(e) => setNewTitle(e.currentTarget.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
handleAddEntry();
|
||
}
|
||
}}
|
||
placeholder="New lore entry title..."
|
||
/>
|
||
<button class={styles.addButton} onClick={handleAddEntry}>
|
||
+ Add Entry
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class={styles.list}>
|
||
{lore.length === 0 && (
|
||
<p class={styles.empty}>No lore entries yet. Add your first entry!</p>
|
||
)}
|
||
|
||
{lore.map((entry, index) => (
|
||
<div key={entry.id} class={styles.entryCard}>
|
||
<div class={styles.cardHeader}>
|
||
<div class={styles.titleRow}>
|
||
{editingId === entry.id ? (
|
||
<input
|
||
type="text"
|
||
class={styles.titleEditInput}
|
||
value={entry.title}
|
||
onInput={(e) => handleEditEntry(entry.id, 'title', e.currentTarget.value)}
|
||
onBlur={() => setEditingId(null)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
setEditingId(null);
|
||
} else if (e.key === 'Escape') {
|
||
setEditingId(null);
|
||
}
|
||
}}
|
||
autoFocus
|
||
/>
|
||
) : (
|
||
<h3
|
||
class={styles.entryTitle}
|
||
onClick={() => setEditingId(entry.id)}
|
||
title="Click to edit"
|
||
>
|
||
{entry.title}
|
||
</h3>
|
||
)}
|
||
</div>
|
||
<div class={styles.actions}>
|
||
<button
|
||
class={styles.moveButton}
|
||
onClick={() => handleMoveUp(index)}
|
||
disabled={index === 0}
|
||
title="Move up"
|
||
>
|
||
↑
|
||
</button>
|
||
<button
|
||
class={styles.moveButton}
|
||
onClick={() => handleMoveDown(index)}
|
||
disabled={index === lore.length - 1}
|
||
title="Move down"
|
||
>
|
||
↓
|
||
</button>
|
||
{showDeleteConfirm === entry.id ? (
|
||
<div class={styles.deleteConfirm}>
|
||
<span>Delete?</span>
|
||
<button
|
||
class={styles.confirmButton}
|
||
onClick={() => {
|
||
handleDeleteEntry(entry.id);
|
||
setShowDeleteConfirm(null);
|
||
}}
|
||
>
|
||
Yes
|
||
</button>
|
||
<button
|
||
class={styles.cancelButton}
|
||
onClick={() => setShowDeleteConfirm(null)}
|
||
>
|
||
No
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
class={styles.deleteButton}
|
||
onClick={() => setShowDeleteConfirm(entry.id)}
|
||
title="Delete"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class={styles.content}>
|
||
<ContentEditable
|
||
autoLines
|
||
class={styles.textarea}
|
||
value={entry.text}
|
||
onInput={(e) => handleEditEntry(entry.id, 'text', e.currentTarget.textContent || '')}
|
||
placeholder="Enter lore content..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|