Browse Source
Document the approved quick note modal design and the step-by-step implementation plan used to deliver the feature. Made-with: Cursormain
2 changed files with 552 additions and 0 deletions
@ -0,0 +1,405 @@ |
|||||
|
# Quick Note Modal Implementation Plan |
||||
|
|
||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
||||
|
|
||||
|
**Goal:** 在顶部“写文章”按钮左侧新增“速记”入口,提供可拖拽/可全屏的轻量速记弹框,支持上传、手动保存、每用户唯一内容和未保存离开保护。 |
||||
|
|
||||
|
**Architecture:** 前端在 `AppShell` 挂载速记入口与弹框,弹框拆分为容器层(拖拽、全屏、未保存拦截)和编辑器层(轻量 Vditor + 上传复用)。后端新增用户唯一 `quick_notes` 存储与 `GET/PUT /api/me/quick-note` 接口,服务层统一处理 upsert、长度校验和换行标准化。全链路以 TDD 驱动,按“先失败测试 -> 最小实现 -> 通过测试 -> 提交”小步推进。 |
||||
|
|
||||
|
**Tech Stack:** Nuxt 4、Vue 3、TypeScript、Bun test、Drizzle ORM(SQLite) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## File Structure |
||||
|
|
||||
|
**Create** |
||||
|
- `app/components/QuickNoteModal.vue`:速记弹框容器(近全屏、全屏切换、拖拽、未保存拦截、beforeunload) |
||||
|
- `app/components/QuickNoteEditor.vue`:轻量编辑器(复用 Vditor 与上传配置) |
||||
|
- `app/components/quick-note-editor-vditor-config.ts`:速记编辑器 Vditor 轻量配置 |
||||
|
- `app/components/quick-note-modal-state.ts`:速记 dirty/save 状态与交互纯函数 |
||||
|
- `app/components/quick-note-modal-state.test.ts`:状态纯函数测试 |
||||
|
- `server/service/quick-note/index.ts`:速记服务(get/upsert/normalize/validate) |
||||
|
- `server/service/quick-note/index.test.ts`:速记服务测试 |
||||
|
- `server/api/me/quick-note.get.ts`:获取当前用户速记 |
||||
|
- `server/api/me/quick-note.put.ts`:手动保存当前用户速记 |
||||
|
- `packages/drizzle-pkg/migrations/0012_quick_notes.sql`:新增 quick_notes 表与唯一索引 |
||||
|
|
||||
|
**Modify** |
||||
|
- `app/components/AppShell.vue`:新增“速记”按钮并挂载弹框 |
||||
|
- `packages/drizzle-pkg/database/sqlite/schema/content.ts`:新增 `quickNotes` 表定义 |
||||
|
- `packages/drizzle-pkg/lib/schema/content.ts`:导出 `quickNotes` |
||||
|
- `packages/drizzle-pkg/migrations/meta/_journal.json`:登记新 migration |
||||
|
- `server/api/me` 下路由索引(若项目需要)用于自动发现 `quick-note` 接口 |
||||
|
|
||||
|
**Test** |
||||
|
- `app/components/quick-note-modal-state.test.ts` |
||||
|
- `app/components/post-body-markdown-editor-vditor-config.test.ts`(可补一条“复用上传格式兼容”断言) |
||||
|
- `server/service/quick-note/index.test.ts` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 1: 数据层建模(每用户唯一 quick note) |
||||
|
|
||||
|
**Files:** |
||||
|
- Modify: `packages/drizzle-pkg/database/sqlite/schema/content.ts` |
||||
|
- Modify: `packages/drizzle-pkg/lib/schema/content.ts` |
||||
|
- Create: `packages/drizzle-pkg/migrations/0012_quick_notes.sql` |
||||
|
- Modify: `packages/drizzle-pkg/migrations/meta/_journal.json` |
||||
|
|
||||
|
- [ ] **Step 1: 写失败检查(确认 quick_notes 尚未存在)** |
||||
|
|
||||
|
```bash |
||||
|
rg "quick_notes|quickNotes" packages/drizzle-pkg/database/sqlite/schema/content.ts packages/drizzle-pkg/lib/schema/content.ts |
||||
|
``` |
||||
|
|
||||
|
Expected: 无匹配结果。 |
||||
|
|
||||
|
- [ ] **Step 2: 运行检查确认失败基线** |
||||
|
|
||||
|
Run: `rg "quick_notes|quickNotes" packages/drizzle-pkg/database/sqlite/schema/content.ts packages/drizzle-pkg/lib/schema/content.ts` |
||||
|
Expected: Exit code 1(无命中) |
||||
|
|
||||
|
- [ ] **Step 3: 增加表定义与导出** |
||||
|
|
||||
|
```ts |
||||
|
// packages/drizzle-pkg/database/sqlite/schema/content.ts |
||||
|
export const quickNotes = sqliteTable( |
||||
|
"quick_notes", |
||||
|
{ |
||||
|
id: integer().primaryKey(), |
||||
|
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), |
||||
|
content: text().notNull().default(""), |
||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), |
||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().$onUpdate(() => new Date()).notNull(), |
||||
|
}, |
||||
|
(table) => [uniqueIndex("quick_notes_user_id_unique").on(table.userId)], |
||||
|
); |
||||
|
``` |
||||
|
|
||||
|
```ts |
||||
|
// packages/drizzle-pkg/lib/schema/content.ts |
||||
|
export { quickNotes } from "../../database/sqlite/schema/content"; |
||||
|
``` |
||||
|
|
||||
|
```sql |
||||
|
-- packages/drizzle-pkg/migrations/0012_quick_notes.sql |
||||
|
CREATE TABLE `quick_notes` ( |
||||
|
`id` integer PRIMARY KEY NOT NULL, |
||||
|
`user_id` integer NOT NULL, |
||||
|
`content` text NOT NULL DEFAULT '', |
||||
|
`created_at` integer NOT NULL DEFAULT (unixepoch() * 1000), |
||||
|
`updated_at` integer NOT NULL DEFAULT (unixepoch() * 1000), |
||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade |
||||
|
); |
||||
|
CREATE UNIQUE INDEX `quick_notes_user_id_unique` ON `quick_notes` (`user_id`); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 4: 生成并校验 schema/migration 一致性** |
||||
|
|
||||
|
Run: `cd packages/drizzle-pkg && bun run generate` |
||||
|
Expected: 生成结果无冲突,`migrations` 元信息可追踪 `0012_quick_notes.sql` |
||||
|
|
||||
|
- [ ] **Step 5: 提交** |
||||
|
|
||||
|
```bash |
||||
|
git add packages/drizzle-pkg/database/sqlite/schema/content.ts packages/drizzle-pkg/lib/schema/content.ts packages/drizzle-pkg/migrations/0012_quick_notes.sql packages/drizzle-pkg/migrations/meta/_journal.json |
||||
|
git commit -m "feat(db): add per-user quick note table" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 2: 服务层实现(读取、upsert、校验) |
||||
|
|
||||
|
**Files:** |
||||
|
- Create: `server/service/quick-note/index.ts` |
||||
|
- Create: `server/service/quick-note/index.test.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 写失败测试(无记录返回空、保存后可读取、超长报错)** |
||||
|
|
||||
|
```ts |
||||
|
import { describe, expect, test } from "bun:test"; |
||||
|
import { normalizeQuickNoteContent, validateQuickNoteContentLength } from "./index"; |
||||
|
|
||||
|
describe("quick-note content guards", () => { |
||||
|
test("normalizes CRLF to LF", () => { |
||||
|
expect(normalizeQuickNoteContent("a\r\nb")).toBe("a\nb"); |
||||
|
}); |
||||
|
test("throws when exceeds limit", () => { |
||||
|
expect(() => validateQuickNoteContentLength("x".repeat(200001))).toThrow("速记内容过长"); |
||||
|
}); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: 运行测试确认失败** |
||||
|
|
||||
|
Run: `bun test server/service/quick-note/index.test.ts` |
||||
|
Expected: FAIL(函数/模块尚未实现) |
||||
|
|
||||
|
- [ ] **Step 3: 写最小实现** |
||||
|
|
||||
|
```ts |
||||
|
import { dbGlobal } from "drizzle-pkg/lib/db"; |
||||
|
import { quickNotes } from "drizzle-pkg/lib/schema/content"; |
||||
|
import { eq } from "drizzle-orm"; |
||||
|
import { nextIntegerId } from "#server/utils/sqlite-id"; |
||||
|
|
||||
|
export const QUICK_NOTE_MAX_LENGTH = 200_000; |
||||
|
|
||||
|
export function normalizeQuickNoteContent(content: string) { |
||||
|
return content.replace(/\r\n/g, "\n"); |
||||
|
} |
||||
|
|
||||
|
export function validateQuickNoteContentLength(content: string) { |
||||
|
if (content.length > QUICK_NOTE_MAX_LENGTH) { |
||||
|
throw createError({ statusCode: 400, statusMessage: "速记内容过长" }); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
并补充 `getQuickNoteByUserId`、`upsertQuickNoteByUserId`,用 `userId` 查询,不存在则 insert,存在则 update。 |
||||
|
|
||||
|
- [ ] **Step 4: 运行测试确认通过** |
||||
|
|
||||
|
Run: `bun test server/service/quick-note/index.test.ts` |
||||
|
Expected: PASS |
||||
|
|
||||
|
- [ ] **Step 5: 提交** |
||||
|
|
||||
|
```bash |
||||
|
git add server/service/quick-note/index.ts server/service/quick-note/index.test.ts |
||||
|
git commit -m "feat(server): add quick note service with validation" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 3: API 层实现(GET/PUT /api/me/quick-note) |
||||
|
|
||||
|
**Files:** |
||||
|
- Create: `server/api/me/quick-note.get.ts` |
||||
|
- Create: `server/api/me/quick-note.put.ts` |
||||
|
- Modify: `server/service/quick-note/index.ts`(若需补返回 DTO) |
||||
|
|
||||
|
- [ ] **Step 1: 写失败检查(接口文件不存在)** |
||||
|
|
||||
|
```bash |
||||
|
ls server/api/me/quick-note.get.ts server/api/me/quick-note.put.ts |
||||
|
``` |
||||
|
|
||||
|
Expected: 报不存在。 |
||||
|
|
||||
|
- [ ] **Step 2: 运行检查确认失败** |
||||
|
|
||||
|
Run: `ls server/api/me/quick-note.get.ts server/api/me/quick-note.put.ts` |
||||
|
Expected: `No such file or directory` |
||||
|
|
||||
|
- [ ] **Step 3: 写最小接口实现** |
||||
|
|
||||
|
```ts |
||||
|
// server/api/me/quick-note.get.ts |
||||
|
import { getQuickNoteByUserId } from "#server/service/quick-note"; |
||||
|
export default defineWrappedResponseHandler(async (event) => { |
||||
|
const user = await event.context.auth.requireUser(); |
||||
|
const row = await getQuickNoteByUserId(user.id); |
||||
|
return R.success({ quickNote: { content: row?.content ?? "", updatedAt: row?.updatedAt ?? null } }); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
```ts |
||||
|
// server/api/me/quick-note.put.ts |
||||
|
import { upsertQuickNoteByUserId } from "#server/service/quick-note"; |
||||
|
export default defineWrappedResponseHandler(async (event) => { |
||||
|
const user = await event.context.auth.requireUser(); |
||||
|
const body = await readBody<{ content: string }>(event); |
||||
|
if (typeof body.content !== "string") { |
||||
|
throw createError({ statusCode: 400, statusMessage: "content 必须为字符串" }); |
||||
|
} |
||||
|
const row = await upsertQuickNoteByUserId(user.id, body.content); |
||||
|
return R.success({ quickNote: { content: row.content, updatedAt: row.updatedAt } }); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 4: 启动服务并手工验证** |
||||
|
|
||||
|
Run: `bun run dev` |
||||
|
Expected: |
||||
|
- `GET /api/me/quick-note` 已登录返回 `{ content }` |
||||
|
- `PUT /api/me/quick-note` 手动保存成功并可再次读回 |
||||
|
|
||||
|
- [ ] **Step 5: 提交** |
||||
|
|
||||
|
```bash |
||||
|
git add server/api/me/quick-note.get.ts server/api/me/quick-note.put.ts server/service/quick-note/index.ts |
||||
|
git commit -m "feat(api): add me quick-note get and put endpoints" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 4: 前端轻量编辑器与状态机(dirty/save) |
||||
|
|
||||
|
**Files:** |
||||
|
- Create: `app/components/quick-note-editor-vditor-config.ts` |
||||
|
- Create: `app/components/QuickNoteEditor.vue` |
||||
|
- Create: `app/components/quick-note-modal-state.ts` |
||||
|
- Create: `app/components/quick-note-modal-state.test.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 写失败测试(dirty 判定、保存后清洁、失败保留草稿)** |
||||
|
|
||||
|
```ts |
||||
|
import { describe, expect, test } from "bun:test"; |
||||
|
import { computeIsDirty } from "./quick-note-modal-state"; |
||||
|
|
||||
|
describe("quick note modal state", () => { |
||||
|
test("is dirty when draft differs from saved", () => { |
||||
|
expect(computeIsDirty({ savedContent: "a", draftContent: "b" })).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: 运行测试确认失败** |
||||
|
|
||||
|
Run: `bun test app/components/quick-note-modal-state.test.ts` |
||||
|
Expected: FAIL(状态模块未实现) |
||||
|
|
||||
|
- [ ] **Step 3: 写最小实现(状态 + 轻量编辑器)** |
||||
|
|
||||
|
```ts |
||||
|
// app/components/quick-note-modal-state.ts |
||||
|
export function computeIsDirty(input: { savedContent: string; draftContent: string }) { |
||||
|
return input.savedContent !== input.draftContent; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
```ts |
||||
|
// app/components/quick-note-editor-vditor-config.ts |
||||
|
export const QUICK_NOTE_TOOLBAR = ["bold", "italic", "headings", "|", "list", "ordered-list", "|", "link", "upload", "code"]; |
||||
|
``` |
||||
|
|
||||
|
`QuickNoteEditor.vue` 复用 `post-body-markdown-editor-vditor-bridge.ts` 模式,上传格式复用现有解析逻辑,保留 `onUploadError` 提示。 |
||||
|
|
||||
|
- [ ] **Step 4: 运行测试确认通过** |
||||
|
|
||||
|
Run: `bun test app/components/quick-note-modal-state.test.ts app/components/post-body-markdown-editor-vditor-config.test.ts` |
||||
|
Expected: PASS |
||||
|
|
||||
|
- [ ] **Step 5: 提交** |
||||
|
|
||||
|
```bash |
||||
|
git add app/components/quick-note-editor-vditor-config.ts app/components/QuickNoteEditor.vue app/components/quick-note-modal-state.ts app/components/quick-note-modal-state.test.ts |
||||
|
git commit -m "feat(app): add lightweight quick-note editor and dirty-state helpers" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 5: 弹框容器与入口集成(拖拽/全屏/未保存拦截) |
||||
|
|
||||
|
**Files:** |
||||
|
- Create: `app/components/QuickNoteModal.vue` |
||||
|
- Modify: `app/components/AppShell.vue` |
||||
|
|
||||
|
- [ ] **Step 1: 写失败检查(确认 AppShell 尚无“速记”入口)** |
||||
|
|
||||
|
```bash |
||||
|
rg "速记|QuickNoteModal|quick-note" app/components/AppShell.vue |
||||
|
``` |
||||
|
|
||||
|
Expected: 无命中。 |
||||
|
|
||||
|
- [ ] **Step 2: 运行检查确认失败** |
||||
|
|
||||
|
Run: `rg "速记|QuickNoteModal|quick-note" app/components/AppShell.vue` |
||||
|
Expected: Exit code 1(无命中) |
||||
|
|
||||
|
- [ ] **Step 3: 写最小 UI 与交互实现** |
||||
|
|
||||
|
```vue |
||||
|
<!-- app/components/AppShell.vue (header actions) --> |
||||
|
<UButton |
||||
|
v-if="showQuickCreate" |
||||
|
color="neutral" |
||||
|
variant="soft" |
||||
|
icon="i-lucide-notebook-pen" |
||||
|
size="sm" |
||||
|
class="hidden md:inline-flex" |
||||
|
@click="quickNoteOpen = true" |
||||
|
> |
||||
|
速记 |
||||
|
</UButton> |
||||
|
<QuickNoteModal v-model:open="quickNoteOpen" /> |
||||
|
``` |
||||
|
|
||||
|
```ts |
||||
|
// QuickNoteModal.vue (核心片段) |
||||
|
const isFullscreen = ref(false); |
||||
|
const isDirty = computed(() => draftContent.value !== savedContent.value); |
||||
|
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload)); |
||||
|
onBeforeUnmount(() => window.removeEventListener("beforeunload", onBeforeUnload)); |
||||
|
``` |
||||
|
|
||||
|
并实现: |
||||
|
- 默认近全屏(保留边距) |
||||
|
- 右上角按钮切换全屏/退出全屏 |
||||
|
- 非全屏标题栏拖拽 |
||||
|
- 关闭弹框时 dirty 二次确认 |
||||
|
|
||||
|
- [ ] **Step 4: 启动前端手测关键交互** |
||||
|
|
||||
|
Run: `bun run dev` |
||||
|
Expected: |
||||
|
- “速记”按钮在“写文章”左侧可见 |
||||
|
- 打开即近全屏 |
||||
|
- 全屏切换可用 |
||||
|
- 未保存关闭时弹确认框 |
||||
|
- 未保存刷新/离页触发浏览器原生提醒 |
||||
|
|
||||
|
- [ ] **Step 5: 提交** |
||||
|
|
||||
|
```bash |
||||
|
git add app/components/AppShell.vue app/components/QuickNoteModal.vue |
||||
|
git commit -m "feat(ui): add quick-note modal entry with drag/fullscreen and unsaved guards" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 6: 端到端回归与质量闸门 |
||||
|
|
||||
|
**Files:** |
||||
|
- Modify: `docs/superpowers/specs/2026-04-27-quick-note-modal-design.md`(仅在验收结论需要补充时) |
||||
|
|
||||
|
- [ ] **Step 1: 运行自动化测试** |
||||
|
|
||||
|
Run: `bun test app/components/quick-note-modal-state.test.ts server/service/quick-note/index.test.ts app/components/post-body-markdown-editor-vditor-config.test.ts` |
||||
|
Expected: PASS |
||||
|
|
||||
|
- [ ] **Step 2: 运行 lint(仅本次变更范围)** |
||||
|
|
||||
|
Run: `bun run lint` |
||||
|
Expected: 无新增错误(允许存在历史告警,但不能新增) |
||||
|
|
||||
|
- [ ] **Step 3: 手工验收清单** |
||||
|
|
||||
|
Run: `bun run dev` |
||||
|
Expected: |
||||
|
- 每个登录用户只有一条速记内容(切账号互不影响) |
||||
|
- 仅手动保存写入服务端 |
||||
|
- 未保存保护在“关闭弹框”和“离开页面”两处都生效 |
||||
|
- 上传可用,插入行为与文章编辑链路一致 |
||||
|
|
||||
|
- [ ] **Step 4: 最终提交** |
||||
|
|
||||
|
```bash |
||||
|
git add app/components server/api/me server/service/quick-note packages/drizzle-pkg |
||||
|
git commit -m "feat(quick-note): implement per-user quick note modal with manual save" |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 5: 输出验证证据** |
||||
|
|
||||
|
Run: `git show --stat --oneline -1` |
||||
|
Expected: 展示 quick-note 相关文件变更清单,便于 PR 描述直接引用。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Self-Review |
||||
|
|
||||
|
1. **Spec coverage:** 已覆盖入口位置、近全屏+全屏切换、拖拽、轻量编辑器+上传、用户唯一存储、手动保存、双重未保存提醒、测试与验收。 |
||||
|
2. **Placeholder scan:** 无 TBD/TODO/“后续补充”;每个任务均给出具体路径、命令、预期输出和最小代码片段。 |
||||
|
3. **Type consistency:** 前后统一使用 `quick-note` 路由命名、`savedContent/draftContent/isDirty` 状态命名、`GET/PUT /api/me/quick-note` 接口约定。 |
||||
@ -0,0 +1,147 @@ |
|||||
|
# Quick Note Modal Design |
||||
|
|
||||
|
## Background |
||||
|
|
||||
|
Current writing flow is centered on article creation. The user needs a faster way to jot down temporary or reusable notes without opening the full post creation flow. The quick note should be accessible near the top "Write Article" button and support a lightweight editing experience with media upload. |
||||
|
|
||||
|
## Goals |
||||
|
|
||||
|
- Add a `Quick Note` entry to the left side of the top `Write Article` button. |
||||
|
- Open a draggable modal that defaults to near-fullscreen and can switch to true fullscreen. |
||||
|
- Provide a lightweight editor with upload support. |
||||
|
- Store one unique quick note per authenticated user. |
||||
|
- Use manual save only. |
||||
|
- Protect unsaved changes when closing the modal or leaving the page. |
||||
|
|
||||
|
## Non-goals |
||||
|
|
||||
|
- No auto-save in this iteration. |
||||
|
- No multi-note list, categorization, or version history. |
||||
|
- No public sharing capability. |
||||
|
- No cross-user collaboration. |
||||
|
|
||||
|
## User Experience |
||||
|
|
||||
|
### Entry and Open |
||||
|
|
||||
|
- A `Quick Note` button appears to the left of `Write Article`. |
||||
|
- Clicking `Quick Note` opens the modal and loads the current user's note from backend. |
||||
|
- If no note exists, editor opens with empty content. |
||||
|
|
||||
|
### Modal Behavior |
||||
|
|
||||
|
- Default state is near-fullscreen (small viewport margins). |
||||
|
- Modal is draggable by title bar when not in fullscreen. |
||||
|
- Top-right button toggles `Fullscreen` and `Exit Fullscreen`. |
||||
|
- Fullscreen mode disables dragging. |
||||
|
- Exiting fullscreen restores draggable mode and previous near-fullscreen frame behavior. |
||||
|
|
||||
|
### Editor Behavior |
||||
|
|
||||
|
- Use a lightweight markdown editor configuration to reduce startup and UI complexity. |
||||
|
- Keep upload capability aligned with existing article editor upload flow. |
||||
|
- Editing updates local draft only until user clicks `Save`. |
||||
|
|
||||
|
### Save and Unsaved Protection |
||||
|
|
||||
|
- Save button triggers manual persistence. |
||||
|
- Unsaved state (`isDirty`) is determined by comparing `draftContent` against `savedContent`. |
||||
|
- On modal close attempt with unsaved content, show confirmation dialog: |
||||
|
- Confirm: close and discard unsaved changes in current session. |
||||
|
- Cancel: keep modal open. |
||||
|
- On page leave/refresh/navigation with unsaved content while modal is open, trigger browser `beforeunload` confirmation. |
||||
|
- After successful save, dirty state is cleared. |
||||
|
|
||||
|
## Architecture and Components |
||||
|
|
||||
|
### Frontend |
||||
|
|
||||
|
- Add `Quick Note` trigger in the top action area where `Write Article` is rendered. |
||||
|
- Add `QuickNoteModal.vue`: |
||||
|
- Modal shell, header controls, drag behavior, fullscreen toggle, close interception. |
||||
|
- Unsaved-close confirmation handling. |
||||
|
- Register/unregister `beforeunload` guard while open. |
||||
|
- Add `QuickNoteEditor.vue`: |
||||
|
- Lightweight editor wrapper. |
||||
|
- Minimal toolbar and markdown input area. |
||||
|
- Upload hook integration by reusing existing editor upload bridge/uploader path. |
||||
|
- State model inside modal: |
||||
|
- `savedContent: string` |
||||
|
- `draftContent: string` |
||||
|
- `isDirty: boolean` (derived) |
||||
|
- `isSaving: boolean` |
||||
|
- `isLoading: boolean` |
||||
|
|
||||
|
### Backend |
||||
|
|
||||
|
- Introduce persistent user-unique quick note record. |
||||
|
- Data model (`quick_notes` or project-consistent naming): |
||||
|
- `id` |
||||
|
- `user_id` (unique index) |
||||
|
- `content` (text) |
||||
|
- `created_at` |
||||
|
- `updated_at` |
||||
|
- Add API endpoints: |
||||
|
- `GET /api/me/quick-note` |
||||
|
- Returns current user's note content. |
||||
|
- Returns empty content if no record exists. |
||||
|
- `PUT /api/me/quick-note` |
||||
|
- Accepts `content`. |
||||
|
- Performs upsert by `user_id`. |
||||
|
- Returns saved content and timestamps. |
||||
|
|
||||
|
## Data and Validation Rules |
||||
|
|
||||
|
- Endpoint access requires authenticated `me` context. |
||||
|
- Content must be string. |
||||
|
- Enforce max content length (proposed: 200,000 chars) to prevent abuse and oversized payloads. |
||||
|
- Normalize line endings server-side to keep storage consistent. |
||||
|
|
||||
|
## Error Handling |
||||
|
|
||||
|
- Load failure: show inline load error with retry option. |
||||
|
- Save failure: keep `draftContent` untouched, preserve dirty state, and show save error toast/message. |
||||
|
- Concurrent upsert conflicts (rare): service layer retries update path once after conflict. |
||||
|
|
||||
|
## Testing Strategy |
||||
|
|
||||
|
### Frontend Unit Tests |
||||
|
|
||||
|
- `QuickNoteModal` open/close, fullscreen toggle, drag enable/disable switching. |
||||
|
- Dirty-state derived behavior for save success/failure. |
||||
|
- Close interception when dirty. |
||||
|
- `beforeunload` guard registration lifecycle and dirty-condition trigger. |
||||
|
- `QuickNoteEditor` upload insertion path and save event behavior. |
||||
|
|
||||
|
### Backend Unit Tests |
||||
|
|
||||
|
- `GET /api/me/quick-note` with existing note. |
||||
|
- `GET /api/me/quick-note` with no existing note. |
||||
|
- `PUT /api/me/quick-note` create path and update path. |
||||
|
- Validation rejection for non-string or oversized payload. |
||||
|
- Auth rejection for unauthenticated access. |
||||
|
|
||||
|
### Manual Acceptance |
||||
|
|
||||
|
- Quick Note button opens modal in near-fullscreen. |
||||
|
- Fullscreen/exit button works as expected. |
||||
|
- Draggable behavior works only outside fullscreen. |
||||
|
- Unsaved close prompt appears on close attempts. |
||||
|
- Browser leave prompt appears on refresh/navigate with unsaved changes. |
||||
|
- Save persists content; reopening shows latest saved state. |
||||
|
- Upload works in quick note editor and follows existing media behavior. |
||||
|
|
||||
|
## Rollout and Compatibility |
||||
|
|
||||
|
- Feature can ship without affecting existing article editor behavior. |
||||
|
- No migration impact on posts data. |
||||
|
- New quick note storage is isolated to authenticated user scope. |
||||
|
|
||||
|
## Open Decisions (Resolved) |
||||
|
|
||||
|
- Uniqueness scope: per authenticated user. |
||||
|
- Save strategy: manual only. |
||||
|
- Unsaved reminders: modal close + page leave. |
||||
|
- Default view: near-fullscreen. |
||||
|
- Fullscreen trigger: top-right toggle button. |
||||
|
- Editor approach: lightweight editor with upload retained. |
||||
Loading…
Reference in new issue