feat: unify editable note templates
This commit is contained in:
1 parent
db5cd5431b
commit
c9d0785c81
25 files changed
+432
-780
No files matched your search
@@ -59,7 +59,12 @@ type AuthRouteDependencies = {
|
||||
authenticateRequest: (context: AppContext, touch: boolean) => Promise<AuthContext | null>;
|
||||
authenticateSession: (context: AppContext, touch: boolean) => Promise<AuthContext | null>;
|
||||
createSession: (context: AppContext, user: UserRow, requestedDeviceId?: string) => Promise<LoginSession>;
|
||||
ensureUserWorkspace: (database: DatabaseAdapter, userId: string, username: string) => Promise<UserWorkspace>;
|
||||
ensureUserWorkspace: (
|
||||
database: DatabaseAdapter,
|
||||
userId: string,
|
||||
username: string,
|
||||
locale?: string | null,
|
||||
) => Promise<UserWorkspace>;
|
||||
getBearerToken: (context: AppContext) => string | null;
|
||||
getInstanceAuthMode: (environment: Bindings) => Promise<InstanceAuthMode>;
|
||||
getLoginAttemptKeys: (context: AppContext, username: string) => Promise<LoginAttemptKey[]>;
|
||||
@@ -273,6 +278,7 @@ export const registerAuthRoutes = (
|
||||
context.env.storage.db,
|
||||
user.id,
|
||||
user.username,
|
||||
context.req.header("accept-language"),
|
||||
);
|
||||
const session = await dependencies.createSession(context, user, input.deviceId);
|
||||
dependencies.setSessionCookie(context, session.token, session.maxAge);
|
||||
|
||||
@@ -1313,7 +1313,7 @@ const authenticateSessionToken = async (c: AppContext, token: string, touch: boo
|
||||
|
||||
const workspace = row.workspace_id && row.role
|
||||
? { workspaceId: row.workspace_id, role: row.role }
|
||||
: await ensureUserWorkspace(c.env.storage.db, row.user_id, row.username);
|
||||
: await ensureUserWorkspace(c.env.storage.db, row.user_id, row.username, c.req.header("accept-language"));
|
||||
|
||||
return {
|
||||
kind: "user",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const migration = (name) => readFileSync(`migrations/${name}`, "utf8");
|
||||
|
||||
describe("editable template migration", () => {
|
||||
test("replaces untouched legacy seed and preserves a user-edited copy", () => {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE workspaces (id TEXT PRIMARY KEY);
|
||||
INSERT INTO workspaces (id) VALUES ('ws_clean'), ('ws_edited');
|
||||
`);
|
||||
sqlite.exec(migration("0016_templates.sql"));
|
||||
sqlite.exec(migration("0017_seed_default_templates.sql"));
|
||||
sqlite.exec(migration("0018_fix_seed_template_markdown.sql"));
|
||||
sqlite.query(`
|
||||
UPDATE memo_templates
|
||||
SET name = '我的项目周报', updated_at = '2099-01-01T00:00:00.000Z'
|
||||
WHERE workspace_id = 'ws_edited'
|
||||
`).run();
|
||||
|
||||
sqlite.exec(migration("0030_unify_editable_note_templates.sql"));
|
||||
|
||||
const clean = sqlite.query(`SELECT id, name FROM memo_templates WHERE workspace_id = ? ORDER BY id`).all("ws_clean");
|
||||
const edited = sqlite.query(`SELECT id, name FROM memo_templates WHERE workspace_id = ? ORDER BY id`).all("ws_edited");
|
||||
expect(clean).toHaveLength(6);
|
||||
expect(clean.some((row) => row.id === "ws_clean_template_project_weekly")).toBe(false);
|
||||
expect(edited).toHaveLength(7);
|
||||
expect(edited.some((row) => row.name === "我的项目周报")).toBe(true);
|
||||
expect(clean.every((row) => !row.name.includes("内置"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -99,7 +99,12 @@ export const registerUserRoutes = (
|
||||
`INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, ?, ?, 'notebook', ?, ?, ?, ?)`,
|
||||
).bind(notebook.id, workspaceId, notebook.name, notebook.slug, notebook.color, notebook.sortOrder, now, now)),
|
||||
...createWorkspaceDefaultSeedStatements(context.env.storage.db, workspaceId, now),
|
||||
...createWorkspaceDefaultSeedStatements(
|
||||
context.env.storage.db,
|
||||
workspaceId,
|
||||
now,
|
||||
context.req.header("accept-language"),
|
||||
),
|
||||
auditStatement(context.env.storage.db, "user", context.get("auth").actorId, "user.create", "user", userId, {
|
||||
username: input.username,
|
||||
}),
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("workspace provisioning", () => {
|
||||
expect(calls[0].sql).toContain("FROM workspace_members WHERE user_id");
|
||||
});
|
||||
|
||||
test("builds static template and AI prompt statements for new workspaces", () => {
|
||||
test("builds editable localized template and AI prompt statements for new workspaces", () => {
|
||||
const calls = [];
|
||||
const db = {
|
||||
prepare: (sql) => statement(sql, calls),
|
||||
@@ -50,11 +50,14 @@ describe("workspace provisioning", () => {
|
||||
db,
|
||||
"ws_new",
|
||||
"2026-08-14T00:00:00.000Z",
|
||||
"en-US",
|
||||
);
|
||||
|
||||
expect(statements.length).toBeGreaterThan(1);
|
||||
expect(statements.length).toBeGreaterThan(6);
|
||||
expect(calls[0].sql).toContain("INSERT OR IGNORE INTO memo_templates");
|
||||
expect(calls[0].values).toContain("ws_new_template_project_weekly");
|
||||
expect(calls[0].values).toContain("ws_new_template_quick-note");
|
||||
expect(calls[0].values).toContain("Quick Spark");
|
||||
expect(calls.filter((call) => call.sql.includes("INSERT OR IGNORE INTO memo_templates"))).toHaveLength(6);
|
||||
expect(calls.some((call) => call.sql.includes("INSERT OR IGNORE INTO ai_prompt_templates"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,11 @@ import {
|
||||
DEFAULT_AI_PROMPT_SEEDS,
|
||||
defaultAiPromptId,
|
||||
} from "@edgeever/shared/ai-prompt-seeds";
|
||||
import {
|
||||
DEFAULT_MEMO_TEMPLATE_SEEDS,
|
||||
defaultMemoTemplateId,
|
||||
localizeMemoTemplateSeed,
|
||||
} from "@edgeever/shared/memo-template-seeds";
|
||||
import { createId, isoNow } from "./entity-utils";
|
||||
import type {
|
||||
DatabaseAdapter,
|
||||
@@ -31,55 +36,36 @@ export const createDefaultNotebookRows = (workspaceId: string): DefaultNotebookR
|
||||
{ id: `${workspaceId}_personal`, name: "生活个人", slug: "personal-life", color: "#ea580c", sortOrder: 50 },
|
||||
];
|
||||
|
||||
const PROJECT_WEEKLY_TEMPLATE_MARKDOWN = "## 本周进展\n\n- \n\n## 关键成果\n\n- \n\n## 风险与阻塞\n\n- \n\n## 下周计划\n\n- [ ] \n\n## 需要协助\n\n- ";
|
||||
|
||||
// This is the build-time result of parsing PROJECT_WEEKLY_TEMPLATE_MARKDOWN.
|
||||
// Keeping it static avoids loading and executing the TipTap Markdown parser
|
||||
// merely to provision a workspace.
|
||||
const PROJECT_WEEKLY_TEMPLATE_CONTENT_JSON = JSON.stringify({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "本周进展" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "- " }] },
|
||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "关键成果" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "- " }] },
|
||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "风险与阻塞" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "- " }] },
|
||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "下周计划" }] },
|
||||
{
|
||||
type: "taskList",
|
||||
content: [{
|
||||
type: "taskItem",
|
||||
attrs: { checked: false },
|
||||
content: [{ type: "paragraph", content: [] }],
|
||||
}],
|
||||
},
|
||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "需要协助" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "- " }] },
|
||||
],
|
||||
});
|
||||
// Template use and editing derive the TipTap document from content_markdown.
|
||||
// Keeping this initial value static avoids loading the Markdown parser merely
|
||||
// to provision a workspace.
|
||||
const EMPTY_TEMPLATE_CONTENT_JSON = JSON.stringify({ type: "doc", content: [] });
|
||||
|
||||
export const createWorkspaceDefaultSeedStatements = (
|
||||
db: DatabaseAdapter,
|
||||
workspaceId: string,
|
||||
now = isoNow(),
|
||||
locale?: string | null,
|
||||
): PreparedStatementAdapter[] => [
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO memo_templates (
|
||||
id, workspace_id, name, description, title, content_json, content_markdown, tags_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).bind(
|
||||
`${workspaceId}_template_project_weekly`,
|
||||
workspaceId,
|
||||
"项目周报模板",
|
||||
"每周同步项目进展、风险与下一步计划",
|
||||
"项目周报|第 {{周次}} 周",
|
||||
PROJECT_WEEKLY_TEMPLATE_CONTENT_JSON,
|
||||
PROJECT_WEEKLY_TEMPLATE_MARKDOWN,
|
||||
JSON.stringify(["项目管理", "周报"]),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
...DEFAULT_MEMO_TEMPLATE_SEEDS.map((seed) => {
|
||||
const localized = localizeMemoTemplateSeed(seed, locale);
|
||||
return db.prepare(
|
||||
`INSERT OR IGNORE INTO memo_templates (
|
||||
id, workspace_id, name, description, title, content_json, content_markdown, tags_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).bind(
|
||||
defaultMemoTemplateId(workspaceId, seed.key),
|
||||
workspaceId,
|
||||
localized.title,
|
||||
localized.description,
|
||||
localized.title,
|
||||
EMPTY_TEMPLATE_CONTENT_JSON,
|
||||
localized.contentMarkdown,
|
||||
JSON.stringify(["template", seed.tag]),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
}),
|
||||
...DEFAULT_AI_PROMPT_SEEDS.map((seed) => db.prepare(
|
||||
`INSERT OR IGNORE INTO ai_prompt_templates (
|
||||
id, workspace_id, seed_key, action, parameter_kind, result_mode,
|
||||
@@ -106,6 +92,7 @@ export const ensureUserWorkspace = async (
|
||||
db: DatabaseAdapter,
|
||||
userId: string,
|
||||
username: string,
|
||||
locale?: string | null,
|
||||
): Promise<UserWorkspace> => {
|
||||
const existing = await db.prepare(
|
||||
`SELECT workspace_id, role FROM workspace_members WHERE user_id = ? LIMIT 1`,
|
||||
@@ -125,7 +112,7 @@ export const ensureUserWorkspace = async (
|
||||
`SELECT workspace_id, role FROM workspace_members WHERE user_id = ? LIMIT 1`,
|
||||
).bind(userId).first<{ workspace_id: string; role: "owner" | "member" }>();
|
||||
if (claimed) {
|
||||
await db.batch(createWorkspaceDefaultSeedStatements(db, claimed.workspace_id));
|
||||
await db.batch(createWorkspaceDefaultSeedStatements(db, claimed.workspace_id, isoNow(), locale));
|
||||
return { workspaceId: claimed.workspace_id, role: claimed.role };
|
||||
}
|
||||
}
|
||||
@@ -153,7 +140,7 @@ export const ensureUserWorkspace = async (
|
||||
now,
|
||||
now,
|
||||
)),
|
||||
...createWorkspaceDefaultSeedStatements(db, workspaceId, now),
|
||||
...createWorkspaceDefaultSeedStatements(db, workspaceId, now, locale),
|
||||
]);
|
||||
return { workspaceId, role: "member" };
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Server template
|
||||
|
||||
struct MemoTemplate: Codable, Equatable, Sendable, Identifiable {
|
||||
var id: String
|
||||
var name: String
|
||||
@@ -17,8 +15,6 @@ struct TemplatesResponse: Codable, Sendable {
|
||||
var templates: [MemoTemplate]
|
||||
}
|
||||
|
||||
// MARK: - Create seed (Android MobileCreateMemoSeed)
|
||||
|
||||
struct CreateMemoSeed: Equatable, Sendable {
|
||||
var title: String
|
||||
var contentMarkdown: String
|
||||
@@ -31,13 +27,6 @@ struct CreateMemoSeed: Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selectable row (built-in + saved)
|
||||
|
||||
enum MemoTemplateSource: String, Equatable, Sendable {
|
||||
case builtin
|
||||
case saved
|
||||
}
|
||||
|
||||
struct SelectableMemoTemplate: Identifiable, Equatable, Sendable {
|
||||
var id: String
|
||||
var name: String
|
||||
@@ -45,7 +34,15 @@ struct SelectableMemoTemplate: Identifiable, Equatable, Sendable {
|
||||
var title: String
|
||||
var contentMarkdown: String
|
||||
var tags: [String]
|
||||
var source: MemoTemplateSource
|
||||
|
||||
init(_ template: MemoTemplate) {
|
||||
id = template.id
|
||||
name = template.name
|
||||
description = template.description ?? ""
|
||||
title = template.title ?? template.name
|
||||
contentMarkdown = template.contentMarkdown
|
||||
tags = template.tags
|
||||
}
|
||||
|
||||
var createSeed: CreateMemoSeed {
|
||||
CreateMemoSeed(
|
||||
@@ -55,107 +52,3 @@ struct SelectableMemoTemplate: Identifiable, Equatable, Sendable {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Built-in catalog (parity with apps/mobile + web i18n)
|
||||
|
||||
enum BuiltInMemoTemplates {
|
||||
private struct Item {
|
||||
var id: String
|
||||
var tag: String
|
||||
var titleZH: String
|
||||
var titleEN: String
|
||||
var descriptionZH: String
|
||||
var descriptionEN: String
|
||||
var contentMarkdownZH: String
|
||||
var contentMarkdownEN: String
|
||||
}
|
||||
|
||||
private static let items: [Item] = [
|
||||
Item(
|
||||
id: "quick-note",
|
||||
tag: "quick-note",
|
||||
titleZH: "灵感速记",
|
||||
titleEN: "Quick Spark",
|
||||
descriptionZH: "快速捕捉闪念、临时灵感、资料链接与即刻行动。",
|
||||
descriptionEN: "Capture fleeting thoughts, ideas, links, and immediate action items.",
|
||||
contentMarkdownZH: "## 💡 闪念记录\n\n- \n\n## 📌 背景与补充说明\n\n\n\n## 🚀 下一步动作\n\n- [ ] ",
|
||||
contentMarkdownEN: "## 💡 Fleeting Thoughts\n\n- \n\n## 📌 Context & Notes\n\n\n\n## 🚀 Next Actions\n\n- [ ] "
|
||||
),
|
||||
Item(
|
||||
id: "meeting",
|
||||
tag: "meeting",
|
||||
titleZH: "会议纪要",
|
||||
titleEN: "Meeting Minutes",
|
||||
descriptionZH: "结构化记录议题背景、核心结论与带负责人的待办事项。",
|
||||
descriptionEN: "Structured log for agenda, key decisions, and action items with owners.",
|
||||
contentMarkdownZH: "# 📝 会议纪要\n\n- **时间**:\n- **主持人/记录人**:\n- **参会人**:\n\n---\n\n## 🎯 会议目标\n\n- \n\n## 💬 核心讨论与决策\n\n1. **[议题 1]**\n - 讨论要点:\n - ✅ **决议**:\n\n2. **[议题 2]**\n - 讨论要点:\n - ✅ **决议**:\n\n## 📋 待办事项 (Action Items)\n\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n",
|
||||
contentMarkdownEN: "# 📝 Meeting Minutes\n\n- **Time**:\n- **Host/Recorder**:\n- **Attendees**:\n\n---\n\n## 🎯 Goal\n\n- \n\n## 💬 Discussion & Decisions\n\n1. **[Topic 1]**\n - Points:\n - ✅ **Decision**:\n\n2. **[Topic 2]**\n - Points:\n - ✅ **Decision**:\n\n## 📋 Action Items\n\n- [ ] **[Owner]** Task description (Due: MM-DD)\n- [ ] **[Owner]** Task description (Due: MM-DD)\n"
|
||||
),
|
||||
Item(
|
||||
id: "weekly-review",
|
||||
tag: "weekly-review",
|
||||
titleZH: "周报与进展复盘",
|
||||
titleEN: "Weekly Review & Status",
|
||||
descriptionZH: "梳理本周核心产出、风险卡点与下周关键优先级。",
|
||||
descriptionEN: "Summarize weekly highlights, blockers, and next week's key priorities.",
|
||||
contentMarkdownZH: "# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 总结与思考\n\n- \n",
|
||||
contentMarkdownEN: "# 🗓️ Weekly Status Report\n\n## 🌟 Highlights\n\n- [x] **[Project/Feature]** Accomplishment details\n- [x] **[Project/Feature]** Accomplishment details\n\n## 🚧 Blockers & Risks\n\n- ⚠️ **Blocker**: Reason and required support\n\n## 🎯 Next Week Priorities\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 Reflection & Insights\n\n- \n"
|
||||
),
|
||||
Item(
|
||||
id: "reading",
|
||||
tag: "reading",
|
||||
titleZH: "深度阅读卡片",
|
||||
titleEN: "Reading Note Card",
|
||||
descriptionZH: "提炼核心观点、精妙摘录、个人理解与关联知识卡片。",
|
||||
descriptionEN: "Extract key takeaways, quotes, reflections, and connected concepts.",
|
||||
contentMarkdownZH: "# 📖 深度阅读卡片\n\n- **书名/文章**:\n- **作者/来源**:\n- **推荐指数**:⭐⭐⭐⭐⭐\n\n---\n\n## 💡 一句话总结 (Key Takeaway)\n\n> \n\n## ✍️ 核心观点与金句摘录\n\n> [摘录内容]\n> —— *原书/原文*\n\n## 🧠 我的理解与延伸思考\n\n- \n\n## 🔗 关联知识与行动\n\n- [ ] **落地实践**:\n",
|
||||
contentMarkdownEN: "# 📖 Reading Note Card\n\n- **Book/Article**:\n- **Author/Source**:\n- **Rating**: ⭐⭐⭐⭐⭐\n\n---\n\n## 💡 Key Takeaway\n\n> \n\n## ✍️ Highlights & Quotes\n\n> [Quote content]\n> —— *Original Source*\n\n## 🧠 Personal Reflections\n\n- \n\n## 🔗 Action & Practice\n\n- [ ] **Action Plan**:\n"
|
||||
),
|
||||
Item(
|
||||
id: "okr",
|
||||
tag: "okr",
|
||||
titleZH: "目标与任务拆解",
|
||||
titleEN: "Goal & Task Breakdown",
|
||||
descriptionZH: "明确 OKR 目标、关键结果、里程碑与具体执行清单。",
|
||||
descriptionEN: "Define OKRs, Key Results, milestones, and task checklists.",
|
||||
contentMarkdownZH: "# 🎯 目标拆解\n\n- **周期**:\n- **负责人**:\n\n---\n\n## 📌 目标 (Objective)\n\n> \n\n## 📈 关键结果 (Key Results)\n\n- **KR 1**:期望指标 -> 当前进度\n- **KR 2**:期望指标 -> 当前进度\n\n## 🗓️ 里程碑节点 (Milestones)\n\n- [ ] **阶段一 (日期)**:完成标的\n- [ ] **阶段二 (日期)**:完成标的\n\n## 📋 执行任务清单\n\n- [ ] \n- [ ] \n",
|
||||
contentMarkdownEN: "# 🎯 Goal Breakdown\n\n- **Period**:\n- **Owner**:\n\n---\n\n## 📌 Objective\n\n> \n\n## 📈 Key Results\n\n- **KR 1**: Target metric -> Current progress\n- **KR 2**: Target metric -> Current progress\n\n## 🗓️ Milestones\n\n- [ ] **Phase 1 (Date)**: Target\n- [ ] **Phase 2 (Date)**: Target\n\n## 📋 Execution Checklist\n\n- [ ] \n- [ ] \n"
|
||||
),
|
||||
Item(
|
||||
id: "post-mortem",
|
||||
tag: "post-mortem",
|
||||
titleZH: "问题排查与复盘",
|
||||
titleEN: "Problem & Post-mortem",
|
||||
descriptionZH: "记录故障现象、根因分析 (5 Whys) 与防范机制。",
|
||||
descriptionEN: "Document incident symptoms, 5-Whys root cause, and preventive measures.",
|
||||
contentMarkdownZH: "# 🔍 问题排查与复盘 (Post-mortem)\n\n- **发生时间**:\n- **影响范围**:\n- **处理状态**:已解决 / 处理中\n\n---\n\n## 🚨 故障现象与影响\n\n\n\n## 🛠️ 排查过程与解决方案\n\n1. \n2. \n\n## 🔬 根因分析 (Root Cause / 5 Whys)\n\n- **根本原因**:\n\n## 🛡️ 预防措施 (Action Items)\n\n- [ ] **[短期规避]** \n- [ ] **[长期优化]** \n",
|
||||
contentMarkdownEN: "# 🔍 Post-mortem & Problem Investigation\n\n- **Occurred At**:\n- **Impact Scope**:\n- **Status**: Resolved / In-Progress\n\n---\n\n## 🚨 Symptoms & Impact\n\n\n\n## 🛠️ Investigation & Fix Steps\n\n1. \n2. \n\n## 🔬 Root Cause Analysis (5 Whys)\n\n- **Root Cause**:\n\n## 🛡️ Action Items & Prevention\n\n- [ ] **[Short-term]** \n- [ ] **[Long-term]** \n"
|
||||
),
|
||||
]
|
||||
|
||||
static func all(isEnglish: Bool) -> [SelectableMemoTemplate] {
|
||||
items.map { item in
|
||||
SelectableMemoTemplate(
|
||||
id: item.id,
|
||||
name: isEnglish ? item.titleEN : item.titleZH,
|
||||
description: isEnglish ? item.descriptionEN : item.descriptionZH,
|
||||
title: isEnglish ? item.titleEN : item.titleZH,
|
||||
contentMarkdown: isEnglish ? item.contentMarkdownEN : item.contentMarkdownZH,
|
||||
tags: ["template", item.tag],
|
||||
source: .builtin
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func fromSaved(_ template: MemoTemplate) -> SelectableMemoTemplate {
|
||||
SelectableMemoTemplate(
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description ?? "",
|
||||
title: template.title ?? template.name,
|
||||
contentMarkdown: template.contentMarkdown,
|
||||
tags: template.tags,
|
||||
source: .saved
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -111,20 +111,16 @@ struct CreateChoiceSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Android `MobileTemplatePickerModal` — saved + built-in templates.
|
||||
/// Android `MobileTemplatePickerModal` — persisted, editable templates.
|
||||
struct TemplatePickerSheet: View {
|
||||
@Environment(AppEnvironment.self) private var env
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var onSelect: (CreateMemoSeed) -> Void
|
||||
|
||||
@State private var saved: [SelectableMemoTemplate] = []
|
||||
@State private var isLoadingSaved = false
|
||||
@State private var savedLoadFailed = false
|
||||
|
||||
private var builtIn: [SelectableMemoTemplate] {
|
||||
BuiltInMemoTemplates.all(isEnglish: env.preferences.isEnglish)
|
||||
}
|
||||
@State private var templates: [SelectableMemoTemplate] = []
|
||||
@State private var isLoading = false
|
||||
@State private var loadFailed = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -140,8 +136,8 @@ struct TemplatePickerSheet: View {
|
||||
.font(.system(size: 15, weight: .heavy))
|
||||
.foregroundStyle(AppTheme.title)
|
||||
Text(env.preferences.t(
|
||||
"选择预设结构快速开始,也可使用网页端保存的自定义模板。",
|
||||
en: "Start from a preset structure, or use custom templates saved on the web."
|
||||
"选择一个模板快速开始。所有模板都可以在网页端修改或删除。",
|
||||
en: "Choose a template to get started. Every template can be edited or deleted on the web."
|
||||
))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppTheme.secondary)
|
||||
@@ -167,9 +163,9 @@ struct TemplatePickerSheet: View {
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
sectionTitle(env.preferences.t("我的自定义模板", en: "My custom templates"))
|
||||
sectionTitle(env.preferences.t("模板", en: "Templates"))
|
||||
|
||||
if isLoadingSaved {
|
||||
if isLoading {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(env.preferences.t("正在加载模板", en: "Loading templates"))
|
||||
@@ -178,26 +174,19 @@ struct TemplatePickerSheet: View {
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 10)
|
||||
} else if savedLoadFailed {
|
||||
} else if loadFailed {
|
||||
hint(env.preferences.t(
|
||||
"自定义模板暂时无法加载,仍可使用下方内置模板。",
|
||||
en: "Custom templates could not load. Built-in templates are still available."
|
||||
"模板暂时无法加载,请稍后重试。",
|
||||
en: "Templates could not load. Please try again later."
|
||||
))
|
||||
} else if saved.isEmpty {
|
||||
} else if templates.isEmpty {
|
||||
hint(env.preferences.t(
|
||||
"暂无自定义模板。可在网页端将常用笔记另存为模板。",
|
||||
en: "No custom templates yet. Save notes as templates on the web."
|
||||
"暂无模板。可在网页端新建模板,或将常用笔记另存为模板。",
|
||||
en: "No templates yet. Create one or save a note as a template on the web."
|
||||
))
|
||||
}
|
||||
|
||||
ForEach(saved) { template in
|
||||
templateRow(template)
|
||||
}
|
||||
|
||||
divider
|
||||
|
||||
sectionTitle(env.preferences.t("内置推荐模板", en: "Recommended templates"))
|
||||
ForEach(builtIn) { template in
|
||||
ForEach(templates) { template in
|
||||
templateRow(template)
|
||||
}
|
||||
}
|
||||
@@ -210,19 +199,19 @@ struct TemplatePickerSheet: View {
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.accessibilityIdentifier("templatePickerSheet")
|
||||
.task { await loadSaved() }
|
||||
.task { await loadTemplates() }
|
||||
}
|
||||
|
||||
private func loadSaved() async {
|
||||
isLoadingSaved = true
|
||||
savedLoadFailed = false
|
||||
defer { isLoadingSaved = false }
|
||||
private func loadTemplates() async {
|
||||
isLoading = true
|
||||
loadFailed = false
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let list = try await env.session.client.listTemplates()
|
||||
saved = list.map(BuiltInMemoTemplates.fromSaved)
|
||||
templates = list.map(SelectableMemoTemplate.init)
|
||||
} catch {
|
||||
savedLoadFailed = true
|
||||
saved = []
|
||||
loadFailed = true
|
||||
templates = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,13 +233,6 @@ struct TemplatePickerSheet: View {
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle()
|
||||
.fill(AppTheme.searchFill)
|
||||
.frame(height: 1)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func templateRow(_ template: SelectableMemoTemplate) -> some View {
|
||||
Button {
|
||||
onSelect(template.createSeed)
|
||||
@@ -272,7 +254,6 @@ struct TemplatePickerSheet: View {
|
||||
.foregroundStyle(AppTheme.title)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
badge(for: template.source)
|
||||
}
|
||||
if !template.description.isEmpty {
|
||||
Text(template.description)
|
||||
@@ -291,19 +272,4 @@ struct TemplatePickerSheet: View {
|
||||
.accessibilityIdentifier("templateRow-\(template.id)")
|
||||
}
|
||||
|
||||
private func badge(for source: MemoTemplateSource) -> some View {
|
||||
let isCustom = source == .saved
|
||||
return Text(isCustom
|
||||
? env.preferences.t("自定义", en: "Custom")
|
||||
: env.preferences.t("内置", en: "Built-in"))
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(isCustom ? AppTheme.accentStrong : AppTheme.secondary)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 2)
|
||||
.background(isCustom ? AppTheme.accentSoft : AppTheme.searchFill)
|
||||
.overlay(
|
||||
Capsule().stroke(isCustom ? AppTheme.accentBorder : AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,17 @@ import XCTest
|
||||
@testable import EdgeEver
|
||||
|
||||
final class MemoTemplatesTests: XCTestCase {
|
||||
func testBuiltInCatalogHasSixItemsBothLocales() {
|
||||
let zh = BuiltInMemoTemplates.all(isEnglish: false)
|
||||
let en = BuiltInMemoTemplates.all(isEnglish: true)
|
||||
XCTAssertEqual(zh.count, 6)
|
||||
XCTAssertEqual(en.count, 6)
|
||||
XCTAssertEqual(zh[0].name, "灵感速记")
|
||||
XCTAssertEqual(en[0].name, "Quick Spark")
|
||||
XCTAssertTrue(zh[1].contentMarkdown.contains("会议纪要"))
|
||||
XCTAssertTrue(en[1].contentMarkdown.contains("Meeting Minutes"))
|
||||
XCTAssertEqual(zh[0].source, .builtin)
|
||||
XCTAssertEqual(zh[0].tags, ["template", "quick-note"])
|
||||
}
|
||||
|
||||
func testCreateSeedFromSelectableTemplate() {
|
||||
let template = SelectableMemoTemplate(
|
||||
let template = SelectableMemoTemplate(MemoTemplate(
|
||||
id: "tpl",
|
||||
name: "我的周报",
|
||||
description: "desc",
|
||||
title: "【周报】",
|
||||
contentMarkdown: "## 本周",
|
||||
tags: ["work", "weekly"],
|
||||
source: .saved
|
||||
)
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z"
|
||||
))
|
||||
XCTAssertEqual(template.createSeed.title, "【周报】")
|
||||
XCTAssertEqual(template.createSeed.contentMarkdown, "## 本周")
|
||||
XCTAssertEqual(template.createSeed.tagsText, "work, weekly")
|
||||
@@ -44,8 +32,7 @@ final class MemoTemplatesTests: XCTestCase {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z"
|
||||
)
|
||||
let row = BuiltInMemoTemplates.fromSaved(saved)
|
||||
XCTAssertEqual(row.source, .saved)
|
||||
let row = SelectableMemoTemplate(saved)
|
||||
XCTAssertEqual(row.title, "自定义")
|
||||
XCTAssertEqual(row.contentMarkdown, "# Hi")
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { createEdgeEverClient } from "@edgeever/client";
|
||||
import { useMemo } from "react";
|
||||
import { Modal, Pressable, ScrollView, View } from "react-native";
|
||||
import { ActivityIndicator, FileText, LayoutTemplate, X } from "./icons";
|
||||
import { Text } from "./LocalizedText";
|
||||
import {
|
||||
getMobileBuiltInTemplates,
|
||||
mobileTemplateToCreateSeed,
|
||||
toMobileSelectableTemplate,
|
||||
type MobileCreateMemoSeed,
|
||||
type MobileSelectableTemplate,
|
||||
type MobileSupportedLocale,
|
||||
} from "../lib/mobile-templates";
|
||||
import { useMobileLocale } from "../lib/mobile-locale";
|
||||
import { styles } from "../screens/workspace-styles";
|
||||
@@ -38,11 +35,7 @@ export const MobileTemplatePickerModal = ({
|
||||
presentation?: "modal" | "overlay";
|
||||
visible: boolean;
|
||||
}) => {
|
||||
const { resolvedLocale, translate } = useMobileLocale();
|
||||
const builtInTemplates = useMemo(
|
||||
() => getMobileBuiltInTemplates(resolvedLocale as MobileSupportedLocale).map((template) => toMobileSelectableTemplate(template, "builtin")),
|
||||
[resolvedLocale]
|
||||
);
|
||||
const { translate } = useMobileLocale();
|
||||
|
||||
const savedTemplatesQuery = useQuery({
|
||||
queryKey: ["mobile", "templates"],
|
||||
@@ -55,7 +48,7 @@ export const MobileTemplatePickerModal = ({
|
||||
throw new Error("Client is missing listTemplates; reload the app to pick up the latest bundle.");
|
||||
}
|
||||
const response = await client.listTemplates();
|
||||
return response.templates.map((template) => toMobileSelectableTemplate(template, "saved"));
|
||||
return response.templates.map(toMobileSelectableTemplate);
|
||||
},
|
||||
staleTime: 15_000,
|
||||
retry: 1,
|
||||
@@ -93,7 +86,7 @@ export const MobileTemplatePickerModal = ({
|
||||
<View style={styles.listActionSheetHeaderText}>
|
||||
<Text numberOfLines={1} style={styles.actionSheetTitle}>{translate("从模板新建")}</Text>
|
||||
<Text numberOfLines={2} style={styles.actionSheetSubtitle}>
|
||||
{translate("选择预设结构快速开始,也可使用网页端保存的自定义模板。")}
|
||||
{translate("选择一个模板快速开始。所有模板都可以在网页端修改或删除。")}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable accessibilityLabel={translate("关闭")} accessibilityRole="button" onPress={onClose} style={styles.sheetCloseButton}>
|
||||
@@ -102,7 +95,7 @@ export const MobileTemplatePickerModal = ({
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.listActionSheetContent} style={styles.listActionSheetScroll} keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.actionSheetSectionTitle}>{translate("我的自定义模板")}</Text>
|
||||
<Text style={styles.actionSheetSectionTitle}>{translate("模板")}</Text>
|
||||
{isLoadingSaved ? (
|
||||
<View style={styles.templatePickerLoading}>
|
||||
<ActivityIndicator color="#0f172a" size="small" />
|
||||
@@ -112,7 +105,7 @@ export const MobileTemplatePickerModal = ({
|
||||
{!isLoadingSaved && savedTemplatesQuery.isError ? (
|
||||
<View style={styles.templatePickerErrorBlock}>
|
||||
<Text style={styles.templatePickerHint}>
|
||||
{translate("自定义模板暂时无法加载,仍可使用下方内置模板。")}
|
||||
{translate("模板暂时无法加载,请稍后重试。")}
|
||||
</Text>
|
||||
{savedLoadErrorMessage ? (
|
||||
<Text style={styles.templatePickerErrorDetail} numberOfLines={3}>
|
||||
@@ -130,17 +123,11 @@ export const MobileTemplatePickerModal = ({
|
||||
) : null}
|
||||
{!isLoadingSaved && !savedTemplatesQuery.isError && savedTemplates.length === 0 ? (
|
||||
<Text style={styles.templatePickerHint}>
|
||||
{translate("暂无自定义模板。可在网页端将常用笔记另存为模板。")}
|
||||
{translate("暂无模板。可在网页端新建模板,或将常用笔记另存为模板。")}
|
||||
</Text>
|
||||
) : null}
|
||||
{savedTemplates.map((template) => (
|
||||
<TemplateRow key={`saved-${template.id}`} onPress={() => handleSelect(template)} template={template} />
|
||||
))}
|
||||
|
||||
<View style={styles.listActionDivider} />
|
||||
<Text style={styles.actionSheetSectionTitle}>{translate("内置推荐模板")}</Text>
|
||||
{builtInTemplates.map((template) => (
|
||||
<TemplateRow key={`builtin-${template.id}`} onPress={() => handleSelect(template)} template={template} />
|
||||
<TemplateRow key={template.id} onPress={() => handleSelect(template)} template={template} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
@@ -237,7 +224,6 @@ const TemplateRow = ({
|
||||
onPress: () => void;
|
||||
template: MobileSelectableTemplate;
|
||||
}) => {
|
||||
const { translate } = useMobileLocale();
|
||||
return (
|
||||
<Pressable accessibilityRole="button" onPress={onPress} style={styles.templateRow}>
|
||||
<View style={styles.templateRowIcon}>
|
||||
@@ -246,11 +232,6 @@ const TemplateRow = ({
|
||||
<View style={styles.templateRowText}>
|
||||
<View style={styles.templateRowTitleRow}>
|
||||
<Text numberOfLines={1} style={styles.templateRowTitle}>{template.name}</Text>
|
||||
<View style={[styles.templateBadge, template.source === "saved" ? styles.templateBadgeCustom : styles.templateBadgeBuiltIn]}>
|
||||
<Text style={[styles.templateBadgeText, template.source === "saved" ? styles.templateBadgeTextCustom : styles.templateBadgeTextBuiltIn]}>
|
||||
{template.source === "saved" ? translate("自定义") : translate("内置")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{template.description ? (
|
||||
<Text numberOfLines={2} style={styles.templateRowDescription}>{template.description}</Text>
|
||||
|
||||
@@ -141,19 +141,16 @@ const mobileOnlyTranslations = new Map<string, string>([
|
||||
["筛选:{{filter}} · {{count}} 条", "Filter: {{filter}} · {{count}} notes"],
|
||||
["已加载 {{loaded}} / {{total}} 条笔记", "Loaded {{loaded}} of {{total}} notes"],
|
||||
["从模板新建", "New from template"],
|
||||
["选择预设结构快速开始,也可使用网页端保存的自定义模板。", "Start from a preset structure, or use custom templates saved on the web."],
|
||||
["我的自定义模板", "My custom templates"],
|
||||
["内置推荐模板", "Recommended templates"],
|
||||
["模板", "Templates"],
|
||||
["选择一个模板快速开始。所有模板都可以在网页端修改或删除。", "Choose a template to get started. Every template can be edited or deleted on the web."],
|
||||
["模板暂时无法加载,请稍后重试。", "Templates could not load. Please try again later."],
|
||||
["暂无模板。可在网页端新建模板,或将常用笔记另存为模板。", "No templates yet. Create one or save a note as a template on the web."],
|
||||
["正在加载模板", "Loading templates"],
|
||||
["自定义模板暂时无法加载,仍可使用下方内置模板。", "Custom templates could not load. Built-in templates are still available."],
|
||||
["暂无自定义模板。可在网页端将常用笔记另存为模板。", "No custom templates yet. Save notes as templates on the web."],
|
||||
["新建笔记", "New note"],
|
||||
["选择创建方式", "Choose how to create"],
|
||||
["空白笔记", "Blank note"],
|
||||
["从空白页开始记录", "Start with an empty page"],
|
||||
["使用会议纪要、周报等预设结构", "Use meeting notes, weekly reviews, and more"],
|
||||
["自定义", "Custom"],
|
||||
["内置", "Built-in"],
|
||||
["模板", "Template"],
|
||||
["应用模板?", "Apply template?"],
|
||||
["当前内容将被模板内容替换。", "The current content will be replaced by the template."],
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createMemoSeedHasContent,
|
||||
getMobileBuiltInTemplates,
|
||||
mobileTemplateToCreateSeed,
|
||||
toMobileSelectableTemplate,
|
||||
} from "./mobile-templates";
|
||||
|
||||
describe("mobile-templates", () => {
|
||||
test("returns built-in templates for both locales", () => {
|
||||
const zh = getMobileBuiltInTemplates("zh-CN");
|
||||
const en = getMobileBuiltInTemplates("en-US");
|
||||
expect(zh.length).toBe(6);
|
||||
expect(en.length).toBe(6);
|
||||
expect(zh[0]?.title).toBe("灵感速记");
|
||||
expect(en[0]?.title).toBe("Quick Spark");
|
||||
expect(zh[1]?.contentMarkdown).toContain("会议纪要");
|
||||
expect(en[1]?.contentMarkdown).toContain("Meeting Minutes");
|
||||
});
|
||||
|
||||
test("maps built-in and saved templates to selectable rows", () => {
|
||||
const builtIn = getMobileBuiltInTemplates("zh-CN")[0]!;
|
||||
const selectableBuiltIn = toMobileSelectableTemplate(builtIn, "builtin");
|
||||
expect(selectableBuiltIn.source).toBe("builtin");
|
||||
expect(selectableBuiltIn.name).toBe(builtIn.title);
|
||||
|
||||
test("maps a persisted template to a selectable row", () => {
|
||||
const selectableSaved = toMobileSelectableTemplate(
|
||||
{
|
||||
id: "tpl_1",
|
||||
@@ -36,9 +19,7 @@ describe("mobile-templates", () => {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
"saved"
|
||||
);
|
||||
expect(selectableSaved.source).toBe("saved");
|
||||
expect(selectableSaved.title).toBe("【周报】");
|
||||
expect(mobileTemplateToCreateSeed(selectableSaved)).toEqual({
|
||||
title: "【周报】",
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { enUS, zhCN } from "@edgeever/shared/i18n";
|
||||
import type { MemoTemplate } from "@edgeever/shared";
|
||||
|
||||
export type MobileSupportedLocale = "zh-CN" | "en-US";
|
||||
|
||||
export type MobileCreateMemoSeed = {
|
||||
title: string;
|
||||
contentMarkdown: string;
|
||||
tagsText: string;
|
||||
};
|
||||
|
||||
export type MobileBuiltInTemplate = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
contentMarkdown: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type MobileSelectableTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -24,65 +13,18 @@ export type MobileSelectableTemplate = {
|
||||
title: string;
|
||||
contentMarkdown: string;
|
||||
tags: string[];
|
||||
source: "builtin" | "saved";
|
||||
};
|
||||
|
||||
const BUILTIN_TEMPLATE_DEFS = [
|
||||
{ id: "quick-note", key: "quickNote", tag: "quick-note" },
|
||||
{ id: "meeting", key: "meeting", tag: "meeting" },
|
||||
{ id: "weekly-review", key: "weeklyReview", tag: "weekly-review" },
|
||||
{ id: "reading", key: "reading", tag: "reading" },
|
||||
{ id: "okr", key: "okr", tag: "okr" },
|
||||
{ id: "post-mortem", key: "postMortem", tag: "post-mortem" },
|
||||
] as const;
|
||||
|
||||
type BuiltInItemKey = (typeof BUILTIN_TEMPLATE_DEFS)[number]["key"];
|
||||
|
||||
const getTemplateCatalog = (locale: MobileSupportedLocale) =>
|
||||
(locale === "en-US" ? enUS.templates.items : zhCN.templates.items) as Record<
|
||||
BuiltInItemKey,
|
||||
{ title: string; description: string; contentMarkdown: string }
|
||||
>;
|
||||
|
||||
export const getMobileBuiltInTemplates = (locale: MobileSupportedLocale): MobileBuiltInTemplate[] => {
|
||||
const catalog = getTemplateCatalog(locale);
|
||||
return BUILTIN_TEMPLATE_DEFS.map((item) => {
|
||||
const content = catalog[item.key];
|
||||
return {
|
||||
id: item.id,
|
||||
title: content.title,
|
||||
description: content.description,
|
||||
contentMarkdown: content.contentMarkdown,
|
||||
tags: ["template", item.tag],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const toMobileSelectableTemplate = (
|
||||
template: MobileBuiltInTemplate | MemoTemplate,
|
||||
source: "builtin" | "saved"
|
||||
template: MemoTemplate,
|
||||
): MobileSelectableTemplate => {
|
||||
if (source === "builtin") {
|
||||
const builtIn = template as MobileBuiltInTemplate;
|
||||
return {
|
||||
id: builtIn.id,
|
||||
name: builtIn.title,
|
||||
description: builtIn.description,
|
||||
title: builtIn.title,
|
||||
contentMarkdown: builtIn.contentMarkdown,
|
||||
tags: builtIn.tags,
|
||||
source,
|
||||
};
|
||||
}
|
||||
const saved = template as MemoTemplate;
|
||||
return {
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
description: saved.description ?? "",
|
||||
title: saved.title ?? saved.name,
|
||||
contentMarkdown: saved.contentMarkdown,
|
||||
tags: saved.tags,
|
||||
source,
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description ?? "",
|
||||
title: template.title ?? template.name,
|
||||
contentMarkdown: template.contentMarkdown,
|
||||
tags: template.tags,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1002,30 +1002,6 @@ const baseWorkspaceStyles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
templateBadge: {
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
templateBadgeBuiltIn: {
|
||||
backgroundColor: "#f8fafc",
|
||||
borderColor: "#e2e8f0",
|
||||
},
|
||||
templateBadgeCustom: {
|
||||
backgroundColor: "#ecfdf5",
|
||||
borderColor: "#a7f3d0",
|
||||
},
|
||||
templateBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: "700",
|
||||
},
|
||||
templateBadgeTextBuiltIn: {
|
||||
color: "#64748b",
|
||||
},
|
||||
templateBadgeTextCustom: {
|
||||
color: "#047857",
|
||||
},
|
||||
templateChoiceRow: {
|
||||
alignItems: "center",
|
||||
borderRadius: 12,
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
Calendar,
|
||||
Check,
|
||||
CheckSquare,
|
||||
ChevronLeft,
|
||||
Eye,
|
||||
FileText,
|
||||
LayoutList,
|
||||
Pencil,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
Tag,
|
||||
ArrowRight,
|
||||
FilePlus
|
||||
@@ -24,32 +18,43 @@ import { marked } from "marked";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { getMemoTemplates, type MemoTemplate } from "@/lib/app-helpers";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
|
||||
import type { MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
|
||||
|
||||
const renderTemplateIcon = (iconName?: string) => {
|
||||
switch (iconName) {
|
||||
case "zap":
|
||||
return <Zap className="h-4.5 w-4.5 text-amber-600" />;
|
||||
case "calendar":
|
||||
return <Calendar className="h-4.5 w-4.5 text-blue-600" />;
|
||||
case "checklist":
|
||||
return <CheckSquare className="h-4.5 w-4.5 text-emerald-600" />;
|
||||
case "book":
|
||||
return <BookOpen className="h-4.5 w-4.5 text-purple-600" />;
|
||||
case "sun":
|
||||
return <Sun className="h-4.5 w-4.5 text-rose-600" />;
|
||||
default:
|
||||
return <FileText className="h-4.5 w-4.5 text-emerald-700" />;
|
||||
}
|
||||
};
|
||||
const TemplateIconAction = ({
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className: string;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
}) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={label}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
export const TemplatesPane = ({
|
||||
canCreateMemo,
|
||||
isCreating,
|
||||
onClose,
|
||||
onCreateMemo,
|
||||
onCreateSavedTemplate,
|
||||
savedTemplates,
|
||||
onUseSavedTemplate,
|
||||
@@ -59,7 +64,6 @@ export const TemplatesPane = ({
|
||||
canCreateMemo: boolean;
|
||||
isCreating: boolean;
|
||||
onClose: () => void;
|
||||
onCreateMemo: (template: MemoTemplate) => void;
|
||||
onCreateSavedTemplate: (payload: { name: string; description: string | null; title: string | null; contentMarkdown: string; tags: string[] }) => Promise<void>;
|
||||
savedTemplates: SavedMemoTemplate[];
|
||||
onUseSavedTemplate: (template: SavedMemoTemplate) => void;
|
||||
@@ -67,7 +71,6 @@ export const TemplatesPane = ({
|
||||
onUpdateSavedTemplate: (templateId: string, payload: { name: string; description: string | null; title: string | null; contentMarkdown: string; tags: string[] }) => Promise<void>;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const memoTemplates = useMemo(() => getMemoTemplates(t), [t]);
|
||||
const [editingTemplate, setEditingTemplate] = useState<SavedMemoTemplate | null>(null);
|
||||
const [creatingTemplate, setCreatingTemplate] = useState(false);
|
||||
const [draft, setDraft] = useState({ name: "", description: "", title: "", contentMarkdown: "", tags: "" });
|
||||
@@ -79,7 +82,6 @@ export const TemplatesPane = ({
|
||||
noteTitle?: string | null;
|
||||
tags: string[];
|
||||
contentMarkdown: string;
|
||||
badge: string;
|
||||
onUse: () => void;
|
||||
} | null>(null);
|
||||
|
||||
@@ -140,13 +142,19 @@ export const TemplatesPane = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col bg-slate-50/60">
|
||||
<TooltipProvider>
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col bg-slate-50/60">
|
||||
{/* Header */}
|
||||
<header className="flex h-[calc(3.75rem+env(safe-area-inset-top))] shrink-0 items-end border-b border-slate-200/80 bg-white px-6 pb-3 pt-[env(safe-area-inset-top)] lg:h-16 lg:items-center lg:pb-0 lg:pt-0 shadow-2xs">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button size="icon" variant="ghost" title={t("common.back")} aria-label={t("common.back")} onClick={onClose}>
|
||||
<ChevronLeft className="h-5 w-5 text-slate-500" />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="ghost" aria-label={t("common.back")} onClick={onClose}>
|
||||
<ChevronLeft className="h-5 w-5 text-slate-500" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("common.back")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="min-w-0">
|
||||
<h1 className={`flex items-center gap-2 text-slate-900 ${WORKSPACE_PAGE_TITLE_CLASSNAME}`}>
|
||||
<LayoutList className="h-4.5 w-4.5 text-emerald-600" />
|
||||
@@ -161,7 +169,7 @@ export const TemplatesPane = ({
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-6 lg:px-8 lg:py-8">
|
||||
<div className="mx-auto w-full max-w-5xl space-y-8">
|
||||
|
||||
{/* Section 1: My Custom Templates */}
|
||||
{/* Every template is workspace-owned and fully editable. */}
|
||||
<section>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -300,7 +308,7 @@ export const TemplatesPane = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Templates Grid */}
|
||||
{/* Templates Grid */}
|
||||
{savedTemplates.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{savedTemplates.map((template) => (
|
||||
@@ -314,7 +322,6 @@ export const TemplatesPane = ({
|
||||
noteTitle: template.title,
|
||||
tags: template.tags,
|
||||
contentMarkdown: template.contentMarkdown,
|
||||
badge: t("templates.badgeCustom"),
|
||||
onUse: () => onUseSavedTemplate(template),
|
||||
})
|
||||
}
|
||||
@@ -327,9 +334,6 @@ export const TemplatesPane = ({
|
||||
</span>
|
||||
<h3 className="truncate font-bold text-slate-900 text-sm">{template.name}</h3>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 border border-emerald-200/60">
|
||||
{t("templates.badgeCustom")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-slate-500 min-h-[2.25rem]">
|
||||
@@ -350,34 +354,31 @@ export const TemplatesPane = ({
|
||||
|
||||
<div className="mt-4 border-t border-slate-100 pt-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 text-slate-400">
|
||||
<button
|
||||
type="button"
|
||||
<TemplateIconAction
|
||||
className="p-1 hover:text-slate-700 transition rounded-md hover:bg-slate-100"
|
||||
title={t("templates.edit")}
|
||||
disabled={isCreating}
|
||||
label={t("templates.edit")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing(template);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</TemplateIconAction>
|
||||
<TemplateIconAction
|
||||
className="p-1 hover:text-rose-600 transition rounded-md hover:bg-rose-50"
|
||||
title={t("templates.delete")}
|
||||
disabled={isCreating}
|
||||
label={t("templates.delete")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteConfirmTemplate(template);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</TemplateIconAction>
|
||||
<TemplateIconAction
|
||||
className="p-1 hover:text-slate-700 transition rounded-md hover:bg-slate-100"
|
||||
title={t("templates.previewTemplate")}
|
||||
label={t("templates.previewTemplate")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewTemplate({
|
||||
@@ -386,13 +387,12 @@ export const TemplatesPane = ({
|
||||
noteTitle: template.title,
|
||||
tags: template.tags,
|
||||
contentMarkdown: template.contentMarkdown,
|
||||
badge: t("templates.badgeCustom"),
|
||||
onUse: () => onUseSavedTemplate(template),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TemplateIconAction>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -426,95 +426,6 @@ export const TemplatesPane = ({
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Section 2: Built-in Templates */}
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-bold text-slate-900">{t("templates.builtIn")}</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{t("templates.builtInSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{memoTemplates.map((template) => {
|
||||
const theme = template.themeColor ?? {
|
||||
iconBg: "bg-emerald-100/80 text-emerald-700",
|
||||
iconText: "text-emerald-700",
|
||||
badgeBg: "bg-emerald-50 text-emerald-800 border-emerald-200/60",
|
||||
badgeText: "text-emerald-800",
|
||||
accentBorder: "hover:border-emerald-300",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`group relative flex flex-col justify-between rounded-xl border border-slate-200 bg-white p-4 transition-all duration-200 hover:shadow-md cursor-pointer ${theme.accentBorder}`}
|
||||
onClick={() =>
|
||||
setPreviewTemplate({
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
tags: template.tags,
|
||||
contentMarkdown: template.contentMarkdown,
|
||||
badge: t("templates.badgeBuiltIn"),
|
||||
onUse: () => onCreateMemo(template),
|
||||
})
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${theme.iconBg}`}>
|
||||
{renderTemplateIcon(template.iconName)}
|
||||
</span>
|
||||
<h3 className="truncate font-bold text-slate-900 text-sm">{template.title}</h3>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||
{t("templates.badgeBuiltIn")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-slate-500 min-h-[2.25rem]">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-slate-100 pt-3 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-slate-800 transition py-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewTemplate({
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
tags: template.tags,
|
||||
contentMarkdown: template.contentMarkdown,
|
||||
badge: t("templates.badgeBuiltIn"),
|
||||
onUse: () => onCreateMemo(template),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 text-slate-400" />
|
||||
{t("templates.previewTemplate")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-emerald-200/80 bg-emerald-50/70 px-3 py-1.5 text-xs font-semibold text-emerald-800 hover:bg-emerald-600 hover:text-white hover:border-emerald-600 transition-all duration-200 disabled:opacity-50"
|
||||
disabled={!canCreateMemo || isCreating}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreateMemo(template);
|
||||
}}
|
||||
>
|
||||
{t("templates.useThisTemplate")}
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!canCreateMemo && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3.5 text-xs font-medium text-amber-900">
|
||||
{t("templates.unavailable")}
|
||||
@@ -528,12 +439,7 @@ export const TemplatesPane = ({
|
||||
<Dialog open={true} onOpenChange={(open) => { if (!open) setPreviewTemplate(null); }}>
|
||||
<DialogContent className="max-w-xl bg-white p-0 overflow-hidden border border-slate-200 rounded-xl shadow-xl">
|
||||
<DialogHeader className="border-b border-slate-100 px-6 py-4 text-left bg-slate-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-md bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-800">
|
||||
{previewTemplate.badge}
|
||||
</span>
|
||||
<DialogTitle className="text-base font-bold text-slate-900">{previewTemplate.title}</DialogTitle>
|
||||
</div>
|
||||
<DialogTitle className="text-base font-bold text-slate-900">{previewTemplate.title}</DialogTitle>
|
||||
{previewTemplate.description && (
|
||||
<DialogDescription className="mt-1 text-xs text-slate-500 leading-relaxed">
|
||||
{previewTemplate.description}
|
||||
@@ -613,6 +519,7 @@ export const TemplatesPane = ({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -50,7 +50,6 @@ import type {
|
||||
NotebookNode,
|
||||
NotebookDropPosition,
|
||||
NotebookMoveOption,
|
||||
MemoTemplate,
|
||||
MemoFilterMode,
|
||||
MemoSortMode,
|
||||
MemoDocumentAction,
|
||||
@@ -1846,7 +1845,7 @@ export const WorkspaceApp = ({
|
||||
setNotebookDeleteConfirmation(notebook);
|
||||
};
|
||||
|
||||
const handleCreateMemo = (template?: MemoTemplate) => {
|
||||
const handleCreateMemo = () => {
|
||||
const targetNotebookId = createMemoNotebookId;
|
||||
|
||||
if (!targetNotebookId || memoView === "trash") {
|
||||
@@ -1858,9 +1857,9 @@ export const WorkspaceApp = ({
|
||||
creatingMemoSelectionRef.current = true;
|
||||
createMemoMutation.mutate({
|
||||
notebookId: targetNotebookId,
|
||||
title: template?.title ?? "",
|
||||
contentMarkdown: template?.contentMarkdown ?? "",
|
||||
tags: template?.tags ?? [],
|
||||
title: "",
|
||||
contentMarkdown: "",
|
||||
tags: [],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2898,7 +2897,6 @@ export const WorkspaceApp = ({
|
||||
canCreateMemo={canCreateMemo}
|
||||
isCreating={createMemoMutation.isPending || createTemplateMutation.isPending}
|
||||
onClose={handleCloseTemplates}
|
||||
onCreateMemo={handleCreateMemo}
|
||||
onCreateSavedTemplate={async (payload) => {
|
||||
await createTemplateMutation.mutateAsync(payload);
|
||||
}}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, File as FileIcon, LayoutList, Pencil, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Check, LayoutList, Pencil, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getMemoTemplates, type MemoTemplate } from "@/lib/app-helpers";
|
||||
import type { MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
|
||||
import type { MemoTemplate } from "@edgeever/shared";
|
||||
|
||||
export const TemplatesDialog = ({
|
||||
canCreateMemo,
|
||||
isCreating,
|
||||
onClose,
|
||||
onCreateMemo,
|
||||
savedTemplates,
|
||||
onUseSavedTemplate,
|
||||
onDeleteSavedTemplate,
|
||||
@@ -20,18 +18,16 @@ export const TemplatesDialog = ({
|
||||
canCreateMemo: boolean;
|
||||
isCreating: boolean;
|
||||
onClose: () => void;
|
||||
onCreateMemo: (template: MemoTemplate) => void;
|
||||
savedTemplates: SavedMemoTemplate[];
|
||||
onUseSavedTemplate: (template: SavedMemoTemplate) => void;
|
||||
onDeleteSavedTemplate: (template: SavedMemoTemplate) => void;
|
||||
savedTemplates: MemoTemplate[];
|
||||
onUseSavedTemplate: (template: MemoTemplate) => void;
|
||||
onDeleteSavedTemplate: (template: MemoTemplate) => void;
|
||||
onUpdateSavedTemplate: (templateId: string, payload: { name: string; description: string | null; title: string | null; contentMarkdown: string; tags: string[] }) => Promise<void>;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const memoTemplates = useMemo(() => getMemoTemplates(t), [t]);
|
||||
const [editingTemplate, setEditingTemplate] = useState<SavedMemoTemplate | null>(null);
|
||||
const [editingTemplate, setEditingTemplate] = useState<MemoTemplate | null>(null);
|
||||
const [draft, setDraft] = useState({ name: "", description: "", title: "", contentMarkdown: "", tags: "" });
|
||||
|
||||
const startEditing = (template: SavedMemoTemplate) => {
|
||||
const startEditing = (template: MemoTemplate) => {
|
||||
setEditingTemplate(template);
|
||||
setDraft({
|
||||
name: template.name,
|
||||
@@ -72,8 +68,8 @@ export const TemplatesDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto p-5">
|
||||
{savedTemplates.length > 0 && (
|
||||
<section className="mb-6">
|
||||
{savedTemplates.length > 0 ? (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-bold text-slate-900">{t("templates.myTemplates")}</h3>
|
||||
{editingTemplate && (
|
||||
<div className="mb-4 space-y-3 rounded-xl border border-emerald-200/80 bg-white p-4 shadow-xs">
|
||||
@@ -107,9 +103,6 @@ export const TemplatesDialog = ({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-sm font-bold text-slate-900 truncate">{template.name}</span>
|
||||
<span className="shrink-0 rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700">
|
||||
{t("templates.badgeCustom")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-1 block line-clamp-2 text-xs leading-relaxed text-slate-500">{template.description || template.title || t("templates.savedDescription")}</span>
|
||||
</button>
|
||||
@@ -135,32 +128,9 @@ export const TemplatesDialog = ({
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">{t("templates.emptyMyTemplatesHint")}</p>
|
||||
)}
|
||||
<h3 className="mb-2 text-xs font-bold text-slate-900">{t("templates.builtIn")}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{memoTemplates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
className="group flex flex-col justify-between rounded-xl border border-slate-200 bg-white p-3.5 text-left transition hover:border-emerald-300 hover:shadow-xs disabled:opacity-50"
|
||||
type="button"
|
||||
disabled={!canCreateMemo || isCreating}
|
||||
onClick={() => onCreateMemo(template)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700 font-medium">
|
||||
<FileIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-sm font-bold text-slate-900">{template.title}</span>
|
||||
</div>
|
||||
<span className="mt-2 line-clamp-2 text-xs leading-relaxed text-slate-500">{template.description}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-end text-xs font-semibold text-emerald-700 group-hover:text-emerald-800">
|
||||
{t("templates.useThisTemplate")}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!canCreateMemo && (
|
||||
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3.5 py-2.5 text-xs font-medium text-amber-900">
|
||||
{t("templates.unavailable")}
|
||||
|
||||
@@ -39,115 +39,6 @@ export type NotebookNameDialogState =
|
||||
|
||||
export type AppNoticeDialogState = { title: string; description: string };
|
||||
|
||||
export type MemoTemplate = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
contentMarkdown: string;
|
||||
tags: string[];
|
||||
iconName?: "zap" | "calendar" | "checklist" | "book" | "sun" | "file";
|
||||
themeColor?: {
|
||||
iconBg: string;
|
||||
iconText: string;
|
||||
badgeBg: string;
|
||||
badgeText: string;
|
||||
accentBorder: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const getMemoTemplates = (t: TFunction): MemoTemplate[] => [
|
||||
{
|
||||
id: "quick-note",
|
||||
title: t("templates.items.quickNote.title"),
|
||||
description: t("templates.items.quickNote.description"),
|
||||
contentMarkdown: t("templates.items.quickNote.contentMarkdown"),
|
||||
tags: ["template", "quick-note"],
|
||||
iconName: "zap",
|
||||
themeColor: {
|
||||
iconBg: "bg-amber-100/80 text-amber-700",
|
||||
iconText: "text-amber-700",
|
||||
badgeBg: "bg-amber-50 text-amber-800 border-amber-200/60",
|
||||
badgeText: "text-amber-800",
|
||||
accentBorder: "hover:border-amber-300/80",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "meeting",
|
||||
title: t("templates.items.meeting.title"),
|
||||
description: t("templates.items.meeting.description"),
|
||||
contentMarkdown: t("templates.items.meeting.contentMarkdown"),
|
||||
tags: ["template", "meeting"],
|
||||
iconName: "calendar",
|
||||
themeColor: {
|
||||
iconBg: "bg-blue-100/80 text-blue-700",
|
||||
iconText: "text-blue-700",
|
||||
badgeBg: "bg-blue-50 text-blue-800 border-blue-200/60",
|
||||
badgeText: "text-blue-800",
|
||||
accentBorder: "hover:border-blue-300/80",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "weekly-review",
|
||||
title: t("templates.items.weeklyReview.title"),
|
||||
description: t("templates.items.weeklyReview.description"),
|
||||
contentMarkdown: t("templates.items.weeklyReview.contentMarkdown"),
|
||||
tags: ["template", "weekly-review"],
|
||||
iconName: "checklist",
|
||||
themeColor: {
|
||||
iconBg: "bg-emerald-100/80 text-emerald-700",
|
||||
iconText: "text-emerald-700",
|
||||
badgeBg: "bg-emerald-50 text-emerald-800 border-emerald-200/60",
|
||||
badgeText: "text-emerald-800",
|
||||
accentBorder: "hover:border-emerald-300/80",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "reading",
|
||||
title: t("templates.items.reading.title"),
|
||||
description: t("templates.items.reading.description"),
|
||||
contentMarkdown: t("templates.items.reading.contentMarkdown"),
|
||||
tags: ["template", "reading"],
|
||||
iconName: "book",
|
||||
themeColor: {
|
||||
iconBg: "bg-purple-100/80 text-purple-700",
|
||||
iconText: "text-purple-700",
|
||||
badgeBg: "bg-purple-50 text-purple-800 border-purple-200/60",
|
||||
badgeText: "text-purple-800",
|
||||
accentBorder: "hover:border-purple-300/80",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "okr",
|
||||
title: t("templates.items.okr.title"),
|
||||
description: t("templates.items.okr.description"),
|
||||
contentMarkdown: t("templates.items.okr.contentMarkdown"),
|
||||
tags: ["template", "okr"],
|
||||
iconName: "sun",
|
||||
themeColor: {
|
||||
iconBg: "bg-rose-100/80 text-rose-700",
|
||||
iconText: "text-rose-700",
|
||||
badgeBg: "bg-rose-50 text-rose-800 border-rose-200/60",
|
||||
badgeText: "text-rose-800",
|
||||
accentBorder: "hover:border-rose-300/80",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "post-mortem",
|
||||
title: t("templates.items.postMortem.title"),
|
||||
description: t("templates.items.postMortem.description"),
|
||||
contentMarkdown: t("templates.items.postMortem.contentMarkdown"),
|
||||
tags: ["template", "post-mortem"],
|
||||
iconName: "file",
|
||||
themeColor: {
|
||||
iconBg: "bg-indigo-100/80 text-indigo-700",
|
||||
iconText: "text-indigo-700",
|
||||
badgeBg: "bg-indigo-50 text-indigo-800 border-indigo-200/60",
|
||||
badgeText: "text-indigo-800",
|
||||
accentBorder: "hover:border-indigo-300/80",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const isTextEntryTarget = (target: EventTarget | null) =>
|
||||
target instanceof HTMLElement &&
|
||||
Boolean(target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .ProseMirror"));
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Remove the untouched legacy project-weekly seed. If a user edited it, keep
|
||||
-- it as an ordinary custom template alongside the new starter templates.
|
||||
DELETE FROM memo_templates
|
||||
WHERE id = workspace_id || '_template_project_weekly'
|
||||
AND name = '项目周报模板'
|
||||
AND description = '每周同步项目进展、风险与下一步计划'
|
||||
AND title = '项目周报|第 {{周次}} 周'
|
||||
AND updated_at = created_at;
|
||||
|
||||
-- Existing workspaces have no persisted locale preference, so migrate them
|
||||
-- with the historical Simplified Chinese catalog. New workspaces use the
|
||||
-- request language in workspace-provisioning.ts.
|
||||
WITH seed_catalog AS (
|
||||
SELECT
|
||||
json_extract(value, '$.key') AS seed_key,
|
||||
json_extract(value, '$.name') AS name,
|
||||
json_extract(value, '$.description') AS description,
|
||||
json_extract(value, '$.contentMarkdown') AS content_markdown,
|
||||
json_extract(value, '$.tags') AS tags_json
|
||||
FROM json_each('[
|
||||
{"key":"quick-note","name":"灵感速记","description":"快速捕捉闪念、临时灵感、资料链接与即刻行动。","contentMarkdown":"## 💡 闪念记录\n\n- \n\n## 📌 背景与补充说明\n\n\n\n## 🚀 下一步动作\n\n- [ ] ","tags":["template","quick-note"]},
|
||||
{"key":"meeting","name":"会议纪要","description":"结构化记录议题背景、核心结论与带负责人的待办事项。","contentMarkdown":"# 📝 会议纪要\n\n- **时间**:\n- **主持人/记录人**:\n- **参会人**:\n\n---\n\n## 🎯 会议目标\n\n- \n\n## 💬 核心讨论与决策\n\n1. **[议题 1]**\n - 讨论要点:\n - ✅ **决议**:\n\n2. **[议题 2]**\n - 讨论要点:\n - ✅ **决议**:\n\n## 📋 待办事项 (Action Items)\n\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n","tags":["template","meeting"]},
|
||||
{"key":"weekly-review","name":"周报与进展复盘","description":"梳理本周核心产出、风险卡点与下周关键优先级。","contentMarkdown":"# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 总结与思考\n\n- \n","tags":["template","weekly-review"]},
|
||||
{"key":"reading","name":"深度阅读卡片","description":"提炼核心观点、精妙摘录、个人理解与关联知识卡片。","contentMarkdown":"# 📖 深度阅读卡片\n\n- **书名/文章**:\n- **作者/来源**:\n- **推荐指数**:⭐⭐⭐⭐⭐\n\n---\n\n## 💡 一句话总结 (Key Takeaway)\n\n> \n\n## ✍️ 核心观点与金句摘录\n\n> [摘录内容]\n> —— *原书/原文*\n\n## 🧠 我的理解与延伸思考\n\n- \n\n## 🔗 关联知识与行动\n\n- [ ] **落地实践**:\n","tags":["template","reading"]},
|
||||
{"key":"okr","name":"目标与任务拆解","description":"明确 OKR 目标、关键结果、里程碑与具体执行清单。","contentMarkdown":"# 🎯 目标拆解\n\n- **周期**:\n- **负责人**:\n\n---\n\n## 📌 目标 (Objective)\n\n> \n\n## 📈 关键结果 (Key Results)\n\n- **KR 1**:期望指标 -> 当前进度\n- **KR 2**:期望指标 -> 当前进度\n\n## 🗓️ 里程碑节点 (Milestones)\n\n- [ ] **阶段一 (日期)**:完成标的\n- [ ] **阶段二 (日期)**:完成标的\n\n## 📋 执行任务清单\n\n- [ ] \n- [ ] \n","tags":["template","okr"]},
|
||||
{"key":"post-mortem","name":"问题排查与复盘","description":"记录故障现象、根因分析 (5 Whys) 与防范机制。","contentMarkdown":"# 🔍 问题排查与复盘 (Post-mortem)\n\n- **发生时间**:\n- **影响范围**:\n- **处理状态**:已解决 / 处理中\n\n---\n\n## 🚨 故障现象与影响\n\n\n\n## 🛠️ 排查过程与解决方案\n\n1. \n2. \n\n## 🔬 根因分析 (Root Cause / 5 Whys)\n\n- **根本原因**:\n\n## 🛡️ 预防措施 (Action Items)\n\n- [ ] **[短期规避]** \n- [ ] **[长期优化]** \n","tags":["template","post-mortem"]}
|
||||
]')
|
||||
)
|
||||
INSERT OR IGNORE INTO memo_templates (
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
description,
|
||||
title,
|
||||
content_json,
|
||||
content_markdown,
|
||||
tags_json
|
||||
)
|
||||
SELECT
|
||||
workspaces.id || '_template_' || seed_catalog.seed_key,
|
||||
workspaces.id,
|
||||
seed_catalog.name,
|
||||
seed_catalog.description,
|
||||
seed_catalog.name,
|
||||
'{"type":"doc","content":[]}',
|
||||
seed_catalog.content_markdown,
|
||||
seed_catalog.tags_json
|
||||
FROM workspaces
|
||||
CROSS JOIN seed_catalog;
|
||||
@@ -8,6 +8,7 @@
|
||||
"./ai-prompt-seeds": "./src/ai-prompt-seeds.ts",
|
||||
"./image-display": "./src/image-display.ts",
|
||||
"./i18n": "./src/i18n/index.ts",
|
||||
"./memo-template-seeds": "./src/memo-template-seeds.ts",
|
||||
"./mathematics": "./src/mathematics.ts",
|
||||
"./mobile-editor": "./src/mobile-editor.ts",
|
||||
"./mobile-ui": "./src/mobile-ui.ts"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { memoTemplateSeedTranslations } from "../memo-template-seeds";
|
||||
|
||||
// Shared by the PWA and native mobile clients.
|
||||
export const enUS = {
|
||||
common: {
|
||||
@@ -1257,28 +1259,26 @@ export const enUS = {
|
||||
},
|
||||
templates: {
|
||||
title: "Template Center",
|
||||
description: "Start notes instantly with preset structures or customize your reusable templates.",
|
||||
create: "New Custom Template",
|
||||
description: "Create, edit, and reuse your note templates.",
|
||||
create: "New Template",
|
||||
useTemplate: "New note from template",
|
||||
useThisTemplate: "Use Template",
|
||||
previewTemplate: "Preview",
|
||||
previewTitle: "Template Preview",
|
||||
createDescription: "Create a reusable template from scratch.",
|
||||
myTemplates: "My Custom Templates",
|
||||
myTemplatesSubtitle: "Saved personal templates ready to reuse and edit anytime",
|
||||
builtIn: "Recommended Templates",
|
||||
builtInSubtitle: "Ready-to-use standard structures to jumpstart your writing",
|
||||
savedDescription: "Custom template saved from a note.",
|
||||
emptyMyTemplates: "No custom templates yet",
|
||||
emptyMyTemplatesHint: "Click \"New Custom Template\" above or save any note as a template directly in the editor.",
|
||||
myTemplates: "Templates",
|
||||
myTemplatesSubtitle: "Every template belongs to this workspace and can be edited or deleted",
|
||||
savedDescription: "A reusable note template.",
|
||||
emptyMyTemplates: "No templates yet",
|
||||
emptyMyTemplatesHint: "Click \"New Template\" above or save any note as a template directly in the editor.",
|
||||
delete: "Delete template",
|
||||
deleteConfirmTitle: "Delete template \"{{name}}\"",
|
||||
deleteConfirmDescription: "Are you sure you want to delete this template? This operation cannot be undone.",
|
||||
saveAsTemplate: "Save as template",
|
||||
saveAsTemplateSuccess: "Saved as custom template",
|
||||
saveAsTemplateSuccess: "Saved as template",
|
||||
edit: "Edit template",
|
||||
editTemplateTitle: "Edit Custom Template",
|
||||
createTemplateTitle: "Create Custom Template",
|
||||
editTemplateTitle: "Edit Template",
|
||||
createTemplateTitle: "Create Template",
|
||||
name: "Template name",
|
||||
namePlaceholder: "e.g., Weekly Summary / Reading Card",
|
||||
descriptionField: "Template description",
|
||||
@@ -1295,40 +1295,7 @@ export const enUS = {
|
||||
templateSaved: "Template saved",
|
||||
templateCreated: "Template created",
|
||||
unavailable: "Select an available notebook before creating a note.",
|
||||
badgeBuiltIn: "Built-in",
|
||||
badgeCustom: "Custom",
|
||||
items: {
|
||||
quickNote: {
|
||||
title: "Quick Spark",
|
||||
description: "Capture fleeting thoughts, ideas, links, and immediate action items.",
|
||||
contentMarkdown: "## 💡 Fleeting Thoughts\n\n- \n\n## 📌 Context & Notes\n\n\n\n## 🚀 Next Actions\n\n- [ ] ",
|
||||
},
|
||||
meeting: {
|
||||
title: "Meeting Minutes",
|
||||
description: "Structured log for agenda, key decisions, and action items with owners.",
|
||||
contentMarkdown: "# 📝 Meeting Minutes\n\n- **Time**:\n- **Host/Recorder**:\n- **Attendees**:\n\n---\n\n## 🎯 Goal\n\n- \n\n## 💬 Discussion & Decisions\n\n1. **[Topic 1]**\n - Points:\n - ✅ **Decision**:\n\n2. **[Topic 2]**\n - Points:\n - ✅ **Decision**:\n\n## 📋 Action Items\n\n- [ ] **[Owner]** Task description (Due: MM-DD)\n- [ ] **[Owner]** Task description (Due: MM-DD)\n",
|
||||
},
|
||||
weeklyReview: {
|
||||
title: "Weekly Review & Status",
|
||||
description: "Summarize weekly highlights, blockers, and next week's key priorities.",
|
||||
contentMarkdown: "# 🗓️ Weekly Status Report\n\n## 🌟 Highlights\n\n- [x] **[Project/Feature]** Accomplishment details\n- [x] **[Project/Feature]** Accomplishment details\n\n## 🚧 Blockers & Risks\n\n- ⚠️ **Blocker**: Reason and required support\n\n## 🎯 Next Week Priorities\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 Reflection & Insights\n\n- \n",
|
||||
},
|
||||
reading: {
|
||||
title: "Reading Note Card",
|
||||
description: "Extract key takeaways, quotes, reflections, and connected concepts.",
|
||||
contentMarkdown: "# 📖 Reading Note Card\n\n- **Book/Article**:\n- **Author/Source**:\n- **Rating**: ⭐⭐⭐⭐⭐\n\n---\n\n## 💡 Key Takeaway\n\n> \n\n## ✍️ Highlights & Quotes\n\n> [Quote content]\n> —— *Original Source*\n\n## 🧠 Personal Reflections\n\n- \n\n## 🔗 Action & Practice\n\n- [ ] **Action Plan**:\n",
|
||||
},
|
||||
okr: {
|
||||
title: "Goal & Task Breakdown",
|
||||
description: "Define OKRs, Key Results, milestones, and task checklists.",
|
||||
contentMarkdown: "# 🎯 Goal Breakdown\n\n- **Period**:\n- **Owner**:\n\n---\n\n## 📌 Objective\n\n> \n\n## 📈 Key Results\n\n- **KR 1**: Target metric -> Current progress\n- **KR 2**: Target metric -> Current progress\n\n## 🗓️ Milestones\n\n- [ ] **Phase 1 (Date)**: Target\n- [ ] **Phase 2 (Date)**: Target\n\n## 📋 Execution Checklist\n\n- [ ] \n- [ ] \n",
|
||||
},
|
||||
postMortem: {
|
||||
title: "Problem & Post-mortem",
|
||||
description: "Document incident symptoms, 5-Whys root cause, and preventive measures.",
|
||||
contentMarkdown: "# 🔍 Post-mortem & Problem Investigation\n\n- **Occurred At**:\n- **Impact Scope**:\n- **Status**: Resolved / In-Progress\n\n---\n\n## 🚨 Symptoms & Impact\n\n\n\n## 🛠️ Investigation & Fix Steps\n\n1. \n2. \n\n## 🔬 Root Cause Analysis (5 Whys)\n\n- **Root Cause**:\n\n## 🛡️ Action Items & Prevention\n\n- [ ] **[Short-term]** \n- [ ] **[Long-term]** \n",
|
||||
},
|
||||
},
|
||||
items: memoTemplateSeedTranslations("en-US"),
|
||||
},
|
||||
pwa: {
|
||||
checkingTitle: "New version found",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { memoTemplateSeedTranslations } from "../memo-template-seeds";
|
||||
|
||||
// Shared by the PWA and native mobile clients.
|
||||
export const zhCN = {
|
||||
common: {
|
||||
@@ -1257,28 +1259,26 @@ export const zhCN = {
|
||||
},
|
||||
templates: {
|
||||
title: "模板中心",
|
||||
description: "选择预设结构快捷创建笔记,或打造你的专属自定义模板。",
|
||||
create: "新建自定义模板",
|
||||
description: "创建、修改和复用你的笔记模板。",
|
||||
create: "新建模板",
|
||||
useTemplate: "从模板新建笔记",
|
||||
useThisTemplate: "使用此模板新建",
|
||||
previewTemplate: "预览",
|
||||
previewTitle: "模板结构预览",
|
||||
createDescription: "从零开始创建一个可重复使用的模板。",
|
||||
myTemplates: "我的自定义模板",
|
||||
myTemplatesSubtitle: "已保存的专属笔记模板,可随时复用与修改",
|
||||
builtIn: "内置推荐模板",
|
||||
builtInSubtitle: "开箱即用的标准笔记结构,点击即可快捷生成",
|
||||
savedDescription: "基于已保存笔记的自定义模板。",
|
||||
emptyMyTemplates: "暂无自定义模板",
|
||||
emptyMyTemplatesHint: "你可以点击上方「新建自定义模板」,或在笔记编辑器中将常用笔记另存为模板。",
|
||||
myTemplates: "模板",
|
||||
myTemplatesSubtitle: "所有模板都属于当前工作区,可以自由修改或删除",
|
||||
savedDescription: "可重复使用的笔记模板。",
|
||||
emptyMyTemplates: "暂无模板",
|
||||
emptyMyTemplatesHint: "你可以点击上方「新建模板」,或在笔记编辑器中将常用笔记另存为模板。",
|
||||
delete: "删除模板",
|
||||
deleteConfirmTitle: "删除模板「{{name}}」",
|
||||
deleteConfirmDescription: "确定要删除该模板吗?此操作不可恢复。",
|
||||
saveAsTemplate: "另存为模板",
|
||||
saveAsTemplateSuccess: "已成功保存为自定义模板",
|
||||
saveAsTemplateSuccess: "已成功保存为模板",
|
||||
edit: "编辑模板",
|
||||
editTemplateTitle: "编辑自定义模板",
|
||||
createTemplateTitle: "新建自定义模板",
|
||||
editTemplateTitle: "编辑模板",
|
||||
createTemplateTitle: "新建模板",
|
||||
name: "模板名称",
|
||||
namePlaceholder: "例如:每周工作总结 / 论文阅读卡片",
|
||||
descriptionField: "模板说明",
|
||||
@@ -1295,40 +1295,7 @@ export const zhCN = {
|
||||
templateSaved: "模板已保存",
|
||||
templateCreated: "模板已创建",
|
||||
unavailable: "当前无法创建笔记,请先选择可用笔记本。",
|
||||
badgeBuiltIn: "内置预设",
|
||||
badgeCustom: "自定义",
|
||||
items: {
|
||||
quickNote: {
|
||||
title: "灵感速记",
|
||||
description: "快速捕捉闪念、临时灵感、资料链接与即刻行动。",
|
||||
contentMarkdown: "## 💡 闪念记录\n\n- \n\n## 📌 背景与补充说明\n\n\n\n## 🚀 下一步动作\n\n- [ ] ",
|
||||
},
|
||||
meeting: {
|
||||
title: "会议纪要",
|
||||
description: "结构化记录议题背景、核心结论与带负责人的待办事项。",
|
||||
contentMarkdown: "# 📝 会议纪要\n\n- **时间**:\n- **主持人/记录人**:\n- **参会人**:\n\n---\n\n## 🎯 会议目标\n\n- \n\n## 💬 核心讨论与决策\n\n1. **[议题 1]**\n - 讨论要点:\n - ✅ **决议**:\n\n2. **[议题 2]**\n - 讨论要点:\n - ✅ **决议**:\n\n## 📋 待办事项 (Action Items)\n\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n",
|
||||
},
|
||||
weeklyReview: {
|
||||
title: "周报与进展复盘",
|
||||
description: "梳理本周核心产出、风险卡点与下周关键优先级。",
|
||||
contentMarkdown: "# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 总结与思考\n\n- \n",
|
||||
},
|
||||
reading: {
|
||||
title: "深度阅读卡片",
|
||||
description: "提炼核心观点、精妙摘录、个人理解与关联知识卡片。",
|
||||
contentMarkdown: "# 📖 深度阅读卡片\n\n- **书名/文章**:\n- **作者/来源**:\n- **推荐指数**:⭐⭐⭐⭐⭐\n\n---\n\n## 💡 一句话总结 (Key Takeaway)\n\n> \n\n## ✍️ 核心观点与金句摘录\n\n> [摘录内容]\n> —— *原书/原文*\n\n## 🧠 我的理解与延伸思考\n\n- \n\n## 🔗 关联知识与行动\n\n- [ ] **落地实践**:\n",
|
||||
},
|
||||
okr: {
|
||||
title: "目标与任务拆解",
|
||||
description: "明确 OKR 目标、关键结果、里程碑与具体执行清单。",
|
||||
contentMarkdown: "# 🎯 目标拆解\n\n- **周期**:\n- **负责人**:\n\n---\n\n## 📌 目标 (Objective)\n\n> \n\n## 📈 关键结果 (Key Results)\n\n- **KR 1**:期望指标 -> 当前进度\n- **KR 2**:期望指标 -> 当前进度\n\n## 🗓️ 里程碑节点 (Milestones)\n\n- [ ] **阶段一 (日期)**:完成标的\n- [ ] **阶段二 (日期)**:完成标的\n\n## 📋 执行任务清单\n\n- [ ] \n- [ ] \n",
|
||||
},
|
||||
postMortem: {
|
||||
title: "问题排查与复盘",
|
||||
description: "记录故障现象、根因分析 (5 Whys) 与防范机制。",
|
||||
contentMarkdown: "# 🔍 问题排查与复盘 (Post-mortem)\n\n- **发生时间**:\n- **影响范围**:\n- **处理状态**:已解决 / 处理中\n\n---\n\n## 🚨 故障现象与影响\n\n\n\n## 🛠️ 排查过程与解决方案\n\n1. \n2. \n\n## 🔬 根因分析 (Root Cause / 5 Whys)\n\n- **根本原因**:\n\n## 🛡️ 预防措施 (Action Items)\n\n- [ ] **[短期规避]** \n- [ ] **[长期优化]** \n",
|
||||
},
|
||||
},
|
||||
items: memoTemplateSeedTranslations("zh-CN"),
|
||||
},
|
||||
pwa: {
|
||||
checkingTitle: "发现新版本",
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from "./markdown-image";
|
||||
export * from "./memo-safety";
|
||||
export * from "./memo-conflict";
|
||||
export * from "./memo-content-style";
|
||||
export * from "./memo-template-seeds";
|
||||
export * from "./note-links";
|
||||
export * from "./notebooks";
|
||||
export * from "./revision-diff";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
DEFAULT_MEMO_TEMPLATE_SEEDS,
|
||||
defaultMemoTemplateId,
|
||||
localizeMemoTemplateSeed,
|
||||
} from "./memo-template-seeds.ts";
|
||||
|
||||
describe("memo template seeds", () => {
|
||||
test("provides six unique editable starter templates in both supported languages", () => {
|
||||
expect(DEFAULT_MEMO_TEMPLATE_SEEDS).toHaveLength(6);
|
||||
expect(new Set(DEFAULT_MEMO_TEMPLATE_SEEDS.map((seed) => seed.key)).size).toBe(6);
|
||||
expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "zh-CN").title).toBe("灵感速记");
|
||||
expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "en-GB").title).toBe("Quick Spark");
|
||||
});
|
||||
|
||||
test("uses workspace-scoped deterministic ids", () => {
|
||||
expect(defaultMemoTemplateId("ws_1", "meeting")).toBe("ws_1_template_meeting");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
export type MemoTemplateSeedLocale = "zh-CN" | "en-US";
|
||||
|
||||
export type MemoTemplateSeedTranslation = {
|
||||
title: string;
|
||||
description: string;
|
||||
contentMarkdown: string;
|
||||
};
|
||||
|
||||
export type MemoTemplateSeed = {
|
||||
key: "quick-note" | "meeting" | "weekly-review" | "reading" | "okr" | "post-mortem";
|
||||
translationKey: "quickNote" | "meeting" | "weeklyReview" | "reading" | "okr" | "postMortem";
|
||||
tag: string;
|
||||
translations: Record<MemoTemplateSeedLocale, MemoTemplateSeedTranslation>;
|
||||
};
|
||||
|
||||
const seed = (
|
||||
metadata: Omit<MemoTemplateSeed, "translations">,
|
||||
zhCN: MemoTemplateSeedTranslation,
|
||||
enUS: MemoTemplateSeedTranslation,
|
||||
): MemoTemplateSeed => ({
|
||||
...metadata,
|
||||
translations: { "zh-CN": zhCN, "en-US": enUS },
|
||||
});
|
||||
|
||||
export const DEFAULT_MEMO_TEMPLATE_SEEDS: readonly MemoTemplateSeed[] = [
|
||||
seed(
|
||||
{ key: "quick-note", translationKey: "quickNote", tag: "quick-note" },
|
||||
{
|
||||
title: "灵感速记",
|
||||
description: "快速捕捉闪念、临时灵感、资料链接与即刻行动。",
|
||||
contentMarkdown: "## 💡 闪念记录\n\n- \n\n## 📌 背景与补充说明\n\n\n\n## 🚀 下一步动作\n\n- [ ] ",
|
||||
},
|
||||
{
|
||||
title: "Quick Spark",
|
||||
description: "Capture fleeting thoughts, ideas, links, and immediate action items.",
|
||||
contentMarkdown: "## 💡 Fleeting Thoughts\n\n- \n\n## 📌 Context & Notes\n\n\n\n## 🚀 Next Actions\n\n- [ ] ",
|
||||
},
|
||||
),
|
||||
seed(
|
||||
{ key: "meeting", translationKey: "meeting", tag: "meeting" },
|
||||
{
|
||||
title: "会议纪要",
|
||||
description: "结构化记录议题背景、核心结论与带负责人的待办事项。",
|
||||
contentMarkdown: "# 📝 会议纪要\n\n- **时间**:\n- **主持人/记录人**:\n- **参会人**:\n\n---\n\n## 🎯 会议目标\n\n- \n\n## 💬 核心讨论与决策\n\n1. **[议题 1]**\n - 讨论要点:\n - ✅ **决议**:\n\n2. **[议题 2]**\n - 讨论要点:\n - ✅ **决议**:\n\n## 📋 待办事项 (Action Items)\n\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n- [ ] **[负责人]** 任务描述 (截止日期:MM-DD)\n",
|
||||
},
|
||||
{
|
||||
title: "Meeting Minutes",
|
||||
description: "Structured log for agenda, key decisions, and action items with owners.",
|
||||
contentMarkdown: "# 📝 Meeting Minutes\n\n- **Time**:\n- **Host/Recorder**:\n- **Attendees**:\n\n---\n\n## 🎯 Goal\n\n- \n\n## 💬 Discussion & Decisions\n\n1. **[Topic 1]**\n - Points:\n - ✅ **Decision**:\n\n2. **[Topic 2]**\n - Points:\n - ✅ **Decision**:\n\n## 📋 Action Items\n\n- [ ] **[Owner]** Task description (Due: MM-DD)\n- [ ] **[Owner]** Task description (Due: MM-DD)\n",
|
||||
},
|
||||
),
|
||||
seed(
|
||||
{ key: "weekly-review", translationKey: "weeklyReview", tag: "weekly-review" },
|
||||
{
|
||||
title: "周报与进展复盘",
|
||||
description: "梳理本周核心产出、风险卡点与下周关键优先级。",
|
||||
contentMarkdown: "# 🗓️ 工作周报\n\n## 🌟 本周核心进展 (Highlights)\n\n- [x] **[项目/功能]** 完成情况与成果说明\n- [x] **[项目/功能]** 完成情况与成果说明\n\n## 🚧 卡点与风险 (Blockers & Risks)\n\n- ⚠️ **阻塞项**:原因及所需支持\n\n## 🎯 下周优先级 (Next Week Priorities)\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 总结与思考\n\n- \n",
|
||||
},
|
||||
{
|
||||
title: "Weekly Review & Status",
|
||||
description: "Summarize weekly highlights, blockers, and next week's key priorities.",
|
||||
contentMarkdown: "# 🗓️ Weekly Status Report\n\n## 🌟 Highlights\n\n- [x] **[Project/Feature]** Accomplishment details\n- [x] **[Project/Feature]** Accomplishment details\n\n## 🚧 Blockers & Risks\n\n- ⚠️ **Blocker**: Reason and required support\n\n## 🎯 Next Week Priorities\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 Reflection & Insights\n\n- \n",
|
||||
},
|
||||
),
|
||||
seed(
|
||||
{ key: "reading", translationKey: "reading", tag: "reading" },
|
||||
{
|
||||
title: "深度阅读卡片",
|
||||
description: "提炼核心观点、精妙摘录、个人理解与关联知识卡片。",
|
||||
contentMarkdown: "# 📖 深度阅读卡片\n\n- **书名/文章**:\n- **作者/来源**:\n- **推荐指数**:⭐⭐⭐⭐⭐\n\n---\n\n## 💡 一句话总结 (Key Takeaway)\n\n> \n\n## ✍️ 核心观点与金句摘录\n\n> [摘录内容]\n> —— *原书/原文*\n\n## 🧠 我的理解与延伸思考\n\n- \n\n## 🔗 关联知识与行动\n\n- [ ] **落地实践**:\n",
|
||||
},
|
||||
{
|
||||
title: "Reading Note Card",
|
||||
description: "Extract key takeaways, quotes, reflections, and connected concepts.",
|
||||
contentMarkdown: "# 📖 Reading Note Card\n\n- **Book/Article**:\n- **Author/Source**:\n- **Rating**: ⭐⭐⭐⭐⭐\n\n---\n\n## 💡 Key Takeaway\n\n> \n\n## ✍️ Highlights & Quotes\n\n> [Quote content]\n> —— *Original Source*\n\n## 🧠 Personal Reflections\n\n- \n\n## 🔗 Action & Practice\n\n- [ ] **Action Plan**:\n",
|
||||
},
|
||||
),
|
||||
seed(
|
||||
{ key: "okr", translationKey: "okr", tag: "okr" },
|
||||
{
|
||||
title: "目标与任务拆解",
|
||||
description: "明确 OKR 目标、关键结果、里程碑与具体执行清单。",
|
||||
contentMarkdown: "# 🎯 目标拆解\n\n- **周期**:\n- **负责人**:\n\n---\n\n## 📌 目标 (Objective)\n\n> \n\n## 📈 关键结果 (Key Results)\n\n- **KR 1**:期望指标 -> 当前进度\n- **KR 2**:期望指标 -> 当前进度\n\n## 🗓️ 里程碑节点 (Milestones)\n\n- [ ] **阶段一 (日期)**:完成标的\n- [ ] **阶段二 (日期)**:完成标的\n\n## 📋 执行任务清单\n\n- [ ] \n- [ ] \n",
|
||||
},
|
||||
{
|
||||
title: "Goal & Task Breakdown",
|
||||
description: "Define OKRs, Key Results, milestones, and task checklists.",
|
||||
contentMarkdown: "# 🎯 Goal Breakdown\n\n- **Period**:\n- **Owner**:\n\n---\n\n## 📌 Objective\n\n> \n\n## 📈 Key Results\n\n- **KR 1**: Target metric -> Current progress\n- **KR 2**: Target metric -> Current progress\n\n## 🗓️ Milestones\n\n- [ ] **Phase 1 (Date)**: Target\n- [ ] **Phase 2 (Date)**: Target\n\n## 📋 Execution Checklist\n\n- [ ] \n- [ ] \n",
|
||||
},
|
||||
),
|
||||
seed(
|
||||
{ key: "post-mortem", translationKey: "postMortem", tag: "post-mortem" },
|
||||
{
|
||||
title: "问题排查与复盘",
|
||||
description: "记录故障现象、根因分析 (5 Whys) 与防范机制。",
|
||||
contentMarkdown: "# 🔍 问题排查与复盘 (Post-mortem)\n\n- **发生时间**:\n- **影响范围**:\n- **处理状态**:已解决 / 处理中\n\n---\n\n## 🚨 故障现象与影响\n\n\n\n## 🛠️ 排查过程与解决方案\n\n1. \n2. \n\n## 🔬 根因分析 (Root Cause / 5 Whys)\n\n- **根本原因**:\n\n## 🛡️ 预防措施 (Action Items)\n\n- [ ] **[短期规避]** \n- [ ] **[长期优化]** \n",
|
||||
},
|
||||
{
|
||||
title: "Problem & Post-mortem",
|
||||
description: "Document incident symptoms, 5-Whys root cause, and preventive measures.",
|
||||
contentMarkdown: "# 🔍 Post-mortem & Problem Investigation\n\n- **Occurred At**:\n- **Impact Scope**:\n- **Status**: Resolved / In-Progress\n\n---\n\n## 🚨 Symptoms & Impact\n\n\n\n## 🛠️ Investigation & Fix Steps\n\n1. \n2. \n\n## 🔬 Root Cause Analysis (5 Whys)\n\n- **Root Cause**:\n\n## 🛡️ Action Items & Prevention\n\n- [ ] **[Short-term]** \n- [ ] **[Long-term]** \n",
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
export const normalizeMemoTemplateSeedLocale = (
|
||||
locale: string | null | undefined,
|
||||
): MemoTemplateSeedLocale => locale?.toLowerCase().startsWith("en") ? "en-US" : "zh-CN";
|
||||
|
||||
export const localizeMemoTemplateSeed = (
|
||||
templateSeed: MemoTemplateSeed,
|
||||
locale: string | null | undefined,
|
||||
): MemoTemplateSeedTranslation => templateSeed.translations[normalizeMemoTemplateSeedLocale(locale)];
|
||||
|
||||
export const defaultMemoTemplateId = (workspaceId: string, seedKey: string) =>
|
||||
`${workspaceId}_template_${seedKey}`;
|
||||
|
||||
export const memoTemplateSeedTranslations = (locale: MemoTemplateSeedLocale) =>
|
||||
Object.fromEntries(DEFAULT_MEMO_TEMPLATE_SEEDS.map((item) => [
|
||||
item.translationKey,
|
||||
item.translations[locale],
|
||||
])) as Record<MemoTemplateSeed["translationKey"], MemoTemplateSeedTranslation>;
|
||||
Reference in new issue
Block a user