feat: unify editable note templates

This commit is contained in:
tianma-if committed 2026-08-15 21:02:55 +08:00
1 parent db5cd5431b
commit c9d0785c81
25 files changed
+432 -780

No files matched your search

+7 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
});
});
+6 -1
View File
@@ -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,
}),
+6 -3
View File
@@ -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);
});
});
+32 -45
View File
@@ -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>
+4 -7
View File
@@ -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 -20
View File
@@ -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: "【周报】",
+7 -65
View File
@@ -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,
+54 -147
View File
@@ -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>
);
};
+4 -6
View File
@@ -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")}
-109
View File
@@ -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;
+1
View File
@@ -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"
+13 -46
View File
@@ -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",
+13 -46
View File
@@ -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: "发现新版本",
+1
View File
@@ -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");
});
});
+122
View File
@@ -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>;