diff --git a/src/games/storywriter/assets/character-editor.module.css b/src/games/storywriter/assets/character-editor.module.css new file mode 100644 index 0000000..ef6a781 --- /dev/null +++ b/src/games/storywriter/assets/character-editor.module.css @@ -0,0 +1,334 @@ +.characterEditor { + 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; +} + +.characterCard { + 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%; +} + +.addInput { + display: flex; + gap: 6px; +} + +.addInputField { + padding: 4px 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 12px; + color: var(--text); + font-family: inherit; + + &:focus { + outline: none; + border-color: var(--accent); + } + + &[placeholder="Add nickname..."] { + width: 120px; + } + + &[placeholder="Relationship"] { + width: 100px; + } + + &.relationInput { + flex: 1; + min-width: 0; + } +} + +.relationInput { + flex: 1; + min-width: 0; + padding: 8px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 13px; + color: var(--text); + font-family: inherit; + + &: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); + } +} + +.smallButton { + padding: 4px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 12px; + color: var(--text); + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--accent); + color: var(--accent); + } +} + +.nicknames { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + min-height: 40px; + align-items: center; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: var(--bg-active); + border-radius: 16px; + font-size: 13px; + color: var(--text); +} + +.badgeRemove { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + background: transparent; + border: none; + border-radius: 50%; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + line-height: 1; + + &:hover { + background: var(--danger); + color: var(--bg); + } +} + +.relations { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; +} + +.relationRow { + display: flex; + align-items: center; + gap: 8px; +} diff --git a/src/games/storywriter/assets/favicon.ico b/src/games/storywriter/assets/favicon.ico new file mode 100644 index 0000000..31ec923 Binary files /dev/null and b/src/games/storywriter/assets/favicon.ico differ diff --git a/src/games/storywriter/assets/pwa_icon.png b/src/games/storywriter/assets/pwa_icon.png new file mode 100644 index 0000000..ac2056a Binary files /dev/null and b/src/games/storywriter/assets/pwa_icon.png differ diff --git a/src/games/storywriter/components/character-editor.tsx b/src/games/storywriter/components/character-editor.tsx new file mode 100644 index 0000000..920f130 --- /dev/null +++ b/src/games/storywriter/components/character-editor.tsx @@ -0,0 +1,301 @@ +import { useAppState, type Character } from "../contexts/state"; +import { useState } from "preact/hooks"; +import styles from '../assets/character-editor.module.css'; + +export const CharacterEditor = () => { + const { currentStory, dispatch } = useAppState(); + const [newNickname, setNewNickname] = useState>({}); + const [newRelation, setNewRelation] = useState>({}); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); + + if (!currentStory) { + return null; + } + + const handleAddCharacter = () => { + dispatch({ + type: 'ADD_CHARACTER', + storyId: currentStory.id, + character: { + id: crypto.randomUUID(), + name: 'New Character', + nicknames: [], + shortDescription: '', + description: '', + relations: [], + }, + }); + }; + + const handleEditCharacter = (characterId: string, field: keyof Character, value: any) => { + dispatch({ + type: 'EDIT_CHARACTER', + storyId: currentStory.id, + characterId, + updates: { [field]: value }, + }); + }; + + const handleDeleteCharacter = (characterId: string) => { + dispatch({ + type: 'DELETE_CHARACTER', + storyId: currentStory.id, + characterId, + }); + }; + + const handleAddRelation = (characterId: string) => { + const rel = newRelation[characterId] || { name: '', relation: '' }; + if (!rel.name.trim() || !rel.relation.trim()) return; + + dispatch({ + type: 'ADD_CHARACTER_RELATION', + storyId: currentStory.id, + characterId, + relation: { name: rel.name.trim(), relation: rel.relation.trim() }, + }); + setNewRelation({ ...newRelation, [characterId]: { name: '', relation: '' } }); + }; + + const handleEditRelation = (characterId: string, targetName: string, field: 'name' | 'relation', value: string) => { + dispatch({ + type: 'EDIT_CHARACTER_RELATION', + storyId: currentStory.id, + characterId, + targetName, + updates: { [field]: value }, + }); + }; + + const handleDeleteRelation = (characterId: string, targetName: string) => { + dispatch({ + type: 'DELETE_CHARACTER_RELATION', + storyId: currentStory.id, + characterId, + targetName, + }); + }; + + const handleNicknameAdd = (characterId: string) => { + const nickname = (newNickname[characterId] || '').trim(); + if (!nickname) return; + + const character = currentStory.characters.find(c => c.id === characterId); + if (character) { + handleEditCharacter(characterId, 'nicknames', [...character.nicknames, nickname]); + setNewNickname({ ...newNickname, [characterId]: '' }); + } + }; + + const handleNicknameDelete = (characterId: string, nickname: string) => { + const character = currentStory.characters.find(c => c.id === characterId); + if (character) { + handleEditCharacter(characterId, 'nicknames', character.nicknames.filter(n => n !== nickname)); + } + }; + + const handleNewRelationChange = (characterId: string, field: 'name' | 'relation', value: string) => { + const current = newRelation[characterId] || { name: '', relation: '' }; + setNewRelation({ ...newRelation, [characterId]: { ...current, [field]: value } }); + }; + + return ( +
+
+

Characters

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

No characters yet. Add your first character!

+ )} + + {currentStory.characters.map((character) => ( +
+
+ handleEditCharacter(character.id, 'name', e.currentTarget.value)} + placeholder="Character name" + /> + {showDeleteConfirm === character.id ? ( +
+ Delete? + + +
+ ) : ( + + )} +
+ +
+
Description
+