Compare commits
No commits in common. "13277a472cc1825aa292f77aadf5a201fc058ab6" and "f5406130dfa456347b8e39c467aad68d1cba176b" have entirely different histories.
13277a472c
...
f5406130df
372
AGENTS.md
372
AGENTS.md
|
|
@ -1,372 +0,0 @@
|
||||||
# AI Agent Instructions for TS-Games
|
|
||||||
|
|
||||||
This document provides guidelines for AI agents working with the TS-Games project.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
TS-Games is a custom framework/build system for creating simple single-file TypeScript games. All games compile into one standalone HTML file with no external dependencies—all assets are inlined as data URLs.
|
|
||||||
|
|
||||||
## Key Concepts
|
|
||||||
|
|
||||||
### Two Types of Applications
|
|
||||||
|
|
||||||
1. **Games** - Interactive applications with game loops
|
|
||||||
- Located in `src/games/<name>/`
|
|
||||||
- Entry point: `index.ts` or `index.tsx`
|
|
||||||
- Default export: `runGame()` function
|
|
||||||
- Use `gameLoop()` from `@common/game` for structured game development
|
|
||||||
|
|
||||||
2. **Apps** - More complex applications (like AI-Story)
|
|
||||||
- Also located in `src/games/<name>/`
|
|
||||||
- Entry point: `index.tsx`
|
|
||||||
- Default export: `main()` function
|
|
||||||
- May use Preact components and complex state management
|
|
||||||
|
|
||||||
### Single HTML File Architecture
|
|
||||||
|
|
||||||
- All games build into a single `.html` file in `dist/`
|
|
||||||
- No external dependencies at runtime
|
|
||||||
- All assets (images, audio, fonts, WASM) are inlined as base64 data URLs
|
|
||||||
- Build process uses Bun's plugin system to transform assets
|
|
||||||
|
|
||||||
## Creating a New Game
|
|
||||||
|
|
||||||
### Step 1: Create Directory Structure
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p src/games/<yourgame>/assets
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Create Entry Point
|
|
||||||
|
|
||||||
Create `src/games/<yourgame>/index.ts` or `index.tsx`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Simple game example (index.ts)
|
|
||||||
import { gameLoop } from "@common/game";
|
|
||||||
import Input from "@common/input";
|
|
||||||
|
|
||||||
const setup = () => {
|
|
||||||
// Initialize game state
|
|
||||||
return { x: 0, y: 0 };
|
|
||||||
};
|
|
||||||
|
|
||||||
const frame = (dt: number, state: ReturnType<typeof setup>) => {
|
|
||||||
// Update game state
|
|
||||||
state.x += Input.getHorizontal() * dt;
|
|
||||||
state.y += Input.getVertical() * dt;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default gameLoop(setup, frame);
|
|
||||||
```
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Preact component example (index.tsx)
|
|
||||||
import { render } from "preact";
|
|
||||||
import App from "./components/app";
|
|
||||||
|
|
||||||
export default function main() {
|
|
||||||
render(<App />, document.body);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Add Assets (Optional)
|
|
||||||
|
|
||||||
Place assets in `src/games/<yourgame>/assets/`:
|
|
||||||
|
|
||||||
- **Images**: `.png`, `.jpg`, `.jpeg` → Import as `HTMLImageElement`
|
|
||||||
- **Audio**: `.wav`, `.mp3`, `.ogg` → Import as `HTMLAudioElement`
|
|
||||||
- **CSS**: `.css`, `.module.css` → Import as styles
|
|
||||||
- **Fonts**: `.font.css` → Import font faces
|
|
||||||
- **WASM**: `.c`, `.cpp`, `.wasm` → Import as WASM module
|
|
||||||
- **Icons**: `favicon.ico` → Auto-used as page icon
|
|
||||||
- **PWA**: `pwa_icon.png` → Used for PWA manifest
|
|
||||||
|
|
||||||
### Step 4: Import Assets
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Import image
|
|
||||||
import spritesheet from './assets/spritesheet.png';
|
|
||||||
console.log(spritesheet); // <img src="data:image/png;base64,..." />
|
|
||||||
|
|
||||||
// Import audio
|
|
||||||
import heal from './assets/heal.ogg';
|
|
||||||
heal.play();
|
|
||||||
|
|
||||||
// Import CSS
|
|
||||||
import './assets/styles.css';
|
|
||||||
|
|
||||||
// Import CSS modules
|
|
||||||
import styles from './assets/styles.module.css';
|
|
||||||
<div className={styles.root} />
|
|
||||||
|
|
||||||
// Import WASM (C/C++)
|
|
||||||
import wasmModule from './assets/game.c';
|
|
||||||
// Access exports: wasmModule.memory, wasmModule.functionName()
|
|
||||||
|
|
||||||
// Import GLSL shader
|
|
||||||
import shader from './assets/shader.glsl';
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
### Running Development Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun start
|
|
||||||
```
|
|
||||||
|
|
||||||
Navigate to `http://localhost:3000` to see the game list. Games rebuild on each page reload.
|
|
||||||
|
|
||||||
To run a specific game:
|
|
||||||
```bash
|
|
||||||
bun start --game=<yourgame>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Building for Production
|
|
||||||
|
|
||||||
Build a specific game:
|
|
||||||
```bash
|
|
||||||
bun run build <yourgame>
|
|
||||||
```
|
|
||||||
|
|
||||||
Or select from interactive list:
|
|
||||||
```bash
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Output: `dist/<yourgame>.html`
|
|
||||||
|
|
||||||
### Building with Options
|
|
||||||
|
|
||||||
- `--local`: Build for local testing (no PWA, different index)
|
|
||||||
- `--production`: Minify JS/CSS/HTML (default for build)
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
tsgames/
|
|
||||||
├── src/
|
|
||||||
│ ├── games/ # All games/apps live here
|
|
||||||
│ │ ├── <game1>/ # Individual game directory
|
|
||||||
│ │ │ ├── index.ts # Entry point (required)
|
|
||||||
│ │ │ └── assets/ # Game assets
|
|
||||||
│ │ └── index/ # Special: game list page
|
|
||||||
│ ├── common/ # Shared utilities
|
|
||||||
│ │ ├── game.ts # Game loop helper
|
|
||||||
│ │ ├── input.ts # Input handling
|
|
||||||
│ │ ├── dom.ts # DOM utilities
|
|
||||||
│ │ ├── utils.ts # General utilities
|
|
||||||
│ │ ├── errors.ts # Error formatting
|
|
||||||
│ │ ├── components/ # Shared Preact components
|
|
||||||
│ │ ├── hooks/ # React-like hooks
|
|
||||||
│ │ ├── display/ # Canvas/graphics utilities
|
|
||||||
│ │ └── physics/ # Physics utilities
|
|
||||||
│ └── types.d.ts # Global type definitions
|
|
||||||
├── build/
|
|
||||||
│ ├── build.ts # Build script
|
|
||||||
│ ├── server.ts # Dev server
|
|
||||||
│ ├── html.ts # HTML generation
|
|
||||||
│ ├── isGame.ts # Game detection
|
|
||||||
│ ├── imagePlugin.ts # Image bundler plugin
|
|
||||||
│ ├── audioPlugin.ts # Audio bundler plugin
|
|
||||||
│ ├── fontPlugin.ts # Font bundler plugin
|
|
||||||
│ ├── wasmPlugin.ts # WASM bundler plugin
|
|
||||||
│ └── filePlugin.ts # Generic file plugin
|
|
||||||
├── dist/ # Built HTML files
|
|
||||||
└── test/ # Tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
### Bun Configuration
|
|
||||||
|
|
||||||
- Use `bun` for all package management and script running
|
|
||||||
- `.env` files are automatically loaded (no dotenv needed)
|
|
||||||
- Use `Bun.serve()` for servers (not express)
|
|
||||||
- Use `Bun.file()` for file operations (not fs)
|
|
||||||
- Use `Bun.$` for shell commands (not execa)
|
|
||||||
|
|
||||||
### TypeScript Configuration
|
|
||||||
|
|
||||||
- JSX: `react-jsx` with Preact
|
|
||||||
- Module resolution: `bundler`
|
|
||||||
- Path aliases: `@common/*` → `./src/common/*`
|
|
||||||
- Strict mode enabled
|
|
||||||
|
|
||||||
### Asset Processing
|
|
||||||
|
|
||||||
All asset processing happens at build time via Bun plugins:
|
|
||||||
|
|
||||||
1. **Images** (`.png`, `.jpg`, `.jpeg`):
|
|
||||||
- Converted to base64 data URLs
|
|
||||||
- Return `HTMLImageElement` that's ready to use
|
|
||||||
|
|
||||||
2. **Audio** (`.wav`, `.mp3`, `.ogg`):
|
|
||||||
- Converted to base64 data URLs
|
|
||||||
- Return `HTMLAudioElement` with loaded source
|
|
||||||
|
|
||||||
3. **CSS**:
|
|
||||||
- Bundled and inlined in `<style>` tag
|
|
||||||
- CSS modules supported (`.module.css`)
|
|
||||||
- Modern CSS features transpiled (nesting, vendor prefixes)
|
|
||||||
|
|
||||||
4. **Fonts**:
|
|
||||||
- Import via `.font.css` files
|
|
||||||
- Base64 inlined
|
|
||||||
|
|
||||||
5. **WASM**:
|
|
||||||
- `.c`/`.cpp`: Compiled to WASM at build time (requires clang)
|
|
||||||
- `.wasm`: Direct inclusion
|
|
||||||
- All inlined as base64 data URLs
|
|
||||||
|
|
||||||
### WASM Development
|
|
||||||
|
|
||||||
For C/C++ files:
|
|
||||||
|
|
||||||
```c
|
|
||||||
// Example: src/games/life/life.c
|
|
||||||
EXPORT(init) void init();
|
|
||||||
EXPORT(step) void step();
|
|
||||||
EXPORT(getCell) int getCell(int x, int y);
|
|
||||||
|
|
||||||
// Must export memory
|
|
||||||
```
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- Install: `sudo apt install clang lld wabt`
|
|
||||||
- Only function exports and `memory` supported
|
|
||||||
|
|
||||||
### PWA Support
|
|
||||||
|
|
||||||
Production builds include PWA manifest with:
|
|
||||||
- App name from game directory name (capitalized)
|
|
||||||
- Icon from `favicon.ico` or `pwa_icon.png`
|
|
||||||
- Fullscreen display mode
|
|
||||||
|
|
||||||
### Publishing
|
|
||||||
|
|
||||||
Configure `.env`:
|
|
||||||
```bash
|
|
||||||
PUBLISH_LOCATION=ssh.example.com:/var/www/games/
|
|
||||||
PUBLISH_URL=https://example.com/
|
|
||||||
```
|
|
||||||
|
|
||||||
Build and publish:
|
|
||||||
```bash
|
|
||||||
bun run build <yourgame>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Game Loop Pattern
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { gameLoop } from "@common/game";
|
|
||||||
import Input from "@common/input";
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
playerX: number;
|
|
||||||
playerY: number;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const setup = (): State => {
|
|
||||||
return {
|
|
||||||
playerX: 100,
|
|
||||||
playerY: 100,
|
|
||||||
score: 0,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const frame = (dt: number, state: State): State => {
|
|
||||||
// Update based on input
|
|
||||||
state.playerX += Input.getHorizontal() * 200 * dt;
|
|
||||||
state.playerY += Input.getVertical() * 200 * dt;
|
|
||||||
|
|
||||||
// Update score
|
|
||||||
state.score += dt;
|
|
||||||
|
|
||||||
// Return new state (optional)
|
|
||||||
return state;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default gameLoop(setup, frame);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Preact Component Pattern
|
|
||||||
|
|
||||||
```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={clsx(styles.container, {
|
|
||||||
[styles.active]: isActive,
|
|
||||||
})}>
|
|
||||||
<h1>Count: {count}</h1>
|
|
||||||
<button onClick={() => setCount(c => c + 1)}>
|
|
||||||
Increment
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** Always use `clsx` for conditional classnames instead of string concatenation or template literals.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run tests:
|
|
||||||
```bash
|
|
||||||
bun test
|
|
||||||
```
|
|
||||||
|
|
||||||
Tests use Bun's built-in test framework:
|
|
||||||
```typescript
|
|
||||||
import { test, expect } from "bun:test";
|
|
||||||
|
|
||||||
test("example", () => {
|
|
||||||
expect(1 + 1).toBe(2);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
1. **No external runtime dependencies** - Everything must be bundled
|
|
||||||
2. **Single entry point** - Each game has one default export
|
|
||||||
3. **Game name from directory** - Directory name becomes game name and URL path
|
|
||||||
4. **Hot reload** - Dev server rebuilds on each page reload
|
|
||||||
5. **Mobile support** - Use `?mobile=true` to enable Eruda debugger
|
|
||||||
6. **Production debugging** - Use `?production=true` to test production builds in dev
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
Study existing games for patterns:
|
|
||||||
- **Simple**: `playground/` - Basic game loop example
|
|
||||||
- **Canvas**: `binario/` - Canvas-based puzzle game
|
|
||||||
- **Preact**: `ai-story/` - Complex app with components and state
|
|
||||||
- **WASM**: `life/` - C code compiled to WASM
|
|
||||||
- **Text-based**: `text-dungeon/` - Text adventure game
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Build fails with "No entry point found"
|
|
||||||
- Ensure your game has `index.ts` or `index.tsx` with default export
|
|
||||||
|
|
||||||
### Assets not loading
|
|
||||||
- Check file extensions are supported
|
|
||||||
- Verify assets are in `src/games/<name>/assets/`
|
|
||||||
|
|
||||||
### WASM compilation fails
|
|
||||||
- Install clang and WASM toolchain
|
|
||||||
- Check C/C++ code compiles with `-Werror`
|
|
||||||
|
|
||||||
### TypeScript errors
|
|
||||||
- Run `bunx tsc --noEmit --skipLibCheck` to check types
|
|
||||||
- Use `@common/*` path alias for common imports
|
|
||||||
15
README.md
15
README.md
|
|
@ -92,6 +92,21 @@ bun run build
|
||||||
import "./assets/lcd.font.css";
|
import "./assets/lcd.font.css";
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- [AssemblyScript](https://www.assemblyscript.org/) support (TypeScript-like language compiled into WebAssembly)
|
||||||
|
- Example: `src/games/playground/awoo.wasm.ts`
|
||||||
|
- Triggered by file name `*.wasm.ts`
|
||||||
|
|
||||||
|
- Import `*.c`/`*.cpp` files (compile to wasm on the fly)
|
||||||
|
- Example: `src/games/life/life.c`
|
||||||
|
- To use, `clang` and wasm toochain should be present in the system
|
||||||
|
```bash
|
||||||
|
sudo apt install clang lld wabt
|
||||||
|
```
|
||||||
|
- Supports only function exports & `memory`
|
||||||
|
- `EXPORT(jsName) void c_function();`
|
||||||
|
- No stdlib
|
||||||
|
|
||||||
|
|
||||||
## Publishing
|
## Publishing
|
||||||
|
|
||||||
- Make sure you have `scp` installed (it most certainly is)
|
- Make sure you have `scp` installed (it most certainly is)
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
import { useEffect, useRef } from "preact/hooks";
|
|
||||||
import type { JSX } from "preact";
|
|
||||||
|
|
||||||
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
|
|
||||||
value: string;
|
|
||||||
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getCaretOffset(el: HTMLElement): number {
|
|
||||||
const sel = window.getSelection();
|
|
||||||
if (!sel || sel.rangeCount === 0) return 0;
|
|
||||||
const range = sel.getRangeAt(0).cloneRange();
|
|
||||||
range.selectNodeContents(el);
|
|
||||||
range.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset);
|
|
||||||
return range.toString().length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCaretOffset(el: HTMLElement, offset: number) {
|
|
||||||
const sel = window.getSelection();
|
|
||||||
if (!sel) return;
|
|
||||||
const range = document.createRange();
|
|
||||||
let remaining = offset;
|
|
||||||
|
|
||||||
function traverse(node: Node): boolean {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
|
||||||
const len = node.textContent?.length ?? 0;
|
|
||||||
if (remaining <= len) {
|
|
||||||
range.setStart(node, remaining);
|
|
||||||
range.collapse(true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
remaining -= len;
|
|
||||||
} else {
|
|
||||||
for (const child of Array.from(node.childNodes)) {
|
|
||||||
if (traverse(child)) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!traverse(el)) {
|
|
||||||
range.selectNodeContents(el);
|
|
||||||
range.collapse(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
sel.removeAllRanges();
|
|
||||||
sel.addRange(range);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ContentEditable = ({ value, onInput, ...props }: Props) => {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el || el.innerHTML === value) return;
|
|
||||||
|
|
||||||
const offset = document.activeElement === el ? getCaretOffset(el) : null;
|
|
||||||
el.innerHTML = value;
|
|
||||||
if (offset !== null) setCaretOffset(el, offset);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const handleKeyDown: JSX.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
|
||||||
if (e.key !== 'Enter') return;
|
|
||||||
const prevTextContent = (e.target as HTMLDivElement).textContent;
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const sel = window.getSelection();
|
|
||||||
if (!sel || sel.rangeCount === 0) return;
|
|
||||||
|
|
||||||
const range = sel.getRangeAt(0);
|
|
||||||
range.deleteContents();
|
|
||||||
|
|
||||||
const newline = document.createTextNode('\n');
|
|
||||||
range.insertNode(newline);
|
|
||||||
range.setStartAfter(newline);
|
|
||||||
range.collapse(true);
|
|
||||||
|
|
||||||
const nextTextContent = (e.target as HTMLDivElement).textContent;
|
|
||||||
|
|
||||||
// A trailing \n needs a following character to render in pre-line.
|
|
||||||
// If nothing follows the inserted newline, add a sentinel \n so the
|
|
||||||
// new line is visible, then place the caret before it.
|
|
||||||
const atEnd = nextTextContent.startsWith(prevTextContent) && nextTextContent !== prevTextContent && nextTextContent.length === prevTextContent.length + 1 && nextTextContent.at(-1) === '\n';
|
|
||||||
if (atEnd) {
|
|
||||||
const sentinel = document.createTextNode('\n');
|
|
||||||
range.insertNode(sentinel);
|
|
||||||
range.setStartBefore(sentinel);
|
|
||||||
range.collapse(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
sel.removeAllRanges();
|
|
||||||
sel.addRange(range);
|
|
||||||
|
|
||||||
ref.current?.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
contentEditable
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onInput={onInput}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
@ -10,8 +10,6 @@ export const useInputState = (defaultValue = ''): [string, (e: Event | string) =
|
||||||
const { target } = e;
|
const { target } = e;
|
||||||
if (target && 'value' in target && typeof target.value === 'string') {
|
if (target && 'value' in target && typeof target.value === 'string') {
|
||||||
setValue(target.value);
|
setValue(target.value);
|
||||||
} else if (target instanceof HTMLElement) {
|
|
||||||
setValue(target.innerHTML);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useReducer, useState, type Dispatch, t
|
||||||
import { MessageTools, type IMessage } from "../tools/messages";
|
import { MessageTools, type IMessage } from "../tools/messages";
|
||||||
import { useInputState } from "@common/hooks/useInputState";
|
import { useInputState } from "@common/hooks/useInputState";
|
||||||
import { type IConnection } from "../tools/connection";
|
import { type IConnection } from "../tools/connection";
|
||||||
import { loadObject, saveObject } from "../../../common/storage";
|
import { loadObject, saveObject } from "../tools/storage";
|
||||||
import { useInputCallback } from "@common/hooks/useInputCallback";
|
import { useInputCallback } from "@common/hooks/useInputCallback";
|
||||||
import { callUpdater, throttle } from "@common/utils";
|
import { callUpdater, throttle } from "@common/utils";
|
||||||
import { Huggingface } from "../tools/huggingface";
|
import { Huggingface } from "../tools/huggingface";
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { gguf } from '@huggingface/gguf';
|
||||||
import * as hub from '@huggingface/hub';
|
import * as hub from '@huggingface/hub';
|
||||||
import { Template } from '@huggingface/jinja';
|
import { Template } from '@huggingface/jinja';
|
||||||
import { Tokenizer } from '@huggingface/tokenizers';
|
import { Tokenizer } from '@huggingface/tokenizers';
|
||||||
import { loadObject, saveObject } from '@common/storage';
|
|
||||||
import { normalizeModel } from './model';
|
import { normalizeModel } from './model';
|
||||||
|
import { loadObject, saveObject } from './storage';
|
||||||
|
|
||||||
export namespace Huggingface {
|
export namespace Huggingface {
|
||||||
export interface ITemplateMessage {
|
export interface ITemplateMessage {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
width: 100dvw;
|
|
||||||
height: 100dvh;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
.editor {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--bg);
|
|
||||||
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%;
|
|
||||||
resize: none;
|
|
||||||
padding: 0 72px;
|
|
||||||
font-family: 'Georgia', serif;
|
|
||||||
font-size: 17px;
|
|
||||||
line-height: 1.9;
|
|
||||||
color: var(--textColor);
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::selection {
|
|
||||||
background: var(--bg-active);
|
|
||||||
color: var(--yellow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
.menu {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newButton {
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px 8px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--accent-alt);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--accent-alt);
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.itemWrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 0;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--bg-active);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
flex: 1;
|
|
||||||
padding: 6px 8px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: inherit;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.active .item {
|
|
||||||
background: transparent;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.1s;
|
|
||||||
|
|
||||||
.itemWrapper:hover & {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionButton {
|
|
||||||
padding: 4px 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-radius: 2px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--bg-hover);
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
.sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
transition: width 0.2s ease, min-width 0.2s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar:last-child {
|
|
||||||
border-right: none;
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
|
|
||||||
& .toggle {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.open {
|
|
||||||
width: 260px;
|
|
||||||
min-width: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.open[data-side="right"] {
|
|
||||||
width: 30%;
|
|
||||||
min-width: 30%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.closed {
|
|
||||||
width: 32px;
|
|
||||||
min-width: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-self: flex-end;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
:root {
|
|
||||||
/* Monokai-inspired palette */
|
|
||||||
--bg: #272822;
|
|
||||||
--bg-panel: #1e1f1a;
|
|
||||||
--bg-hover: #3e3d32;
|
|
||||||
--bg-active: #49483e;
|
|
||||||
--border: #3e3d32;
|
|
||||||
--accent: #f92672;
|
|
||||||
--accent-alt: #a6e22e;
|
|
||||||
--text: #f8f8f2;
|
|
||||||
--text-muted: #75715e;
|
|
||||||
--text-dim: #cfcfc2;
|
|
||||||
--yellow: #e6db74;
|
|
||||||
--orange: #fd971f;
|
|
||||||
--blue: #66d9ef;
|
|
||||||
--purple: #ae81ff;
|
|
||||||
|
|
||||||
--radius: 4px;
|
|
||||||
--transition: 0.15s ease;
|
|
||||||
|
|
||||||
--textColor: #DCDCD2;
|
|
||||||
--italicColor: #AFAFAF;
|
|
||||||
--quoteColor: #D4E5FF;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--bg-active) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: 'Georgia', serif;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.6;
|
|
||||||
width: 100dvw;
|
|
||||||
height: 100dvh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: inherit;
|
|
||||||
transition: color var(--transition), background var(--transition);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { MenuSidebar } from "./menu-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 />
|
|
||||||
<ChatSidebar />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";
|
|
||||||
import { useAppState } from "../contexts/state";
|
|
||||||
import styles from '../assets/editor.module.css';
|
|
||||||
import { highlight } from "../utils/highlight";
|
|
||||||
import { useMemo } from "preact/hooks";
|
|
||||||
|
|
||||||
export const Editor = () => {
|
|
||||||
const { currentStory, dispatch } = useAppState();
|
|
||||||
|
|
||||||
if (!currentStory) {
|
|
||||||
return <div class={styles.editor} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInput = (e: Event) => {
|
|
||||||
const text = (e.target as HTMLElement).textContent || '';
|
|
||||||
dispatch({
|
|
||||||
type: 'EDIT_STORY',
|
|
||||||
id: currentStory.id,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = useMemo(() => highlight(currentStory.text), [currentStory.text]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={styles.editor}>
|
|
||||||
<div class={styles.title}>{currentStory.title}</div>
|
|
||||||
<ContentEditable
|
|
||||||
class={styles.editable}
|
|
||||||
value={value}
|
|
||||||
onInput={handleInput}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import clsx from "clsx";
|
|
||||||
import { Sidebar } from "./sidebar";
|
|
||||||
import { useAppState } from "../contexts/state";
|
|
||||||
import type { Story } from "../contexts/state";
|
|
||||||
import styles from '../assets/menu-sidebar.module.css';
|
|
||||||
import { useState } from "preact/hooks";
|
|
||||||
|
|
||||||
// ─── Story Item ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface StoryItemProps {
|
|
||||||
story: Story;
|
|
||||||
active: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
onRename: (newTitle: string) => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StoryItem = ({ story, active, onSelect, onRename, onDelete }: StoryItemProps) => {
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [editTitle, setEditTitle] = useState(story.title);
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (editTitle.trim()) {
|
|
||||||
onRename(editTitle.trim());
|
|
||||||
}
|
|
||||||
setIsEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleSubmit();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setEditTitle(story.title);
|
|
||||||
setIsEditing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
|
||||||
handleSubmit();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditing) {
|
|
||||||
return (
|
|
||||||
<div class={clsx(styles.itemWrapper, active && styles.active)}>
|
|
||||||
<input
|
|
||||||
class={styles.input}
|
|
||||||
value={editTitle}
|
|
||||||
onInput={(e) => setEditTitle((e.target as HTMLInputElement).value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={clsx(styles.itemWrapper, active && styles.active)}>
|
|
||||||
<button
|
|
||||||
class={clsx(styles.item, active && styles.active)}
|
|
||||||
onClick={onSelect}
|
|
||||||
>
|
|
||||||
{story.title}
|
|
||||||
</button>
|
|
||||||
<div class={styles.actions}>
|
|
||||||
<button class={styles.actionButton} onClick={() => setIsEditing(true)} title="Rename">
|
|
||||||
✎
|
|
||||||
</button>
|
|
||||||
<button class={styles.actionButton} onClick={onDelete} title="Delete">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Menu Sidebar ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const MenuSidebar = () => {
|
|
||||||
const { stories, currentStory, dispatch } = useAppState();
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
dispatch({ type: 'CREATE_STORY', title: 'New Story' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (id: string) => {
|
|
||||||
dispatch({ type: 'SELECT_STORY', id });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRename = (id: string, newTitle: string) => {
|
|
||||||
dispatch({ type: 'RENAME_STORY', id, title: newTitle });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
|
||||||
const story = stories.find(s => s.id === id);
|
|
||||||
if (!story) return;
|
|
||||||
if (confirm(`Delete "${story.title}"?`)) {
|
|
||||||
dispatch({ type: 'DELETE_STORY', id });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sidebar side="left">
|
|
||||||
<div class={styles.menu}>
|
|
||||||
<button class={styles.newButton} onClick={handleCreate}>
|
|
||||||
+ New Story
|
|
||||||
</button>
|
|
||||||
<div class={styles.list}>
|
|
||||||
{stories.map(story => (
|
|
||||||
<StoryItem
|
|
||||||
key={story.id}
|
|
||||||
story={story}
|
|
||||||
active={story.id === currentStory?.id}
|
|
||||||
onSelect={() => handleSelect(story.id)}
|
|
||||||
onRename={(newTitle) => handleRename(story.id, newTitle)}
|
|
||||||
onDelete={() => handleDelete(story.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import clsx from "clsx";
|
|
||||||
import type { JSX } from "preact";
|
|
||||||
import { useBool } from "@common/hooks/useBool";
|
|
||||||
import styles from '../assets/sidebar.module.css';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
side: 'left' | 'right';
|
|
||||||
children?: JSX.Element | JSX.Element[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Sidebar = ({ side, children }: Props) => {
|
|
||||||
const open = useBool(true);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
{open.value && (
|
|
||||||
<div class={styles.content}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import { createContext } from "preact";
|
|
||||||
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 ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface IState {
|
|
||||||
stories: Story[];
|
|
||||||
currentStoryId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Actions ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| { type: 'CREATE_STORY'; title: string }
|
|
||||||
| { 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: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage }
|
|
||||||
| { type: 'CLEAR_CHAT'; storyId: string };
|
|
||||||
|
|
||||||
// ─── Initial State ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEFAULT_STATE: IState = {
|
|
||||||
stories: [],
|
|
||||||
currentStoryId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Reducer ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function reducer(state: IState, action: Action): IState {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'CREATE_STORY': {
|
|
||||||
const story: Story = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
title: action.title,
|
|
||||||
text: '',
|
|
||||||
chatMessages: [],
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stories: [...state.stories, story],
|
|
||||||
currentStoryId: story.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'RENAME_STORY': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s =>
|
|
||||||
s.id === action.id ? { ...s, title: action.title } : s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'EDIT_STORY': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s =>
|
|
||||||
s.id === action.id ? { ...s, text: action.text } : s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'DELETE_STORY': {
|
|
||||||
const remaining = state.stories.filter(s => s.id !== action.id);
|
|
||||||
const deletingCurrent = state.currentStoryId === action.id;
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stories: remaining,
|
|
||||||
currentStoryId: deletingCurrent ? null : state.currentStoryId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'SELECT_STORY': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Context ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface IStateContext {
|
|
||||||
stories: Story[];
|
|
||||||
currentStory: Story | null;
|
|
||||||
dispatch: (action: Action) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StateContext = createContext<IStateContext>({} as IStateContext);
|
|
||||||
|
|
||||||
export const useAppState = () => useContext(StateContext);
|
|
||||||
|
|
||||||
// ─── Provider ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const StateContextProvider = ({ children }: { children?: any }) => {
|
|
||||||
const [state, dispatch] = useReducer(reducer, DEFAULT_STATE);
|
|
||||||
|
|
||||||
const value = useMemo<IStateContext>(() => ({
|
|
||||||
stories: state.stories,
|
|
||||||
currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null,
|
|
||||||
dispatch,
|
|
||||||
}), [state]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StateContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</StateContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { render } from "preact";
|
|
||||||
import { StateContextProvider } from "./contexts/state";
|
|
||||||
import { App } from "./components/app";
|
|
||||||
|
|
||||||
import './assets/style.css';
|
|
||||||
|
|
||||||
export default function main() {
|
|
||||||
render(
|
|
||||||
<StateContextProvider>
|
|
||||||
<App />
|
|
||||||
</StateContextProvider>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
export const highlight = (message: string): string => {
|
|
||||||
const replaceRegex = /(\*\*?|")/ig;
|
|
||||||
const splitToken = '___SPLIT_AWOORWA___';
|
|
||||||
|
|
||||||
const preparedMessage = message.replace(replaceRegex, `${splitToken}$1${splitToken}`);
|
|
||||||
const parts = preparedMessage.split(splitToken);
|
|
||||||
|
|
||||||
const stack: string[] = [];
|
|
||||||
|
|
||||||
let resultHTML = '';
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
const isClose = stack.at(-1) === part;
|
|
||||||
if (isClose) {
|
|
||||||
stack.pop();
|
|
||||||
if (part === '*' || part === '**' || part === '"') {
|
|
||||||
resultHTML += `${part}</span>`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (part === '*') {
|
|
||||||
stack.push(part);
|
|
||||||
resultHTML += `<span style="font-style:italic;color:var(--italicColor)">`;
|
|
||||||
} else if (part === '**') {
|
|
||||||
stack.push(part);
|
|
||||||
resultHTML += `<span style="font-weight:bold">`;
|
|
||||||
} else if (part === '"') {
|
|
||||||
stack.push(part);
|
|
||||||
resultHTML += `<span style="color:var(--quoteColor)">`;
|
|
||||||
}
|
|
||||||
resultHTML += part;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (stack.length) {
|
|
||||||
const part = stack.pop();
|
|
||||||
if (part === '*' || part === '**' || part === '"') {
|
|
||||||
resultHTML += `</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resultHTML;
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue