Chat interface
This commit is contained in:
parent
b274d0d018
commit
13277a472c
|
|
@ -298,13 +298,17 @@ export default gameLoop(setup, frame);
|
|||
|
||||
```jsx
|
||||
import { useState, useEffect } from "preact/hooks";
|
||||
import clsx from "clsx";
|
||||
import styles from './app.module.css';
|
||||
|
||||
export function App() {
|
||||
const [count, setCount] = useState(0);
|
||||
const isActive = true;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={clsx(styles.container, {
|
||||
[styles.active]: isActive,
|
||||
})}>
|
||||
<h1>Count: {count}</h1>
|
||||
<button onClick={() => setCount(c => c + 1)}>
|
||||
Increment
|
||||
|
|
@ -314,6 +318,8 @@ export function App() {
|
|||
}
|
||||
```
|
||||
|
||||
**Note:** Always use `clsx` for conditional classnames instead of string concatenation or template literals.
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,10 +8,18 @@
|
|||
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 {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
resize: none;
|
||||
padding: 0 72px;
|
||||
font-family: 'Georgia', serif;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@
|
|||
min-width: 260px;
|
||||
}
|
||||
|
||||
.open[data-side="right"] {
|
||||
width: 30%;
|
||||
min-width: 30%;
|
||||
}
|
||||
|
||||
.closed {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
import { MenuSidebar } from "./menu-sidebar";
|
||||
import { Sidebar } from "./sidebar";
|
||||
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';
|
||||
|
||||
export const App = () => {
|
||||
const { currentStory } = useAppState();
|
||||
|
||||
return (
|
||||
<div class={styles.root}>
|
||||
{currentStory
|
||||
? <Title>{currentStory.title} - Storywriter</Title>
|
||||
: <Title>Storywriter</Title>}
|
||||
<MenuSidebar />
|
||||
<Editor />
|
||||
<Sidebar side="right" />
|
||||
<ChatSidebar />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -24,6 +24,7 @@ export const Editor = () => {
|
|||
|
||||
return (
|
||||
<div class={styles.editor}>
|
||||
<div class={styles.title}>{currentStory.title}</div>
|
||||
<ContentEditable
|
||||
class={styles.editable}
|
||||
value={value}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export const Sidebar = ({ side, children }: Props) => {
|
|||
const open = useBool(true);
|
||||
|
||||
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}>
|
||||
{side === 'left' ? (open.value ? '◀' : '▶') : (open.value ? '▶' : '◀')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,17 @@ import { useContext, useMemo, useReducer } from "preact/hooks";
|
|||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Story {
|
||||
id: string;
|
||||
title: string;
|
||||
text: string;
|
||||
chatMessages: ChatMessage[];
|
||||
}
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
|
@ -23,7 +30,9 @@ type Action =
|
|||
| { type: 'RENAME_STORY'; id: string; title: string }
|
||||
| { type: 'EDIT_STORY'; id: string; text: 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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -41,6 +50,7 @@ function reducer(state: IState, action: Action): IState {
|
|||
id: crypto.randomUUID(),
|
||||
title: action.title,
|
||||
text: '',
|
||||
chatMessages: [],
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
|
|
@ -79,6 +89,22 @@ function reducer(state: IState, action: Action): IState {
|
|||
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:
|
||||
return state;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue