Compare commits
13
Commits
f9ea37427b
...
c81cf221ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c81cf221ef | ||
|
|
e0e7250180 | ||
|
|
12f8d8542c | ||
|
|
49bed7d279 | ||
|
|
40b6fbe157 | ||
|
|
b5f25e6b60 | ||
|
|
be247cd48c | ||
|
|
c3cdc724bd | ||
|
|
421af125bb | ||
|
|
a65ca2ab93 | ||
|
|
68b976575a | ||
|
|
30ec8a43cf | ||
|
|
59f87bffda |
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
name: git-commits
|
||||||
|
description: Write Git commit messages for this project using Conventional Commits, splitting work into one commit per concern. Use when staging changes, writing or amending commit messages, or deciding how to break a set of changes into commits.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git Commits
|
||||||
|
|
||||||
|
Use this skill whenever you write a commit message or decide how to group changes into commits.
|
||||||
|
|
||||||
|
## Commit message style: Conventional Commits
|
||||||
|
|
||||||
|
Every commit message must follow the Conventional Commits format:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<optional scope>): <description>
|
||||||
|
|
||||||
|
<optional body>
|
||||||
|
|
||||||
|
<optional footer(s)>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- `feat`: a new feature
|
||||||
|
- `fix`: a bug fix
|
||||||
|
- `docs`: documentation only
|
||||||
|
- `style`: formatting, whitespace, or other changes that do not affect behavior
|
||||||
|
- `refactor`: a code change that neither fixes a bug nor adds a feature
|
||||||
|
- `perf`: a performance improvement
|
||||||
|
- `test`: adding or correcting tests
|
||||||
|
- `build`: changes to the build system or dependencies
|
||||||
|
- `ci`: changes to CI configuration and scripts
|
||||||
|
- `chore`: other changes that do not modify source or tests
|
||||||
|
- `revert`: revert a previous commit
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- Use the imperative mood in the description ("add", not "added" or "adds").
|
||||||
|
- Keep the description lowercase and do not end it with a period.
|
||||||
|
- Limit the subject line to 50 characters where practical; never exceed 72.
|
||||||
|
- Add a scope in parentheses when it clarifies the area of change, e.g. `fix(parser): ...`.
|
||||||
|
- Separate the subject from the body with a blank line.
|
||||||
|
- Use the body only when it adds useful information; do not restate the subject.
|
||||||
|
- Wrap the body at 72 characters.
|
||||||
|
- Reference issues in the footer when relevant, e.g. `Closes #123`.
|
||||||
|
- Mark breaking changes with `!` after the type/scope (e.g. `feat(api)!: ...`) and explain them in a `BREAKING CHANGE:` footer.
|
||||||
|
|
||||||
|
## Separate commits by concern
|
||||||
|
|
||||||
|
Do not bundle unrelated changes into a single commit. Split work so that each commit represents one logical concern:
|
||||||
|
|
||||||
|
- One type of change per commit (a feature and its unrelated refactor are separate commits).
|
||||||
|
- Keep formatting-only changes separate from behavior changes.
|
||||||
|
- Keep test additions separate from the implementation they cover when they are independently meaningful, but do not split so finely that a commit is not self-contained.
|
||||||
|
- Each commit should build and pass tests on its own where feasible.
|
||||||
|
- Stage files selectively (e.g. `git add -p`, `git add <path>`) to isolate concerns rather than committing everything at once.
|
||||||
|
|
||||||
|
When a set of changes spans multiple concerns, propose the split to the user before committing.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Inspect what changed with `git --no-optional-locks status` and `git --no-pager diff`.
|
||||||
|
2. Group the changes by concern.
|
||||||
|
3. For each group, stage only the relevant files or hunks.
|
||||||
|
4. Write a Conventional Commits message for that group.
|
||||||
|
5. Repeat until all changes are committed.
|
||||||
|
|
||||||
|
Do not commit or create branches unless the user explicitly asks.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Generated reference docs are emitted with LF by scripts/generate-reference.ts.
|
||||||
|
# Pin them to LF so the drift guard in src/cli/validate/known.test.ts compares
|
||||||
|
# like-for-like on Windows, where core.autocrlf would otherwise check them out
|
||||||
|
# as CRLF.
|
||||||
|
docs/reference.md text eol=lf
|
||||||
|
.agents/skills/tttk-authoring/reference.md text eol=lf
|
||||||
+20
-13
@@ -39,14 +39,14 @@ tttk serve [dir] -p 3000
|
|||||||
| 选项 | 说明 | 默认值 |
|
| 选项 | 说明 | 默认值 |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| `-p, --port <port>` | 端口号 | `3000` |
|
| `-p, --port <port>` | 端口号 | `3000` |
|
||||||
| `-h, --host <host>` | 主机地址 | `0.0.0.0` |
|
| `--host <host>` | 主机地址 | `0.0.0.0` |
|
||||||
|
|
||||||
**功能:**
|
**功能:**
|
||||||
|
|
||||||
- 扫描目录下的所有 `.md`、`.csv`、`.yarn` 文件
|
- 扫描目录下的 `.md`、`.csv`、`.yarn`、`.svg` 文件(跳过以 `.` 开头的文件和 `node_modules`)
|
||||||
- 为每个文件创建路由
|
- 把内容目录挂在 URL 根路径下,文档 `rules/combat.md` 对应路由 `/rules/combat`
|
||||||
- 提供实时文件监听,修改后自动刷新
|
- 监听文件变化并重建内存中的内容注册表
|
||||||
- 通过 `/__CONTENT_INDEX.json` 提供文件索引
|
- 提供 `/__CONTENT_INDEX.json`、`/__CONTENT_REGISTRY.json`、`/__COMPLETIONS.json` 等接口(见[开发服务器特性](#开发服务器特性))
|
||||||
|
|
||||||
**示例:**
|
**示例:**
|
||||||
|
|
||||||
@@ -218,20 +218,27 @@ my-ttrpg-content/
|
|||||||
|
|
||||||
## 开发服务器特性
|
## 开发服务器特性
|
||||||
|
|
||||||
### 实时索引
|
### 内容接口
|
||||||
|
|
||||||
访问 `http://localhost:3000/__CONTENT_INDEX.json` 可获取当前内容目录的文件索引。
|
- `/__CONTENT_INDEX.json` — 原始文件索引(`{ 路径: 文件内容 }`)
|
||||||
|
- `/__CONTENT_REGISTRY.json` — 内容注册表(文件路径索引 + 文档内联内容)
|
||||||
|
- `/__COMPLETIONS.json` — 补全数据(骰点、链接、spark 表格、声明、标签修饰符)
|
||||||
|
|
||||||
### 自动刷新
|
### 内容目录与 URL 的对应关系
|
||||||
|
|
||||||
文件变化时,服务器会自动重新加载内容,无需手动刷新页面。
|
内容目录挂在 URL 根路径下,文档去掉 `.md` 扩展名后即为路由:
|
||||||
|
|
||||||
|
- 文档 `rules/combat.md` → `http://localhost:3000/rules/combat`
|
||||||
|
- 图片 `rules/maps/city.png` → `http://localhost:3000/rules/maps/city.png`
|
||||||
|
|
||||||
|
### 文件监听
|
||||||
|
|
||||||
|
服务器会监听内容目录的变化并重建内存中的注册表;重新加载页面即可看到最新内容。
|
||||||
|
|
||||||
### SPA 路由
|
### SPA 路由
|
||||||
|
|
||||||
使用 hash 路由支持单页应用导航:
|
路由基于 History API(而非 hash)。直接访问深层链接或刷新页面时,未知路径会回退到 `index.html`,
|
||||||
|
因此浏览器前进/后退与深链接均可正常工作。
|
||||||
- `/content/rules/combat.md` → `#/content/rules/combat.md`
|
|
||||||
- 支持浏览器前进/后退
|
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -252,8 +252,13 @@ npm test -- --watch
|
|||||||
- Web 前端:rsbuild 开发服务器,端口 **5173**
|
- Web 前端:rsbuild 开发服务器,端口 **5173**
|
||||||
- CLI 内容服务器:`tttk serve`,端口 **3000**(内容 API + Journal)
|
- CLI 内容服务器:`tttk serve`,端口 **3000**(内容 API + Journal)
|
||||||
|
|
||||||
rsbuild 会把 `/__CONTENT_INDEX.json`、`/__COMPLETIONS.json`、`/__CONTENT_REGISTRY.json`、`/content`、`/.ttrpg` 代理到 CLI 的
|
rsbuild 会把 `/__CONTENT_INDEX.json`、`/__COMPLETIONS.json`、`/__CONTENT_REGISTRY.json`、`/.ttrpg`
|
||||||
3000 端口,因此必须先启动 CLI,否则前端拿不到内容。
|
以及内容目录中的静态资源(图片、CSS、CSV 等)代理到 CLI 的 3000 端口,因此必须先启动 CLI,
|
||||||
|
否则前端拿不到内容。
|
||||||
|
|
||||||
|
CLI 把内容目录挂在 URL 根路径下(例如 `/adventures/map.png`,**不是** `/content/...`),
|
||||||
|
所以 rsbuild 会按“请求路径是否对应内容目录中的真实文件”来决定是否代理;`/static/` 下的
|
||||||
|
前端构建产物仍由 web dev server 自己提供。
|
||||||
|
|
||||||
### Web 开发
|
### Web 开发
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Icon migration — unicode/emoji → `solid-icons`
|
||||||
|
|
||||||
|
**Status: implemented.** This document is both the audit and the record of the
|
||||||
|
migration. It lists the pack/conventions used and the per-file mapping.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace ad-hoc unicode symbols and emoji in **UI chrome** with real SVG icon
|
||||||
|
components from [`solid-icons`](https://solid-icons.vercel.app), so icons are
|
||||||
|
consistent, themeable (`currentColor`, `size`), and render identically across
|
||||||
|
platforms (emoji fonts vary wildly between OSes).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope — UI chrome** (`src/components/**`, `src/App.tsx`, doc-entry
|
||||||
|
frontmatter, `RevealManager` inline SVGs, the editor-panel align PNGs).
|
||||||
|
|
||||||
|
**Out of scope — left as-is:**
|
||||||
|
|
||||||
|
| Thing | Why |
|
||||||
|
|---|---|
|
||||||
|
| `:[icon-name]` content directive (`src/markdown/icon.ts`, `assets/`) | Game-content icons resolved from user `assets/` dirs via CSS `background-image`. Not UI chrome. |
|
||||||
|
| CLI console output (`✅ ❌ 📦` in `src/cli/**`) | Terminal output, not a browser. `solid-icons` is a SolidJS/browser library. |
|
||||||
|
| Emoji in `README.md`, `docs/*.md` prose | Documentation text, not rendered UI. |
|
||||||
|
| Dimension text `×` in `PltPreview` / `PrintPreviewHeader` | A multiplication sign in "100mm × 50mm", not an icon. |
|
||||||
|
| `content/**` sample content | User-authored data. |
|
||||||
|
| `PltPreview.tsx` / `PrintPreview.tsx` `<svg>` | Rendered card/page artwork, not icons. |
|
||||||
|
|
||||||
|
## Pack
|
||||||
|
|
||||||
|
**Tabler** (`solid-icons/tb`). `solid-icons@1.2.0` does **not** ship Lucide; the
|
||||||
|
available packs are `ai, bi, bs, cg, fa, fi, hi, im, io, md, oc, ri, si, tb, ti,
|
||||||
|
vs, wi`. Tabler was chosen because it is stroke-based, uses `currentColor`, and
|
||||||
|
covers everything needed (dice, ruler, grip, alignment, arrows, …).
|
||||||
|
|
||||||
|
> Exports are prefixed `TbOutline*` (outline) and `TbFill*` (filled). Always use
|
||||||
|
> `TbOutline*`.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { TbOutlineFolderOpen, TbOutlineX } from "solid-icons/tb";
|
||||||
|
|
||||||
|
<TbOutlineFolderOpen size={16} class="text-gray-400" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- `size?: string | number` defaults to `"1em"`; `color` defaults to
|
||||||
|
`currentColor`. Icons therefore inherit the surrounding font-size and text
|
||||||
|
color, so existing Tailwind text-color/size classes keep working.
|
||||||
|
- **Inline alignment:** an inline SVG sits on the text baseline, so icons placed
|
||||||
|
inline next to text get `class="inline-block align-[-0.125em]"`. Icons inside
|
||||||
|
a `flex`/`inline-flex` container don't need it.
|
||||||
|
- **Fixed-size buttons** get an explicit `size` (`16` for `w-7 h-7`/`w-8 h-8`,
|
||||||
|
`14` for `w-5 h-5`/`w-6 h-6`).
|
||||||
|
- Text labels next to a glyph (e.g. `📥 导出 PDF`) are preserved; only the glyph
|
||||||
|
is replaced.
|
||||||
|
|
||||||
|
## Shared modules
|
||||||
|
|
||||||
|
- **`src/components/doc-icons.ts`** — maps doc-entry frontmatter `icon:` keys
|
||||||
|
(e.g. `dice`, `table`) to Tabler components. Frontmatter is YAML, so it can't
|
||||||
|
hold a component; it stores a semantic key instead. `docIcon(key)` falls back
|
||||||
|
to `TbOutlineFile`.
|
||||||
|
- **`CompletionItem.icon`** (`src/components/journal/command-parser.ts`) — an
|
||||||
|
optional `IconTypes` rendered by `CompletionsDropdown.tsx` before the label,
|
||||||
|
so completion entries carry real icons instead of emoji in the label string.
|
||||||
|
|
||||||
|
## Inventory
|
||||||
|
|
||||||
|
### Shell / navigation
|
||||||
|
|
||||||
|
| File | Glyph | Purpose | Icon |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src/App.tsx` | `☰` | mobile menu | `TbOutlineMenu2` |
|
||||||
|
| `src/App.tsx` | `📋` | journal panel toggle | `TbOutlineClipboardList` |
|
||||||
|
| `src/App.tsx` | `?` | docs/help | `TbOutlineHelpCircle` |
|
||||||
|
| `src/components/Sidebar.tsx` | `📂` | content source | `TbOutlineFolderOpen` |
|
||||||
|
| `src/components/Sidebar.tsx` | `✕` | close sidebar | `TbOutlineX` |
|
||||||
|
| `src/components/FileTree.tsx` | `📂` / `📁` | folder open / closed | `TbOutlineFolderOpen` / `TbOutlineFolder` |
|
||||||
|
| `src/components/FileTree.tsx` | `📄` | file | `TbOutlineFile` |
|
||||||
|
| `src/components/FileTree.tsx` | `▶` | expand chevron | `TbOutlineChevronRight` |
|
||||||
|
| `src/components/DocDialog.tsx` | `▾` | dropdown caret | `TbOutlineChevronDown` |
|
||||||
|
| `src/components/DocDialog.tsx` | `📖` | directives doc set | `TbOutlineBook2` |
|
||||||
|
| `src/components/DocDialog.tsx` | `✓` | selected doc set | `TbOutlineCheck` |
|
||||||
|
| `src/components/DocDialog.tsx` | `📋` | journal doc set | `TbOutlineClipboardList` |
|
||||||
|
| `src/components/DocDialog.tsx` | `✕` | close dialog | `TbOutlineX` |
|
||||||
|
| `src/components/DocDialog.tsx` | entry `icon:` | doc entry icon | `<Dynamic component={docIcon(entry.icon)} />` |
|
||||||
|
|
||||||
|
### md-* components
|
||||||
|
|
||||||
|
| File | Glyph | Purpose | Icon |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src/components/md-dice.tsx` | `🎲` | dice reset | `TbOutlineDice5` |
|
||||||
|
| `src/components/md-table.tsx` | `🎲` | random row | `TbOutlineDice5` |
|
||||||
|
| `src/components/md-pins.tsx` | `📋` | copy pin coords | `TbOutlineCopy` |
|
||||||
|
| `src/components/md-yarn-spinner.tsx` | `🔄` | restart | `TbOutlineRotate` |
|
||||||
|
| `src/components/md-yarn-spinner.tsx` | `⏩` | advance | `TbOutlinePlayerTrackNext` |
|
||||||
|
| `src/components/md-yarn-spinner.tsx` | `→` | option bullet | `TbOutlineArrowRight` |
|
||||||
|
|
||||||
|
### Journal
|
||||||
|
|
||||||
|
| File | Glyph | Purpose | Icon |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src/components/journal/ConnectDialog.tsx` | `🗑` | delete session | `TbOutlineTrash` |
|
||||||
|
| `src/components/journal/CreateSessionDialog.tsx` | `✕` | close | `TbOutlineX` |
|
||||||
|
| `src/components/journal/DeleteSessionDialog.tsx` | `✕` | close | `TbOutlineX` |
|
||||||
|
| `src/components/journal/ErrorPopup.tsx` | `✕` | close | `TbOutlineX` |
|
||||||
|
| `src/components/journal/InviteDialog.tsx` | `✕` | close | `TbOutlineX` |
|
||||||
|
| `src/components/journal/JournalHeader.tsx` | `←` | back to session list | `TbOutlineArrowLeft` |
|
||||||
|
| `src/components/journal/JournalHeader.tsx` | `▸` | hide panel | `TbOutlineLayoutSidebarRightCollapse` |
|
||||||
|
| `src/components/journal/SessionDropdown.tsx` | `▾` | dropdown caret | `TbOutlineChevronDown` |
|
||||||
|
| `src/components/journal/SessionDropdown.tsx` | `✓` | active session | `TbOutlineCheck` |
|
||||||
|
| `src/components/journal/SessionDropdown.tsx` | `🗑` | delete session | `TbOutlineTrash` |
|
||||||
|
| `src/components/journal/StreamMessage.tsx` | `×` | revert message | `TbOutlineArrowBackUp` |
|
||||||
|
| `src/components/journal/types/intent.tsx` | `🙋` | intent | `TbOutlineHandStop` |
|
||||||
|
| `src/components/journal/types/intent.tsx` | `✅` | verdict | `TbOutlineCircleCheck` |
|
||||||
|
| `src/components/journal/types/link.tsx` | `📄` | link | `TbOutlineFileText` |
|
||||||
|
| `src/components/journal/types/roll.tsx` | `🎲` | roll | `TbOutlineDice5` |
|
||||||
|
| `src/components/journal/types/roll.tsx` | `→` | notation → total | `TbOutlineArrowRight` |
|
||||||
|
| `src/components/journal/types/var.tsx` | `🔢` | variable | `TbOutlineHash` |
|
||||||
|
| `src/components/journal/command-completions.ts` | `🎲` | dice completion | `TbOutlineDice5` (via `item.icon`) |
|
||||||
|
| `src/components/journal/command-completions.ts` | `✨` | spark completion | `TbOutlineSparkles` (via `item.icon`) |
|
||||||
|
|
||||||
|
### md-deck
|
||||||
|
|
||||||
|
| File | Glyph | Purpose | Icon |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src/components/md-deck/DeckHeader.tsx` | `✓` / `✏️` | editing toggle | `TbOutlineCheck` / `TbOutlinePencil` |
|
||||||
|
| `src/components/md-deck/DeckHeader.tsx` | `📥` | export PDF | `TbOutlineDownload` |
|
||||||
|
| `src/components/md-deck/DeckHeader.tsx` | `📋` | copy code | `TbOutlineCopy` |
|
||||||
|
| `src/components/md-deck/PltPreview.tsx` | `📥` | download PLT | `TbOutlineDownload` |
|
||||||
|
| `src/components/md-deck/PrintPreviewFooter.tsx` | `❌` | export error | `TbOutlineCircleX` |
|
||||||
|
| `src/components/md-deck/PrintPreviewFooter.tsx` | `✕` | close | `TbOutlineX` |
|
||||||
|
| `src/components/md-deck/PrintPreviewHeader.tsx` | `➕` | add deck | `TbOutlinePlus` |
|
||||||
|
| `src/components/md-deck/PrintPreviewHeader.tsx` | `📐` | PLT preview | `TbOutlineRuler` |
|
||||||
|
| `src/components/md-deck/PrintPreviewHeader.tsx` | `📥` | export PDF | `TbOutlineDownload` |
|
||||||
|
| `src/components/md-deck/CardList.tsx` | `‹` | previous page | `TbOutlineChevronLeft` |
|
||||||
|
| `src/components/md-deck/CardList.tsx` | `›` | next page | `TbOutlineChevronRight` |
|
||||||
|
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `⠿` | drag handle | `TbOutlineGripVertical` |
|
||||||
|
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `👁` / `👁🗨` | visible / hidden | `TbOutlineEye` / `TbOutlineEyeOff` |
|
||||||
|
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `↑ → ↓ ←` | orientation | `TbOutlineArrowUp` / `TbOutlineArrowRight` / `TbOutlineArrowDown` / `TbOutlineArrowLeft` |
|
||||||
|
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `↖ ↑ ↗ ↙ ↓ ↘` | alignment | `TbOutlineArrowUpLeft` / `TbOutlineArrowUp` / `TbOutlineArrowUpRight` / `TbOutlineArrowDownLeft` / `TbOutlineArrowDown` / `TbOutlineArrowDownRight` |
|
||||||
|
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `✕` | delete layer | `TbOutlineTrash` |
|
||||||
|
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `align-*.png` | align presets | `TbOutlineAlignLeft` / `TbOutlineAlignCenter` / `TbOutlineAlignRight` |
|
||||||
|
|
||||||
|
The three `align-*.png` assets were deleted; the `icons/` directory is gone.
|
||||||
|
|
||||||
|
### `RevealManager.tsx` (formerly inline SVG strings)
|
||||||
|
|
||||||
|
| Former | Purpose | Icon |
|
||||||
|
|---|---|---|
|
||||||
|
| `LINK_SVG` | floating `/link` action | `TbOutlineLink` |
|
||||||
|
| `SPARK_SVG` | floating `/roll` spark action | `TbOutlineSparkles` |
|
||||||
|
|
||||||
|
`FloatingButton.svg: string` (rendered via `innerHTML`) became
|
||||||
|
`FloatingButton.icon: IconTypes`, rendered as `<Dynamic component={icon} />`
|
||||||
|
children.
|
||||||
|
|
||||||
|
### `src/doc-entries/*.md` frontmatter `icon:`
|
||||||
|
|
||||||
|
| File / tag | Key |
|
||||||
|
|---|---|
|
||||||
|
| `journal-gm.md` | `gm` |
|
||||||
|
| `journal-link.md` | `link` |
|
||||||
|
| `journal-player.md` | `player` |
|
||||||
|
| `journal-roll.md` | `roll` |
|
||||||
|
| `journal-set.md` | `set` |
|
||||||
|
| `journal-spark.md` | `spark` |
|
||||||
|
| `md-bg.md` | `background` |
|
||||||
|
| `md-border.md` | `border` |
|
||||||
|
| `md-deck.md` | `deck` |
|
||||||
|
| `md-dice.md` | `dice` |
|
||||||
|
| `md-embed.md` | `embed` |
|
||||||
|
| `md-font.md` | `font` |
|
||||||
|
| `md-link.md` | `link` |
|
||||||
|
| `md-pins.md` | `pins` |
|
||||||
|
| `md-table.md` | `table` |
|
||||||
|
| `md-yarn-spinner.md` | `yarn` |
|
||||||
|
|
||||||
|
Keys are resolved through `docIcon()` in `src/components/doc-icons.ts`.
|
||||||
|
|
||||||
|
## Follow-ups / not migrated (by design)
|
||||||
|
|
||||||
|
- Doc-entry **body prose** that referenced a replaced button glyph was reworded
|
||||||
|
to name the button (e.g. `md-dice.md` now says "点击骰子图标"). Remaining emoji
|
||||||
|
in `src/doc-entries/*.md` are illustrative — coloured status dots
|
||||||
|
(`🟢🟡🔴⚪` in `journal-gm.md`) — not UI icons.
|
||||||
|
- CLI console / MCP output (`✅ ❌ 📦` in `src/cli/**`) keeps its emoji: it is
|
||||||
|
terminal text, not rendered UI.
|
||||||
|
- `command-completions.ts` `CompletionItem` now carries an optional `icon`; any
|
||||||
|
future completion source can use it.
|
||||||
+9
-9
@@ -100,19 +100,19 @@
|
|||||||
|
|
||||||
**注意:** CSS class 名称始终使用不带扩展名的图标名称(如 `icon-attack`),方便统一样式控制。
|
**注意:** CSS class 名称始终使用不带扩展名的图标名称(如 `icon-attack`),方便统一样式控制。
|
||||||
|
|
||||||
### 配置图标前缀
|
### 图标查找路径
|
||||||
|
|
||||||
可通过 `iconPath` 属性指定图标目录:
|
图标没有单独的配置项。`:[name]` 会从当前文档所在目录开始,逐级向上查找 `assets/` 目录,
|
||||||
|
直到内容目录根:
|
||||||
|
|
||||||
```markdown
|
```text
|
||||||
<Article src="/content/page.md" iconPath="./assets/icons" />
|
rules/combat.md 中的 :[attack]
|
||||||
|
→ rules/assets/attack.png
|
||||||
|
→ assets/attack.png
|
||||||
```
|
```
|
||||||
|
|
||||||
或使用空字符串禁用图标前缀:
|
因此把图标放在内容目录(或其子目录)的 `assets/` 下即可。`tttk check` 会用同样的规则
|
||||||
|
校验图标是否存在(诊断 `TTTK-A001`)。
|
||||||
```markdown
|
|
||||||
<Article src="/content/page.md" iconPath="" />
|
|
||||||
```
|
|
||||||
|
|
||||||
## 组件库
|
## 组件库
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -246,10 +246,12 @@ ttrpg mcp generate-card-deck \
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "预览文件已创建",
|
"message": "预览文件已创建",
|
||||||
"preview_url": "http://localhost:3000/#/content/cards.md"
|
"preview_url": "http://localhost:3000/cards"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`preview_url` 以服务器工作目录为基准,去掉了 `.md` 扩展名(需要 `tttk serve` 正在运行)。
|
||||||
|
|
||||||
## CSV 文件格式
|
## CSV 文件格式
|
||||||
|
|
||||||
CSV 文件使用 YAML frontmatter 定义模板和配置:
|
CSV 文件使用 YAML frontmatter 定义模板和配置:
|
||||||
|
|||||||
Generated
+10
@@ -26,6 +26,7 @@
|
|||||||
"mermaid": "^11.0.0",
|
"mermaid": "^11.0.0",
|
||||||
"mqtt": "^5.15.1",
|
"mqtt": "^5.15.1",
|
||||||
"solid-element": "^1.9.1",
|
"solid-element": "^1.9.1",
|
||||||
|
"solid-icons": "^1.2.0",
|
||||||
"solid-js": "^1.9.3",
|
"solid-js": "^1.9.3",
|
||||||
"uuid": "^14.0.1",
|
"uuid": "^14.0.1",
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.21.0",
|
||||||
@@ -9589,6 +9590,15 @@
|
|||||||
"solid-js": "^1.9.3"
|
"solid-js": "^1.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/solid-icons": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/solid-icons/-/solid-icons-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-yjQxWQMi9l19P5Af9vjsvalTMDFPDL4XD6etrUdNxNVhFp1bMYF0SFwUa5VYDfJ7u0SF3Qkz/F7BZxqcLUwklw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"solid-js": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/solid-js": {
|
"node_modules/solid-js": {
|
||||||
"version": "1.9.12",
|
"version": "1.9.12",
|
||||||
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz",
|
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"mermaid": "^11.0.0",
|
"mermaid": "^11.0.0",
|
||||||
"mqtt": "^5.15.1",
|
"mqtt": "^5.15.1",
|
||||||
"solid-element": "^1.9.1",
|
"solid-element": "^1.9.1",
|
||||||
|
"solid-icons": "^1.2.0",
|
||||||
"solid-js": "^1.9.3",
|
"solid-js": "^1.9.3",
|
||||||
"uuid": "^14.0.1",
|
"uuid": "^14.0.1",
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.21.0",
|
||||||
|
|||||||
+55
-7
@@ -3,6 +3,43 @@ import { pluginBabel } from "@rsbuild/plugin-babel";
|
|||||||
import { pluginSolid } from "@rsbuild/plugin-solid";
|
import { pluginSolid } from "@rsbuild/plugin-solid";
|
||||||
import tailwindcss from "@tailwindcss/postcss";
|
import tailwindcss from "@tailwindcss/postcss";
|
||||||
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
|
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
|
||||||
|
import { statSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
// The CLI content server (`npm run dev:cli`, i.e. `tttk serve ./content`) keeps
|
||||||
|
// port 3000. It serves the content directory at the URL *root*, so content
|
||||||
|
// assets are requested as root-relative paths (e.g. `/adventures/map.png`),
|
||||||
|
// not under `/content`.
|
||||||
|
const CLI_TARGET = "http://localhost:3000";
|
||||||
|
const CONTENT_DIR = resolve(process.cwd(), "content");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a request targets a real file inside the content directory.
|
||||||
|
*
|
||||||
|
* Mirrors the CLI server's static handling: only files are served, so a
|
||||||
|
* directory path falls through to the SPA fallback. Requests for content
|
||||||
|
* assets must be proxied to the CLI; otherwise they reach
|
||||||
|
* `historyApiFallback` and the browser receives `index.html` instead of the
|
||||||
|
* asset (which is why images failed to render in dev).
|
||||||
|
*/
|
||||||
|
function isContentFile(pathname: string): boolean {
|
||||||
|
// The web dev server owns its own bundled assets under /static/.
|
||||||
|
if (pathname.startsWith("/static/")) return false;
|
||||||
|
|
||||||
|
let relativePath: string;
|
||||||
|
try {
|
||||||
|
relativePath = decodeURIComponent(pathname).replace(/^\/+/, "");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!relativePath) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return statSync(join(CONTENT_DIR, relativePath)).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -51,14 +88,25 @@ export default defineConfig({
|
|||||||
// Dev web server runs on its own port; the CLI content server keeps
|
// Dev web server runs on its own port; the CLI content server keeps
|
||||||
// port 3000 (see the proxy targets below).
|
// port 3000 (see the proxy targets below).
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: [
|
||||||
// Proxy content API requests to the CLI dev server (npm run tttk)
|
{
|
||||||
"/__CONTENT_INDEX.json": "http://localhost:3000",
|
// Content API requests served by the CLI dev server (npm run dev:cli).
|
||||||
"/__COMPLETIONS.json": "http://localhost:3000",
|
context: [
|
||||||
"/__CONTENT_REGISTRY.json": "http://localhost:3000",
|
"/__CONTENT_INDEX.json",
|
||||||
"/content": "http://localhost:3000",
|
"/__COMPLETIONS.json",
|
||||||
"/.ttrpg": "http://localhost:3000",
|
"/__CONTENT_REGISTRY.json",
|
||||||
|
"/.ttrpg",
|
||||||
|
],
|
||||||
|
target: CLI_TARGET,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Content assets (images, css, csv, …) live at the URL root on the CLI
|
||||||
|
// server, so forward any request that maps to a real file under
|
||||||
|
// content/ instead of letting it hit the SPA fallback.
|
||||||
|
context: (pathname: string) => isContentFile(pathname),
|
||||||
|
target: CLI_TARGET,
|
||||||
|
},
|
||||||
|
],
|
||||||
historyApiFallback: true,
|
historyApiFallback: true,
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
+35
-5
@@ -7,6 +7,13 @@ import {
|
|||||||
createSignal,
|
createSignal,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
|
import {
|
||||||
|
TbOutlineMenu2,
|
||||||
|
TbOutlineClipboardList,
|
||||||
|
TbOutlineHelpCircle,
|
||||||
|
TbOutlineLayoutSidebarLeftCollapse,
|
||||||
|
TbOutlineLayoutSidebarLeftExpand,
|
||||||
|
} from "solid-icons/tb";
|
||||||
import { useJournalStream } from "./components/stores/journalStream";
|
import { useJournalStream } from "./components/stores/journalStream";
|
||||||
|
|
||||||
// 导入组件以注册自定义元素
|
// 导入组件以注册自定义元素
|
||||||
@@ -46,6 +53,7 @@ const App: Component = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
||||||
|
const [isDesktopSidebarOpen, setIsDesktopSidebarOpen] = createSignal(true);
|
||||||
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
||||||
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
||||||
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
|
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
|
||||||
@@ -93,13 +101,25 @@ const App: Component = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header class="shrink-0 h-16 bg-white shadow z-30">
|
<header class="shrink-0 h-16 bg-white shadow z-30">
|
||||||
<div class="h-full max-w-4xl mx-auto px-4 flex items-center gap-4">
|
<div class="h-full max-w-4xl mx-auto px-4 flex items-center gap-4">
|
||||||
{/* 仅在移动端显示菜单按钮 */}
|
{/* 移动端:打开抽屉式侧边栏 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSidebarOpen(true)}
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
class="md:hidden text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
|
class="md:hidden text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
|
||||||
title="目录"
|
title="目录"
|
||||||
>
|
>
|
||||||
☰
|
<TbOutlineMenu2 class="inline-block align-[-0.125em]" />
|
||||||
|
</button>
|
||||||
|
{/* 桌面端:折叠/展开固定侧边栏 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDesktopSidebarOpen((v) => !v)}
|
||||||
|
class="hidden md:block text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
|
||||||
|
title={isDesktopSidebarOpen() ? "收起目录" : "展开目录"}
|
||||||
|
>
|
||||||
|
{isDesktopSidebarOpen() ? (
|
||||||
|
<TbOutlineLayoutSidebarLeftCollapse class="inline-block align-[-0.125em]" />
|
||||||
|
) : (
|
||||||
|
<TbOutlineLayoutSidebarLeftExpand class="inline-block align-[-0.125em]" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Tabletop Toolkit</h1>
|
<h1 class="text-2xl font-bold text-gray-900">Tabletop Toolkit</h1>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
@@ -122,14 +142,14 @@ const App: Component = () => {
|
|||||||
"bg-red-500": stream.connectionStatus === "error",
|
"bg-red-500": stream.connectionStatus === "error",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
📋
|
<TbOutlineClipboardList size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsDocOpen(true)}
|
onClick={() => setIsDocOpen(true)}
|
||||||
class="w-8 h-8 rounded-full border border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-400 flex items-center justify-center text-sm font-medium"
|
class="w-8 h-8 rounded-full border border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-400 flex items-center justify-center text-sm font-medium"
|
||||||
title="指令文档"
|
title="指令文档"
|
||||||
>
|
>
|
||||||
?
|
<TbOutlineHelpCircle size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -145,12 +165,22 @@ const App: Component = () => {
|
|||||||
|
|
||||||
{/* Body: sidebar + main + journal */}
|
{/* Body: sidebar + main + journal */}
|
||||||
<div class="flex flex-1 min-h-0">
|
<div class="flex flex-1 min-h-0">
|
||||||
{/* 桌面端固定侧边栏 (flex child) */}
|
{/* 桌面端侧边栏 — flex child,可折叠 */}
|
||||||
|
<div
|
||||||
|
class="hidden md:block md:shrink-0 md:overflow-hidden md:transition-all md:duration-300"
|
||||||
|
classList={{
|
||||||
|
"md:w-64": isDesktopSidebarOpen(),
|
||||||
|
"md:w-0": !isDesktopSidebarOpen(),
|
||||||
|
}}
|
||||||
|
inert={!isDesktopSidebarOpen()}
|
||||||
|
>
|
||||||
<DesktopSidebar
|
<DesktopSidebar
|
||||||
fileTree={fileTree()}
|
fileTree={fileTree()}
|
||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||||
|
onClose={() => setIsDesktopSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main content — the scrollable region */}
|
{/* Main content — the scrollable region */}
|
||||||
<ScrollContainerCtx.Provider value={() => mainRef}>
|
<ScrollContainerCtx.Provider value={() => mainRef}>
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import {
|
||||||
|
appendStream,
|
||||||
|
extractSessionId,
|
||||||
|
handleMetaChange,
|
||||||
|
metaPath,
|
||||||
|
migrateLegacyManifest,
|
||||||
|
repairStreamTail,
|
||||||
|
scanSessions,
|
||||||
|
sessionDir,
|
||||||
|
streamPath,
|
||||||
|
writeFileAtomic,
|
||||||
|
type SessionMeta,
|
||||||
|
} from "./journal";
|
||||||
|
|
||||||
|
function makeRoot(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), "ttrpg-journal-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(root: string): void {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("writeFileAtomic", () => {
|
||||||
|
test("writes content and leaves no temp file behind", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const p = join(root, "meta.json");
|
||||||
|
writeFileAtomic(p, '{"a":1}');
|
||||||
|
expect(readFileSync(p, "utf-8")).toBe('{"a":1}');
|
||||||
|
expect(readdirSync(root)).toEqual(["meta.json"]);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("repairStreamTail", () => {
|
||||||
|
test("truncates a partial trailing line", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const p = join(root, "stream.jsonl");
|
||||||
|
writeFileSync(p, '{"a":1}\n{"b":2');
|
||||||
|
repairStreamTail(p);
|
||||||
|
expect(readFileSync(p, "utf-8")).toBe('{"a":1}\n');
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves a well-formed file untouched", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const p = join(root, "stream.jsonl");
|
||||||
|
writeFileSync(p, '{"a":1}\n');
|
||||||
|
repairStreamTail(p);
|
||||||
|
expect(readFileSync(p, "utf-8")).toBe('{"a":1}\n');
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scanSessions", () => {
|
||||||
|
test("adopts a directory that has a stream but no meta", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(sessionDir(root, "default"), { recursive: true });
|
||||||
|
writeFileSync(streamPath(root, "default"), '{"id":"gm-1"}\n');
|
||||||
|
|
||||||
|
const index = scanSessions(root);
|
||||||
|
|
||||||
|
expect(index["default"]).toMatchObject({ name: "default", players: [] });
|
||||||
|
expect(existsSync(metaPath(root, "default"))).toBe(true);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quarantines corrupt meta and adopts the session", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const dir = sessionDir(root, "broken");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(metaPath(root, "broken"), "{ not json");
|
||||||
|
writeFileSync(streamPath(root, "broken"), '{"id":"gm-1"}\n');
|
||||||
|
|
||||||
|
const index = scanSessions(root);
|
||||||
|
|
||||||
|
expect(index["broken"]).toBeDefined();
|
||||||
|
const quarantined = readdirSync(dir).filter((f) =>
|
||||||
|
f.startsWith("meta.json.corrupt-"),
|
||||||
|
);
|
||||||
|
expect(quarantined).toHaveLength(1);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a valid session that has no stream yet", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(sessionDir(root, "test"), { recursive: true });
|
||||||
|
writeFileAtomic(
|
||||||
|
metaPath(root, "test"),
|
||||||
|
JSON.stringify({ name: "test", created: 1, players: [] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const index = scanSessions(root);
|
||||||
|
|
||||||
|
expect(index["test"]).toEqual({ name: "test", created: 1, players: [] });
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("migrateLegacyManifest", () => {
|
||||||
|
test("splits a legacy manifest into per-session meta and renames it", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "manifest.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
sessions: {
|
||||||
|
aaa: { name: "aaa", created: 1, players: [] },
|
||||||
|
bbb: { name: "bbb", created: 2, players: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
migrateLegacyManifest(root);
|
||||||
|
|
||||||
|
expect(existsSync(join(root, "manifest.json"))).toBe(false);
|
||||||
|
expect(existsSync(join(root, "manifest.json.migrated"))).toBe(true);
|
||||||
|
expect(existsSync(metaPath(root, "aaa"))).toBe(true);
|
||||||
|
expect(existsSync(metaPath(root, "bbb"))).toBe(true);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("appendStream", () => {
|
||||||
|
test("drops unknown sessions and appends known ones", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const index = { known: { name: "known", created: 1, players: [] } };
|
||||||
|
mkdirSync(sessionDir(root, "known"), { recursive: true });
|
||||||
|
|
||||||
|
appendStream(root, "ghost", index, '{"x":1}');
|
||||||
|
expect(existsSync(streamPath(root, "ghost"))).toBe(false);
|
||||||
|
|
||||||
|
appendStream(root, "known", index, '{"x":1}');
|
||||||
|
expect(readFileSync(streamPath(root, "known"), "utf-8")).toBe('{"x":1}\n');
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses to build a path from a malformed id", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(join(root, "sessions"), { recursive: true });
|
||||||
|
const index: Record<string, SessionMeta> = {
|
||||||
|
"..": { name: "evil", created: 1, players: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
appendStream(root, "..", index, '{"x":1}');
|
||||||
|
|
||||||
|
expect(existsSync(join(root, "stream.jsonl"))).toBe(false);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractSessionId", () => {
|
||||||
|
test("returns a valid id from a session topic", () => {
|
||||||
|
expect(extractSessionId("ttrpg/my-game/stream")).toBe("my-game");
|
||||||
|
expect(extractSessionId("ttrpg/A_1/meta")).toBe("A_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects traversal, empty and malformed ids", () => {
|
||||||
|
expect(extractSessionId("ttrpg/../meta")).toBeNull();
|
||||||
|
expect(extractSessionId("ttrpg/./meta")).toBeNull();
|
||||||
|
expect(extractSessionId("ttrpg/..\\..\\evil/meta")).toBeNull();
|
||||||
|
expect(extractSessionId("ttrpg/a b/meta")).toBeNull();
|
||||||
|
expect(extractSessionId("ttrpg//meta")).toBeNull();
|
||||||
|
expect(extractSessionId("ttrpg/meta")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleMetaChange", () => {
|
||||||
|
// Minimal broker stub: handleMetaChange only needs publish() for the
|
||||||
|
// manifest broadcast.
|
||||||
|
function makeBroker() {
|
||||||
|
const published: { topic: string; payload: string }[] = [];
|
||||||
|
const broker = {
|
||||||
|
publish: (
|
||||||
|
packet: { topic: string; payload: Buffer },
|
||||||
|
cb?: () => void,
|
||||||
|
) => {
|
||||||
|
published.push({
|
||||||
|
topic: packet.topic,
|
||||||
|
payload: packet.payload.toString(),
|
||||||
|
});
|
||||||
|
cb?.();
|
||||||
|
},
|
||||||
|
} as unknown as import("aedes").Aedes;
|
||||||
|
return { broker, published };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("writes validated meta, updates the index and publishes the manifest", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const index: Record<string, SessionMeta> = {};
|
||||||
|
const { broker, published } = makeBroker();
|
||||||
|
|
||||||
|
handleMetaChange(
|
||||||
|
root,
|
||||||
|
"game",
|
||||||
|
broker,
|
||||||
|
index,
|
||||||
|
JSON.stringify({ name: "Game", created: 10, players: ["alice"] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(index["game"]).toEqual({
|
||||||
|
name: "Game",
|
||||||
|
created: 10,
|
||||||
|
players: ["alice"],
|
||||||
|
});
|
||||||
|
expect(readFileSync(metaPath(root, "game"), "utf-8")).toContain("Game");
|
||||||
|
expect(published.some((p) => p.topic === "ttrpg/$SESSIONS")).toBe(true);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletes a session on an empty payload", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(sessionDir(root, "doomed"), { recursive: true });
|
||||||
|
writeFileAtomic(
|
||||||
|
metaPath(root, "doomed"),
|
||||||
|
JSON.stringify({ name: "Doomed", created: 1, players: [] }),
|
||||||
|
);
|
||||||
|
const index: Record<string, SessionMeta> = {
|
||||||
|
doomed: { name: "Doomed", created: 1, players: [] },
|
||||||
|
};
|
||||||
|
const { broker } = makeBroker();
|
||||||
|
|
||||||
|
handleMetaChange(root, "doomed", broker, index, "");
|
||||||
|
|
||||||
|
expect(index["doomed"]).toBeUndefined();
|
||||||
|
expect(existsSync(sessionDir(root, "doomed"))).toBe(false);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses malformed ids and never touches the filesystem", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(join(root, "sessions"), { recursive: true });
|
||||||
|
const index: Record<string, SessionMeta> = {};
|
||||||
|
const { broker } = makeBroker();
|
||||||
|
|
||||||
|
handleMetaChange(root, "..", broker, index, JSON.stringify({ name: "evil" }));
|
||||||
|
handleMetaChange(root, ".", broker, index, "");
|
||||||
|
handleMetaChange(root, "..\\..\\evil", broker, index, "");
|
||||||
|
|
||||||
|
expect(existsSync(join(root, "meta.json"))).toBe(false);
|
||||||
|
expect(existsSync(join(root, "sessions", "meta.json"))).toBe(false);
|
||||||
|
expect(Object.keys(index)).toHaveLength(0);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores malformed meta without deleting the session", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
mkdirSync(sessionDir(root, "keep"), { recursive: true });
|
||||||
|
writeFileAtomic(
|
||||||
|
metaPath(root, "keep"),
|
||||||
|
JSON.stringify({ name: "Keep", created: 1, players: [] }),
|
||||||
|
);
|
||||||
|
const index: Record<string, SessionMeta> = {
|
||||||
|
keep: { name: "Keep", created: 1, players: [] },
|
||||||
|
};
|
||||||
|
const { broker } = makeBroker();
|
||||||
|
|
||||||
|
handleMetaChange(
|
||||||
|
root,
|
||||||
|
"keep",
|
||||||
|
broker,
|
||||||
|
index,
|
||||||
|
JSON.stringify({ players: "not-an-array" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(index["keep"]).toEqual({ name: "Keep", created: 1, players: [] });
|
||||||
|
expect(existsSync(metaPath(root, "keep"))).toBe(true);
|
||||||
|
cleanup(root);
|
||||||
|
});
|
||||||
|
});
|
||||||
+307
-83
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 日志流持久化 — MQTT 代理 + JSONL 追加写入 + 会话清单
|
* 日志流持久化 — MQTT 代理 + JSONL 追加写入 + 按会话元数据
|
||||||
*
|
*
|
||||||
* 在现有 HTTP 服务器上挂载 aedes MQTT 代理(WebSocket 传输),
|
* 在现有 HTTP 服务器上挂载 aedes MQTT 代理(WebSocket 传输),
|
||||||
* 使用 ws v8 内置的 createWebSocketStream。
|
* 使用 ws v8 内置的 createWebSocketStream。
|
||||||
@@ -9,31 +9,52 @@
|
|||||||
*
|
*
|
||||||
* 持久化使用 aedes 内部 subscribe/publish API,无需服务器进程
|
* 持久化使用 aedes 内部 subscribe/publish API,无需服务器进程
|
||||||
* 以 MQTT 客户端身份连接自身。
|
* 以 MQTT 客户端身份连接自身。
|
||||||
|
*
|
||||||
|
* 存储模型:目录即事实来源。会话存在当且仅当
|
||||||
|
* `sessions/<id>/meta.json` 存在且可解析;不再维护中心化清单,
|
||||||
|
* 会话列表在内存中派生并通过保留主题 `ttrpg/$SESSIONS` 发布。
|
||||||
|
* 元数据写入使用「临时文件 + fsync + 原子重命名」,避免崩溃时
|
||||||
|
* 截断损坏;损坏的元数据会被隔离而非静默丢弃。
|
||||||
|
*
|
||||||
|
* 会话 ID 会直接决定文件系统路径,因此在 MQTT 边界严格校验
|
||||||
|
* (见 shared/session-id)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join, resolve } from "path";
|
import { join, resolve } from "path";
|
||||||
import {
|
import {
|
||||||
appendFileSync,
|
appendFileSync,
|
||||||
|
closeSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
|
fsyncSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
|
openSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
rmdirSync,
|
readdirSync,
|
||||||
unlinkSync,
|
readSync,
|
||||||
writeFileSync,
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
truncateSync,
|
||||||
|
writeSync,
|
||||||
} from "fs";
|
} from "fs";
|
||||||
import type { Server as HttpServer } from "http";
|
import type { Server as HttpServer } from "http";
|
||||||
import type { AedesPublishPacket } from "aedes";
|
|
||||||
import { WebSocketServer, createWebSocketStream } from "ws";
|
import { WebSocketServer, createWebSocketStream } from "ws";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { isValidSessionId } from "../shared/session-id.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 类型定义
|
// 类型定义
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface SessionMeta {
|
const SessionMetaSchema = z.object({
|
||||||
name: string;
|
name: z.string().min(1),
|
||||||
created: number;
|
created: z.number().default(() => Date.now()),
|
||||||
players: string[];
|
players: z.array(z.string()).default([]),
|
||||||
}
|
});
|
||||||
|
|
||||||
|
type SessionMeta = z.infer<typeof SessionMetaSchema>;
|
||||||
|
|
||||||
|
export type { SessionMeta };
|
||||||
|
|
||||||
interface SessionManifest {
|
interface SessionManifest {
|
||||||
sessions: Record<string, SessionMeta>;
|
sessions: Record<string, SessionMeta>;
|
||||||
@@ -49,42 +70,196 @@ export interface JournalServer {
|
|||||||
// 路径工具函数
|
// 路径工具函数
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function dataDir(contentDir: string): string {
|
export function dataDir(contentDir: string): string {
|
||||||
const d = resolve(contentDir, ".ttrpg");
|
const d = resolve(contentDir, ".ttrpg");
|
||||||
mkdirSync(d, { recursive: true });
|
mkdirSync(d, { recursive: true });
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
function manifestPath(dataRoot: string): string {
|
export function sessionDir(dataRoot: string, id: string): string {
|
||||||
return join(dataRoot, "manifest.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionDir(dataRoot: string, id: string): string {
|
|
||||||
return join(dataRoot, "sessions", id);
|
return join(dataRoot, "sessions", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamPath(dataRoot: string, id: string): string {
|
export function metaPath(dataRoot: string, id: string): string {
|
||||||
|
return join(sessionDir(dataRoot, id), "meta.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function streamPath(dataRoot: string, id: string): string {
|
||||||
return join(sessionDir(dataRoot, id), "stream.jsonl");
|
return join(sessionDir(dataRoot, id), "stream.jsonl");
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadManifest(dataRoot: string): SessionManifest {
|
// ---------------------------------------------------------------------------
|
||||||
const p = manifestPath(dataRoot);
|
// 原子写入
|
||||||
if (!existsSync(p)) return { sessions: {} };
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _tmpCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子写入:先写临时文件并 fsync,再重命名覆盖目标。
|
||||||
|
* rename 在同一文件系统上是原子操作(Windows 上由 libuv 以
|
||||||
|
* MOVEFILE_REPLACE_EXISTING 实现),因此崩溃时目标文件要么是旧内容,
|
||||||
|
* 要么是新内容,绝不会是截断的中间态。
|
||||||
|
*/
|
||||||
|
export function writeFileAtomic(path: string, data: string): void {
|
||||||
|
const tmp = `${path}.tmp-${process.pid}-${Date.now()}-${_tmpCounter++}`;
|
||||||
|
const fd = openSync(tmp, "w");
|
||||||
try {
|
try {
|
||||||
return JSON.parse(readFileSync(p, "utf-8"));
|
writeSync(fd, data);
|
||||||
} catch {
|
fsyncSync(fd);
|
||||||
return { sessions: {} };
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
renameSync(tmp, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 流文件修复
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修复崩溃时被截断的尾行:若文件不以换行结尾,则截断到最后一个换行。
|
||||||
|
* 否则后续追加会与残行粘连,产生一条损坏记录。
|
||||||
|
*/
|
||||||
|
export function repairStreamTail(path: string): void {
|
||||||
|
if (!existsSync(path)) return;
|
||||||
|
try {
|
||||||
|
const size = statSync(path).size;
|
||||||
|
if (size === 0) return;
|
||||||
|
|
||||||
|
const fd = openSync(path, "r");
|
||||||
|
const last = Buffer.alloc(1);
|
||||||
|
try {
|
||||||
|
readSync(fd, last, 0, 1, size - 1);
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
if (last[0] === 0x0a) return;
|
||||||
|
|
||||||
|
const buf = readFileSync(path);
|
||||||
|
const lastNl = buf.lastIndexOf(0x0a);
|
||||||
|
truncateSync(path, lastNl === -1 ? 0 : lastNl + 1);
|
||||||
|
console.warn(`[日志] 已修复截断的流尾行:${path}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[日志] 修复流文件失败:${path}`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveManifest(dataRoot: string, m: SessionManifest): void {
|
// ---------------------------------------------------------------------------
|
||||||
writeFileSync(manifestPath(dataRoot), JSON.stringify(m, null, 2), "utf-8");
|
// 会话扫描 / 迁移
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function safeMtime(path: string): number {
|
||||||
|
try {
|
||||||
|
return statSync(path).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendStream(dataRoot: string, id: string, line: string): void {
|
/**
|
||||||
const sd = sessionDir(dataRoot, id);
|
* 扫描 sessions/ 目录,构建内存索引。
|
||||||
mkdirSync(sd, { recursive: true });
|
*
|
||||||
appendFileSync(streamPath(dataRoot, id), line + "\n", "utf-8");
|
* - 有合法 meta.json 的目录 → 直接纳入索引;
|
||||||
|
* - meta.json 损坏 → 隔离为 meta.json.corrupt-<ts>,再尝试接管;
|
||||||
|
* - 无 meta.json 但有 stream.jsonl → 接管(用目录名与流文件 mtime 合成元数据);
|
||||||
|
* - 其余目录 → 忽略。
|
||||||
|
*/
|
||||||
|
export function scanSessions(dataRoot: string): Record<string, SessionMeta> {
|
||||||
|
const sessionsRoot = join(dataRoot, "sessions");
|
||||||
|
mkdirSync(sessionsRoot, { recursive: true });
|
||||||
|
|
||||||
|
const index: Record<string, SessionMeta> = {};
|
||||||
|
|
||||||
|
for (const entry of readdirSync(sessionsRoot, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const id = entry.name;
|
||||||
|
if (!isValidSessionId(id)) {
|
||||||
|
console.warn(`[日志] 跳过非法会话目录:${id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const mp = metaPath(dataRoot, id);
|
||||||
|
const sp = streamPath(dataRoot, id);
|
||||||
|
|
||||||
|
if (existsSync(mp)) {
|
||||||
|
try {
|
||||||
|
const meta = SessionMetaSchema.parse(
|
||||||
|
JSON.parse(readFileSync(mp, "utf-8")),
|
||||||
|
);
|
||||||
|
index[id] = meta;
|
||||||
|
repairStreamTail(sp);
|
||||||
|
continue;
|
||||||
|
} catch (e) {
|
||||||
|
const quarantine = `${mp}.corrupt-${Date.now()}`;
|
||||||
|
try {
|
||||||
|
renameSync(mp, quarantine);
|
||||||
|
console.error(
|
||||||
|
`[日志] 会话 ${id} 元数据损坏,已隔离到 ${quarantine}:`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
} catch (renameErr) {
|
||||||
|
console.error(`[日志] 会话 ${id} 元数据损坏且隔离失败:`, renameErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(sp)) {
|
||||||
|
const meta: SessionMeta = {
|
||||||
|
name: id,
|
||||||
|
created: safeMtime(sp),
|
||||||
|
players: [],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
writeFileAtomic(mp, JSON.stringify(meta, null, 2));
|
||||||
|
index[id] = meta;
|
||||||
|
repairStreamTail(sp);
|
||||||
|
console.log(`[日志] 已接管孤立会话:${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[日志] 接管孤立会话失败:${id}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移旧的中心化 manifest.json:把每个会话条目拆分为
|
||||||
|
* sessions/<id>/meta.json,然后把旧文件重命名为 manifest.json.migrated。
|
||||||
|
* 已存在 meta.json 的会话会被跳过(支持中断后重跑)。
|
||||||
|
*/
|
||||||
|
export function migrateLegacyManifest(dataRoot: string): void {
|
||||||
|
const legacy = join(dataRoot, "manifest.json");
|
||||||
|
if (!existsSync(legacy)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(legacy, "utf-8")) as SessionManifest;
|
||||||
|
const sessions = parsed?.sessions ?? {};
|
||||||
|
let migrated = 0;
|
||||||
|
|
||||||
|
for (const [id, raw] of Object.entries(sessions)) {
|
||||||
|
if (!isValidSessionId(id)) {
|
||||||
|
console.warn(`[日志] 跳过非法会话 ID:${id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const mp = metaPath(dataRoot, id);
|
||||||
|
if (existsSync(mp)) continue;
|
||||||
|
try {
|
||||||
|
const meta = SessionMetaSchema.parse(raw);
|
||||||
|
mkdirSync(sessionDir(dataRoot, id), { recursive: true });
|
||||||
|
writeFileAtomic(mp, JSON.stringify(meta, null, 2));
|
||||||
|
migrated++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[日志] 跳过无法迁移的会话 ${id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renameSync(legacy, `${legacy}.migrated`);
|
||||||
|
console.log(
|
||||||
|
`[日志] 已迁移 ${migrated} 个会话到按会话元数据;旧清单重命名为 manifest.json.migrated`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[日志] 旧清单迁移失败,保留原文件:", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -93,6 +268,24 @@ function appendStream(dataRoot: string, id: string, line: string): void {
|
|||||||
|
|
||||||
const $SESSIONS = "ttrpg/$SESSIONS";
|
const $SESSIONS = "ttrpg/$SESSIONS";
|
||||||
|
|
||||||
|
function publishManifest(
|
||||||
|
broker: import("aedes").Aedes,
|
||||||
|
index: Record<string, SessionMeta>,
|
||||||
|
): void {
|
||||||
|
const manifest: SessionManifest = { sessions: index };
|
||||||
|
broker.publish(
|
||||||
|
{
|
||||||
|
topic: $SESSIONS,
|
||||||
|
payload: Buffer.from(JSON.stringify(manifest)),
|
||||||
|
qos: 1,
|
||||||
|
retain: true,
|
||||||
|
cmd: "publish" as const,
|
||||||
|
dup: false,
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createJournalServer(
|
export async function createJournalServer(
|
||||||
contentDir: string,
|
contentDir: string,
|
||||||
httpServer: HttpServer,
|
httpServer: HttpServer,
|
||||||
@@ -100,6 +293,11 @@ export async function createJournalServer(
|
|||||||
const root = dataDir(contentDir);
|
const root = dataDir(contentDir);
|
||||||
console.log(`[日志] 数据目录:${root}`);
|
console.log(`[日志] 数据目录:${root}`);
|
||||||
|
|
||||||
|
// 迁移旧清单并扫描现有会话,构建内存索引(目录即事实来源)
|
||||||
|
migrateLegacyManifest(root);
|
||||||
|
const index = scanSessions(root);
|
||||||
|
console.log(`[日志] 已加载 ${Object.keys(index).length} 个会话`);
|
||||||
|
|
||||||
// ---- MQTT 代理 ----
|
// ---- MQTT 代理 ----
|
||||||
const { Aedes: AedesFactory } = await import("aedes");
|
const { Aedes: AedesFactory } = await import("aedes");
|
||||||
// aedes 需要先 listen() 初始化持久化,再处理连接
|
// aedes 需要先 listen() 初始化持久化,再处理连接
|
||||||
@@ -128,7 +326,7 @@ export async function createJournalServer(
|
|||||||
const sessionId = extractSessionId(packet.topic);
|
const sessionId = extractSessionId(packet.topic);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
try {
|
try {
|
||||||
appendStream(root, sessionId, packet.payload.toString());
|
appendStream(root, sessionId, index, packet.payload.toString());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e);
|
console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e);
|
||||||
}
|
}
|
||||||
@@ -143,7 +341,13 @@ export async function createJournalServer(
|
|||||||
(packet, cb) => {
|
(packet, cb) => {
|
||||||
const sessionId = extractSessionId(packet.topic);
|
const sessionId = extractSessionId(packet.topic);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
handleMetaChange(root, sessionId, broker, packet.payload.toString());
|
handleMetaChange(
|
||||||
|
root,
|
||||||
|
sessionId,
|
||||||
|
broker,
|
||||||
|
index,
|
||||||
|
packet.payload.toString(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
cb();
|
cb();
|
||||||
},
|
},
|
||||||
@@ -151,18 +355,7 @@ export async function createJournalServer(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 启动时发布初始清单,让客户端能看到已有会话
|
// 启动时发布初始清单,让客户端能看到已有会话
|
||||||
const initialManifest = loadManifest(root);
|
publishManifest(broker, index);
|
||||||
broker.publish(
|
|
||||||
{
|
|
||||||
topic: $SESSIONS,
|
|
||||||
payload: Buffer.from(JSON.stringify(initialManifest)),
|
|
||||||
qos: 1,
|
|
||||||
retain: true,
|
|
||||||
cmd: "publish" as const,
|
|
||||||
dup: false,
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("[日志] 持久化监听已启动");
|
console.log("[日志] 持久化监听已启动");
|
||||||
console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>");
|
console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>");
|
||||||
@@ -182,60 +375,91 @@ export async function createJournalServer(
|
|||||||
// 工具函数
|
// 工具函数
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function extractSessionId(topic: string): string | null {
|
export function extractSessionId(topic: string): string | null {
|
||||||
const parts = topic.split("/");
|
const parts = topic.split("/");
|
||||||
return parts.length >= 3 ? parts[1] : null;
|
if (parts.length < 3) return null;
|
||||||
|
const id = parts[1];
|
||||||
|
if (!isValidSessionId(id)) {
|
||||||
|
console.warn(`[日志] 拒绝非法会话 ID:${JSON.stringify(id)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 会话生命周期管理
|
// 会话生命周期管理
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function handleMetaChange(
|
/**
|
||||||
|
* 追加一条流记录。会话必须已在索引中(即存在合法 meta.json),
|
||||||
|
* 否则丢弃——这样已删除的会话不会被迟到的消息「复活」。
|
||||||
|
* 目录由元数据处理函数创建,此处不再 mkdir。
|
||||||
|
*/
|
||||||
|
export function appendStream(
|
||||||
|
root: string,
|
||||||
|
id: string,
|
||||||
|
index: Record<string, SessionMeta>,
|
||||||
|
line: string,
|
||||||
|
): void {
|
||||||
|
if (!isValidSessionId(id) || !index[id]) {
|
||||||
|
console.warn(`[日志] 丢弃未知会话 ${id} 的流记录`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appendFileSync(streamPath(root, id), line + "\n", "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleMetaChange(
|
||||||
root: string,
|
root: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
broker: import("aedes").Aedes,
|
broker: import("aedes").Aedes,
|
||||||
|
index: Record<string, SessionMeta>,
|
||||||
rawPayload: string,
|
rawPayload: string,
|
||||||
): void {
|
): void {
|
||||||
const manifest = loadManifest(root);
|
// The id reaches us straight from an MQTT topic level; never build a path
|
||||||
try {
|
// from it without validating, or `..` can escape the sessions directory.
|
||||||
const meta = JSON.parse(rawPayload) as SessionMeta | null;
|
if (!isValidSessionId(sessionId)) {
|
||||||
|
console.warn(
|
||||||
if (!meta || !meta.name) {
|
`[日志] 拒绝非法会话 ID 的元数据操作:${JSON.stringify(sessionId)}`,
|
||||||
if (manifest.sessions[sessionId]) {
|
|
||||||
delete manifest.sessions[sessionId];
|
|
||||||
const sp = streamPath(root, sessionId);
|
|
||||||
if (existsSync(sp)) unlinkSync(sp);
|
|
||||||
const sd = sessionDir(root, sessionId);
|
|
||||||
try {
|
|
||||||
rmdirSync(sd);
|
|
||||||
} catch {
|
|
||||||
/* */
|
|
||||||
}
|
|
||||||
console.log(`[日志] 会话已删除:${sessionId}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
manifest.sessions[sessionId] = {
|
|
||||||
name: meta.name,
|
|
||||||
created: meta.created || Date.now(),
|
|
||||||
players: meta.players || [],
|
|
||||||
};
|
|
||||||
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveManifest(root, manifest);
|
|
||||||
broker.publish(
|
|
||||||
{
|
|
||||||
topic: $SESSIONS,
|
|
||||||
payload: Buffer.from(JSON.stringify(manifest)),
|
|
||||||
qos: 1,
|
|
||||||
retain: true,
|
|
||||||
cmd: "publish" as const,
|
|
||||||
dup: false,
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = rawPayload.trim();
|
||||||
|
|
||||||
|
// 空载荷(或 JSON null)= 删除会话:递归删除目录,移除索引项。
|
||||||
|
// 必须在 JSON.parse 之前判断:JSON.parse("") 会抛错,导致删除永远无法生效。
|
||||||
|
if (payload === "" || payload === "null") {
|
||||||
|
delete index[sessionId];
|
||||||
|
rmSync(sessionDir(root, sessionId), { recursive: true, force: true });
|
||||||
|
console.log(`[日志] 会话已删除:${sessionId}`);
|
||||||
|
publishManifest(broker, index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(payload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e);
|
console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用 schema 校验,避免写入无法回读的数据(例如非数组的 players),
|
||||||
|
// 那会让下次启动扫描时把元数据隔离掉。
|
||||||
|
const parsed = SessionMetaSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error(`[日志] 会话 ${sessionId} 元数据无效,已忽略:`, parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = parsed.data;
|
||||||
|
try {
|
||||||
|
mkdirSync(sessionDir(root, sessionId), { recursive: true });
|
||||||
|
writeFileAtomic(metaPath(root, sessionId), JSON.stringify(meta, null, 2));
|
||||||
|
index[sessionId] = meta;
|
||||||
|
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
|
||||||
|
publishManifest(broker, index);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[日志] 会话 ${sessionId} 元数据写入失败:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
|||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Resolve relative hrefs against the current page path so that
|
// Resolve relative hrefs against the current page path so that
|
||||||
// e.g. "blah.md" on /content/page.md becomes /content/blah
|
// e.g. "blah.md" on /rules/page becomes /rules/blah
|
||||||
const resolved = new URL(href, window.location.origin + window.location.pathname);
|
const resolved = new URL(href, window.location.origin + window.location.pathname);
|
||||||
let pathname = resolved.pathname;
|
let pathname = resolved.pathname;
|
||||||
// Strip .md extension so refreshes hit the SPA fallback, not the raw file
|
// Strip .md extension so refreshes hit the SPA fallback, not the raw file
|
||||||
|
|||||||
@@ -6,8 +6,17 @@ import {
|
|||||||
onCleanup,
|
onCleanup,
|
||||||
onMount,
|
onMount,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
|
import {
|
||||||
|
TbOutlineBook2,
|
||||||
|
TbOutlineCheck,
|
||||||
|
TbOutlineChevronDown,
|
||||||
|
TbOutlineClipboardList,
|
||||||
|
TbOutlineX,
|
||||||
|
} from "solid-icons/tb";
|
||||||
import { docEntries, type DocEntry } from "./doc-data";
|
import { docEntries, type DocEntry } from "./doc-data";
|
||||||
import { journalDocEntries, type JournalDocEntry } from "./journal-doc-data";
|
import { journalDocEntries, type JournalDocEntry } from "./journal-doc-data";
|
||||||
|
import { docIcon } from "./doc-icons";
|
||||||
import { parseMarkdown } from "../markdown";
|
import { parseMarkdown } from "../markdown";
|
||||||
|
|
||||||
type DocSet = "directives" | "journal";
|
type DocSet = "directives" | "journal";
|
||||||
@@ -77,7 +86,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
class="text-lg font-bold text-gray-900 hover:text-blue-600 flex items-center gap-1"
|
class="text-lg font-bold text-gray-900 hover:text-blue-600 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{docSetLabel()}
|
{docSetLabel()}
|
||||||
<span class="text-gray-400 text-sm">▾</span>
|
<TbOutlineChevronDown class="text-gray-400 text-sm" />
|
||||||
</button>
|
</button>
|
||||||
<Show when={dropdownOpen()}>
|
<Show when={dropdownOpen()}>
|
||||||
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-60 py-1">
|
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-60 py-1">
|
||||||
@@ -89,10 +98,10 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
: "text-gray-700"
|
: "text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>📖</span>
|
<TbOutlineBook2 />
|
||||||
<span>指令组件文档</span>
|
<span>指令组件文档</span>
|
||||||
<Show when={docSet() === "directives"}>
|
<Show when={docSet() === "directives"}>
|
||||||
<span class="ml-auto text-blue-500">✓</span>
|
<TbOutlineCheck class="ml-auto text-blue-500" />
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -103,10 +112,10 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
: "text-gray-700"
|
: "text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>📋</span>
|
<TbOutlineClipboardList />
|
||||||
<span>Journal 服务器文档</span>
|
<span>Journal 服务器文档</span>
|
||||||
<Show when={docSet() === "journal"}>
|
<Show when={docSet() === "journal"}>
|
||||||
<span class="ml-auto text-blue-500">✓</span>
|
<TbOutlineCheck class="ml-auto text-blue-500" />
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +126,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
|
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
|
||||||
title="关闭"
|
title="关闭"
|
||||||
>
|
>
|
||||||
✕
|
<TbOutlineX />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,7 +142,10 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||||||
: "text-gray-700 hover:bg-gray-100"
|
: "text-gray-700 hover:bg-gray-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span class="mr-2">{entry.icon}</span>
|
<Dynamic
|
||||||
|
component={docIcon(entry.icon)}
|
||||||
|
class="mr-2 inline-block align-[-0.125em]"
|
||||||
|
/>
|
||||||
<Show
|
<Show
|
||||||
when={docSet() === "directives"}
|
when={docSet() === "directives"}
|
||||||
fallback={<span class="text-xs">{entry.title}</span>}
|
fallback={<span class="text-xs">{entry.title}</span>}
|
||||||
@@ -164,7 +176,11 @@ const DocContent: Component<{ entry: AnyEntry }> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div class="max-w-none">
|
<div class="max-w-none">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">
|
<h2 class="text-2xl font-bold text-gray-900 mb-6">
|
||||||
{e.icon} {e.title}
|
<Dynamic
|
||||||
|
component={docIcon(e.icon)}
|
||||||
|
class="inline-block align-[-0.125em]"
|
||||||
|
/>{" "}
|
||||||
|
{e.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<article
|
<article
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Component, createMemo, createSignal, Show } from "solid-js";
|
import { Component, createMemo, createSignal, Show } from "solid-js";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
|
import {
|
||||||
|
TbOutlineFolderOpen,
|
||||||
|
TbOutlineFolder,
|
||||||
|
TbOutlineFile,
|
||||||
|
TbOutlineChevronRight,
|
||||||
|
} from "solid-icons/tb";
|
||||||
import { type FileNode, type TocNode } from "../data-loader";
|
import { type FileNode, type TocNode } from "../data-loader";
|
||||||
import { useNavigateWithParams } from "./useNavigateWithParams";
|
import { useNavigateWithParams } from "./useNavigateWithParams";
|
||||||
import { useScrollContainer } from "../App";
|
import { useScrollContainer } from "../App";
|
||||||
@@ -61,10 +67,14 @@ export const FileTreeNode: Component<{
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<Show when={isDir}>
|
<Show when={isDir}>
|
||||||
<span class="mr-1 text-gray-400">{isExpanded() ? "📂" : "📁"}</span>
|
{isExpanded() ? (
|
||||||
|
<TbOutlineFolderOpen class="mr-1 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<TbOutlineFolder class="mr-1 text-gray-400" />
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!isDir}>
|
<Show when={!isDir}>
|
||||||
<span class="mr-1 text-gray-400">📄</span>
|
<TbOutlineFile class="mr-1 text-gray-400" />
|
||||||
</Show>
|
</Show>
|
||||||
<span class="text-sm truncate">{props.node.name}</span>
|
<span class="text-sm truncate">{props.node.name}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -151,7 +161,7 @@ export const HeadingNode: Component<{
|
|||||||
<span
|
<span
|
||||||
class={`${hasChildren ? "" : "invisible"} ${isExpanded() ? "rotate-90" : ""} mt-1 inline-block transition-transform `}
|
class={`${hasChildren ? "" : "invisible"} ${isExpanded() ? "rotate-90" : ""} mt-1 inline-block transition-transform `}
|
||||||
>
|
>
|
||||||
▶
|
<TbOutlineChevronRight class="inline-block align-[-0.125em]" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import {
|
|||||||
onCleanup,
|
onCleanup,
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal, Dynamic } from "solid-js/web";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
|
import type { IconTypes } from "solid-icons";
|
||||||
|
import { TbOutlineLink, TbOutlineSparkles } from "solid-icons/tb";
|
||||||
import { useArticleDom } from "./Article";
|
import { useArticleDom } from "./Article";
|
||||||
import { journalStreamState } from "./stores/journalStream";
|
import { journalStreamState } from "./stores/journalStream";
|
||||||
import { useJournalCompletions } from "./journal/completions";
|
import { useJournalCompletions } from "./journal/completions";
|
||||||
@@ -27,25 +29,6 @@ import {
|
|||||||
normalizePath,
|
normalizePath,
|
||||||
} from "./stores/reveal";
|
} from "./stores/reveal";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SVG icons
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const LINK_SVG =
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" ' +
|
|
||||||
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
||||||
'stroke-linecap="round" stroke-linejoin="round">' +
|
|
||||||
'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>' +
|
|
||||||
'<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>' +
|
|
||||||
"</svg>";
|
|
||||||
|
|
||||||
const SPARK_SVG =
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" ' +
|
|
||||||
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
||||||
'stroke-linecap="round" stroke-linejoin="round">' +
|
|
||||||
'<path d="M12 2l1.5 6.5L18 7l-4.5 4.5L16 16l-4-2.5L8 16l1.5-4.5L5 7l4.5-.5z"/>' +
|
|
||||||
"</svg>";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Floating button state
|
// Floating button state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -54,7 +37,7 @@ interface FloatingButton {
|
|||||||
rect: DOMRect;
|
rect: DOMRect;
|
||||||
action: () => void;
|
action: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
svg: string;
|
icon: IconTypes;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +113,7 @@ export const RevealManager: Component = () => {
|
|||||||
text: combinedSlug,
|
text: combinedSlug,
|
||||||
}),
|
}),
|
||||||
title: "Roll spark table",
|
title: "Roll spark table",
|
||||||
svg: SPARK_SVG,
|
icon: TbOutlineSparkles,
|
||||||
color: "text-purple-400 hover:text-purple-600 hover:bg-purple-50",
|
color: "text-purple-400 hover:text-purple-600 hover:bg-purple-50",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -150,7 +133,7 @@ export const RevealManager: Component = () => {
|
|||||||
text: `${normalized}#${text}`,
|
text: `${normalized}#${text}`,
|
||||||
}),
|
}),
|
||||||
title: "Send /link to stream",
|
title: "Send /link to stream",
|
||||||
svg: LINK_SVG,
|
icon: TbOutlineLink,
|
||||||
color: "text-blue-500 hover:bg-blue-50",
|
color: "text-blue-500 hover:bg-blue-50",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -183,14 +166,15 @@ export const RevealManager: Component = () => {
|
|||||||
left: `${btn().rect.left - 20}px`,
|
left: `${btn().rect.left - 20}px`,
|
||||||
}}
|
}}
|
||||||
title={btn().title}
|
title={btn().title}
|
||||||
innerHTML={btn().svg}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
btn().action();
|
btn().action();
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => clearTimeout(hideTimer)}
|
onMouseEnter={() => clearTimeout(hideTimer)}
|
||||||
onMouseLeave={hide}
|
onMouseLeave={hide}
|
||||||
/>
|
>
|
||||||
|
<Dynamic component={btn().icon} size={12} />
|
||||||
|
</button>
|
||||||
</Portal>
|
</Portal>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, createMemo, createSignal, onMount, Show } from "solid-js";
|
import { Component, createMemo, createSignal, onMount, Show } from "solid-js";
|
||||||
import { generateToc, type FileNode, type TocNode } from "../data-loader";
|
import { generateToc, type FileNode, type TocNode } from "../data-loader";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
|
import { TbOutlineFolderOpen, TbOutlineX } from "solid-icons/tb";
|
||||||
import { FileTreeNode, HeadingNode } from "./FileTree";
|
import { FileTreeNode, HeadingNode } from "./FileTree";
|
||||||
import { isPathRevealed } from "./stores/reveal";
|
import { isPathRevealed } from "./stores/reveal";
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ interface SidebarContentProps {
|
|||||||
pathHeadings: Record<string, TocNode[]>;
|
pathHeadings: Record<string, TocNode[]>;
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isDesktop?: boolean;
|
onNavigate?: () => void;
|
||||||
onDataSourceOpen?: () => void;
|
onDataSourceOpen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,17 +58,15 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||||||
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100"
|
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100"
|
||||||
title="Content Source"
|
title="Content Source"
|
||||||
>
|
>
|
||||||
📂
|
<TbOutlineFolderOpen class="inline-block align-[-0.125em]" />
|
||||||
</button>
|
</button>
|
||||||
<Show when={!props.isDesktop}>
|
|
||||||
<button
|
<button
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
class="text-gray-500 hover:text-gray-700"
|
class="text-gray-500 hover:text-gray-700"
|
||||||
title="关闭"
|
title="关闭"
|
||||||
>
|
>
|
||||||
✕
|
<TbOutlineX class="inline-block align-[-0.125em]" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +82,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||||||
currentPath={props.currentPath}
|
currentPath={props.currentPath}
|
||||||
pathHeadings={props.pathHeadings}
|
pathHeadings={props.pathHeadings}
|
||||||
depth={0}
|
depth={0}
|
||||||
onClose={props.onClose}
|
onClose={() => props.onNavigate?.()}
|
||||||
isHidden={isFileHidden}
|
isHidden={isFileHidden}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -150,6 +149,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
|||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
|
onNavigate={props.onClose}
|
||||||
onDataSourceOpen={props.onDataSourceOpen}
|
onDataSourceOpen={props.onDataSourceOpen}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -163,6 +163,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
|||||||
export const DesktopSidebar: Component<{
|
export const DesktopSidebar: Component<{
|
||||||
fileTree?: FileNode[];
|
fileTree?: FileNode[];
|
||||||
pathHeadings?: Record<string, TocNode[]>;
|
pathHeadings?: Record<string, TocNode[]>;
|
||||||
|
onClose: () => void;
|
||||||
onDataSourceOpen?: () => void;
|
onDataSourceOpen?: () => void;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -182,13 +183,12 @@ export const DesktopSidebar: Component<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside class="hidden md:flex flex-col w-64 shrink-0 bg-white shadow-lg overflow-hidden">
|
<aside class="hidden md:flex flex-col h-full w-64 shrink-0 bg-white border-r border-gray-200 overflow-hidden">
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
fileTree={fileTree()}
|
fileTree={fileTree()}
|
||||||
pathHeadings={pathHeadings()}
|
pathHeadings={pathHeadings()}
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
onClose={() => {}}
|
onClose={props.onClose}
|
||||||
isDesktop
|
|
||||||
onDataSourceOpen={props.onDataSourceOpen}
|
onDataSourceOpen={props.onDataSourceOpen}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Icon registry for built-in doc entries.
|
||||||
|
*
|
||||||
|
* Doc-entry frontmatter `icon:` values are semantic keys (e.g. `dice`), not
|
||||||
|
* glyphs — YAML can't reference a component. This maps each key to a Tabler
|
||||||
|
* icon component; unknown keys fall back to a generic file icon.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IconTypes } from "solid-icons";
|
||||||
|
import {
|
||||||
|
TbOutlineCards,
|
||||||
|
TbOutlineDice5,
|
||||||
|
TbOutlineFile,
|
||||||
|
TbOutlineFileText,
|
||||||
|
TbOutlineGitBranch,
|
||||||
|
TbOutlineHash,
|
||||||
|
TbOutlineLink,
|
||||||
|
TbOutlineMapPin,
|
||||||
|
TbOutlinePhoto,
|
||||||
|
TbOutlineSparkles,
|
||||||
|
TbOutlineSquare,
|
||||||
|
TbOutlineTable,
|
||||||
|
TbOutlineTheater,
|
||||||
|
TbOutlineTypography,
|
||||||
|
} from "solid-icons/tb";
|
||||||
|
|
||||||
|
const DOC_ICONS: Record<string, IconTypes> = {
|
||||||
|
dice: TbOutlineDice5,
|
||||||
|
table: TbOutlineTable,
|
||||||
|
link: TbOutlineLink,
|
||||||
|
pins: TbOutlineMapPin,
|
||||||
|
font: TbOutlineTypography,
|
||||||
|
background: TbOutlinePhoto,
|
||||||
|
border: TbOutlineSquare,
|
||||||
|
embed: TbOutlineFileText,
|
||||||
|
deck: TbOutlineCards,
|
||||||
|
yarn: TbOutlineGitBranch,
|
||||||
|
gm: TbOutlineTheater,
|
||||||
|
player: TbOutlineDice5,
|
||||||
|
roll: TbOutlineDice5,
|
||||||
|
set: TbOutlineHash,
|
||||||
|
spark: TbOutlineSparkles,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a frontmatter icon key to a Tabler icon component. */
|
||||||
|
export function docIcon(key: string): IconTypes {
|
||||||
|
return DOC_ICONS[key] ?? TbOutlineFile;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, For, Show, createEffect, onMount, onCleanup } from "solid-js";
|
import { Component, For, Show, createEffect, onMount, onCleanup } from "solid-js";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
import type { CompletionItem } from "./command-parser";
|
import type { CompletionItem } from "./command-parser";
|
||||||
import type { CompletionsState } from "./completions";
|
import type { CompletionsState } from "./completions";
|
||||||
|
|
||||||
@@ -102,6 +103,11 @@ export const CompletionsDropdown: Component<CompletionsDropdownProps> = (
|
|||||||
{item.kind === "command" ? "cmd" : "val"}
|
{item.kind === "command" ? "cmd" : "val"}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={item.icon}>
|
||||||
|
{(icon) => (
|
||||||
|
<Dynamic component={icon()} size={14} class="shrink-0" />
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<span class="font-mono truncate flex-1">{item.label}</span>
|
<span class="font-mono truncate flex-1">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,27 +1,90 @@
|
|||||||
/**
|
/**
|
||||||
* ConnectDialog — name + role picker to join a session
|
* ConnectDialog — broker connection + session lobby (list / create / join)
|
||||||
|
*
|
||||||
|
* The broker connection and the active session are decoupled: this dialog
|
||||||
|
* connects to the broker, which makes the session list available, and joining
|
||||||
|
* or creating a session is a separate step that reuses that connection.
|
||||||
*/
|
*/
|
||||||
import { Component, createSignal, onMount, Show } from "solid-js";
|
import { Component, createSignal, For, onMount, Show } from "solid-js";
|
||||||
|
import { TbOutlineTrash } from "solid-icons/tb";
|
||||||
import {
|
import {
|
||||||
connectStream,
|
connectBroker,
|
||||||
hydrateFromServer,
|
createSession,
|
||||||
useJournalStream,
|
joinSession,
|
||||||
|
resolveBrokerUrl,
|
||||||
|
sessions,
|
||||||
setMyName,
|
setMyName,
|
||||||
setMyRole,
|
setMyRole,
|
||||||
setSessionId,
|
useJournalStream,
|
||||||
} from "../stores/journalStream";
|
} from "../stores/journalStream";
|
||||||
|
import { DeleteSessionDialog } from "./DeleteSessionDialog";
|
||||||
|
|
||||||
export const ConnectDialog: Component = () => {
|
export const ConnectDialog: Component = () => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
|
const manifest = sessions;
|
||||||
const [playerName, setPlayerName] = createSignal(stream.myName);
|
const [playerName, setPlayerName] = createSignal(stream.myName);
|
||||||
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
|
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
|
||||||
stream.myRole,
|
stream.myRole,
|
||||||
);
|
);
|
||||||
const [connecting, setConnecting] = createSignal(false);
|
const [newSessionName, setNewSessionName] = createSignal("");
|
||||||
|
const [busy, setBusy] = createSignal(false);
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
const [autoJoined, setAutoJoined] = createSignal(false);
|
const [autoJoined, setAutoJoined] = createSignal(false);
|
||||||
|
const [sessionToDelete, setSessionToDelete] = createSignal<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Auto-join when URL has both session and player params
|
const ensureBroker = async () => {
|
||||||
|
if (stream.connected) return;
|
||||||
|
await connectBroker(resolveBrokerUrl());
|
||||||
|
};
|
||||||
|
|
||||||
|
const identity = () => ({
|
||||||
|
name: playerName().trim() || stream.myName,
|
||||||
|
role: role(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyIdentity = () => {
|
||||||
|
const { name, role: r } = identity();
|
||||||
|
setMyName(name);
|
||||||
|
setMyRole(r);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = async (sessionId: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
applyIdentity();
|
||||||
|
await ensureBroker();
|
||||||
|
await joinSession(sessionId, identity());
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Connection failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const name = newSessionName().trim();
|
||||||
|
if (!name) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
applyIdentity();
|
||||||
|
await ensureBroker();
|
||||||
|
const id = await createSession(name);
|
||||||
|
if (id) await joinSession(id, identity());
|
||||||
|
setNewSessionName("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Connection failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-join when the URL carries session + player + autojoin params;
|
||||||
|
// otherwise enter the lobby so the session list is available up front.
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const params = new URL(window.location.href).searchParams;
|
const params = new URL(window.location.href).searchParams;
|
||||||
if (
|
if (
|
||||||
@@ -32,51 +95,26 @@ export const ConnectDialog: Component = () => {
|
|||||||
setPlayerName(params.get("player") || "");
|
setPlayerName(params.get("player") || "");
|
||||||
setRole("player");
|
setRole("player");
|
||||||
setAutoJoined(true);
|
setAutoJoined(true);
|
||||||
handleConnect();
|
handleJoin(params.get("session")!);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
ensureBroker().catch((e) => {
|
||||||
|
setError(e instanceof Error ? e.message : "Connection failed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleConnect = async () => {
|
|
||||||
const name = playerName().trim() || stream.myName;
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
// In dev, the MQTT broker lives on the CLI server (port 3000), not the
|
|
||||||
// rsbuild dev server. In production, the CLI serves everything itself.
|
|
||||||
const brokerUrl =
|
|
||||||
process.env.NODE_ENV === "development"
|
|
||||||
? `ws://localhost:3000`
|
|
||||||
: `ws://${window.location.host}`;
|
|
||||||
|
|
||||||
setConnecting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setMyName(name);
|
|
||||||
setMyRole(role());
|
|
||||||
const sessionId = stream.sessionId || "default";
|
|
||||||
setSessionId(sessionId);
|
|
||||||
await hydrateFromServer(sessionId);
|
|
||||||
await connectStream(sessionId, brokerUrl);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : "Connection failed";
|
|
||||||
setError(msg);
|
|
||||||
} finally {
|
|
||||||
setConnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
when={!autoJoined() || error()}
|
when={!autoJoined() || error()}
|
||||||
fallback={
|
fallback={
|
||||||
<p class="text-sm text-gray-500 text-center">
|
<p class="text-sm text-gray-500 text-center">
|
||||||
{connecting() ? "正在加入会话…" : "已加入!"}
|
{busy() ? "正在加入会话…" : "已加入!"}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="w-full max-w-sm space-y-3">
|
<div class="w-full max-w-sm space-y-3">
|
||||||
<p class="text-sm text-gray-600 text-center">
|
<p class="text-sm text-gray-600 text-center">
|
||||||
请输入你的名字和角色来加入会话。
|
输入你的名字和角色,选择或新建一个会话。
|
||||||
<br />
|
<br />
|
||||||
<span class="text-xs text-gray-400">
|
<span class="text-xs text-gray-400">
|
||||||
连接到 {window.location.host}
|
连接到 {window.location.host}
|
||||||
@@ -86,7 +124,7 @@ export const ConnectDialog: Component = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={playerName()}
|
value={playerName()}
|
||||||
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
|
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||||
placeholder="你的名字"
|
placeholder="你的名字"
|
||||||
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||||
autofocus
|
autofocus
|
||||||
@@ -106,17 +144,80 @@ export const ConnectDialog: Component = () => {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Session list */}
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-xs text-gray-500">会话</p>
|
||||||
|
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded divide-y divide-gray-100">
|
||||||
|
<For each={Object.entries(manifest().sessions)}>
|
||||||
|
{([id, meta]) => (
|
||||||
|
<div class="flex items-center">
|
||||||
<button
|
<button
|
||||||
onClick={handleConnect}
|
onClick={() => handleJoin(id)}
|
||||||
disabled={connecting() || !playerName().trim()}
|
disabled={busy()}
|
||||||
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
|
class="flex-1 min-w-0 text-left px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50 truncate"
|
||||||
hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{connecting() ? "连接中..." : "加入"}
|
{meta.name || id}
|
||||||
</button>
|
</button>
|
||||||
|
<Show when={role() === "gm"}>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSessionToDelete({ id, name: meta.name || id })
|
||||||
|
}
|
||||||
|
disabled={busy()}
|
||||||
|
class="shrink-0 px-2 py-1.5 text-[10px] text-gray-300 hover:text-red-500 disabled:opacity-50"
|
||||||
|
title="删除会话"
|
||||||
|
>
|
||||||
|
<TbOutlineTrash />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={Object.keys(manifest().sessions).length === 0}>
|
||||||
|
<p class="px-2 py-2 text-xs text-gray-400">
|
||||||
|
{stream.connected ? "还没有会话" : "正在加载会话…"}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create a new session and join it (GM only) */}
|
||||||
|
<Show when={role() === "gm"}>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newSessionName()}
|
||||||
|
onInput={(e) => setNewSessionName(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||||
|
placeholder="新会话名称"
|
||||||
|
class="flex-1 border border-gray-300 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={busy() || !newSessionName().trim()}
|
||||||
|
class="bg-blue-600 text-white rounded px-3 py-1.5 text-sm font-medium
|
||||||
|
hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
新建并加入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<p class="text-red-500 text-sm text-center">{error()}</p>
|
<p class="text-red-500 text-sm text-center">{error()}</p>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={sessionToDelete()}>
|
||||||
|
{(target) => (
|
||||||
|
<DeleteSessionDialog
|
||||||
|
sessionId={target().id}
|
||||||
|
sessionName={target().name}
|
||||||
|
active={stream.sessionId === target().id}
|
||||||
|
onClose={() => setSessionToDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
/**
|
|
||||||
* ConnectionBar — session picker, player info (connected state only)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, createSignal, Show, For } from "solid-js";
|
|
||||||
import {
|
|
||||||
hydrateFromServer,
|
|
||||||
useJournalStream,
|
|
||||||
sessions,
|
|
||||||
createSession,
|
|
||||||
deleteSession,
|
|
||||||
disconnectStream,
|
|
||||||
setSessionId,
|
|
||||||
} from "../stores/journalStream";
|
|
||||||
import { journalSetState } from "../stores/journalStream";
|
|
||||||
|
|
||||||
export const ConnectionBar: Component = () => {
|
|
||||||
const stream = useJournalStream();
|
|
||||||
const [newSessionName, setNewSessionName] = createSignal("");
|
|
||||||
const [showNewSession, setShowNewSession] = createSignal(false);
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
const manifest = sessions;
|
|
||||||
|
|
||||||
const handleCreateSession = () => {
|
|
||||||
const name = newSessionName().trim();
|
|
||||||
if (!name) return;
|
|
||||||
const id = createSession(name);
|
|
||||||
if (id) {
|
|
||||||
setSessionId(id);
|
|
||||||
hydrateFromServer(id).catch(() => {});
|
|
||||||
}
|
|
||||||
setNewSessionName("");
|
|
||||||
setShowNewSession(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteSession = (id: string) => {
|
|
||||||
if (confirm(`确定要删除会话 "${id}"?此操作不可撤销。`)) {
|
|
||||||
deleteSession(id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSessionSelect = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await hydrateFromServer(id);
|
|
||||||
setSessionId(id);
|
|
||||||
} catch (e) {
|
|
||||||
setError("无法加载会话");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
|
||||||
disconnectStream();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="border-b border-gray-200 px-3 py-2 space-y-2 text-sm">
|
|
||||||
{/* Status row */}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
<span class="text-xs text-gray-500">已连接</span>
|
|
||||||
<Show when={stream.sessionId}>
|
|
||||||
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
|
|
||||||
{stream.sessionName || stream.sessionId}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<div class="flex-1" />
|
|
||||||
<Show when={stream.myName !== "gm"}>
|
|
||||||
<span class="text-xs text-gray-400">扮演 {stream.myName}</span>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
class="text-xs text-gray-400 hover:text-red-500"
|
|
||||||
title="断开连接"
|
|
||||||
>
|
|
||||||
⏻
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GM session management */}
|
|
||||||
<Show when={stream.myName === "gm"}>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<span class="text-xs text-gray-500">会话列表:</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowNewSession((v) => !v)}
|
|
||||||
class="text-xs text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
+ 新建
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={showNewSession()}>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newSessionName()}
|
|
||||||
onInput={(e) => setNewSessionName(e.currentTarget.value)}
|
|
||||||
placeholder="会话名称"
|
|
||||||
class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateSession}
|
|
||||||
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
|
|
||||||
>
|
|
||||||
Create Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<For each={Object.entries(manifest().sessions)}>
|
|
||||||
{([id, meta]) => (
|
|
||||||
<div
|
|
||||||
class={`flex items-center gap-1 px-1 py-0.5 rounded cursor-pointer text-xs ${
|
|
||||||
id === stream.sessionId
|
|
||||||
? "bg-blue-100 text-blue-800"
|
|
||||||
: "hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex-1 truncate"
|
|
||||||
onClick={() => handleSessionSelect(id)}
|
|
||||||
>
|
|
||||||
{meta.name || id}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteSession(id);
|
|
||||||
}}
|
|
||||||
class="text-gray-400 hover:text-red-500 text-xs"
|
|
||||||
title="删除会话"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={error()}>
|
|
||||||
<p class="text-red-500 text-xs">{error()}</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
* CreateSessionDialog — modal for naming a new session
|
* CreateSessionDialog — modal for naming a new session
|
||||||
*/
|
*/
|
||||||
import { Component, createSignal, onMount } from "solid-js";
|
import { Component, createSignal, onMount } from "solid-js";
|
||||||
import { createSession, setSessionId, hydrateFromServer } from "../stores/journalStream";
|
import { TbOutlineX } from "solid-icons/tb";
|
||||||
|
import { createSession, joinSession } from "../stores/journalStream";
|
||||||
|
|
||||||
interface CreateSessionDialogProps {
|
interface CreateSessionDialogProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -19,15 +20,16 @@ export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
|
|||||||
const trimmed = name().trim();
|
const trimmed = name().trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
const id = createSession(trimmed);
|
try {
|
||||||
if (id) {
|
const id = await createSession(trimmed);
|
||||||
setSessionId(id);
|
if (id) await joinSession(id);
|
||||||
// Hydrate the new (empty) session so the UI reflects it immediately
|
} catch (e) {
|
||||||
await hydrateFromServer(id).catch(() => {});
|
console.error("[journal] failed to create session:", e);
|
||||||
}
|
} finally {
|
||||||
setName("");
|
setName("");
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
props.onClose();
|
props.onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => inputRef?.focus());
|
onMount(() => inputRef?.focus());
|
||||||
@@ -47,7 +49,7 @@ export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
|
|||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
class="text-gray-400 hover:text-gray-600 text-sm"
|
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||||
>
|
>
|
||||||
✕
|
<TbOutlineX />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500">输入新会话的名称。</p>
|
<p class="text-xs text-gray-500">输入新会话的名称。</p>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* DeleteSessionDialog — destructive confirmation before removing a session.
|
||||||
|
*
|
||||||
|
* Deletion removes the session directory on the server (meta + full message
|
||||||
|
* log), so the warning spells out that it cannot be undone.
|
||||||
|
*/
|
||||||
|
import { Component, createSignal, Show } from "solid-js";
|
||||||
|
import { TbOutlineX } from "solid-icons/tb";
|
||||||
|
import { deleteSession } from "../stores/journalStream";
|
||||||
|
|
||||||
|
interface DeleteSessionDialogProps {
|
||||||
|
sessionId: string;
|
||||||
|
sessionName: string;
|
||||||
|
/** Whether this is the session the user is currently in. */
|
||||||
|
active: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteSessionDialog: Component<DeleteSessionDialogProps> = (
|
||||||
|
props,
|
||||||
|
) => {
|
||||||
|
const [deleting, setDeleting] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await deleteSession(props.sessionId);
|
||||||
|
props.onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "删除失败");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/20"
|
||||||
|
onClick={props.onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg border border-gray-200 shadow-xl w-[300px] p-4 space-y-3"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-red-600">删除会话</h3>
|
||||||
|
<button
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||||
|
>
|
||||||
|
<TbOutlineX />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600">
|
||||||
|
确定要删除会话{" "}
|
||||||
|
<span class="font-semibold">{props.sessionName}</span>
|
||||||
|
(<span class="font-mono">{props.sessionId}</span>)吗?
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-red-500">
|
||||||
|
此操作不可恢复:该会话的全部消息记录、玩家名单和派生状态都会从服务器删除。
|
||||||
|
{props.active && " 你当前正在该会话中,删除后将返回会话列表。"}
|
||||||
|
</p>
|
||||||
|
<Show when={error()}>
|
||||||
|
<p class="text-xs text-red-500">{error()}</p>
|
||||||
|
</Show>
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="px-3 py-1 text-xs text-gray-600 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting()}
|
||||||
|
class="bg-red-600 text-white rounded px-3 py-1 text-xs font-medium hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting() ? "删除中…" : "删除"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
* the journal input area. Toggled by clicking the truncated error text.
|
* the journal input area. Toggled by clicking the truncated error text.
|
||||||
*/
|
*/
|
||||||
import { Component, Show } from "solid-js";
|
import { Component, Show } from "solid-js";
|
||||||
|
import { TbOutlineX } from "solid-icons/tb";
|
||||||
|
|
||||||
interface ErrorPopupProps {
|
interface ErrorPopupProps {
|
||||||
message: string | null;
|
message: string | null;
|
||||||
@@ -21,7 +22,7 @@ export const ErrorPopup: Component<ErrorPopupProps> = (props) => {
|
|||||||
class="text-red-400 hover:text-red-600 text-xs leading-none ml-2 shrink-0"
|
class="text-red-400 hover:text-red-600 text-xs leading-none ml-2 shrink-0"
|
||||||
title="关闭"
|
title="关闭"
|
||||||
>
|
>
|
||||||
✕
|
<TbOutlineX />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* InviteDialog — input player name, copy invite link
|
* InviteDialog — input player name, copy invite link
|
||||||
*/
|
*/
|
||||||
import { Component, createSignal } from "solid-js";
|
import { Component, createSignal } from "solid-js";
|
||||||
|
import { TbOutlineX } from "solid-icons/tb";
|
||||||
import { useJournalStream } from "../stores/journalStream";
|
import { useJournalStream } from "../stores/journalStream";
|
||||||
|
|
||||||
interface InviteDialogProps {
|
interface InviteDialogProps {
|
||||||
@@ -51,7 +52,7 @@ export const InviteDialog: Component<InviteDialogProps> = (props) => {
|
|||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
class="text-gray-400 hover:text-gray-600 text-sm"
|
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||||
>
|
>
|
||||||
✕
|
<TbOutlineX />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-gray-500">
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* JournalHeader — status dot, player name, session dropdown, disconnect & close
|
* JournalHeader — status dot, player name, session dropdown, leave & close
|
||||||
*/
|
*/
|
||||||
import { Component, Show } from "solid-js";
|
import { Component, Show } from "solid-js";
|
||||||
import { useJournalStream, disconnectStream } from "../stores/journalStream";
|
import {
|
||||||
|
TbOutlineArrowLeft,
|
||||||
|
TbOutlineLayoutSidebarRightCollapse,
|
||||||
|
} from "solid-icons/tb";
|
||||||
|
import { useJournalStream, leaveSession } from "../stores/journalStream";
|
||||||
import { SessionDropdown } from "./SessionDropdown";
|
import { SessionDropdown } from "./SessionDropdown";
|
||||||
|
|
||||||
interface JournalHeaderProps {
|
interface JournalHeaderProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreateSession: () => void;
|
onCreateSession: () => void;
|
||||||
|
onDeleteSession: (id: string, name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
|
|
||||||
const handleDisconnect = () => disconnectStream();
|
const handleLeave = () => leaveSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
|
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
|
||||||
@@ -32,7 +37,9 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||||||
<Show
|
<Show
|
||||||
when={stream.connected && stream.sessionId}
|
when={stream.connected && stream.sessionId}
|
||||||
fallback={
|
fallback={
|
||||||
<h2 class="text-sm font-semibold text-gray-700 truncate">未连接</h2>
|
<h2 class="text-sm font-semibold text-gray-700 truncate">
|
||||||
|
{stream.connected ? "选择会话" : "未连接"}
|
||||||
|
</h2>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
@@ -52,19 +59,22 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||||||
</p>
|
</p>
|
||||||
{/* Clickable subtitle — session dropdown (GM only) */}
|
{/* Clickable subtitle — session dropdown (GM only) */}
|
||||||
<Show when={stream.myRole === "gm"}>
|
<Show when={stream.myRole === "gm"}>
|
||||||
<SessionDropdown onCreate={props.onCreateSession} />
|
<SessionDropdown
|
||||||
|
onCreate={props.onCreateSession}
|
||||||
|
onDelete={props.onDeleteSession}
|
||||||
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<Show when={stream.connected}>
|
<Show when={stream.sessionId}>
|
||||||
<button
|
<button
|
||||||
onClick={handleDisconnect}
|
onClick={handleLeave}
|
||||||
class="text-xs text-gray-400 hover:text-red-500 px-1"
|
class="text-xs text-gray-400 hover:text-blue-500 px-1"
|
||||||
title="断开连接"
|
title="返回会话列表"
|
||||||
>
|
>
|
||||||
⏻
|
<TbOutlineArrowLeft />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<button
|
||||||
@@ -72,7 +82,7 @@ export const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||||||
class="text-gray-400 hover:text-gray-600 text-sm px-1"
|
class="text-gray-400 hover:text-gray-600 text-sm px-1"
|
||||||
title="隐藏面板"
|
title="隐藏面板"
|
||||||
>
|
>
|
||||||
▸
|
<TbOutlineLayoutSidebarRightCollapse />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { JournalHeader } from "./JournalHeader";
|
|||||||
import { ConnectDialog } from "./ConnectDialog";
|
import { ConnectDialog } from "./ConnectDialog";
|
||||||
import { InviteDialog } from "./InviteDialog";
|
import { InviteDialog } from "./InviteDialog";
|
||||||
import { CreateSessionDialog } from "./CreateSessionDialog";
|
import { CreateSessionDialog } from "./CreateSessionDialog";
|
||||||
|
import { DeleteSessionDialog } from "./DeleteSessionDialog";
|
||||||
import { VariableView } from "./VariableView";
|
import { VariableView } from "./VariableView";
|
||||||
|
|
||||||
export interface JournalPanelProps {
|
export interface JournalPanelProps {
|
||||||
@@ -26,6 +27,10 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const [showInvite, setShowInvite] = createSignal(false);
|
const [showInvite, setShowInvite] = createSignal(false);
|
||||||
const [showCreateSession, setShowCreateSession] = createSignal(false);
|
const [showCreateSession, setShowCreateSession] = createSignal(false);
|
||||||
|
const [sessionToDelete, setSessionToDelete] = createSignal<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
const [viewMode, setViewMode] = createSignal<"stream" | "variables">("stream");
|
const [viewMode, setViewMode] = createSignal<"stream" | "variables">("stream");
|
||||||
|
|
||||||
// Player list (exclude gm and observer)
|
// Player list (exclude gm and observer)
|
||||||
@@ -56,10 +61,11 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||||||
<JournalHeader
|
<JournalHeader
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
onCreateSession={() => setShowCreateSession(true)}
|
onCreateSession={() => setShowCreateSession(true)}
|
||||||
|
onDeleteSession={(id, name) => setSessionToDelete({ id, name })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Player list tabs */}
|
{/* Player list tabs */}
|
||||||
<Show when={stream.connected}>
|
<Show when={stream.connected && stream.sessionId}>
|
||||||
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
|
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
|
||||||
<For each={playerEntries()}>
|
<For each={playerEntries()}>
|
||||||
{([name]) => (
|
{([name]) => (
|
||||||
@@ -79,7 +85,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={stream.connected}
|
when={stream.connected && stream.sessionId}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
|
<div class="flex-1 flex items-center justify-center p-4 overflow-y-auto">
|
||||||
<ConnectDialog />
|
<ConnectDialog />
|
||||||
@@ -126,6 +132,18 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||||||
<Show when={showCreateSession()}>
|
<Show when={showCreateSession()}>
|
||||||
<CreateSessionDialog onClose={() => setShowCreateSession(false)} />
|
<CreateSessionDialog onClose={() => setShowCreateSession(false)} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
{/* Delete session modal */}
|
||||||
|
<Show when={sessionToDelete()}>
|
||||||
|
{(target) => (
|
||||||
|
<DeleteSessionDialog
|
||||||
|
sessionId={target().id}
|
||||||
|
sessionName={target().name}
|
||||||
|
active={stream.sessionId === target().id}
|
||||||
|
onClose={() => setSessionToDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</JournalContext.Provider>
|
</JournalContext.Provider>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,15 +2,20 @@
|
|||||||
* SessionDropdown — session switcher for the journal header (GM only)
|
* SessionDropdown — session switcher for the journal header (GM only)
|
||||||
*/
|
*/
|
||||||
import { Component, createSignal, For, Show } from "solid-js";
|
import { Component, createSignal, For, Show } from "solid-js";
|
||||||
|
import {
|
||||||
|
TbOutlineCheck,
|
||||||
|
TbOutlineChevronDown,
|
||||||
|
TbOutlineTrash,
|
||||||
|
} from "solid-icons/tb";
|
||||||
import {
|
import {
|
||||||
sessions,
|
sessions,
|
||||||
setSessionId,
|
joinSession,
|
||||||
hydrateFromServer,
|
|
||||||
useJournalStream,
|
useJournalStream,
|
||||||
} from "../stores/journalStream";
|
} from "../stores/journalStream";
|
||||||
|
|
||||||
interface SessionDropdownProps {
|
interface SessionDropdownProps {
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
|
onDelete: (id: string, name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
|
export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
|
||||||
@@ -25,8 +30,9 @@ export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
setSessionId(id);
|
joinSession(id).catch((e) => {
|
||||||
hydrateFromServer(id).catch(() => {});
|
console.error("[journal] failed to switch session:", e);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close dropdown on outside click
|
// Close dropdown on outside click
|
||||||
@@ -45,7 +51,7 @@ export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
|
|||||||
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
|
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
|
||||||
>
|
>
|
||||||
{stream.sessionName || stream.sessionId}{" "}
|
{stream.sessionName || stream.sessionId}{" "}
|
||||||
<span class="text-gray-300">▾</span>
|
<TbOutlineChevronDown class="text-gray-300 inline-block align-[-0.125em]" />
|
||||||
</button>
|
</button>
|
||||||
<Show when={dropdownOpen()}>
|
<Show when={dropdownOpen()}>
|
||||||
<div
|
<div
|
||||||
@@ -54,22 +60,37 @@ export const SessionDropdown: Component<SessionDropdownProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<For each={Object.entries(manifest().sessions)}>
|
<For each={Object.entries(manifest().sessions)}>
|
||||||
{([id, meta]) => (
|
{([id, meta]) => (
|
||||||
|
<div
|
||||||
|
class={`flex items-center ${
|
||||||
|
id === stream.sessionId ? "bg-blue-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleSessionSelect(id);
|
handleSessionSelect(id);
|
||||||
}}
|
}}
|
||||||
class={`w-full text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
|
class={`flex-1 min-w-0 text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
|
||||||
id === stream.sessionId
|
id === stream.sessionId ? "text-blue-700" : "text-gray-700"
|
||||||
? "bg-blue-50 text-blue-700"
|
|
||||||
: "text-gray-700"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span class="truncate flex-1">{meta.name || id}</span>
|
<span class="truncate flex-1">{meta.name || id}</span>
|
||||||
<Show when={id === stream.sessionId}>
|
<Show when={id === stream.sessionId}>
|
||||||
<span class="text-blue-500 shrink-0">✓</span>
|
<TbOutlineCheck class="text-blue-500 shrink-0" />
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDropdownOpen(false);
|
||||||
|
props.onDelete(id, meta.name || id);
|
||||||
|
}}
|
||||||
|
class="shrink-0 px-2 py-1 text-[10px] text-gray-300 hover:text-red-500"
|
||||||
|
title="删除会话"
|
||||||
|
>
|
||||||
|
<TbOutlineTrash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<div class="border-t border-gray-100 my-0.5" />
|
<div class="border-t border-gray-100 my-0.5" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, Show, createMemo } from "solid-js";
|
import { Component, Show, createMemo } from "solid-js";
|
||||||
|
import { TbOutlineArrowBackUp } from "solid-icons/tb";
|
||||||
import type { StreamMessage as StreamMessageType } from "./registry";
|
import type { StreamMessage as StreamMessageType } from "./registry";
|
||||||
import { getMessageType } from "./registry";
|
import { getMessageType } from "./registry";
|
||||||
import { revertLatest, useJournalStream } from "../stores/journalStream";
|
import { revertLatest, useJournalStream } from "../stores/journalStream";
|
||||||
@@ -47,7 +48,7 @@ export const StreamMessageCard: Component<{
|
|||||||
class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none"
|
class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none"
|
||||||
title="撤回此消息"
|
title="撤回此消息"
|
||||||
>
|
>
|
||||||
×
|
<TbOutlineArrowBackUp />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import type { CompletionItem } from "./command-parser";
|
import type { CompletionItem } from "./command-parser";
|
||||||
import type { JournalCompletions } from "./completions";
|
import type { JournalCompletions } from "./completions";
|
||||||
|
import { TbOutlineDice5, TbOutlineSparkles } from "solid-icons/tb";
|
||||||
|
|
||||||
export interface CompletionsContext {
|
export interface CompletionsContext {
|
||||||
data: JournalCompletions;
|
data: JournalCompletions;
|
||||||
@@ -63,7 +64,8 @@ export function buildCompletions(
|
|||||||
|
|
||||||
for (const d of diceMatches) {
|
for (const d of diceMatches) {
|
||||||
items.push({
|
items.push({
|
||||||
label: `🎲 ${d.notation}`,
|
label: d.notation,
|
||||||
|
icon: TbOutlineDice5,
|
||||||
kind: "value",
|
kind: "value",
|
||||||
insertText: "/roll " + d.notation,
|
insertText: "/roll " + d.notation,
|
||||||
});
|
});
|
||||||
@@ -71,7 +73,8 @@ export function buildCompletions(
|
|||||||
|
|
||||||
for (const s of sparkMatches) {
|
for (const s of sparkMatches) {
|
||||||
items.push({
|
items.push({
|
||||||
label: `✨ ${s.slug} (${s.notation})`,
|
label: `${s.slug} (${s.notation})`,
|
||||||
|
icon: TbOutlineSparkles,
|
||||||
kind: "value",
|
kind: "value",
|
||||||
insertText: `/roll ${s.slug}`,
|
insertText: `/roll ${s.slug}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,14 @@
|
|||||||
* module and keep JournalInput small.
|
* module and keep JournalInput small.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { IconTypes } from "solid-icons";
|
||||||
|
|
||||||
export interface CompletionItem {
|
export interface CompletionItem {
|
||||||
label: string;
|
label: string;
|
||||||
kind: "command" | "value" | "no-results";
|
kind: "command" | "value" | "no-results";
|
||||||
insertText: string;
|
insertText: string;
|
||||||
|
/** Optional icon rendered before the label in the completions dropdown. */
|
||||||
|
icon?: IconTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedInput {
|
export interface ParsedInput {
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ export {
|
|||||||
} from "./registry";
|
} from "./registry";
|
||||||
export type { MessageTypeDef, StreamMessage } from "./registry";
|
export type { MessageTypeDef, StreamMessage } from "./registry";
|
||||||
export {
|
export {
|
||||||
connectStream,
|
connectBroker,
|
||||||
|
joinSession,
|
||||||
|
switchSession,
|
||||||
|
leaveSession,
|
||||||
disconnectStream,
|
disconnectStream,
|
||||||
hydrateFromServer,
|
resolveBrokerUrl,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
revertLatest,
|
revertLatest,
|
||||||
canRevert,
|
canRevert,
|
||||||
@@ -42,7 +45,7 @@ export { ConnectDialog } from "./ConnectDialog";
|
|||||||
export { InviteDialog } from "./InviteDialog";
|
export { InviteDialog } from "./InviteDialog";
|
||||||
export { SessionDropdown } from "./SessionDropdown";
|
export { SessionDropdown } from "./SessionDropdown";
|
||||||
export { CreateSessionDialog } from "./CreateSessionDialog";
|
export { CreateSessionDialog } from "./CreateSessionDialog";
|
||||||
export { ConnectionBar } from "./ConnectionBar";
|
export { DeleteSessionDialog } from "./DeleteSessionDialog";
|
||||||
export { StreamView } from "./StreamView";
|
export { StreamView } from "./StreamView";
|
||||||
export { StreamMessageCard } from "./StreamMessage";
|
export { StreamMessageCard } from "./StreamMessage";
|
||||||
export { ComposePanel } from "./ComposePanel";
|
export { ComposePanel } from "./ComposePanel";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { TbOutlineCircleCheck, TbOutlineHandStop } from "solid-icons/tb";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -25,7 +26,7 @@ registerMessageType<IntentPayload>({
|
|||||||
render: (p) => (
|
render: (p) => (
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-lg">🙋</span>
|
<TbOutlineHandStop class="text-lg" />
|
||||||
<span class="text-sm font-medium text-yellow-700">意图</span>
|
<span class="text-sm font-medium text-yellow-700">意图</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm">{p.description}</p>
|
<p class="text-sm">{p.description}</p>
|
||||||
@@ -58,7 +59,7 @@ registerMessageType<ResolutionPayload>({
|
|||||||
render: (p) => (
|
render: (p) => (
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-lg">✅</span>
|
<TbOutlineCircleCheck class="text-lg" />
|
||||||
<span class="text-sm font-medium text-green-700">裁决</span>
|
<span class="text-sm font-medium text-green-700">裁决</span>
|
||||||
<span class="text-xs text-gray-400">re: {p.intentId}</span>
|
<span class="text-xs text-gray-400">re: {p.intentId}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component } from "solid-js";
|
import { Component } from "solid-js";
|
||||||
|
import { TbOutlineFileText } from "solid-icons/tb";
|
||||||
import { useNavigateWithParams } from "../../useNavigateWithParams";
|
import { useNavigateWithParams } from "../../useNavigateWithParams";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
@@ -69,7 +70,7 @@ const RevealLink: Component<LinkPayload> = (p) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-lg">📄</span>
|
<TbOutlineFileText class="text-lg" />
|
||||||
<div>
|
<div>
|
||||||
<a
|
<a
|
||||||
href={target()}
|
href={target()}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { TbOutlineArrowRight, TbOutlineDice5 } from "solid-icons/tb";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
import { rollFormula } from "../../dice";
|
import { rollFormula } from "../../dice";
|
||||||
|
|
||||||
@@ -55,9 +56,10 @@ registerMessageType<RollPayload>({
|
|||||||
return (
|
return (
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="text-lg">🎲</span>
|
<TbOutlineDice5 class="text-lg" />
|
||||||
<span class="font-mono text-sm">
|
<span class="font-mono text-sm">
|
||||||
{p.notation} →{" "}
|
{p.notation}{" "}
|
||||||
|
<TbOutlineArrowRight class="inline-block align-[-0.125em]" />{" "}
|
||||||
<span class="font-bold text-base">{result.total}</span>
|
<span class="font-bold text-base">{result.total}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { TbOutlineHash } from "solid-icons/tb";
|
||||||
import { registerMessageType } from "../registry";
|
import { registerMessageType } from "../registry";
|
||||||
import { journalSetState } from "../../stores/journalStream";
|
import { journalSetState } from "../../stores/journalStream";
|
||||||
import { produce } from "solid-js/store";
|
import { produce } from "solid-js/store";
|
||||||
@@ -35,7 +36,7 @@ registerMessageType<VarPayload>({
|
|||||||
render: (p) => {
|
render: (p) => {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="text-lg">🔢</span>
|
<TbOutlineHash class="text-lg" />
|
||||||
<span class="font-mono text-sm">
|
<span class="font-mono text-sm">
|
||||||
{p.key} = <span class="font-bold">{p.value}</span>
|
{p.key} = <span class="font-bold">{p.value}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { For, Show, createEffect, createSignal, on } from "solid-js";
|
import { For, Show, createEffect, createSignal, on } from "solid-js";
|
||||||
|
import { TbOutlineChevronLeft, TbOutlineChevronRight } from "solid-icons/tb";
|
||||||
import type { DeckStore } from "./hooks/deckStore";
|
import type { DeckStore } from "./hooks/deckStore";
|
||||||
|
|
||||||
export interface CardListProps {
|
export interface CardListProps {
|
||||||
@@ -67,7 +68,7 @@ export function CardList(props: CardListProps) {
|
|||||||
disabled={page() === 0}
|
disabled={page() === 0}
|
||||||
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||||
>
|
>
|
||||||
‹
|
<TbOutlineChevronLeft />
|
||||||
</button>
|
</button>
|
||||||
<span class="text-xs text-gray-500 tabular-nums">
|
<span class="text-xs text-gray-500 tabular-nums">
|
||||||
{page() + 1} / {totalPages()}
|
{page() + 1} / {totalPages()}
|
||||||
@@ -77,7 +78,7 @@ export function CardList(props: CardListProps) {
|
|||||||
disabled={page() >= totalPages() - 1}
|
disabled={page() >= totalPages() - 1}
|
||||||
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||||
>
|
>
|
||||||
›
|
<TbOutlineChevronRight />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Show, createSignal, onMount } from "solid-js";
|
import { Show, createSignal, onMount } from "solid-js";
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal } from "solid-js/web";
|
||||||
|
import {
|
||||||
|
TbOutlineCheck,
|
||||||
|
TbOutlineCopy,
|
||||||
|
TbOutlineDownload,
|
||||||
|
TbOutlinePencil,
|
||||||
|
} from "solid-icons/tb";
|
||||||
import type { DeckStore } from "./hooks/deckStore";
|
import type { DeckStore } from "./hooks/deckStore";
|
||||||
|
|
||||||
export interface DeckHeaderProps {
|
export interface DeckHeaderProps {
|
||||||
@@ -43,7 +49,15 @@ export function DeckHeader(props: DeckHeaderProps) {
|
|||||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{store.state.isEditing ? "✓ 编辑中" : "✏️ 编辑"}
|
{store.state.isEditing ? (
|
||||||
|
<>
|
||||||
|
<TbOutlineCheck class="inline-block align-[-0.125em]" /> 编辑中
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TbOutlinePencil class="inline-block align-[-0.125em]" /> 编辑
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 导出 PDF 按钮 */}
|
{/* 导出 PDF 按钮 */}
|
||||||
@@ -51,7 +65,7 @@ export function DeckHeader(props: DeckHeaderProps) {
|
|||||||
onClick={() => store.actions.exportDeck()}
|
onClick={() => store.actions.exportDeck()}
|
||||||
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-green-100 text-green-600 hover:bg-green-200"
|
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-green-100 text-green-600 hover:bg-green-200"
|
||||||
>
|
>
|
||||||
📥 导出 PDF
|
<TbOutlineDownload class="inline-block align-[-0.125em]" /> 导出 PDF
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 复制代码按钮 */}
|
{/* 复制代码按钮 */}
|
||||||
@@ -59,7 +73,7 @@ export function DeckHeader(props: DeckHeaderProps) {
|
|||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-purple-100 text-purple-600 hover:bg-purple-200"
|
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-purple-100 text-purple-600 hover:bg-purple-200"
|
||||||
>
|
>
|
||||||
📋 复制代码
|
<TbOutlineCopy class="inline-block align-[-0.125em]" /> 复制代码
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSignal, For, Show, createMemo } from "solid-js";
|
import { createSignal, For, Show, createMemo } from "solid-js";
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal } from "solid-js/web";
|
||||||
|
import { TbOutlineDownload } from "solid-icons/tb";
|
||||||
import { parsePlt, extractCutPaths } from "../../plotcutter/parser";
|
import { parsePlt, extractCutPaths } from "../../plotcutter/parser";
|
||||||
import { generateTravelPaths, travelPathsToSvg } from "../../plotcutter/layout";
|
import { generateTravelPaths, travelPathsToSvg } from "../../plotcutter/layout";
|
||||||
import type { CardPath } from "../../plotcutter/types";
|
import type { CardPath } from "../../plotcutter/types";
|
||||||
@@ -115,7 +116,7 @@ export function PltPreview(props: PltPreviewProps) {
|
|||||||
class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded text-sm font-medium cursor-pointer flex items-center gap-1"
|
class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded text-sm font-medium cursor-pointer flex items-center gap-1"
|
||||||
disabled={parsedData().cardPaths.length === 0}
|
disabled={parsedData().cardPaths.length === 0}
|
||||||
>
|
>
|
||||||
📥 下载 PLT
|
<TbOutlineDownload /> 下载 PLT
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Show } from 'solid-js';
|
import { Show } from 'solid-js';
|
||||||
|
import { TbOutlineCircleX, TbOutlineX } from 'solid-icons/tb';
|
||||||
import type { DeckStore } from './hooks/deckStore';
|
import type { DeckStore } from './hooks/deckStore';
|
||||||
|
|
||||||
export interface PrintPreviewFooterProps {
|
export interface PrintPreviewFooterProps {
|
||||||
@@ -26,14 +27,14 @@ export function PrintPreviewFooter(props: PrintPreviewFooterProps) {
|
|||||||
<Show when={store.state.exportError}>
|
<Show when={store.state.exportError}>
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="flex items-center gap-2 text-red-600">
|
<div class="flex items-center gap-2 text-red-600">
|
||||||
<span>❌</span>
|
<TbOutlineCircleX />
|
||||||
<span class="text-sm font-medium">{store.state.exportError}</span>
|
<span class="text-sm font-medium">{store.state.exportError}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => store.actions.clearExportError()}
|
onClick={() => store.actions.clearExportError()}
|
||||||
class="text-gray-500 hover:text-gray-700 cursor-pointer"
|
class="text-gray-500 hover:text-gray-700 cursor-pointer"
|
||||||
>
|
>
|
||||||
✕
|
<TbOutlineX />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
|
import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
|
||||||
|
import {
|
||||||
|
TbOutlineDownload,
|
||||||
|
TbOutlinePlus,
|
||||||
|
TbOutlineRuler,
|
||||||
|
} from "solid-icons/tb";
|
||||||
import type { DeckStore } from "./hooks/deckStore";
|
import type { DeckStore } from "./hooks/deckStore";
|
||||||
import { getCompatibleDecks } from "./hooks/deck-registry";
|
import { getCompatibleDecks } from "./hooks/deck-registry";
|
||||||
|
|
||||||
@@ -77,7 +82,7 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
|
|||||||
: "添加其他牌组到打印"
|
: "添加其他牌组到打印"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>➕</span>
|
<TbOutlinePlus />
|
||||||
<span>添加牌组</span>
|
<span>添加牌组</span>
|
||||||
<Show when={props.selectedDeckIds.length > 0}>
|
<Show when={props.selectedDeckIds.length > 0}>
|
||||||
<span class="bg-purple-600 text-white text-xs rounded-full w-5 h-5 inline-flex items-center justify-center">
|
<span class="bg-purple-600 text-white text-xs rounded-full w-5 h-5 inline-flex items-center justify-center">
|
||||||
@@ -232,14 +237,14 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
|
|||||||
onClick={props.onOpenPltPreview}
|
onClick={props.onOpenPltPreview}
|
||||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-1.5 rounded text-sm font-medium cursor-pointer flex items-center gap-2"
|
class="bg-green-600 hover:bg-green-700 text-white px-4 py-1.5 rounded text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span>📐</span>
|
<TbOutlineRuler />
|
||||||
<span>PLT 预览</span>
|
<span>PLT 预览</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={props.onExport}
|
onClick={props.onExport}
|
||||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 rounded text-sm font-medium cursor-pointer flex items-center gap-2"
|
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 rounded text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span>📥</span>
|
<TbOutlineDownload />
|
||||||
<span>导出 PDF</span>
|
<span>导出 PDF</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import { For, createSignal, onCleanup, onMount } from "solid-js";
|
import { For, createSignal, onCleanup, onMount } from "solid-js";
|
||||||
|
import type { JSX } from "solid-js";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
|
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
|
||||||
|
import type { IconTypes } from "solid-icons";
|
||||||
|
import {
|
||||||
|
TbOutlineAlignCenter,
|
||||||
|
TbOutlineAlignLeft,
|
||||||
|
TbOutlineAlignRight,
|
||||||
|
TbOutlineArrowDown,
|
||||||
|
TbOutlineArrowDownLeft,
|
||||||
|
TbOutlineArrowDownRight,
|
||||||
|
TbOutlineArrowLeft,
|
||||||
|
TbOutlineArrowRight,
|
||||||
|
TbOutlineArrowUp,
|
||||||
|
TbOutlineArrowUpLeft,
|
||||||
|
TbOutlineArrowUpRight,
|
||||||
|
TbOutlineEye,
|
||||||
|
TbOutlineEyeOff,
|
||||||
|
TbOutlineGripVertical,
|
||||||
|
TbOutlineTrash,
|
||||||
|
} from "solid-icons/tb";
|
||||||
import type { DeckStore } from "../hooks/deckStore";
|
import type { DeckStore } from "../hooks/deckStore";
|
||||||
import type { Align, LayerConfig } from "../types";
|
import type { Align, LayerConfig } from "../types";
|
||||||
import alignLeftIcon from "./icons/align-left.png";
|
|
||||||
import alignCenterIcon from "./icons/align-center.png";
|
|
||||||
import alignRightIcon from "./icons/align-right.png";
|
|
||||||
|
|
||||||
export interface LayerRowProps {
|
export interface LayerRowProps {
|
||||||
store: DeckStore;
|
store: DeckStore;
|
||||||
@@ -22,65 +39,67 @@ export interface LayerRowProps {
|
|||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ORIENTATIONS = [
|
const ORIENTATIONS: {
|
||||||
{ value: "n" as const, label: "↑ 北" },
|
value: "n" | "e" | "s" | "w";
|
||||||
{ value: "e" as const, label: "→ 东" },
|
label: string;
|
||||||
{ value: "s" as const, label: "↓ 南" },
|
icon: IconTypes;
|
||||||
{ value: "w" as const, label: "← 西" },
|
}[] = [
|
||||||
|
{ value: "n", label: "北", icon: TbOutlineArrowUp },
|
||||||
|
{ value: "e", label: "东", icon: TbOutlineArrowRight },
|
||||||
|
{ value: "s", label: "南", icon: TbOutlineArrowDown },
|
||||||
|
{ value: "w", label: "西", icon: TbOutlineArrowLeft },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALIGNS: { value: Align | ""; icon: string }[] = [
|
const ALIGNS: { value: Align | ""; icon: IconTypes }[] = [
|
||||||
{ value: "", icon: alignCenterIcon },
|
{ value: "", icon: TbOutlineAlignCenter },
|
||||||
{ value: "l", icon: alignLeftIcon },
|
{ value: "l", icon: TbOutlineAlignLeft },
|
||||||
{ value: "c", icon: alignCenterIcon },
|
{ value: "c", icon: TbOutlineAlignCenter },
|
||||||
{ value: "r", icon: alignRightIcon },
|
{ value: "r", icon: TbOutlineAlignRight },
|
||||||
{ value: "tl", icon: "↖" },
|
{ value: "tl", icon: TbOutlineArrowUpLeft },
|
||||||
{ value: "tc", icon: "↑" },
|
{ value: "tc", icon: TbOutlineArrowUp },
|
||||||
{ value: "tr", icon: "↗" },
|
{ value: "tr", icon: TbOutlineArrowUpRight },
|
||||||
{ value: "bl", icon: "↙" },
|
{ value: "bl", icon: TbOutlineArrowDownLeft },
|
||||||
{ value: "bc", icon: "↓" },
|
{ value: "bc", icon: TbOutlineArrowDown },
|
||||||
{ value: "br", icon: "↘" },
|
{ value: "br", icon: TbOutlineArrowDownRight },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FONT_PRESETS = [3, 5, 8, 12] as const;
|
const FONT_PRESETS = [3, 5, 8, 12] as const;
|
||||||
|
|
||||||
function orientChar(v: string) {
|
function orientIcon(v: string): IconTypes {
|
||||||
switch (v) {
|
switch (v) {
|
||||||
case "n":
|
case "n":
|
||||||
return "↑";
|
return TbOutlineArrowUp;
|
||||||
case "e":
|
case "e":
|
||||||
return "→";
|
return TbOutlineArrowRight;
|
||||||
case "s":
|
case "s":
|
||||||
return "↓";
|
return TbOutlineArrowDown;
|
||||||
case "w":
|
case "w":
|
||||||
return "←";
|
return TbOutlineArrowLeft;
|
||||||
default:
|
default:
|
||||||
return "↑";
|
return TbOutlineArrowUp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function alignIcon(v: string): import("solid-js").JSX.Element {
|
function alignIcon(v: string): IconTypes {
|
||||||
switch (v) {
|
switch (v) {
|
||||||
case "tl":
|
case "tl":
|
||||||
return "↖";
|
return TbOutlineArrowUpLeft;
|
||||||
case "tc":
|
case "tc":
|
||||||
return "↑";
|
return TbOutlineArrowUp;
|
||||||
case "tr":
|
case "tr":
|
||||||
return "↗";
|
return TbOutlineArrowUpRight;
|
||||||
case "bl":
|
case "bl":
|
||||||
return "↙";
|
return TbOutlineArrowDownLeft;
|
||||||
case "bc":
|
case "bc":
|
||||||
return "↓";
|
return TbOutlineArrowDown;
|
||||||
case "br":
|
case "br":
|
||||||
return "↘";
|
return TbOutlineArrowDownRight;
|
||||||
case "l":
|
case "l":
|
||||||
return <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
|
return TbOutlineAlignLeft;
|
||||||
case "r":
|
case "r":
|
||||||
return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
|
return TbOutlineAlignRight;
|
||||||
default:
|
default:
|
||||||
return (
|
return TbOutlineAlignCenter;
|
||||||
<img src={alignCenterIcon} alt="align" class="w-5 h-5 not-prose" />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +144,7 @@ export function LayerRow(props: LayerRowProps) {
|
|||||||
class="cursor-grab text-gray-400 hover:text-gray-600 text-sm px-0.5 select-none shrink-0"
|
class="cursor-grab text-gray-400 hover:text-gray-600 text-sm px-0.5 select-none shrink-0"
|
||||||
title="拖动排序"
|
title="拖动排序"
|
||||||
>
|
>
|
||||||
⠿
|
<TbOutlineGripVertical class="inline-block align-[-0.125em]" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -133,7 +152,11 @@ export function LayerRow(props: LayerRowProps) {
|
|||||||
class="shrink-0 text-sm cursor-pointer select-none"
|
class="shrink-0 text-sm cursor-pointer select-none"
|
||||||
title={props.layer.visible ? "隐藏" : "显示"}
|
title={props.layer.visible ? "隐藏" : "显示"}
|
||||||
>
|
>
|
||||||
{props.layer.visible ? "👁" : "👁🗨"}
|
{props.layer.visible ? (
|
||||||
|
<TbOutlineEye class="inline-block align-[-0.125em]" />
|
||||||
|
) : (
|
||||||
|
<TbOutlineEyeOff class="inline-block align-[-0.125em]" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@@ -144,7 +167,7 @@ export function LayerRow(props: LayerRowProps) {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<DropdownButton
|
<DropdownButton
|
||||||
icon={orientChar(props.layer.orientation || "n")}
|
icon={orientIcon(props.layer.orientation || "n")}
|
||||||
visible={props.layer.visible}
|
visible={props.layer.visible}
|
||||||
open={props.openDropdown === `orient-${props.index}`}
|
open={props.openDropdown === `orient-${props.index}`}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
@@ -162,6 +185,10 @@ export function LayerRow(props: LayerRowProps) {
|
|||||||
onClick={() => props.onUpdateOrientation(o.value)}
|
onClick={() => props.onUpdateOrientation(o.value)}
|
||||||
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
|
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
|
||||||
>
|
>
|
||||||
|
<Dynamic
|
||||||
|
component={o.icon}
|
||||||
|
class="inline-block align-[-0.125em]"
|
||||||
|
/>{" "}
|
||||||
{o.label}
|
{o.label}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -187,13 +214,11 @@ export function LayerRow(props: LayerRowProps) {
|
|||||||
onClick={() => props.onUpdateAlign(o.value || undefined)}
|
onClick={() => props.onUpdateAlign(o.value || undefined)}
|
||||||
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
|
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{o.icon.endsWith(".png") ? (
|
<Dynamic
|
||||||
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
|
component={o.icon}
|
||||||
) : (
|
size={16}
|
||||||
<span class="w-4 h-4 flex items-center justify-center not-prose">
|
class="not-prose max-w-none"
|
||||||
{o.icon}
|
/>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -260,19 +285,19 @@ export function LayerRow(props: LayerRowProps) {
|
|||||||
class={`shrink-0 w-5 h-5 text-xs rounded cursor-pointer flex items-center justify-center text-red-400 hover:text-red-600 hover:bg-red-50 transition-opacity ${props.hovered ? "opacity-100" : "opacity-0"}`}
|
class={`shrink-0 w-5 h-5 text-xs rounded cursor-pointer flex items-center justify-center text-red-400 hover:text-red-600 hover:bg-red-50 transition-opacity ${props.hovered ? "opacity-100" : "opacity-0"}`}
|
||||||
title="删除图层"
|
title="删除图层"
|
||||||
>
|
>
|
||||||
✕
|
<TbOutlineTrash size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownButton(props: {
|
function DropdownButton(props: {
|
||||||
icon: import("solid-js").JSX.Element;
|
icon: IconTypes;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
children: import("solid-js").JSX.Element;
|
children: JSX.Element;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -284,7 +309,7 @@ function DropdownButton(props: {
|
|||||||
class={`w-7 h-7 rounded cursor-pointer flex items-center justify-center bg-gray-200 hover:bg-gray-300 ${!props.visible ? "invisible pointer-events-none" : ""}`}
|
class={`w-7 h-7 rounded cursor-pointer flex items-center justify-center bg-gray-200 hover:bg-gray-300 ${!props.visible ? "invisible pointer-events-none" : ""}`}
|
||||||
title={props.title}
|
title={props.title}
|
||||||
>
|
>
|
||||||
{props.icon}
|
<Dynamic component={props.icon} size={16} />
|
||||||
</button>
|
</button>
|
||||||
{props.open && (
|
{props.open && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1002 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1006 B |
Binary file not shown.
|
Before Width: | Height: | Size: 996 B |
@@ -1,5 +1,6 @@
|
|||||||
import { customElement, noShadowDOM } from "solid-element";
|
import { customElement, noShadowDOM } from "solid-element";
|
||||||
import { createSignal, onMount } from "solid-js";
|
import { createSignal, onMount } from "solid-js";
|
||||||
|
import { TbOutlineDice5 } from "solid-icons/tb";
|
||||||
import { rollFormula } from "./dice";
|
import { rollFormula } from "./dice";
|
||||||
|
|
||||||
export interface DiceProps {
|
export interface DiceProps {
|
||||||
@@ -86,7 +87,7 @@ customElement("md-dice", { key: "" }, (props, { element }) => {
|
|||||||
class="text-blue-600 hover:text-blue-800 cursor-pointer"
|
class="text-blue-600 hover:text-blue-800 cursor-pointer"
|
||||||
title={rollDetail() || "掷骰子"}
|
title={rollDetail() || "掷骰子"}
|
||||||
>
|
>
|
||||||
🎲
|
<TbOutlineDice5 class="inline-block align-[-0.125em]" />
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
onClick={handleRoll}
|
onClick={handleRoll}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { customElement, noShadowDOM } from "solid-element";
|
import { customElement, noShadowDOM } from "solid-element";
|
||||||
import {Show, For, createResource, createMemo, createSignal} from "solid-js";
|
import {Show, For, createResource, createMemo, createSignal} from "solid-js";
|
||||||
|
import { TbOutlineCopy } from "solid-icons/tb";
|
||||||
import { resolvePath } from "./utils/path";
|
import { resolvePath } from "./utils/path";
|
||||||
import { createPinsStore } from "./stores/pinsStore";
|
import { createPinsStore } from "./stores/pinsStore";
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ customElement("md-pins", { pins: "", fixed: false, labelStart: "A" }, (props, {
|
|||||||
class="bg-gray-800 hover:bg-gray-700 text-white px-3 py-1.5 rounded shadow-lg text-sm flex items-center gap-1 transition-colors"
|
class="bg-gray-800 hover:bg-gray-700 text-white px-3 py-1.5 rounded shadow-lg text-sm flex items-center gap-1 transition-colors"
|
||||||
title="复制所有 pin 坐标"
|
title="复制所有 pin 坐标"
|
||||||
>
|
>
|
||||||
📋 复制
|
<TbOutlineCopy /> 复制
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
|
import { TbOutlineDice5 } from "solid-icons/tb";
|
||||||
import { parseMarkdown } from "../markdown";
|
import { parseMarkdown } from "../markdown";
|
||||||
import { parseCSVString, CSV, processVariables } from "./utils/csv-loader";
|
import { parseCSVString, CSV, processVariables } from "./utils/csv-loader";
|
||||||
import { resolveContentRef } from "./utils/resolve-content";
|
import { resolveContentRef } from "./utils/resolve-content";
|
||||||
@@ -210,7 +211,7 @@ customElement(
|
|||||||
class="text-gray-500 hover:text-gray-700 shrink-0 cursor-pointer"
|
class="text-gray-500 hover:text-gray-700 shrink-0 cursor-pointer"
|
||||||
title="随机切换"
|
title="随机切换"
|
||||||
>
|
>
|
||||||
🎲
|
<TbOutlineDice5 class="inline-block align-[-0.125em]" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { customElement, noShadowDOM } from 'solid-element';
|
import { customElement, noShadowDOM } from 'solid-element';
|
||||||
import { For, Show, createEffect } from 'solid-js';
|
import { For, Show, createEffect } from 'solid-js';
|
||||||
|
import {
|
||||||
|
TbOutlineRotate,
|
||||||
|
TbOutlinePlayerTrackNext,
|
||||||
|
TbOutlineArrowRight,
|
||||||
|
} from 'solid-icons/tb';
|
||||||
import type {TextResult, RuntimeResult, OptionsResult} from 'yarn-spinner-loader';
|
import type {TextResult, RuntimeResult, OptionsResult} from 'yarn-spinner-loader';
|
||||||
import { createYarnStore } from './stores/yarnStore';
|
import { createYarnStore } from './stores/yarnStore';
|
||||||
|
|
||||||
@@ -82,7 +87,7 @@ customElement<{start: string}>('md-yarn-spinner', {start: 'start'}, (props, { el
|
|||||||
rounded-bl-md transition-colors cursor-pointer"
|
rounded-bl-md transition-colors cursor-pointer"
|
||||||
title="重新开始"
|
title="重新开始"
|
||||||
>
|
>
|
||||||
🔄
|
<TbOutlineRotate class="inline-block align-[-0.125em]" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => advance()}
|
onClick={() => advance()}
|
||||||
@@ -90,7 +95,7 @@ customElement<{start: string}>('md-yarn-spinner', {start: 'start'}, (props, { el
|
|||||||
transition-colors cursor-pointer"
|
transition-colors cursor-pointer"
|
||||||
title="继续"
|
title="继续"
|
||||||
>
|
>
|
||||||
⏩
|
<TbOutlinePlayerTrackNext class="inline-block align-[-0.125em]" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,7 +109,7 @@ customElement<{start: string}>('md-yarn-spinner', {start: 'start'}, (props, { el
|
|||||||
class="option-button text-left px-4 py-2 bg-blue-50 hover:bg-blue-100
|
class="option-button text-left px-4 py-2 bg-blue-50 hover:bg-blue-100
|
||||||
rounded border border-blue-200 transition-colors cursor-pointer"
|
rounded border border-blue-200 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
→ {option.text}
|
<TbOutlineArrowRight class="inline-block align-[-0.125em]" /> {option.text}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -282,29 +282,25 @@ export function setMyRole(role: "gm" | "player" | "observer"): void {
|
|||||||
saveRole(role);
|
saveRole(role);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSessionId(id: string | null): void {
|
/**
|
||||||
setState("sessionId", id);
|
* Resolve the broker URL for the current environment. In dev the MQTT broker
|
||||||
if (id) {
|
* lives on the CLI server (port 3000); in production the CLI serves everything.
|
||||||
saveSessionId(id);
|
*/
|
||||||
syncUrlParam("session", id);
|
export function resolveBrokerUrl(): string {
|
||||||
} else {
|
return process.env.NODE_ENV === "development"
|
||||||
removeUrlParam("session");
|
? "ws://localhost:3000"
|
||||||
setState("sessionName", null);
|
: `ws://${window.location.host}`;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectStream(
|
/** Connect to the broker and enter the lobby (no active session). */
|
||||||
sessionId: string,
|
export async function connectBroker(brokerUrl: string): Promise<void> {
|
||||||
brokerUrl: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const api = getWorkerAPI();
|
const api = getWorkerAPI();
|
||||||
setState("connectionStatus", "connecting");
|
setState("connectionStatus", "connecting");
|
||||||
setState("connectionError", null);
|
setState("connectionError", null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.connect(sessionId, brokerUrl, state.myName, state.myRole);
|
await api.connect(brokerUrl, state.myName, state.myRole);
|
||||||
saveBrokerUrl(brokerUrl);
|
saveBrokerUrl(brokerUrl);
|
||||||
saveSessionId(sessionId);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState("connectionStatus", "error");
|
setState("connectionStatus", "error");
|
||||||
setState(
|
setState(
|
||||||
@@ -315,6 +311,32 @@ export async function connectStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join (or switch to) a session. The broker connection is reused, so switching
|
||||||
|
* sessions is a subscription swap rather than a reconnect.
|
||||||
|
*/
|
||||||
|
export async function joinSession(
|
||||||
|
sessionId: string,
|
||||||
|
identity?: { name?: string; role?: "gm" | "player" | "observer" },
|
||||||
|
): Promise<void> {
|
||||||
|
const api = getWorkerAPI();
|
||||||
|
await api.joinSession(sessionId, identity?.name, identity?.role);
|
||||||
|
saveSessionId(sessionId);
|
||||||
|
syncUrlParam("session", sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alias for `joinSession` — reading `switchSession` at call sites is clearer. */
|
||||||
|
export const switchSession = joinSession;
|
||||||
|
|
||||||
|
/** Leave the active session, returning to the lobby (broker stays connected). */
|
||||||
|
export function leaveSession(): void {
|
||||||
|
const api = getWorkerAPI();
|
||||||
|
api.leaveSession();
|
||||||
|
removeUrlParam("session");
|
||||||
|
setState("sessionId", null);
|
||||||
|
setState("sessionName", null);
|
||||||
|
}
|
||||||
|
|
||||||
export function sendMessage<T>(
|
export function sendMessage<T>(
|
||||||
type: string,
|
type: string,
|
||||||
payload: T,
|
payload: T,
|
||||||
@@ -365,28 +387,21 @@ export function revertLatest():
|
|||||||
export function disconnectStream(): void {
|
export function disconnectStream(): void {
|
||||||
const api = getWorkerAPI();
|
const api = getWorkerAPI();
|
||||||
api.disconnect();
|
api.disconnect();
|
||||||
|
removeUrlParam("session");
|
||||||
removeUrlParam("autojoin");
|
removeUrlParam("autojoin");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSession(
|
export async function createSession(
|
||||||
name: string,
|
name: string,
|
||||||
players: string[] = [],
|
players: string[] = [],
|
||||||
): string | null {
|
): Promise<string | null> {
|
||||||
const api = getWorkerAPI();
|
return getWorkerAPI().createSession(name, players);
|
||||||
// This is sync in the worker but async over Comlink.
|
|
||||||
// We generate the ID locally using the same algorithm.
|
|
||||||
const id =
|
|
||||||
name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "") || "session";
|
|
||||||
api.createSession(name, players);
|
|
||||||
return id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteSession(sessionId: string): void {
|
export async function deleteSession(sessionId: string): Promise<void> {
|
||||||
const api = getWorkerAPI();
|
await getWorkerAPI().deleteSession(sessionId);
|
||||||
api.deleteSession(sessionId);
|
// Deleting the active session drops you back to the lobby.
|
||||||
|
if (state.sessionId === sessionId) leaveSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canRevert(): boolean {
|
export function canRevert(): boolean {
|
||||||
@@ -408,11 +423,3 @@ export function useJournalStream() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { state as journalStreamState };
|
export { state as journalStreamState };
|
||||||
|
|
||||||
/**
|
|
||||||
* Hydrate from server — now handled by the worker during connect().
|
|
||||||
* Kept for API compatibility; does nothing.
|
|
||||||
*/
|
|
||||||
export async function hydrateFromServer(_sessionId: string): Promise<void> {
|
|
||||||
// Hydration is now handled by the worker during connect()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
* Dev 工作流:运行 CLI 服务器并在 rsbuild 配置中设置代理
|
* Dev 工作流:运行 CLI 服务器并在 rsbuild 配置中设置代理
|
||||||
* > npm run tttk serve ./content
|
* > npm run tttk serve ./content
|
||||||
* > npm run dev
|
* > npm run dev
|
||||||
* rsbuild 会将 /__CONTENT_INDEX.json、/__COMPLETIONS.json、/__CONTENT_REGISTRY.json、/content/ 代理到 CLI 服务器
|
* rsbuild 会将 /__CONTENT_INDEX.json、/__COMPLETIONS.json、/__CONTENT_REGISTRY.json、
|
||||||
|
* /.ttrpg 以及内容目录中的静态资源代理到 CLI 服务器
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
---
|
---
|
||||||
tag: journal-gm
|
tag: journal-gm
|
||||||
icon: 🎭
|
icon: gm
|
||||||
title: 主持人指南
|
title: 主持人指南
|
||||||
---
|
---
|
||||||
|
|
||||||
作为 GM 创建会话、管理玩家、使用命令与玩家互动。
|
作为 GM 创建会话、管理玩家、使用命令与玩家互动。
|
||||||
|
|
||||||
点击顶部导航栏的 📋 按钮打开 Journal 面板。
|
点击顶部导航栏的剪贴板按钮打开 Journal 面板。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
|
1. 点击顶部导航栏的剪贴板按钮打开 Journal 面板
|
||||||
2. 输入你的名字,选择"主持人"角色,点击"加入"
|
2. 输入你的名字,选择"主持人"角色,点击"加入"
|
||||||
3. 连接成功后,你可以创建新会话或选择已有会话
|
3. 连接成功后,你可以创建新会话或选择已有会话
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: journal-link
|
tag: journal-link
|
||||||
icon: 🔗
|
icon: link
|
||||||
title: 链接命令
|
title: 链接命令
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: journal-player
|
tag: journal-player
|
||||||
icon: 🎲
|
icon: player
|
||||||
title: 玩家指南
|
title: 玩家指南
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ GM 会发送一个包含 `?session=xxx&player=你的名字&autojoin=1` 的链接
|
|||||||
|
|
||||||
### 手动加入
|
### 手动加入
|
||||||
|
|
||||||
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
|
1. 点击顶部导航栏的剪贴板按钮打开 Journal 面板
|
||||||
2. 输入你的名字
|
2. 输入你的名字
|
||||||
3. 选择"玩家"角色
|
3. 选择"玩家"角色
|
||||||
4. 点击"加入"
|
4. 点击"加入"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: journal-roll
|
tag: journal-roll
|
||||||
icon: 🎲
|
icon: roll
|
||||||
title: 掷骰与火花表命令
|
title: 掷骰与火花表命令
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: journal-set
|
tag: journal-set
|
||||||
icon: 🔢
|
icon: set
|
||||||
title: 变量系统
|
title: 变量系统
|
||||||
description: 在文档中声明变量和标签,通过 /set 命令设置变量值,自动计算派生变量和标签效果。
|
description: 在文档中声明变量和标签,通过 /set 命令设置变量值,自动计算派生变量和标签效果。
|
||||||
syntax: '```yaml role=declare'
|
syntax: '```yaml role=declare'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: journal-spark
|
tag: journal-spark
|
||||||
icon: 🎰
|
icon: spark
|
||||||
title: 火花表
|
title: 火花表
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: md-bg
|
tag: md-bg
|
||||||
icon: 🖼️
|
icon: background
|
||||||
title: 背景组件
|
title: 背景组件
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: md-border
|
tag: md-border
|
||||||
icon: 🖼️
|
icon: border
|
||||||
title: 边框组件
|
title: 边框组件
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: md-deck
|
tag: md-deck
|
||||||
icon: 🃏
|
icon: deck
|
||||||
title: 卡牌组件
|
title: 卡牌组件
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: md-dice
|
tag: md-dice
|
||||||
icon: 🎲
|
icon: dice
|
||||||
title: 骰子组件
|
title: 骰子组件
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ title: 骰子组件
|
|||||||
:md-dice[2d20k1+5]{key="advantage"}
|
:md-dice[2d20k1+5]{key="advantage"}
|
||||||
```
|
```
|
||||||
|
|
||||||
点击 🎲 图标执行投掷,显示结果;点击文字重置为原始公式。
|
点击骰子图标执行投掷,显示结果;点击文字重置为原始公式。
|
||||||
|
|
||||||
## 属性
|
## 属性
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: md-embed
|
tag: md-embed
|
||||||
icon: 📄
|
icon: embed
|
||||||
title: 嵌入组件
|
title: 嵌入组件
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: md-font
|
tag: md-font
|
||||||
icon: 🔤
|
icon: font
|
||||||
title: 字体组件
|
title: 字体组件
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: md-link
|
tag: md-link
|
||||||
icon: 🔗
|
icon: link
|
||||||
title: 链接组件
|
title: 链接组件
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: md-pins
|
tag: md-pins
|
||||||
icon: 📍
|
icon: pins
|
||||||
title: 标记组件
|
title: 标记组件
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ title: 标记组件
|
|||||||
|
|
||||||
## 复制坐标
|
## 复制坐标
|
||||||
|
|
||||||
非 `fixed` 模式下,图片右上角会显示 📋 复制按钮,点击可将所有标记的坐标复制到剪贴板,方便粘贴回文档的 `pins` 属性中。
|
非 `fixed` 模式下,图片右上角会显示复制按钮,点击可将所有标记的坐标复制到剪贴板,方便粘贴回文档的 `pins` 属性中。
|
||||||
|
|
||||||
## 属性
|
## 属性
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: md-table
|
tag: md-table
|
||||||
icon: 📊
|
icon: table
|
||||||
title: 表格组件
|
title: 表格组件
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ B,另一段描述,第二章]{}
|
|||||||
|
|
||||||
## 随机抽取
|
## 随机抽取
|
||||||
|
|
||||||
当 `roll=true` 时,表格顶部显示 🎲 随机按钮:
|
当 `roll=true` 时,表格顶部显示骰子随机按钮:
|
||||||
- 普通随机:从所有行中均匀随机选取
|
- 普通随机:从所有行中均匀随机选取
|
||||||
- **加权随机**:当 `label` 列全部为整数或整数范围格式(如 `1-3`)时,以 label 值为权重进行加权随机抽取
|
- **加权随机**:当 `label` 列全部为整数或整数范围格式(如 `1-3`)时,以 label 值为权重进行加权随机抽取
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
tag: md-yarn-spinner
|
tag: md-yarn-spinner
|
||||||
icon: 🧶
|
icon: yarn
|
||||||
title: 叙事线组件
|
title: 叙事线组件
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,8 +19,8 @@ Yarn Spinner 是用于游戏对话系统的格式,支持选项、条件分支
|
|||||||
|
|
||||||
- **对话历史**:上半部分显示已进行的对话,说话者以蓝色粗体显示,命令以灰色斜体显示
|
- **对话历史**:上半部分显示已进行的对话,说话者以蓝色粗体显示,命令以灰色斜体显示
|
||||||
- **当前选项**:下半部分显示可选的对话选项,点击选项推进剧情
|
- **当前选项**:下半部分显示可选的对话选项,点击选项推进剧情
|
||||||
- **⏩ 继续**:点击右上角继续按钮推进到下一段对话
|
- **继续**:点击右上角继续按钮推进到下一段对话
|
||||||
- **🔄 重新开始**:点击右上角重启按钮从头开始对话
|
- **重新开始**:点击右上角重启按钮从头开始对话
|
||||||
|
|
||||||
## 属性
|
## 属性
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
SESSION_ID_MAX_LENGTH,
|
||||||
|
isValidSessionId,
|
||||||
|
toSessionId,
|
||||||
|
} from "./session-id";
|
||||||
|
|
||||||
|
describe("isValidSessionId", () => {
|
||||||
|
test.each(["default", "a", "A1", "my-game", "under_score", "-"])(
|
||||||
|
"accepts %p",
|
||||||
|
(id) => {
|
||||||
|
expect(isValidSessionId(id)).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
"",
|
||||||
|
".",
|
||||||
|
"..",
|
||||||
|
"../evil",
|
||||||
|
"..\\..\\evil",
|
||||||
|
"a/b",
|
||||||
|
"a\\b",
|
||||||
|
"a b",
|
||||||
|
"a.b",
|
||||||
|
"a/b/../c",
|
||||||
|
])("rejects %p", (id) => {
|
||||||
|
expect(isValidSessionId(id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-strings and over-long ids", () => {
|
||||||
|
expect(isValidSessionId(null)).toBe(false);
|
||||||
|
expect(isValidSessionId(undefined)).toBe(false);
|
||||||
|
expect(isValidSessionId(42)).toBe(false);
|
||||||
|
expect(isValidSessionId("a".repeat(SESSION_ID_MAX_LENGTH + 1))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects Windows reserved device names", () => {
|
||||||
|
expect(isValidSessionId("con")).toBe(false);
|
||||||
|
expect(isValidSessionId("COM1")).toBe(false);
|
||||||
|
expect(isValidSessionId("Nul")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toSessionId", () => {
|
||||||
|
test("slugifies names", () => {
|
||||||
|
expect(toSessionId("My Game")).toBe("my-game");
|
||||||
|
expect(toSessionId(" Curse of Strahd!! ")).toBe("curse-of-strahd");
|
||||||
|
expect(toSessionId("")).toBe("session");
|
||||||
|
expect(toSessionId("!!!")).toBe("session");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("avoids Windows reserved device names", () => {
|
||||||
|
expect(toSessionId("CON")).toBe("con-session");
|
||||||
|
expect(isValidSessionId(toSessionId("CON"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("always yields a valid id, including for hostile input", () => {
|
||||||
|
const names = [
|
||||||
|
"My Game",
|
||||||
|
"",
|
||||||
|
"..",
|
||||||
|
"../etc/passwd",
|
||||||
|
"a/b\\c",
|
||||||
|
"CON",
|
||||||
|
"Ünïcödé",
|
||||||
|
"x".repeat(200),
|
||||||
|
"\0\0",
|
||||||
|
];
|
||||||
|
for (const name of names) {
|
||||||
|
expect(isValidSessionId(toSessionId(name))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Session id rules — shared by the CLI broker (server) and the web client.
|
||||||
|
*
|
||||||
|
* A session id becomes a directory name under `<contentDir>/.ttrpg/sessions/`
|
||||||
|
* on the server, and is embedded in MQTT topics (`ttrpg/<id>/...`). Because the
|
||||||
|
* server derives filesystem paths directly from that topic, ids must be
|
||||||
|
* strictly validated at every trust boundary: without it, a topic level such as
|
||||||
|
* `..`, `.` or (on Windows) `..\..\evil` escapes the sessions directory.
|
||||||
|
*
|
||||||
|
* The pattern is intentionally conservative — only ASCII letters, digits, `_`
|
||||||
|
* and `-` — while still accepting ids that older versions may have written so
|
||||||
|
* existing sessions remain manageable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SESSION_ID_MAX_LENGTH = 64;
|
||||||
|
|
||||||
|
/** Valid session ids: 1-64 chars of `[A-Za-z0-9_-]`, never a path segment. */
|
||||||
|
export const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows reserves these names (case-insensitively) for devices, so they can
|
||||||
|
* never be used as a directory name — even with an extension.
|
||||||
|
*/
|
||||||
|
const RESERVED_DEVICE_NAMES = new Set<string>([
|
||||||
|
"con",
|
||||||
|
"prn",
|
||||||
|
"aux",
|
||||||
|
"nul",
|
||||||
|
"com1",
|
||||||
|
"com2",
|
||||||
|
"com3",
|
||||||
|
"com4",
|
||||||
|
"com5",
|
||||||
|
"com6",
|
||||||
|
"com7",
|
||||||
|
"com8",
|
||||||
|
"com9",
|
||||||
|
"lpt1",
|
||||||
|
"lpt2",
|
||||||
|
"lpt3",
|
||||||
|
"lpt4",
|
||||||
|
"lpt5",
|
||||||
|
"lpt6",
|
||||||
|
"lpt7",
|
||||||
|
"lpt8",
|
||||||
|
"lpt9",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** True when `id` is safe to use as a sessions-directory name and topic level. */
|
||||||
|
export function isValidSessionId(id: unknown): id is string {
|
||||||
|
return (
|
||||||
|
typeof id === "string" &&
|
||||||
|
id.length > 0 &&
|
||||||
|
id.length <= SESSION_ID_MAX_LENGTH &&
|
||||||
|
SESSION_ID_PATTERN.test(id) &&
|
||||||
|
!RESERVED_DEVICE_NAMES.has(id.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a filesystem-safe session id from a human-readable name.
|
||||||
|
* The result is always a valid session id.
|
||||||
|
*/
|
||||||
|
export function toSessionId(name: string): string {
|
||||||
|
const slug =
|
||||||
|
name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, SESSION_ID_MAX_LENGTH)
|
||||||
|
.replace(/-+$/g, "") || "session";
|
||||||
|
|
||||||
|
// Some names slugify onto a reserved device name (`Con` -> `con`); suffix
|
||||||
|
// those so the session can actually be created on Windows.
|
||||||
|
return RESERVED_DEVICE_NAMES.has(slug) ? `${slug}-session` : slug;
|
||||||
|
}
|
||||||
+189
-36
@@ -12,6 +12,11 @@
|
|||||||
|
|
||||||
import * as Comlink from "comlink";
|
import * as Comlink from "comlink";
|
||||||
import type { StreamMessage } from "../components/journal/registry";
|
import type { StreamMessage } from "../components/journal/registry";
|
||||||
|
import {
|
||||||
|
SESSION_ID_MAX_LENGTH,
|
||||||
|
isValidSessionId,
|
||||||
|
toSessionId,
|
||||||
|
} from "../shared/session-id.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -139,11 +144,50 @@ async function hydrateFromServer(sessionId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// MQTT Connect
|
// MQTT Connect — the broker connection is independent of the active session
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function connectStream(
|
function subscribeSession(
|
||||||
|
client: import("mqtt").MqttClient,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
): void {
|
||||||
|
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
|
||||||
|
if (err) console.error("[worker] stream sub err:", err);
|
||||||
|
});
|
||||||
|
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
|
||||||
|
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
|
||||||
|
|
||||||
|
// Publish own presence (retained)
|
||||||
|
client.publish(
|
||||||
|
`ttrpg/${sessionId}/presence/${state.myName}`,
|
||||||
|
JSON.stringify({ name: state.myName, role: state.myRole }),
|
||||||
|
{ qos: 1, retain: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribeSession(
|
||||||
|
client: import("mqtt").MqttClient,
|
||||||
|
sessionId: string,
|
||||||
|
): void {
|
||||||
|
// Clear own retained presence before dropping the subscription.
|
||||||
|
client.publish(
|
||||||
|
`ttrpg/${sessionId}/presence/${state.myName}`,
|
||||||
|
"",
|
||||||
|
{ qos: 1, retain: true },
|
||||||
|
);
|
||||||
|
client.unsubscribe(
|
||||||
|
[
|
||||||
|
`ttrpg/${sessionId}/stream`,
|
||||||
|
`ttrpg/${sessionId}/meta`,
|
||||||
|
`ttrpg/${sessionId}/presence/+`,
|
||||||
|
],
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error("[worker] unsubscribe err:", err);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectBroker(
|
||||||
brokerUrl: string,
|
brokerUrl: string,
|
||||||
name: string,
|
name: string,
|
||||||
role: "gm" | "player" | "observer",
|
role: "gm" | "player" | "observer",
|
||||||
@@ -172,22 +216,13 @@ async function connectStream(
|
|||||||
state.connectionError = null;
|
state.connectionError = null;
|
||||||
state.brokerUrl = brokerUrl;
|
state.brokerUrl = brokerUrl;
|
||||||
|
|
||||||
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
|
// Broker-level subscription: the session list is available in the lobby.
|
||||||
if (err) console.error("[worker] stream sub err:", err);
|
|
||||||
});
|
|
||||||
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
|
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
|
||||||
if (err) console.error("[worker] sessions sub err:", err);
|
if (err) console.error("[worker] sessions sub err:", err);
|
||||||
});
|
});
|
||||||
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
|
|
||||||
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
|
|
||||||
|
|
||||||
// Publish own presence (retained)
|
// Restore the active session's subscriptions after an auto-reconnect.
|
||||||
const presenceData = JSON.stringify({ name, role });
|
if (state.sessionId) subscribeSession(client, state.sessionId);
|
||||||
client.publish(
|
|
||||||
`ttrpg/${sessionId}/presence/${name}`,
|
|
||||||
presenceData,
|
|
||||||
{ qos: 1, retain: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
notifySubscribers({ type: "connectionStatus", status: "connected" });
|
notifySubscribers({ type: "connectionStatus", status: "connected" });
|
||||||
broadcastFullState();
|
broadcastFullState();
|
||||||
@@ -229,6 +264,10 @@ async function connectStream(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ignore session-scoped traffic that isn't for the active session, so a
|
||||||
|
// late message from the previous session can't leak into the new one.
|
||||||
|
if (parts[1] !== state.sessionId) return;
|
||||||
|
|
||||||
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
|
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -268,6 +307,67 @@ async function connectStream(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session lifecycle — join/leave without reconnecting the broker
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function joinSession(
|
||||||
|
sessionId: string,
|
||||||
|
name?: string,
|
||||||
|
role?: "gm" | "player" | "observer",
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isValidSessionId(sessionId)) {
|
||||||
|
throw new Error(`Invalid session id: ${JSON.stringify(sessionId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = _mqttClient;
|
||||||
|
if (!client || !_mqttConnected) {
|
||||||
|
throw new Error("Not connected to broker");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionId === state.sessionId) {
|
||||||
|
if (name) state.myName = name;
|
||||||
|
if (role) state.myRole = role;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the old session under the old identity before applying any change.
|
||||||
|
if (state.sessionId) unsubscribeSession(client, state.sessionId);
|
||||||
|
|
||||||
|
if (name) state.myName = name;
|
||||||
|
if (role) state.myRole = role;
|
||||||
|
|
||||||
|
// Reset session-scoped state before hydrating (a 404 leaves it empty).
|
||||||
|
state.sessionId = sessionId;
|
||||||
|
state.sessionName = sessionList.sessions[sessionId]?.name ?? null;
|
||||||
|
state.messages = [];
|
||||||
|
state.senderSeq = {};
|
||||||
|
state.players = {};
|
||||||
|
|
||||||
|
await hydrateFromServer(sessionId);
|
||||||
|
|
||||||
|
subscribeSession(client, sessionId);
|
||||||
|
|
||||||
|
notifySubscribers({ type: "sessionName", name: state.sessionName });
|
||||||
|
notifySubscribers({ type: "players", players: {} });
|
||||||
|
broadcastFullState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveSession(): void {
|
||||||
|
const client = _mqttClient;
|
||||||
|
if (client && _mqttConnected && state.sessionId) {
|
||||||
|
unsubscribeSession(client, state.sessionId);
|
||||||
|
}
|
||||||
|
state.sessionId = null;
|
||||||
|
state.sessionName = null;
|
||||||
|
state.messages = [];
|
||||||
|
state.senderSeq = {};
|
||||||
|
state.players = {};
|
||||||
|
notifySubscribers({ type: "sessionName", name: null });
|
||||||
|
notifySubscribers({ type: "players", players: {} });
|
||||||
|
broadcastFullState();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Receive
|
// Receive
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -376,10 +476,9 @@ function revertLatest():
|
|||||||
|
|
||||||
function disconnectStream(): void {
|
function disconnectStream(): void {
|
||||||
if (_mqttClient) {
|
if (_mqttClient) {
|
||||||
const sessionId = state.sessionId;
|
if (state.sessionId) {
|
||||||
if (sessionId) {
|
|
||||||
_mqttClient.publish(
|
_mqttClient.publish(
|
||||||
`ttrpg/${sessionId}/presence/${state.myName}`,
|
`ttrpg/${state.sessionId}/presence/${state.myName}`,
|
||||||
"",
|
"",
|
||||||
{ qos: 1, retain: true },
|
{ qos: 1, retain: true },
|
||||||
);
|
);
|
||||||
@@ -390,6 +489,10 @@ function disconnectStream(): void {
|
|||||||
}
|
}
|
||||||
state.connected = false;
|
state.connected = false;
|
||||||
state.connectionStatus = "disconnected";
|
state.connectionStatus = "disconnected";
|
||||||
|
state.sessionId = null;
|
||||||
|
state.sessionName = null;
|
||||||
|
state.messages = [];
|
||||||
|
state.senderSeq = {};
|
||||||
state.players = {};
|
state.players = {};
|
||||||
notifySubscribers({ type: "connectionStatus", status: "disconnected" });
|
notifySubscribers({ type: "connectionStatus", status: "disconnected" });
|
||||||
notifySubscribers({ type: "players", players: {} });
|
notifySubscribers({ type: "players", players: {} });
|
||||||
@@ -399,28 +502,68 @@ function disconnectStream(): void {
|
|||||||
// Session lifecycle
|
// Session lifecycle
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function createSession(name: string, players: string[] = []): string | null {
|
/**
|
||||||
if (!_mqttClient || !_mqttConnected) return null;
|
* Avoid silently overwriting an existing session whose name slugifies to the
|
||||||
|
* same id (e.g. "My Game" and "my game"). Best-effort: the retained manifest
|
||||||
|
* may not have arrived yet.
|
||||||
|
*/
|
||||||
|
function uniqueSessionId(base: string): string {
|
||||||
|
if (!sessionList.sessions[base]) return base;
|
||||||
|
const stem = base.slice(0, SESSION_ID_MAX_LENGTH - 5);
|
||||||
|
for (let n = 2; n < 1000; n++) {
|
||||||
|
const candidate = `${stem}-${n}`;
|
||||||
|
if (!sessionList.sessions[candidate]) return candidate;
|
||||||
|
}
|
||||||
|
return `${stem}-${Date.now()}`.slice(0, SESSION_ID_MAX_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
const id =
|
function createSession(
|
||||||
name
|
name: string,
|
||||||
.toLowerCase()
|
players: string[] = [],
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
): Promise<string | null> {
|
||||||
.replace(/^-|-$/g, "") || "session";
|
if (!_mqttClient || !_mqttConnected) return Promise.resolve(null);
|
||||||
|
|
||||||
|
const id = uniqueSessionId(toSessionId(name));
|
||||||
|
if (!isValidSessionId(id)) return Promise.resolve(null);
|
||||||
|
|
||||||
const meta: SessionMeta = { name, created: Date.now(), players };
|
const meta: SessionMeta = { name, created: Date.now(), players };
|
||||||
|
|
||||||
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), {
|
return new Promise((resolve) => {
|
||||||
qos: 1,
|
_mqttClient!.publish(
|
||||||
retain: true,
|
`ttrpg/${id}/meta`,
|
||||||
|
JSON.stringify(meta),
|
||||||
|
{ qos: 1, retain: true },
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("[worker] create session publish error:", err);
|
||||||
|
resolve(null);
|
||||||
|
} else {
|
||||||
|
resolve(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSession(sessionId: string): void {
|
function deleteSession(sessionId: string): Promise<void> {
|
||||||
if (!_mqttClient || !_mqttConnected) return;
|
if (!isValidSessionId(sessionId)) {
|
||||||
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true });
|
return Promise.reject(
|
||||||
|
new Error(`Invalid session id: ${JSON.stringify(sessionId)}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!_mqttClient || !_mqttConnected) {
|
||||||
|
return Promise.reject(new Error("Not connected to stream"));
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Empty retained payload: the broker clears the retained meta and the
|
||||||
|
// server treats the empty payload as a delete.
|
||||||
|
_mqttClient!.publish(
|
||||||
|
`ttrpg/${sessionId}/meta`,
|
||||||
|
"",
|
||||||
|
{ qos: 1, retain: true },
|
||||||
|
(err) => (err ? reject(err) : resolve()),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -445,13 +588,23 @@ function visibleMessages(): StreamMessage[] {
|
|||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
async connect(
|
async connect(
|
||||||
sessionId: string,
|
|
||||||
brokerUrl: string,
|
brokerUrl: string,
|
||||||
name: string,
|
name: string,
|
||||||
role: "gm" | "player" | "observer",
|
role: "gm" | "player" | "observer",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await hydrateFromServer(sessionId);
|
await connectBroker(brokerUrl, name, role);
|
||||||
await connectStream(sessionId, brokerUrl, name, role);
|
},
|
||||||
|
|
||||||
|
async joinSession(
|
||||||
|
sessionId: string,
|
||||||
|
name?: string,
|
||||||
|
role?: "gm" | "player" | "observer",
|
||||||
|
): Promise<void> {
|
||||||
|
await joinSession(sessionId, name, role);
|
||||||
|
},
|
||||||
|
|
||||||
|
leaveSession(): void {
|
||||||
|
leaveSession();
|
||||||
},
|
},
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
@@ -471,7 +624,7 @@ const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteSession(sessionId: string) {
|
deleteSession(sessionId: string) {
|
||||||
deleteSession(sessionId);
|
return deleteSession(sessionId);
|
||||||
},
|
},
|
||||||
|
|
||||||
getState(): WorkerState {
|
getState(): WorkerState {
|
||||||
|
|||||||
Reference in New Issue
Block a user