Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
19c1c0d
refactor(admin): migrate admin app to React
Innei May 24, 2026
dbcabc7
feat: update dependencies and add new packages
Innei May 24, 2026
1a7e5f9
refactor: update pnpm workspace configuration to remove redundant pac…
Innei May 25, 2026
0c993c1
refactor(admin): flatten src/app/* into src/ root
Innei May 25, 2026
0da0971
refactor(admin): extract Drawer UI and align write-page header
Innei May 25, 2026
abf48d1
feat(admin): add headless DateTimePicker built on @rehookify/datepicker
Innei May 25, 2026
dc01b70
docs: add imperative modal system design spec
Innei May 25, 2026
41ffa18
feat(admin): redesign write-page editing canvas to Notion-style
Innei May 25, 2026
60a1a60
feat(admin): replace haklex ToolbarPlugin with custom Notion-style ed…
Innei May 25, 2026
6fd0429
docs: address codex review findings in imperative modal spec
Innei May 25, 2026
1516802
feat(admin): add imperative modal system and migrate DraftRecoveryDialog
Innei May 25, 2026
78e1528
docs: add admin mobile layout design spec
Innei May 25, 2026
f4a60c1
chore(admin): snapshot in-progress UI/modal/portal work before mobile…
Innei May 25, 2026
9466617
feat(admin/ui): add BottomSheet primitive
Innei May 25, 2026
557111c
feat(admin): mobile sidebar drawer + ShellNavContext
Innei May 25, 2026
8f9b976
feat(admin/ui): PageHeader hamburger trigger + typed actions API
Innei May 25, 2026
8fe8791
feat(admin/ui): ContentLayout mobile branch via BottomSheet
Innei May 25, 2026
5f9bc56
feat(admin/ui): ResponsiveDataTable + DefaultRowCard primitives
Innei May 25, 2026
a95db8f
fix(admin/ui): page-layout review feedback
Innei May 25, 2026
2ca518e
refactor(admin/ui): extract useMediaQuery; address T4/T5 review feedback
Innei May 25, 2026
c28e932
fix(admin): shell mobile top bar; wire write-page onCloseAside
Innei May 25, 2026
bb32003
feat(admin): smart shell top bar fallback + MobileHamburger primitive
Innei May 25, 2026
7a14fc9
refactor(admin): split route pages into feature domains
Innei May 26, 2026
57203ae
fix(admin): tolerate null/empty timestamps in i18n + draft tag
Innei May 26, 2026
ad25d58
feat(admin): port CodeMirror markdown editor + write panel polish
Innei May 26, 2026
dd281d7
refactor(admin): reorganize src/ui by category; extract vendor + feat…
Innei May 26, 2026
68eb020
docs: spec for convention-based admin routing
Innei May 26, 2026
0885358
refactor(admin): convention-based file-system routing
Innei May 26, 2026
a09cc1f
docs(spec): /posts /notes row redesign + singleton ContextMenu
Innei May 26, 2026
a9fab9a
feat(admin): /posts /notes row redesign + ContextMenu primitive
Innei May 26, 2026
eff042b
feat(admin): focus scope + keyboard navigation for content lists
Innei May 26, 2026
b419ed9
refactor(admin): migrate ad-hoc stores to Zustand + Jotai
Innei May 26, 2026
be75d26
chore(admin): drop the old codemirror/editor-store.ts shim
Innei May 26, 2026
f48262f
docs(spec): ContentListToolbar layout rebalance
Innei May 26, 2026
b08c824
refactor(admin): rebalance ContentListToolbar layout
Innei May 26, 2026
23fdf24
feat(admin): i18n migration — extract hardcoded zh-CN strings via tra…
Innei May 27, 2026
f390be3
refactor(admin): dashboard block spacing & stats grid hairline
Innei May 27, 2026
adcfc03
feat: enhance button component with icon-only variant and refactor cl…
Innei May 27, 2026
24b7dea
Refactor topic and webhook forms to use modals
Innei May 27, 2026
8ac2311
feat: redesign files management routes with new components and hooks
Innei May 27, 2026
e15d5d6
feat(admin): redesign /assets/template page with master-detail layout
Innei May 27, 2026
eebfd69
feat(admin): redesign /ai/{summary,translation,insights} with article…
Innei May 27, 2026
1c23798
feat(admin): delete /ai/slug-backfill and redesign /ai/translation-en…
Innei May 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
174 changes: 174 additions & 0 deletions .claude/skills/master-detail-list-keyboard/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
---
name: master-detail-list-keyboard
description: Mandatory recipe for wiring j/k/↑/↓ keyboard navigation in admin-vue3 master-detail list panes. Apply when adding `useListKeyboard` to any list, building a new master-detail view, refactoring `ListRow` consumers, or whenever the user complains that "hjkl moves but the selected row doesn't update" / "键盘移动没有高亮" / "j/k 没反应" / "detail 不跟着键盘走" / "panel 打开后 j/k 切 item 右侧不刷新" / "edit drawer 不随键盘切换". Triggers on `useListKeyboard`, `FocusScope`, `ListRow`, `selection.selectOne`, `onItemFocus`, master-detail keyboard nav, drawer/panel-sync-with-keyboard.
---

# master-detail list keyboard wiring (MUST)

## The trap

`useListKeyboard` exposes a `selection` model intended for **multi-select**. By default it calls `selection.selectOne(focusedId)` on every j/k tick. But the row's visual `selected` prop is **NOT auto-wired** — you must read `selection.isSelected(id)` yourself, otherwise pressing j/k changes nothing visible and the user thinks the keyboard is dead.

This bug bites every new master-detail page. Always recur. Hence this skill.

## The contract

Two distinct cases. **Pick one per scope** and wire it end-to-end.

### Case A — Outer master list (j/k drives the detail target)

This is the mail-client feel: arrow keys preview the next item by opening it in the detail pane.

```tsx
useListKeyboard<Article>({
scopeId: 'foo-articles',
items: articles,
getId: (a) => a.id,
resetOn: [search],
// 1. override default: drive the detail target, not the internal selection
onItemFocus: (id) => {
const article = articles.find((a) => a.id === id)
if (article) setSelectedArticleId(article.id)
},
actions: [
{
key: 'open',
label: 'Open',
shortcut: 'Enter',
run: (targets) => {
const t = targets[0]
if (t) setSelectedArticleId(t.id)
},
},
],
})

// row:
<ArticleRow
selected={article.id === selectedArticleId} // 2. selected = detail target
isDetailTarget={article.id === selectedArticleId}
onSelect={() => setSelectedArticleId(article.id)}
/>
```

**Both `onItemFocus` and `selected={... === selectedDetailId}` are required.** Without the override, j/k only mutates an internal selection model that nothing renders.

### Case B — Inner detail list (j/k highlights, Enter opens edit)

For a list inside the detail pane — e.g. summary rows under an article — j/k should highlight the focused row, NOT auto-open the editor. Use the hook's selection model directly:

```tsx
const { selection } = useListKeyboard<Item>({
scopeId: 'foo-items',
items,
getId: (it) => it.id,
resetOn: [parentArticleId],
// no onItemFocus — default `selection.selectOne(id)` is correct
actions: [
{ shortcut: 'Enter', run: (t) => openEditDrawer(t[0]) },
{ shortcut: 'Backspace', run: (t) => confirmDelete(t[0]) },
],
})

// row:
<ItemRow
selected={selection.isSelected(item.id)} // ← read from selection model
onSelect={() => openEditDrawer(item)}
onDelete={() => confirmDelete(item)}
/>
```

**Reading `selection.isSelected(id)` is required** — it's the only place the j/k focus state surfaces.

#### Click → must also call selection.selectOne

Mouse clicks do NOT update the selection model automatically. ListRow's click handler only fires `onSelect(mode)`. If your row's visual `selected` is bound to `selection.isSelected(id)` (per Case B), clicking won't highlight the row — only j/k will. To make clicks behave the same as j/k, call `selection.selectOne(id)` in the click handler:

```tsx
<ItemRow
selected={selection.isSelected(item.id)}
onSelect={(mode) => {
if (mode === 'toggle') selection.toggle(item.id)
else if (mode === 'range') selection.selectRange(item.id)
else {
selection.selectOne(item.id) // ← required for click highlight
openEditDrawer(item)
}
}}
/>
```

(Drafts' `DraftsRouteViewContent.tsx` is the canonical example.)

#### When an external panel/drawer is open, sync target on j/k

If clicking an item opens an edit drawer / right-side panel, the user expects j/k to **also** swap the drawer's target while the drawer is open. Without this, the drawer is stuck on the originally-clicked item even though the highlight has moved.

Override `onItemFocus` to call `selection.selectOne` (preserve the highlight) **and** the external sync callback. Gate the sync on whether the drawer is currently open so j/k never opens a closed drawer from scratch:

```tsx
const { selection } = useListKeyboard<Item>({
scopeId: 'foo-items',
items,
getId: (it) => it.id,
resetOn: [parentArticleId],
onItemFocus: (id) => {
selection.selectOne(id) // ← preserve default highlight
const item = items.find((it) => it.id === id)
if (item) onItemFocus?.(item) // ← inform parent
},
actions: [/* Enter, Backspace */],
})
```

…and at the parent, gate the sync:

```tsx
<DetailPane
onItemFocus={(item) => {
if (editingItemId !== null) {
setEditingItemId(item.id) // ← only when drawer is already open
}
}}
/>
```

Also: when an edit drawer's body owns local state (`useState` initialized from props), give it `key={editingItem.id}` so it remounts on item switch — otherwise the form keeps the old item's values.

```tsx
<EditDrawerBody
item={editingItem}
key={editingItem.id} // ← required when switching items inside an open drawer
...
/>
```

## Forbidden

- `selected={false}` constant on a `ListRow` inside a `FocusScope`. Means j/k is dead.
- Wiring `selected={detailId === id}` while not passing `onItemFocus`. j/k advances the internal selection model invisibly; the user sees no movement.
- Two scopes using the same `scopeId` on one page — `FocusScope` ids must be unique per page (use a `scopeIdPrefix` and append `-articles` / `-items`).
- Auto-opening an edit drawer / modal from `onItemFocus`. That maps Up/Down to "open editor", which feels broken. Use `onItemFocus` only to drive **already-visible** state (the detail target, a preview, a highlight).
- Forgetting `<FocusScope id={scopeId}>` around the rows. `useListKeyboard` is scope-gated; without the wrapper, nothing fires.

## Checklist when reviewing a list pane

1. Is there a `<FocusScope id={scopeId}>` around the row list? If no — keyboard nav is silently broken.
2. Does `useListKeyboard` get the same `scopeId`? Mismatch → silent breakage.
3. What drives `selected` on the row?
- If `selection.isSelected(id)` → Case B. If you override `onItemFocus`, call `selection.selectOne(id)` yourself first.
- If `someExternalId === id` → Case A. Must pass `onItemFocus` that updates `someExternalId`.
- If literal `false` → bug.
4. Does `resetOn` include the inputs that should clear focus (filter changes, search changes, parent article switch)? Without it, j/k can land on a stale id after list reshape.
5. Does **clicking** a row update `selected` visually? If `selected` reads from `selection.isSelected(id)`, the row's `onSelect` handler must call `selection.selectOne(id)` — mouse clicks do not auto-update the selection model.
6. If an edit drawer / right-side panel opens from the row, does j/k update it while it's open? Override `onItemFocus` to fire an external sync (gated on drawer open) AND keep `selection.selectOne(id)`. Pass `key={item.id}` to the drawer body so its local state resets on item switch.

## Reference views to copy

- `apps/admin/src/features/drafts/components/DraftsRouteViewContent.tsx` — Case B canonical (selection drives `selected`, Enter opens detail through the action registry).
- `apps/admin/src/features/ai/components/article-grouped/ArticleListPane.tsx` — Case A canonical (j/k drives the article-detail target).
- `apps/admin/src/features/ai/components/article-grouped/ArticleDetailPane.tsx` — Case B canonical (inner item list).

## Why a hook exposes both

`useListKeyboard` was built for multi-select lists (posts, notes, comments) where the selection model owns checkbox state AND focus visuals. For master-detail single-target, the selection model is the wrong source of truth — the URL-synced detail id is. Don't fight the hook; just pick the right case above. If a project-wide `useMasterDetailKeyboard` wrapper materializes, prefer it.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ bundle-analyze
stats.html
.env.development
.idea
.superpowers

/src/views/dev
g.d.ts
.turbo/
76 changes: 42 additions & 34 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,51 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

MX Admin (admin-vue3) is the dashboard for MX Space, a personal blog management system. Built with Vue 3, Naive UI, and UnoCSS. This is the v4.0 admin interface for Mix Space Server v5.0.
MX Admin is the dashboard for MX Space, a personal blog management system. The repo is a pnpm workspace whose only active app is `apps/admin` — a React 19 SPA built with Base UI primitives, React Router (HashRouter), TanStack Query, Sonner, UnoCSS, and a Tailwind v4 layer. Despite the repo name `admin-vue3`, the Vue codebase has been retired; React is the sole runtime.

## Development Commands

Root scripts proxy to `apps/admin` via `pnpm -C apps/admin <task>`:

```bash
pnpm install # Install dependencies
pnpm dev # Start development server (opens browser automatically)
pnpm build # Build for production
pnpm lint # Lint code with Biome
pnpm lint:fix # Lint and auto-fix
npx tsc --noEmit # Type check (use this instead of build for validation)
pnpm dev # Start Vite dev server (opens browser automatically)
pnpm build # Production build
pnpm lint # oxlint
pnpm lint:fix # oxlint --fix
pnpm typecheck # tsc --noEmit (per-package)
```

Scope checks to changed files only — never run lint/typecheck/build over the whole tree just to verify a small edit. For a one-off file typecheck inside the app: `pnpm -C apps/admin exec tsc --noEmit --pretty false`.

Local API endpoint lives in `apps/admin/.env` as `VITE_APP_BASE_API`.

## Architecture Overview

### Technology Stack

- **Vue 3** with Composition API and TSX (JSX via `@vitejs/plugin-vue-jsx`)
- **Naive UI** - Component library with Vercel-style neutral theme
- **UnoCSS** (preset-wind4) - Tailwind-compatible utility classes
- **Pinia** - State management
- **TanStack Query** (`@tanstack/vue-query`) - Server state management with localStorage persistence
- **Socket.IO** - Real-time WebSocket updates
- **CodeMirror/Monaco** - Code editors
- **React 19** + TSX, react-compiler enabled via Babel
- **Base UI** (`@base-ui/react`) — headless primitives; UI wrappers live in `apps/admin/src/ui/`
- **React Router 7** (`HashRouter`) — `apps/admin/src/routes.tsx` is the single source of route → lazy-view mapping
- **UnoCSS** (preset-wind4) + **Tailwind v4** layer via `@tailwindcss/vite`
- **TanStack Query** — created in `apps/admin/src/query-client.ts`, mounted in `providers.tsx`
- **Sonner** — toast layer mounted alongside the query provider
- **Socket.IO** — `src/socket/SocketBridge` hangs off the authenticated shell
- **better-auth** + passkey for login; auth gate in `App.tsx` (`checkLogged` query) wraps everything except `/setup`, `/setup-api`, `/login`

### Entry & Shell

`main.tsx` → `App.tsx` (mounts providers, HashRouter, auth gate, installs theme tokens via `installThemeTokens`) → `AdminShell` (nav chrome + `SocketBridge` + `AppRoutes`). All views in `routes.tsx` are `lazy()`-loaded and wrapped in `<Suspense>`; add new pages by registering a lazy import there.

### Path Aliases

```typescript
import { something } from '~/utils/...' // ~ maps to ./src
import { something } from '~/utils/...' // ~ → apps/admin/src
```

### API Layer (`src/api/`)
### API Layer (`apps/admin/src/api/`)

API services use the custom request layer built on `ofetch`. The backend wraps array responses as `{ data: [...] }`, which is automatically unwrapped by the request layer.
API services use the fetch-based helpers in `apps/admin/src/api/http.ts`.

When using TanStack Query, extract arrays with:
```typescript
Expand All @@ -48,14 +59,6 @@ select: (res: any) => Array.isArray(res) ? res : res?.data ?? []
- `BusinessError` - Application-level errors (4xx responses)
- `SystemError` - Network/server errors (5xx responses, network failures)

### State Management

**Pinia Stores (`src/stores/`):**
- `useUIStore` - Theme mode (light/dark/system), viewport dimensions, sidebar state
- `useUserStore` - User authentication state
- `useAppStore` - Global application state
- `useCategoryStore` - Category data

### Responsive Breakpoints (UnoCSS)

- `phone:` - max-width: 768px
Expand All @@ -66,7 +69,7 @@ select: (res: any) => Array.isArray(res) ? res : res?.data ?? []

### Validation

After modifying code, run type check only (`npx tsc --noEmit`). Do not run `pnpm build` for validation.
After modifying code, run focused type checking and linting. Run production build before reporting completion for broad application changes.

### Gray Scale Colors

Expand All @@ -89,22 +92,27 @@ Do NOT use arbitrary font sizes (e.g., `text-[11px]`, `text-[13px]`). Use standa

See `docs/typography.md` for full guidelines.

## Layout Conventions

New admin views must follow the master-detail / content-layout convention. See:

- `docs/master-detail-layout.md` — list+detail pages (comments, drafts, topics)
- `docs/typography.md` — full typography rules
- `apps/admin/src/ui/content-layout.tsx` and `page-layout.tsx` — reusable shells

## Configuration Files

- `uno.config.ts` - UnoCSS configuration with custom breakpoints and theme colors
- `src/utils/color.ts` - Naive UI theme overrides (Vercel-style neutral gray palette)
- `biome.json` - Linter/formatter configuration with Vue globals
- `.env` - Local dev API endpoint (`VITE_APP_BASE_API`)
- `apps/admin/vite.config.mts` — Vite + react-compiler + Tailwind + mkcert + checker
- `apps/admin/uno.config.ts` — UnoCSS breakpoints (`phone:`, `tablet:`, `desktop:`) and theme colors (if present)
- `apps/admin/src/theme.ts` — CSS token installation for the shell
- `apps/admin/src/index.css` — global stylesheet + Tailwind layer

## Related Projects

- **mx-core** — Backend API server (NestJS + MongoDB), located at `../mx-core`
- **Shiroi** — Next.js frontend (blog), located at `../Shiroi`
- **haklex** — Rich editor packages (`@haklex/*`), located at `../haklex` (standalone) or `../Shiroi/haklex` (original host)

### Rich Editor Integration (React-in-Vue)
### Rich Editor Integration

admin-vue3 is a Vue 3 project but embeds the React-based haklex editor via a bridge pattern in `src/components/editor/rich/RichEditor.tsx`:
- Uses `createRoot()` from `react-dom/client` inside Vue `defineComponent` to mount the local `ShiroEditor` (`packages/rich-react/src/shiro/`)
- Local Shiro lives in `packages/rich-react/src/shiro/` — composes `@haklex/rich-editor` + per-feature `@haklex/rich-ext-*` / `@haklex/rich-renderer-*` packages directly
- All `@haklex/*` packages are pinned npm versions (not workspace links). After haklex releases, update versions here.
The admin app no longer mounts rich editor surfaces through a framework bridge. React editor work should be integrated as ordinary React components and kept out of compatibility shims.
10 changes: 5 additions & 5 deletions apps/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
link.href = favicon
document.head.appendChild(link)
</script>
<title>Mx Space Admin Vue 3 v2</title>
<title>Mx Space Admin</title>
<script>
window.injectData = {}
window.version = 'N/A'
Expand Down Expand Up @@ -98,15 +98,15 @@
</div>
<noscript>
<strong
>We're sorry but MX Space Admin Vue 3 doesn't work properly without
JavaScript enabled. Please enable it to continue.</strong
>We're sorry but MX Space Admin doesn't work properly without JavaScript
enabled. Please enable it to continue.</strong
>
<strong>
It may be a network problem that caused the failure to load the JS file.
</strong>
</noscript>
<script>
// Initialize theme before Vue loads to prevent flash
// Initialize theme before the application loads to prevent flash
;(function () {
var themeMode = localStorage.getItem('theme-mode')
// Remove quotes if stored as JSON string
Expand All @@ -124,6 +124,6 @@
}
})()
</script>
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
Loading