Chat interface
This commit is contained in:
parent
b274d0d018
commit
13277a472c
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue