diff --git a/src/games/storywriter/assets/location-editor.module.css b/src/games/storywriter/assets/location-editor.module.css new file mode 100644 index 0000000..2f8b1d5 --- /dev/null +++ b/src/games/storywriter/assets/location-editor.module.css @@ -0,0 +1,225 @@ +.locationEditor { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + gap: 24px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.header h2 { + margin: 0; + font-size: 24px; + font-weight: 600; + color: var(--text); +} + +.addButton { + padding: 8px 16px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + background: var(--accent-alt); + } +} + +.deleteConfirm { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: var(--bg-panel); + border: 1px solid var(--accent); + border-radius: var(--radius); + font-size: 13px; + + & span { + font-weight: 500; + color: var(--accent); + } +} + +.confirmButton, +.cancelButton { + padding: 4px 10px; + border: 1px solid transparent; + border-radius: var(--radius); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); +} + +.confirmButton { + background: var(--accent); + color: var(--bg); + border-color: var(--accent); + + &:hover { + background: var(--bg); + color: var(--accent); + } +} + +.cancelButton { + background: transparent; + color: var(--text-muted); + border-color: var(--border); + + &:hover { + background: var(--bg-hover); + color: var(--text); + } +} + +.list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +.empty { + color: var(--text-muted); + font-style: italic; + font-size: 14px; +} + +.locationCard { + background: var(--bg-secondary); + border-radius: 8px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.cardHeader { + display: flex; + align-items: center; + gap: 12px; +} + +.nameInput { + flex: 1; + padding: 8px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 18px; + font-weight: 600; + color: var(--text); + font-family: inherit; + + &:focus { + outline: none; + border-color: var(--accent); + } +} + +.deleteButton { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + font-size: 20px; + cursor: pointer; + transition: all var(--transition); + + &:hover { + background: var(--accent); + border-color: var(--accent); + color: var(--bg); + } +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.field .label { + font-size: 13px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; +} + +.select { + width: 100%; + padding: 10px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; + color: var(--text); + font-family: inherit; + cursor: pointer; + + &:focus { + outline: none; + border-color: var(--accent); + } +} + +.generateButton { + padding: 4px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 12px; + color: var(--text); + cursor: pointer; + transition: all var(--transition); + + &:hover { + border-color: var(--accent); + color: var(--accent); + } +} + +.textarea { + width: 100%; + padding: 10px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; + color: var(--text); + font-family: inherit; + resize: vertical; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: var(--accent); + } +} diff --git a/src/games/storywriter/components/character-editor.tsx b/src/games/storywriter/components/character-editor.tsx index 920f130..00858a4 100644 --- a/src/games/storywriter/components/character-editor.tsx +++ b/src/games/storywriter/components/character-editor.tsx @@ -121,6 +121,7 @@ export const CharacterEditor = () => { class={styles.nameInput} value={character.name} onInput={(e) => handleEditCharacter(character.id, 'name', e.currentTarget.value)} + onFocus={(e) => e.currentTarget.select()} placeholder="Character name" /> {showDeleteConfirm === character.id ? ( diff --git a/src/games/storywriter/components/chat-sidebar.tsx b/src/games/storywriter/components/chat-sidebar.tsx index 11655c6..171276c 100644 --- a/src/games/storywriter/components/chat-sidebar.tsx +++ b/src/games/storywriter/components/chat-sidebar.tsx @@ -50,6 +50,9 @@ export const ChatSidebar = () => { const messagesRef = useRef(null); const abortControllerRef = useRef(new AbortController()); + const appStateRef = useRef(appState); + appStateRef.current = appState; + useEffect(() => { if (messagesRef.current) { messagesRef.current.scrollTo({ @@ -88,7 +91,7 @@ export const ChatSidebar = () => { messages.push({ role: 'user', content: input.trim() }); } - const chatRequest = Prompt.compilePrompt(appState, messages); + const chatRequest = Prompt.compilePrompt(appStateRef.current, messages); const countRequest: LLM.CountTokensRequest = { model: model.id, input: chatRequest?.messages ?? [], @@ -134,7 +137,7 @@ export const ChatSidebar = () => { }, }); - const request = Prompt.compilePrompt(appState, newMessages); + const request = Prompt.compilePrompt(appStateRef.current, newMessages); if (!request) { setError('Failed to compile prompt'); @@ -195,7 +198,7 @@ export const ChatSidebar = () => { if (tool_calls) { const toolMessages: ChatMessage[] = []; for (const tool of tool_calls) { - const content = await Tools.executeTool(appState, tool); + const content = await Tools.executeTool(appStateRef.current, tool); const message: ChatMessage = { id: crypto.randomUUID(), role: 'tool', @@ -219,7 +222,7 @@ export const ChatSidebar = () => { setError(errorMessage); } - }, [appState, currentStory, connection, model]); + }, [currentStory, connection, model]); const handleSendMessage = useCallback(async () => { if (!currentStory || !input.trim() || !connection || !model || isLoading) return; @@ -240,7 +243,7 @@ export const ChatSidebar = () => { setIsLoading(false); abortControllerRef.current = new AbortController(); } - }, [currentStory, input, connection, model, isLoading]); + }, [currentStory, input, connection, model, isLoading, sendMessage]); const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { diff --git a/src/games/storywriter/components/editor.tsx b/src/games/storywriter/components/editor.tsx index 40ee3fa..49cb82c 100644 --- a/src/games/storywriter/components/editor.tsx +++ b/src/games/storywriter/components/editor.tsx @@ -4,6 +4,7 @@ import styles from '../assets/editor.module.css'; import { highlight } from "../utils/highlight"; import { useMemo } from "preact/hooks"; import { CharacterEditor } from "./character-editor"; +import { LocationEditor } from "./location-editor"; const TABS: { id: Tab; label: string }[] = [ { id: "story", label: "Story" }, @@ -72,9 +73,7 @@ export const Editor = () => { )} {currentStory.currentTab === "locations" && ( -
-

Locations content placeholder

-
+ )}
diff --git a/src/games/storywriter/components/location-editor.tsx b/src/games/storywriter/components/location-editor.tsx new file mode 100644 index 0000000..426c573 --- /dev/null +++ b/src/games/storywriter/components/location-editor.tsx @@ -0,0 +1,151 @@ +import { useAppState, type Location, LocationScale } from "../contexts/state"; +import { useState } from "preact/hooks"; +import styles from '../assets/location-editor.module.css'; + +const SCALE_OPTIONS = Object.entries(LocationScale) + .filter(([, value]) => typeof value === 'number') + .map(([label, value]) => ({ + value, + label, + })); + +export const LocationEditor = () => { + const { currentStory, dispatch } = useAppState(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); + + if (!currentStory) { + return null; + } + + const handleAddLocation = () => { + dispatch({ + type: 'ADD_LOCATION', + storyId: currentStory.id, + location: { + id: crypto.randomUUID(), + name: 'New Location', + shortDescription: '', + description: '', + scale: LocationScale.Room, + }, + }); + }; + + const handleEditLocation = (locationId: string, field: keyof Location, value: any) => { + dispatch({ + type: 'EDIT_LOCATION', + storyId: currentStory.id, + locationId, + updates: { [field]: value }, + }); + }; + + const handleDeleteLocation = (locationId: string) => { + dispatch({ + type: 'DELETE_LOCATION', + storyId: currentStory.id, + locationId, + }); + }; + + return ( +
+
+

Locations

+ +
+ +
+ {currentStory.locations.length === 0 && ( +

No locations yet. Add your first location!

+ )} + + {currentStory.locations.map((location) => ( +
+
+ handleEditLocation(location.id, 'name', e.currentTarget.value)} + onFocus={(e) => e.currentTarget.select()} + placeholder="Location name" + /> + {showDeleteConfirm === location.id ? ( +
+ Delete? + + +
+ ) : ( + + )} +
+ +
+
Scale
+ +
+ +
+
Description
+