1
0
Fork 0

Chat interface

This commit is contained in:
Pabloader 2026-03-19 20:44:49 +00:00
parent b274d0d018
commit 13277a472c
10 changed files with 296 additions and 6 deletions

View File

@ -298,13 +298,17 @@ export default gameLoop(setup, frame);
```jsx ```jsx
import { useState, useEffect } from "preact/hooks"; import { useState, useEffect } from "preact/hooks";
import clsx from "clsx";
import styles from './app.module.css'; import styles from './app.module.css';
export function App() { export function App() {
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const isActive = true;
return ( return (
<div className={styles.container}> <div className={clsx(styles.container, {
[styles.active]: isActive,
})}>
<h1>Count: {count}</h1> <h1>Count: {count}</h1>
<button onClick={() => setCount(c => c + 1)}> <button onClick={() => setCount(c => c + 1)}>
Increment Increment
@ -314,6 +318,8 @@ export function App() {
} }
``` ```
**Note:** Always use `clsx` for conditional classnames instead of string concatenation or template literals.
## Testing ## Testing
Run tests: Run tests:

View File

@ -0,0 +1,15 @@
import { useEffect } from "preact/hooks";
interface Props {
children: unknown;
}
export const Title = ({ children }: Props) => {
useEffect(() => {
if (!children) return;
document.title = Array.isArray(children)
? children.join('')
: children.toString();
}, [children]);
return null;
};

View File

@ -0,0 +1,120 @@
.chat {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
text-align: center;
padding: 16px;
}
.messages {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
}
.message {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border-radius: 4px;
}
.message[data-role="user"] {
background: var(--bg-active);
}
.message[data-role="assistant"] {
background: var(--bg-panel);
}
.message[data-role="system"] {
background: var(--bg-hover);
}
.role {
font-size: 11px;
font-weight: bold;
color: var(--accent);
text-transform: uppercase;
}
.content {
font-size: 13px;
color: var(--text);
white-space: pre-wrap;
word-wrap: break-word;
}
.inputContainer {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.input {
width: 100%;
padding: 8px;
font-size: 13px;
font-family: inherit;
resize: vertical;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
outline: none;
&:focus {
border-color: var(--accent);
}
&::placeholder {
color: var(--text-muted);
}
}
.sendButton {
padding: 8px 16px;
font-size: 13px;
font-weight: bold;
color: var(--bg);
background: var(--accent);
border: none;
border-radius: 4px;
cursor: pointer;
&:hover {
background: var(--accent-alt);
}
}
.clearButton {
padding: 8px 16px;
font-size: 12px;
color: var(--text-muted);
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
align-self: center;
margin-top: 8px;
&:hover {
color: var(--text);
border-color: var(--text-muted);
}
}

View File

@ -8,10 +8,18 @@
padding: 36px 0; padding: 36px 0;
} }
.title {
padding: 0 72px 24px;
font-family: 'Georgia', serif;
font-size: 32px;
font-weight: bold;
color: var(--text);
text-align: center;
}
.editable { .editable {
flex: 1; flex: 1;
width: 100%; width: 100%;
min-height: 100%;
resize: none; resize: none;
padding: 0 72px; padding: 0 72px;
font-family: 'Georgia', serif; font-family: 'Georgia', serif;

View File

@ -23,6 +23,11 @@
min-width: 260px; min-width: 260px;
} }
.open[data-side="right"] {
width: 30%;
min-width: 30%;
}
.closed { .closed {
width: 32px; width: 32px;
min-width: 32px; min-width: 32px;

View File

@ -1,14 +1,21 @@
import { MenuSidebar } from "./menu-sidebar"; import { MenuSidebar } from "./menu-sidebar";
import { Sidebar } from "./sidebar";
import { Editor } from "./editor"; import { Editor } from "./editor";
import { ChatSidebar } from "./chat-sidebar";
import { Title } from "@common/components/Title";
import { useAppState } from "../contexts/state";
import styles from '../assets/app.module.css'; import styles from '../assets/app.module.css';
export const App = () => { export const App = () => {
const { currentStory } = useAppState();
return ( return (
<div class={styles.root}> <div class={styles.root}>
{currentStory
? <Title>{currentStory.title} - Storywriter</Title>
: <Title>Storywriter</Title>}
<MenuSidebar /> <MenuSidebar />
<Editor /> <Editor />
<Sidebar side="right" /> <ChatSidebar />
</div> </div>
); );
}; };

View File

@ -0,0 +1,102 @@
import { Sidebar } from "./sidebar";
import { useAppState } from "../contexts/state";
import styles from '../assets/chat-sidebar.module.css';
import { useState, useRef, useEffect } from "preact/hooks";
export const ChatSidebar = () => {
const { currentStory, dispatch } = useAppState();
const [input, setInput] = useState('');
const messagesRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (messagesRef.current) {
messagesRef.current.scrollTo({
top: messagesRef.current.scrollHeight,
behavior: 'smooth',
});
}
}, [currentStory?.chatMessages.length]);
const sendMessage = () => {
if (!currentStory || !input.trim()) return;
dispatch({
type: 'ADD_CHAT_MESSAGE',
storyId: currentStory.id,
message: {
id: crypto.randomUUID(),
role: 'user',
content: input.trim(),
},
});
dispatch({
type: 'ADD_CHAT_MESSAGE',
storyId: currentStory.id,
message: {
id: crypto.randomUUID(),
role: 'assistant',
content: 'Assistant message goes here...',
},
});
setInput('');
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const handleClear = () => {
if (!currentStory) return;
dispatch({
type: 'CLEAR_CHAT',
storyId: currentStory.id,
});
};
return (
<Sidebar side="right">
<div class={styles.chat}>
{!currentStory ? (
<div class={styles.placeholder}>
Select a story to start chatting
</div>
) : currentStory.chatMessages.length === 0 ? (
<div class={styles.placeholder}>
No messages yet
</div>
) : (
<div class={styles.messages} ref={messagesRef}>
{currentStory.chatMessages.map((message) => (
<div key={message.id} class={styles.message} data-role={message.role}>
<div class={styles.role}>{message.role}</div>
<div class={styles.content}>{message.content}</div>
</div>
))}
<button class={styles.clearButton} onClick={handleClear}>
Clear chat
</button>
</div>
)}
{currentStory && (
<div class={styles.inputContainer}>
<textarea
class={styles.input}
value={input}
onInput={(e) => setInput((e.target as HTMLTextAreaElement).value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
rows={3}
/>
<button class={styles.sendButton} onClick={sendMessage}>
Send
</button>
</div>
)}
</div>
</Sidebar>
);
};

View File

@ -24,6 +24,7 @@ export const Editor = () => {
return ( return (
<div class={styles.editor}> <div class={styles.editor}>
<div class={styles.title}>{currentStory.title}</div>
<ContentEditable <ContentEditable
class={styles.editable} class={styles.editable}
value={value} value={value}

View File

@ -12,7 +12,7 @@ export const Sidebar = ({ side, children }: Props) => {
const open = useBool(true); const open = useBool(true);
return ( return (
<div class={clsx(styles.sidebar, open.value ? styles.open : styles.closed)}> <div class={clsx(styles.sidebar, open.value ? styles.open : styles.closed)} data-side={side}>
<button class={styles.toggle} onClick={open.toggle}> <button class={styles.toggle} onClick={open.toggle}>
{side === 'left' ? (open.value ? '◀' : '▶') : (open.value ? '▶' : '◀')} {side === 'left' ? (open.value ? '◀' : '▶') : (open.value ? '▶' : '◀')}
</button> </button>

View File

@ -3,10 +3,17 @@ import { useContext, useMemo, useReducer } from "preact/hooks";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
}
export interface Story { export interface Story {
id: string; id: string;
title: string; title: string;
text: string; text: string;
chatMessages: ChatMessage[];
} }
// ─── State ─────────────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────────────
@ -23,7 +30,9 @@ type Action =
| { type: 'RENAME_STORY'; id: string; title: string } | { type: 'RENAME_STORY'; id: string; title: string }
| { type: 'EDIT_STORY'; id: string; text: string } | { type: 'EDIT_STORY'; id: string; text: string }
| { type: 'DELETE_STORY'; id: string } | { type: 'DELETE_STORY'; id: string }
| { type: 'SELECT_STORY'; id: string }; | { type: 'SELECT_STORY'; id: string }
| { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage }
| { type: 'CLEAR_CHAT'; storyId: string };
// ─── Initial State ─────────────────────────────────────────────────────────── // ─── Initial State ───────────────────────────────────────────────────────────
@ -41,6 +50,7 @@ function reducer(state: IState, action: Action): IState {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: action.title, title: action.title,
text: '', text: '',
chatMessages: [],
}; };
return { return {
...state, ...state,
@ -79,6 +89,22 @@ function reducer(state: IState, action: Action): IState {
currentStoryId: action.id, currentStoryId: action.id,
}; };
} }
case 'ADD_CHAT_MESSAGE': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId ? { ...s, chatMessages: [...s.chatMessages, action.message] } : s
),
};
}
case 'CLEAR_CHAT': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId ? { ...s, chatMessages: [] } : s
),
};
}
default: default:
return state; return state;
} }