手机端改用抽屉侧栏并移除底部导航,补充 Markdown 编辑工具栏,收拢开发者选项并改善社区扩展下载错误反馈。 Co-Authored-By: Codex <noreply@anthropic.com>
3013 lines
95 KiB
TypeScript
3013 lines
95 KiB
TypeScript
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
|
||
import type { ComponentChildren } from "preact";
|
||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||
import {
|
||
BookOpen,
|
||
Bell,
|
||
Bold,
|
||
Box,
|
||
Calendar,
|
||
CheckCircle,
|
||
CheckSquare,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Circle,
|
||
Clipboard,
|
||
Clock,
|
||
Cloud,
|
||
Code,
|
||
Command,
|
||
CornerUpLeft,
|
||
CornerUpRight,
|
||
Download,
|
||
Feather,
|
||
FilePlus,
|
||
FileText,
|
||
Folder,
|
||
GitBranch,
|
||
Grid,
|
||
Hash,
|
||
Home,
|
||
Image as ImageIcon,
|
||
Italic,
|
||
Link,
|
||
List,
|
||
Menu,
|
||
Moon,
|
||
Plus,
|
||
Repeat,
|
||
Search,
|
||
Settings,
|
||
Sliders,
|
||
Star,
|
||
Tag,
|
||
User,
|
||
X,
|
||
Zap,
|
||
} from "preact-feather";
|
||
import { parsePageMetaLastModified } from "../lib/page_meta.ts";
|
||
import type { PlainleafEditorAction } from "./plainleaf_markdown_toolbar.ts";
|
||
|
||
type BeforeInstallPromptEvent = Event & {
|
||
prompt: () => Promise<void>;
|
||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||
};
|
||
import {
|
||
calendarMonth,
|
||
dueReminderTasks,
|
||
groupScheduledTasks,
|
||
groupLogbookTasks,
|
||
groupTasksByProject,
|
||
isOverdueTask,
|
||
localDateKey,
|
||
logbookTasks,
|
||
nextRepeatedTask,
|
||
parseNaturalTaskDate,
|
||
plainleafTaskInbox,
|
||
projectForTask,
|
||
projectSummaries,
|
||
sortTodayTasks,
|
||
staleProjects,
|
||
subtaskProgress,
|
||
taskDate,
|
||
taskDepth,
|
||
taskHealthIssues,
|
||
taskMatchesFilters,
|
||
tasksForCalendarDate,
|
||
tasksForAnytime,
|
||
tasksForInbox,
|
||
type PlainleafNewTask,
|
||
type PlainleafDeletedTaskBlock,
|
||
type PlainleafTask,
|
||
type PlainleafTaskList,
|
||
type PlainleafTaskPatch,
|
||
type PlainleafRepeatRule,
|
||
repeatRuleLabels,
|
||
} from "./plainleaf_tasks.ts";
|
||
|
||
export type {
|
||
PlainleafDeletedTaskBlock,
|
||
PlainleafNewTask,
|
||
PlainleafRepeatRule,
|
||
PlainleafTask,
|
||
PlainleafTaskPatch,
|
||
} from "./plainleaf_tasks.ts";
|
||
|
||
export type PageTreeFile = {
|
||
kind: "page";
|
||
name: string;
|
||
label: string;
|
||
meta: PageMeta;
|
||
};
|
||
|
||
export type PageTreeFolder = {
|
||
kind: "folder";
|
||
name: string;
|
||
path: string;
|
||
children: PageTreeNode[];
|
||
};
|
||
|
||
export type PageTreeNode = PageTreeFile | PageTreeFolder;
|
||
|
||
export type PlainleafSearchScope = "all" | "title" | "content";
|
||
|
||
export type PlainleafSearchResult = {
|
||
page: string;
|
||
title: string;
|
||
path: string;
|
||
excerpt: string;
|
||
updated?: string;
|
||
};
|
||
|
||
export function plainleafTaskReference(task: PlainleafTask): string {
|
||
const ref = task.ref.trim();
|
||
if (ref.includes("@") || ref.includes("#") || ref.includes("$")) return ref;
|
||
return `${task.page}$${ref}`;
|
||
}
|
||
|
||
export type PlainleafAdvancedTool =
|
||
"commands" | "graph" | "libraries" | "configuration" | "system-pages";
|
||
|
||
export type { PlainleafEditorAction } from "./plainleaf_markdown_toolbar.ts";
|
||
|
||
type PlainleafView =
|
||
"notes" | "search" | "tasks" | "tags" | "settings" | "more";
|
||
|
||
type MutableFolder = {
|
||
name: string;
|
||
path: string;
|
||
folders: Map<string, MutableFolder>;
|
||
pages: PageTreeFile[];
|
||
};
|
||
|
||
const systemPageRoots = ["library", "repositories", "std"];
|
||
const systemTags = ["meta", "template", "library", "repository"];
|
||
const showSystemContentStorageKey = "plainleaf.showSystemContent";
|
||
const developerToolsStorageKey = "plainleaf.developerTools";
|
||
|
||
function readShowSystemContent(): boolean {
|
||
try {
|
||
return (
|
||
globalThis.localStorage?.getItem(showSystemContentStorageKey) === "true"
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function storeShowSystemContent(show: boolean): void {
|
||
try {
|
||
globalThis.localStorage?.setItem(showSystemContentStorageKey, String(show));
|
||
} catch {
|
||
// Storage can be unavailable in private browsing; the in-memory switch
|
||
// still works for the current session.
|
||
}
|
||
}
|
||
|
||
function readDeveloperTools(): boolean {
|
||
try {
|
||
return (
|
||
globalThis.localStorage?.getItem(developerToolsStorageKey) === "true"
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function storeDeveloperTools(show: boolean): void {
|
||
try {
|
||
globalThis.localStorage?.setItem(developerToolsStorageKey, String(show));
|
||
} catch {
|
||
// Keep the setting for this session when persistent storage is unavailable.
|
||
}
|
||
}
|
||
|
||
export function isPlainleafSystemPage(page: PageMeta): boolean {
|
||
const name = (page.name || page.ref || "").toLocaleLowerCase();
|
||
const root = name.split("/")[0];
|
||
if (systemPageRoots.includes(root) || name === "config") return true;
|
||
return (page.tags ?? []).some((tag) => {
|
||
const normalized = tag.toLocaleLowerCase();
|
||
return (
|
||
systemTags.includes(normalized) ||
|
||
normalized.startsWith("meta/") ||
|
||
normalized.startsWith("library/") ||
|
||
normalized.startsWith("repository/")
|
||
);
|
||
});
|
||
}
|
||
|
||
export function visiblePlainleafPages(
|
||
allPages: PageMeta[],
|
||
showSystemContent = false,
|
||
): PageMeta[] {
|
||
return allPages.filter(
|
||
(page) =>
|
||
!!page.name &&
|
||
!page._isAspiring &&
|
||
page.pageDecoration?.hide !== true &&
|
||
(showSystemContent || !isPlainleafSystemPage(page)),
|
||
);
|
||
}
|
||
|
||
export function plainleafPageLabel(name: string, indexPage = "index"): string {
|
||
if (name === indexPage) return "首页";
|
||
const segment = name.split("/").at(-1) || name;
|
||
const systemLabels: Record<string, string> = {
|
||
CONFIG: "系统配置",
|
||
Library: "扩展库",
|
||
Repositories: "扩展仓库",
|
||
Std: "内置",
|
||
};
|
||
return systemLabels[segment] ?? segment;
|
||
}
|
||
|
||
export function collectPlainleafTags(
|
||
allPages: PageMeta[],
|
||
showSystemContent = false,
|
||
): Array<{ name: string; count: number }> {
|
||
const counts = new Map<string, number>();
|
||
for (const page of visiblePlainleafPages(allPages, showSystemContent)) {
|
||
for (const tag of page.tags ?? []) {
|
||
const normalized = tag.toLocaleLowerCase();
|
||
if (
|
||
!showSystemContent &&
|
||
(systemTags.includes(normalized) ||
|
||
normalized.startsWith("meta/") ||
|
||
normalized.startsWith("library/") ||
|
||
normalized.startsWith("repository/"))
|
||
) {
|
||
continue;
|
||
}
|
||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||
}
|
||
}
|
||
return [...counts.entries()]
|
||
.map(([name, count]) => ({ name, count }))
|
||
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "zh-CN"));
|
||
}
|
||
|
||
function compareTreeNodes(a: PageTreeNode, b: PageTreeNode): number {
|
||
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
||
const aLabel = a.kind === "page" ? a.label : a.name;
|
||
const bLabel = b.kind === "page" ? b.label : b.name;
|
||
return aLabel.localeCompare(bLabel, "zh-CN");
|
||
}
|
||
|
||
function parentFolderPaths(page?: string): string[] {
|
||
const parts = page?.split("/").slice(0, -1) ?? [];
|
||
return parts.map((_, index) => parts.slice(0, index + 1).join("/"));
|
||
}
|
||
|
||
function finalizeFolder(folder: MutableFolder): PageTreeNode[] {
|
||
return [
|
||
...Array.from(folder.folders.values()).map((child): PageTreeFolder => ({
|
||
kind: "folder",
|
||
name: child.name,
|
||
path: child.path,
|
||
children: finalizeFolder(child),
|
||
})),
|
||
...folder.pages,
|
||
].sort(compareTreeNodes);
|
||
}
|
||
|
||
/** Derives a display-only folder tree from user-owned SilverBullet pages. */
|
||
export function buildPageTree(
|
||
allPages: PageMeta[],
|
||
indexPage = "index",
|
||
showSystemContent = false,
|
||
): PageTreeNode[] {
|
||
const root: MutableFolder = {
|
||
name: "",
|
||
path: "",
|
||
folders: new Map(),
|
||
pages: [],
|
||
};
|
||
|
||
for (const page of visiblePlainleafPages(allPages, showSystemContent)) {
|
||
const parts = page.name.split("/").filter(Boolean);
|
||
if (parts.length === 0) continue;
|
||
|
||
let parent = root;
|
||
for (const part of parts.slice(0, -1)) {
|
||
let folder = parent.folders.get(part);
|
||
if (!folder) {
|
||
const path = parent.path ? `${parent.path}/${part}` : part;
|
||
folder = {
|
||
name: plainleafPageLabel(part, indexPage),
|
||
path,
|
||
folders: new Map(),
|
||
pages: [],
|
||
};
|
||
parent.folders.set(part, folder);
|
||
}
|
||
parent = folder;
|
||
}
|
||
|
||
parent.pages.push({
|
||
kind: "page",
|
||
name: page.name,
|
||
label: plainleafPageLabel(page.name, indexPage),
|
||
meta: page,
|
||
});
|
||
}
|
||
|
||
return finalizeFolder(root);
|
||
}
|
||
|
||
function countPages(nodes: PageTreeNode[]): number {
|
||
return nodes.reduce(
|
||
(total, node) =>
|
||
total + (node.kind === "page" ? 1 : countPages(node.children)),
|
||
0,
|
||
);
|
||
}
|
||
|
||
function pageTimestamp(page: PageMeta): number {
|
||
return page.lastOpened ?? parsePageMetaLastModified(page.lastModified) ?? 0;
|
||
}
|
||
|
||
function formatUpdated(page: PageMeta): string {
|
||
const timestamp = pageTimestamp(page);
|
||
if (!timestamp) return "";
|
||
const date = new Date(timestamp);
|
||
const now = new Date();
|
||
const sameDay = date.toDateString() === now.toDateString();
|
||
if (sameDay) {
|
||
return date.toLocaleTimeString("zh-CN", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
return date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
||
}
|
||
|
||
function PageTree({
|
||
nodes,
|
||
currentPage,
|
||
expanded,
|
||
onToggleFolder,
|
||
onOpenPage,
|
||
depth = 0,
|
||
}: {
|
||
nodes: PageTreeNode[];
|
||
currentPage?: string;
|
||
expanded: Set<string>;
|
||
onToggleFolder: (path: string) => void;
|
||
onOpenPage: (page: string) => void;
|
||
depth?: number;
|
||
}) {
|
||
return (
|
||
<div class="pl-page-tree">
|
||
{nodes.map((node) => {
|
||
if (node.kind === "page") {
|
||
return (
|
||
<button
|
||
key={`page:${node.name}`}
|
||
type="button"
|
||
class={`pl-tree-row pl-tree-page ${currentPage === node.name ? "is-active" : ""}`}
|
||
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
||
title={node.name}
|
||
onClick={() => onOpenPage(node.name)}
|
||
>
|
||
<span class="pl-tree-disclosure" />
|
||
<FileText size={15} />
|
||
<span class="pl-tree-label">{node.label}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const isExpanded = expanded.has(node.path);
|
||
return (
|
||
<div class="pl-tree-folder" key={`folder:${node.path}`}>
|
||
<button
|
||
type="button"
|
||
class="pl-tree-row"
|
||
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
||
title={node.path}
|
||
aria-expanded={isExpanded}
|
||
onClick={() => onToggleFolder(node.path)}
|
||
>
|
||
<span class="pl-tree-disclosure">
|
||
{isExpanded ? (
|
||
<ChevronDown size={14} />
|
||
) : (
|
||
<ChevronRight size={14} />
|
||
)}
|
||
</span>
|
||
<Folder size={15} />
|
||
<span class="pl-tree-label">{node.name}</span>
|
||
<span class="pl-tree-count">{countPages(node.children)}</span>
|
||
</button>
|
||
{isExpanded && (
|
||
<PageTree
|
||
nodes={node.children}
|
||
currentPage={currentPage}
|
||
expanded={expanded}
|
||
onToggleFolder={onToggleFolder}
|
||
onOpenPage={onOpenPage}
|
||
depth={depth + 1}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NavButton({
|
||
view,
|
||
activeView,
|
||
label,
|
||
count,
|
||
children,
|
||
onClick,
|
||
}: {
|
||
view: PlainleafView;
|
||
activeView: PlainleafView;
|
||
label: string;
|
||
count?: number;
|
||
children: ComponentChildren;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
class={`pl-primary-nav-item ${activeView === view ? "is-active" : ""}`}
|
||
onClick={onClick}
|
||
>
|
||
{children}
|
||
<span>{label}</span>
|
||
{count !== undefined && count > 0 && (
|
||
<span class="pl-nav-count">{count}</span>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function SearchResultRow({
|
||
result,
|
||
onOpen,
|
||
}: {
|
||
result: PlainleafSearchResult;
|
||
onOpen: () => void;
|
||
}) {
|
||
return (
|
||
<button type="button" class="pl-search-result" onClick={onOpen}>
|
||
<span class="pl-search-result-heading">
|
||
<strong>{result.title}</strong>
|
||
<time>{result.updated}</time>
|
||
</span>
|
||
{result.excerpt && (
|
||
<span class="pl-search-excerpt">{result.excerpt}</span>
|
||
)}
|
||
<span class="pl-search-path">{result.path}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function AdvancedTools({
|
||
onOpen,
|
||
}: {
|
||
onOpen: (tool: PlainleafAdvancedTool) => void;
|
||
}) {
|
||
const tools: Array<{
|
||
id: PlainleafAdvancedTool;
|
||
label: string;
|
||
description: string;
|
||
icon: ComponentChildren;
|
||
}> = [
|
||
{
|
||
id: "commands",
|
||
label: "命令面板",
|
||
description: "运行自动化和不常用操作",
|
||
icon: <Command size={16} />,
|
||
},
|
||
{
|
||
id: "graph",
|
||
label: "关系图",
|
||
description: "查看笔记之间的链接",
|
||
icon: <GitBranch size={16} />,
|
||
},
|
||
{
|
||
id: "libraries",
|
||
label: "社区扩展",
|
||
description: "从 GitHub 安装第三方能力",
|
||
icon: <Box size={16} />,
|
||
},
|
||
{
|
||
id: "configuration",
|
||
label: "配置",
|
||
description: "调整高级行为",
|
||
icon: <Sliders size={16} />,
|
||
},
|
||
{
|
||
id: "system-pages",
|
||
label: "系统页面",
|
||
description: "查看默认隐藏的内部页面",
|
||
icon: <FileText size={16} />,
|
||
},
|
||
];
|
||
return (
|
||
<div class="pl-advanced-tools">
|
||
{tools.map((tool) => (
|
||
<button key={tool.id} type="button" onClick={() => onOpen(tool.id)}>
|
||
<span class="pl-advanced-icon">{tool.icon}</span>
|
||
<span>
|
||
<strong>{tool.label}</strong>
|
||
<small>{tool.description}</small>
|
||
</span>
|
||
<ChevronRight size={14} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MarkdownToolbar({
|
||
readOnly,
|
||
onAction,
|
||
}: {
|
||
readOnly: boolean;
|
||
onAction: (action: PlainleafEditorAction) => void;
|
||
}) {
|
||
const button = (
|
||
action: PlainleafEditorAction,
|
||
label: string,
|
||
icon: ComponentChildren,
|
||
) => (
|
||
<button
|
||
type="button"
|
||
aria-label={label}
|
||
title={label}
|
||
disabled={readOnly && action !== "undo" && action !== "redo"}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => onAction(action)}
|
||
>
|
||
{icon}
|
||
</button>
|
||
);
|
||
|
||
return (
|
||
<div
|
||
class="pl-markdown-toolbar"
|
||
role="toolbar"
|
||
aria-label="Markdown 编辑工具"
|
||
>
|
||
<div class="pl-toolbar-group">
|
||
{button("undo", "撤销", <CornerUpLeft size={16} />)}
|
||
{button("redo", "重做", <CornerUpRight size={16} />)}
|
||
</div>
|
||
<span class="pl-toolbar-divider" />
|
||
<div class="pl-toolbar-group">
|
||
{button("heading-1", "一级标题", <strong>H1</strong>)}
|
||
{button("heading-2", "二级标题", <strong>H2</strong>)}
|
||
{button("bold", "粗体", <Bold size={16} />)}
|
||
{button("italic", "斜体", <Italic size={16} />)}
|
||
</div>
|
||
<span class="pl-toolbar-divider" />
|
||
<div class="pl-toolbar-group">
|
||
{button("bullet-list", "项目列表", <List size={16} />)}
|
||
{button("numbered-list", "编号列表", <strong>1.</strong>)}
|
||
{button("task-list", "待办事项", <CheckSquare size={16} />)}
|
||
{button("quote", "引用", <strong>></strong>)}
|
||
{button("code", "代码", <Code size={16} />)}
|
||
</div>
|
||
<span class="pl-toolbar-divider" />
|
||
<div class="pl-toolbar-group">
|
||
{button("link", "插入链接", <Link size={16} />)}
|
||
{button("image", "上传图片或文件", <ImageIcon size={16} />)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const taskListLabels: Record<PlainleafTaskList, string> = {
|
||
inbox: "收件箱",
|
||
today: "今天",
|
||
planned: "计划",
|
||
anytime: "随时",
|
||
projects: "项目",
|
||
logbook: "日志簿",
|
||
};
|
||
|
||
function taskListIcon(list: PlainleafTaskList) {
|
||
const props = { size: 16 };
|
||
switch (list) {
|
||
case "inbox":
|
||
return <Clipboard {...props} />;
|
||
case "today":
|
||
return <Star {...props} />;
|
||
case "planned":
|
||
return <Calendar {...props} />;
|
||
case "anytime":
|
||
return <Circle {...props} />;
|
||
case "projects":
|
||
return <Folder {...props} />;
|
||
case "logbook":
|
||
return <CheckCircle {...props} />;
|
||
}
|
||
}
|
||
|
||
function displayTaskDate(value?: string): string {
|
||
const date = taskDate(value);
|
||
if (!date) return "";
|
||
const today = localDateKey();
|
||
if (date === today) return "今天";
|
||
const tomorrow = new Date();
|
||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||
if (date === localDateKey(tomorrow)) return "明天";
|
||
return new Date(`${date}T00:00:00`).toLocaleDateString("zh-CN", {
|
||
month: "numeric",
|
||
day: "numeric",
|
||
weekday: "short",
|
||
});
|
||
}
|
||
|
||
export function plainleafTaskTitle(task: PlainleafTask): string {
|
||
return task.name.replace(/\s*\[\[[^\]]+\]\]/g, "").trim() || task.name;
|
||
}
|
||
|
||
function TaskRow({
|
||
task,
|
||
depth,
|
||
selected,
|
||
selectionMode,
|
||
checked,
|
||
readOnly,
|
||
progress,
|
||
onSelect,
|
||
onToggleSelection,
|
||
onComplete,
|
||
}: {
|
||
task: PlainleafTask;
|
||
depth: number;
|
||
selected: boolean;
|
||
selectionMode: boolean;
|
||
checked: boolean;
|
||
readOnly: boolean;
|
||
progress?: { completed: number; total: number };
|
||
onSelect: () => void;
|
||
onToggleSelection: () => void;
|
||
onComplete: () => void;
|
||
}) {
|
||
const date = taskDate(task.deadline) ?? taskDate(task.scheduled);
|
||
return (
|
||
<div
|
||
class={`pl-task-center-row ${selected ? "is-selected" : ""} ${checked ? "is-checked" : ""} ${task.done ? "is-complete" : ""}`}
|
||
style={{ "--pl-task-depth": Math.min(depth, 3) }}
|
||
>
|
||
<button
|
||
type="button"
|
||
class="pl-task-center-check"
|
||
disabled={readOnly}
|
||
aria-label={
|
||
selectionMode
|
||
? checked
|
||
? "取消选择"
|
||
: "选择待办"
|
||
: task.done
|
||
? "恢复待办"
|
||
: "完成待办"
|
||
}
|
||
onClick={selectionMode ? onToggleSelection : onComplete}
|
||
>
|
||
{task.done || checked ? (
|
||
<CheckCircle size={19} />
|
||
) : (
|
||
<Circle size={19} />
|
||
)}
|
||
</button>
|
||
<button type="button" class="pl-task-center-copy" onClick={onSelect}>
|
||
<strong>{plainleafTaskTitle(task)}</strong>
|
||
<span>
|
||
<small>{projectForTask(task) ?? task.page}</small>
|
||
{progress && (
|
||
<small class="pl-task-progress">
|
||
{progress.completed}/{progress.total} 子任务
|
||
</small>
|
||
)}
|
||
{date && (
|
||
<time class={isOverdueTask(task) ? "is-overdue" : ""}>
|
||
{displayTaskDate(date)}
|
||
</time>
|
||
)}
|
||
{task.repeat && (
|
||
<Repeat size={12} aria-label={repeatRuleLabels[task.repeat]} />
|
||
)}
|
||
{task.reminder && <Bell size={12} aria-label="已设置提醒" />}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TaskEditor({
|
||
task,
|
||
readOnly,
|
||
onClose,
|
||
onSave,
|
||
onAddSubtask,
|
||
onDelete,
|
||
onOpenSource,
|
||
}: {
|
||
task: PlainleafTask;
|
||
readOnly: boolean;
|
||
onClose: () => void;
|
||
onSave: (patch: PlainleafTaskPatch) => Promise<void>;
|
||
onAddSubtask: (input: PlainleafNewTask) => Promise<void>;
|
||
onDelete: () => Promise<void>;
|
||
onOpenSource: () => void;
|
||
}) {
|
||
const [name, setName] = useState(plainleafTaskTitle(task));
|
||
const [scheduled, setScheduled] = useState(taskDate(task.scheduled) ?? "");
|
||
const [deadline, setDeadline] = useState(taskDate(task.deadline) ?? "");
|
||
const [project, setProject] = useState(task.links?.[0] ?? "");
|
||
const [tags, setTags] = useState((task.tags ?? []).join(" "));
|
||
const [repeat, setRepeat] = useState<PlainleafRepeatRule | "">(
|
||
task.repeat ?? "",
|
||
);
|
||
const [reminder, setReminder] = useState(task.reminder ?? "");
|
||
const [subtask, setSubtask] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setName(plainleafTaskTitle(task));
|
||
setScheduled(taskDate(task.scheduled) ?? "");
|
||
setDeadline(taskDate(task.deadline) ?? "");
|
||
setProject(task.links?.[0] ?? "");
|
||
setTags((task.tags ?? []).join(" "));
|
||
setRepeat(task.repeat ?? "");
|
||
setReminder(task.reminder ?? "");
|
||
}, [
|
||
task.ref,
|
||
task.name,
|
||
task.scheduled,
|
||
task.deadline,
|
||
task.links,
|
||
task.repeat,
|
||
task.reminder,
|
||
]);
|
||
|
||
const save = async () => {
|
||
if (!name.trim()) return;
|
||
setSaving(true);
|
||
try {
|
||
await onSave({
|
||
name: name.trim(),
|
||
scheduled: scheduled || null,
|
||
deadline: deadline || null,
|
||
repeat: repeat || null,
|
||
reminder: reminder || null,
|
||
project: project || null,
|
||
tags: tags.split(/[\s,,]+/).filter(Boolean),
|
||
});
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<aside class="pl-task-inspector" aria-label="待办详情">
|
||
<header>
|
||
<strong>待办详情</strong>
|
||
<button type="button" aria-label="关闭详情" onClick={onClose}>
|
||
<X size={18} />
|
||
</button>
|
||
</header>
|
||
<label>
|
||
标题
|
||
<textarea
|
||
value={name}
|
||
disabled={readOnly}
|
||
rows={3}
|
||
onInput={(event) => setName(event.currentTarget.value)}
|
||
/>
|
||
</label>
|
||
<div class="pl-task-date-fields">
|
||
<label>
|
||
计划日期
|
||
<input
|
||
type="date"
|
||
value={scheduled}
|
||
disabled={readOnly}
|
||
onInput={(event) => setScheduled(event.currentTarget.value)}
|
||
/>
|
||
</label>
|
||
<label>
|
||
截止日期
|
||
<input
|
||
type="date"
|
||
value={deadline}
|
||
disabled={readOnly}
|
||
onInput={(event) => setDeadline(event.currentTarget.value)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label>
|
||
项目
|
||
<input
|
||
value={project}
|
||
disabled={readOnly}
|
||
placeholder="例如:项目/Plainleaf"
|
||
onInput={(event) => setProject(event.currentTarget.value)}
|
||
/>
|
||
</label>
|
||
<label>
|
||
标签
|
||
<input
|
||
value={tags}
|
||
disabled={readOnly}
|
||
placeholder="用空格分隔"
|
||
onInput={(event) => setTags(event.currentTarget.value)}
|
||
/>
|
||
</label>
|
||
<div class="pl-task-date-fields">
|
||
<label>
|
||
重复
|
||
<select
|
||
value={repeat}
|
||
disabled={readOnly}
|
||
onChange={(event) =>
|
||
setRepeat(event.currentTarget.value as typeof repeat)
|
||
}
|
||
>
|
||
<option value="">不重复</option>
|
||
{Object.entries(repeatRuleLabels).map(([value, label]) => (
|
||
<option key={value} value={value}>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
提醒时间
|
||
<input
|
||
type="datetime-local"
|
||
value={reminder}
|
||
disabled={readOnly}
|
||
onInput={(event) => setReminder(event.currentTarget.value)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label class="pl-subtask-field">
|
||
新增子任务
|
||
<span>
|
||
<input
|
||
value={subtask}
|
||
disabled={readOnly}
|
||
placeholder="输入子任务标题"
|
||
onInput={(event) => setSubtask(event.currentTarget.value)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={readOnly || !subtask.trim()}
|
||
onClick={() => {
|
||
void onAddSubtask({ name: subtask.trim() }).then(() =>
|
||
setSubtask(""),
|
||
);
|
||
}}
|
||
>
|
||
<Plus size={15} />
|
||
</button>
|
||
</span>
|
||
</label>
|
||
<div class="pl-task-source">
|
||
<span>来源</span>
|
||
<strong>{task.page}</strong>
|
||
</div>
|
||
<footer>
|
||
<button
|
||
type="button"
|
||
class="is-danger"
|
||
disabled={readOnly}
|
||
onClick={() => {
|
||
if (
|
||
confirm(
|
||
`确定删除“${plainleafTaskTitle(task)}”吗?删除后可以立即撤销。`,
|
||
)
|
||
) {
|
||
void onDelete();
|
||
}
|
||
}}
|
||
>
|
||
删除待办
|
||
</button>
|
||
<button type="button" onClick={onOpenSource}>
|
||
在原文中打开
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="is-primary"
|
||
disabled={readOnly || saving || !name.trim()}
|
||
onClick={() => void save()}
|
||
>
|
||
{saving ? "正在保存" : "保存修改"}
|
||
</button>
|
||
</footer>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function NewTaskDialog({
|
||
readOnly,
|
||
onClose,
|
||
onCreate,
|
||
}: {
|
||
readOnly: boolean;
|
||
onClose: () => void;
|
||
onCreate: (input: PlainleafNewTask) => Promise<void>;
|
||
}) {
|
||
const [name, setName] = useState("");
|
||
const [scheduled, setScheduled] = useState("");
|
||
const [naturalDate, setNaturalDate] = useState("");
|
||
const [project, setProject] = useState("");
|
||
const [repeat, setRepeat] = useState<PlainleafNewTask["repeat"]>(null);
|
||
const [advanced, setAdvanced] = useState(false);
|
||
const [creating, setCreating] = useState(false);
|
||
const create = async () => {
|
||
if (!name.trim()) return;
|
||
setCreating(true);
|
||
try {
|
||
await onCreate({
|
||
name: name.trim(),
|
||
scheduled: scheduled || null,
|
||
project: project || null,
|
||
repeat,
|
||
});
|
||
onClose();
|
||
} finally {
|
||
setCreating(false);
|
||
}
|
||
};
|
||
return (
|
||
<div
|
||
class="pl-task-dialog-backdrop"
|
||
onClick={(event) => event.currentTarget === event.target && onClose()}
|
||
>
|
||
<section
|
||
class="pl-task-dialog"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="添加待办"
|
||
>
|
||
<header>
|
||
<h2>添加待办</h2>
|
||
<button type="button" aria-label="关闭" onClick={onClose}>
|
||
<X size={18} />
|
||
</button>
|
||
</header>
|
||
<textarea
|
||
autoFocus
|
||
rows={3}
|
||
value={name}
|
||
disabled={readOnly}
|
||
placeholder="要做什么?"
|
||
onInput={(event) => setName(event.currentTarget.value)}
|
||
onKeyDown={(event) => {
|
||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter")
|
||
void create();
|
||
}}
|
||
/>
|
||
<div class="pl-task-quick-dates">
|
||
<button
|
||
type="button"
|
||
class={scheduled === localDateKey() ? "is-active" : ""}
|
||
onClick={() => setScheduled(localDateKey())}
|
||
>
|
||
<Star size={15} /> 今天
|
||
</button>
|
||
<label>
|
||
<Calendar size={15} />
|
||
<span>{scheduled ? displayTaskDate(scheduled) : "选择日期"}</span>
|
||
<input
|
||
type="date"
|
||
value={scheduled}
|
||
onInput={(event) => setScheduled(event.currentTarget.value)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label class="pl-task-dialog-project">
|
||
用中文安排日期
|
||
<input
|
||
value={naturalDate}
|
||
placeholder="例如:明天、下周一、8月20日"
|
||
onInput={(event) => {
|
||
const value = event.currentTarget.value;
|
||
setNaturalDate(value);
|
||
const parsed = parseNaturalTaskDate(value);
|
||
if (parsed) setScheduled(parsed);
|
||
}}
|
||
/>
|
||
{naturalDate && !parseNaturalTaskDate(naturalDate) && (
|
||
<small>暂时无法识别这个日期</small>
|
||
)}
|
||
</label>
|
||
<label class="pl-task-dialog-project">
|
||
项目(可选)
|
||
<input
|
||
value={project}
|
||
placeholder="输入项目页面名称"
|
||
onInput={(event) => setProject(event.currentTarget.value)}
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
class="pl-task-advanced-toggle"
|
||
aria-expanded={advanced}
|
||
onClick={() => setAdvanced((value) => !value)}
|
||
>
|
||
<Sliders size={14} />
|
||
{advanced ? "收起高级选项" : "高级选项"}
|
||
</button>
|
||
{advanced && (
|
||
<label class="pl-task-dialog-project">
|
||
重复
|
||
<select
|
||
value={repeat ?? ""}
|
||
onChange={(event) =>
|
||
setRepeat(
|
||
(event.currentTarget.value ||
|
||
null) as PlainleafNewTask["repeat"],
|
||
)
|
||
}
|
||
>
|
||
<option value="">不重复</option>
|
||
{Object.entries(repeatRuleLabels).map(([value, label]) => (
|
||
<option key={value} value={value}>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
<p>保存到 {plainleafTaskInbox}.md</p>
|
||
<footer>
|
||
<button type="button" onClick={onClose}>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="is-primary"
|
||
disabled={creating || !name.trim()}
|
||
onClick={() => void create()}
|
||
>
|
||
{creating ? "正在添加" : "添加待办"}
|
||
</button>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PlainleafWorkspace({
|
||
allPages,
|
||
currentPage,
|
||
indexPage,
|
||
isOnline,
|
||
isReadOnly,
|
||
darkMode,
|
||
onOpenPage,
|
||
onCreatePage,
|
||
onOpenDaily,
|
||
onSearchPages,
|
||
onLoadTasks,
|
||
onToggleTask,
|
||
onUpdateTask,
|
||
onBatchUpdateTasks,
|
||
onCreateTask,
|
||
onCreateSubtask,
|
||
onExportMarkdown,
|
||
onImportMarkdown,
|
||
onDeleteTask,
|
||
onRestoreTask,
|
||
accountManaged,
|
||
onOpenTask,
|
||
onOpenAdvancedTool,
|
||
onEditorAction,
|
||
onToggleTheme,
|
||
children,
|
||
}: {
|
||
allPages: PageMeta[];
|
||
currentPage?: string;
|
||
indexPage: string;
|
||
isOnline: boolean;
|
||
isReadOnly: boolean;
|
||
darkMode: boolean;
|
||
onOpenPage: (page: string) => void;
|
||
onCreatePage: (page: string) => Promise<void>;
|
||
onOpenDaily: () => void;
|
||
onSearchPages: (
|
||
query: string,
|
||
scope: PlainleafSearchScope,
|
||
showSystemContent: boolean,
|
||
) => Promise<PlainleafSearchResult[]>;
|
||
onLoadTasks: () => Promise<PlainleafTask[]>;
|
||
onToggleTask: (
|
||
task: PlainleafTask,
|
||
patch: PlainleafTaskPatch,
|
||
) => Promise<void>;
|
||
onUpdateTask: (
|
||
task: PlainleafTask,
|
||
patch: PlainleafTaskPatch,
|
||
) => Promise<void>;
|
||
onBatchUpdateTasks: (
|
||
tasks: PlainleafTask[],
|
||
patch: PlainleafTaskPatch,
|
||
) => Promise<void>;
|
||
onCreateTask: (input: PlainleafNewTask, page?: string) => Promise<string>;
|
||
onCreateSubtask: (
|
||
task: PlainleafTask,
|
||
input: PlainleafNewTask,
|
||
) => Promise<string>;
|
||
onExportMarkdown: () => Promise<number>;
|
||
onImportMarkdown: (file: File) => Promise<number>;
|
||
onDeleteTask: (task: PlainleafTask) => Promise<PlainleafDeletedTaskBlock>;
|
||
onRestoreTask: (block: PlainleafDeletedTaskBlock) => Promise<void>;
|
||
accountManaged: boolean;
|
||
onOpenTask: (task: PlainleafTask) => void;
|
||
onOpenAdvancedTool: (tool: PlainleafAdvancedTool) => void;
|
||
onEditorAction: (action: PlainleafEditorAction) => void;
|
||
onToggleTheme: () => void;
|
||
children: ComponentChildren;
|
||
}) {
|
||
const [developerTools, setDeveloperTools] = useState(readDeveloperTools);
|
||
const [showSystemContent, setShowSystemContent] = useState(
|
||
() => readDeveloperTools() && readShowSystemContent(),
|
||
);
|
||
const pages = useMemo(
|
||
() => visiblePlainleafPages(allPages, showSystemContent),
|
||
[allPages, showSystemContent],
|
||
);
|
||
const tree = useMemo(
|
||
() =>
|
||
buildPageTree(
|
||
pages.filter((page) => page.name !== indexPage),
|
||
indexPage,
|
||
showSystemContent,
|
||
),
|
||
[pages, indexPage, showSystemContent],
|
||
);
|
||
const recentPages = useMemo(
|
||
() =>
|
||
[...pages]
|
||
.filter((page) => page.name !== indexPage)
|
||
.sort((a, b) => pageTimestamp(b) - pageTimestamp(a))
|
||
.slice(0, 6),
|
||
[pages, indexPage],
|
||
);
|
||
const favoritePages = useMemo(
|
||
() =>
|
||
pages.filter((page) =>
|
||
page.tags?.some((tag) =>
|
||
["favorite", "收藏"].includes(tag.toLocaleLowerCase()),
|
||
),
|
||
),
|
||
[pages],
|
||
);
|
||
const tags = useMemo(
|
||
() => collectPlainleafTags(pages, showSystemContent),
|
||
[pages, showSystemContent],
|
||
);
|
||
const [view, setView] = useState<PlainleafView>("notes");
|
||
const [expanded, setExpanded] = useState<Set<string>>(
|
||
() => new Set(parentFolderPaths(currentPage)),
|
||
);
|
||
const [mobileOpen, setMobileOpen] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [searchScope, setSearchScope] = useState<PlainleafSearchScope>("all");
|
||
const [searchResults, setSearchResults] = useState<PlainleafSearchResult[]>(
|
||
[],
|
||
);
|
||
const [searchLoading, setSearchLoading] = useState(false);
|
||
const [selectedTag, setSelectedTag] = useState<string>("");
|
||
const [tasks, setTasks] = useState<PlainleafTask[]>([]);
|
||
const [tasksLoading, setTasksLoading] = useState(true);
|
||
const [taskList, setTaskList] = useState<PlainleafTaskList>("today");
|
||
const [selectedTaskRef, setSelectedTaskRef] = useState<string>();
|
||
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
||
const [taskError, setTaskError] = useState("");
|
||
const [taskNotice, setTaskNotice] = useState("");
|
||
const [taskQuery, setTaskQuery] = useState("");
|
||
const [taskTagFilter, setTaskTagFilter] = useState("");
|
||
const [taskProjectFilter, setTaskProjectFilter] = useState("");
|
||
const [taskCalendar, setTaskCalendar] = useState(false);
|
||
const [calendarDate, setCalendarDate] = useState(localDateKey());
|
||
const [selectedTaskRefs, setSelectedTaskRefs] = useState<Set<string>>(
|
||
new Set(),
|
||
);
|
||
const [taskSelectionMode, setTaskSelectionMode] = useState(false);
|
||
const [taskToolsOpen, setTaskToolsOpen] = useState(false);
|
||
const [logbookPeriod, setLogbookPeriod] = useState<"week" | "month">("week");
|
||
const [undoTask, setUndoTask] = useState<{
|
||
task: PlainleafTask;
|
||
repeatedTask?: PlainleafTask;
|
||
}>();
|
||
const [deletedTask, setDeletedTask] = useState<{
|
||
tasks: PlainleafTask[];
|
||
block: PlainleafDeletedTaskBlock;
|
||
}>();
|
||
const [installPrompt, setInstallPrompt] =
|
||
useState<BeforeInstallPromptEvent>();
|
||
const [newNoteOpen, setNewNoteOpen] = useState(false);
|
||
const [newNoteTitle, setNewNoteTitle] = useState("");
|
||
const [newNoteFolder, setNewNoteFolder] = useState("收件箱");
|
||
const [createError, setCreateError] = useState("");
|
||
const [creating, setCreating] = useState(false);
|
||
const searchRun = useRef(0);
|
||
|
||
useEffect(() => {
|
||
const parentPaths = parentFolderPaths(currentPage);
|
||
if (parentPaths.length === 0) return;
|
||
setExpanded((previous) => {
|
||
const next = new Set(previous);
|
||
parentPaths.forEach((path) => next.add(path));
|
||
return next;
|
||
});
|
||
}, [currentPage]);
|
||
|
||
const refreshTasks = async () => {
|
||
setTasksLoading(true);
|
||
try {
|
||
const visibleNames = new Set(pages.map((page) => page.name));
|
||
setTasks(
|
||
(await onLoadTasks()).filter((task) => visibleNames.has(task.page)),
|
||
);
|
||
} catch (error) {
|
||
console.error("Failed to load Plainleaf tasks", error);
|
||
setTasks([]);
|
||
} finally {
|
||
setTasksLoading(false);
|
||
}
|
||
};
|
||
|
||
const completeTask = async (task: PlainleafTask) => {
|
||
setTaskError("");
|
||
let repeatedTask: PlainleafTask | undefined;
|
||
try {
|
||
const done = !task.done;
|
||
const completed = done ? localDateKey() : undefined;
|
||
const repeated = done ? nextRepeatedTask(task) : undefined;
|
||
if (repeated) {
|
||
const ref = await onCreateTask(repeated, task.page);
|
||
repeatedTask = {
|
||
ref,
|
||
page: task.page,
|
||
name: repeated.name,
|
||
state: " ",
|
||
done: false,
|
||
scheduled: repeated.scheduled ?? undefined,
|
||
deadline: repeated.deadline ?? undefined,
|
||
repeat: repeated.repeat ?? undefined,
|
||
reminder: repeated.reminder ?? undefined,
|
||
links: repeated.project ? [repeated.project] : undefined,
|
||
tags: repeated.tags,
|
||
};
|
||
}
|
||
await onToggleTask(task, {
|
||
state: done ? "x" : " ",
|
||
completed: completed ?? null,
|
||
});
|
||
setTasks((previous) =>
|
||
previous.map((candidate) =>
|
||
candidate.ref === task.ref
|
||
? {
|
||
...candidate,
|
||
state: done ? "x" : " ",
|
||
done,
|
||
completed,
|
||
}
|
||
: candidate,
|
||
),
|
||
);
|
||
if (repeatedTask) {
|
||
setTasks((previous) => [...previous, repeatedTask!]);
|
||
}
|
||
setUndoTask(done ? { task, repeatedTask } : undefined);
|
||
} catch (error) {
|
||
console.error("Failed to complete Plainleaf task", error);
|
||
let cleanupFailed = false;
|
||
if (repeatedTask) {
|
||
try {
|
||
await onDeleteTask(repeatedTask);
|
||
} catch (cleanupError) {
|
||
cleanupFailed = true;
|
||
console.error("Failed to roll back repeated task", cleanupError);
|
||
}
|
||
}
|
||
await refreshTasks();
|
||
setTaskError(
|
||
cleanupFailed
|
||
? "完成失败,自动清理未完全成功,请核对重复任务"
|
||
: error instanceof Error
|
||
? error.message
|
||
: "更新待办失败",
|
||
);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!undoTask) return;
|
||
const timeout = globalThis.setTimeout(() => setUndoTask(undefined), 5000);
|
||
return () => globalThis.clearTimeout(timeout);
|
||
}, [undoTask]);
|
||
|
||
useEffect(() => {
|
||
if (!deletedTask) return;
|
||
const timeout = globalThis.setTimeout(
|
||
() => setDeletedTask(undefined),
|
||
8000,
|
||
);
|
||
return () => globalThis.clearTimeout(timeout);
|
||
}, [deletedTask]);
|
||
|
||
useEffect(() => {
|
||
if (!taskNotice) return;
|
||
const timeout = globalThis.setTimeout(() => setTaskNotice(""), 5000);
|
||
return () => globalThis.clearTimeout(timeout);
|
||
}, [taskNotice]);
|
||
|
||
useEffect(() => {
|
||
const onInstallPrompt = (event: Event) => {
|
||
event.preventDefault();
|
||
setInstallPrompt(event as BeforeInstallPromptEvent);
|
||
};
|
||
const onInstalled = () => setInstallPrompt(undefined);
|
||
addEventListener("beforeinstallprompt", onInstallPrompt);
|
||
addEventListener("appinstalled", onInstalled);
|
||
return () => {
|
||
removeEventListener("beforeinstallprompt", onInstallPrompt);
|
||
removeEventListener("appinstalled", onInstalled);
|
||
};
|
||
}, []);
|
||
|
||
const updateTask = async (task: PlainleafTask, patch: PlainleafTaskPatch) => {
|
||
setTaskError("");
|
||
try {
|
||
await onUpdateTask(task, patch);
|
||
setTasks((previous) =>
|
||
previous.map((candidate) => {
|
||
if (candidate.ref !== task.ref) return candidate;
|
||
const next = { ...candidate, ...patch } as PlainleafTask;
|
||
if (patch.project !== undefined) {
|
||
next.links = patch.project ? [patch.project] : [];
|
||
}
|
||
return next;
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
setTaskError(error instanceof Error ? error.message : "保存待办失败");
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const createTask = async (input: PlainleafNewTask) => {
|
||
setTaskError("");
|
||
try {
|
||
const ref = await onCreateTask(input);
|
||
setTasks((previous) => [
|
||
...previous,
|
||
{
|
||
ref,
|
||
page: plainleafTaskInbox,
|
||
name: input.name,
|
||
state: " ",
|
||
done: false,
|
||
scheduled: input.scheduled ?? undefined,
|
||
deadline: input.deadline ?? undefined,
|
||
repeat: input.repeat ?? undefined,
|
||
reminder: input.reminder ?? undefined,
|
||
links: input.project ? [input.project] : undefined,
|
||
tags: input.tags,
|
||
},
|
||
]);
|
||
setTaskList(input.scheduled === localDateKey() ? "today" : "inbox");
|
||
} catch (error) {
|
||
setTaskError(error instanceof Error ? error.message : "添加待办失败");
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const createSubtask = async (
|
||
task: PlainleafTask,
|
||
input: PlainleafNewTask,
|
||
) => {
|
||
const ref = await onCreateSubtask(task, input);
|
||
setTasks((previous) => [
|
||
...previous,
|
||
{
|
||
ref,
|
||
page: task.page,
|
||
parent: task.ref,
|
||
name: input.name,
|
||
state: " ",
|
||
done: false,
|
||
scheduled: input.scheduled ?? undefined,
|
||
},
|
||
]);
|
||
};
|
||
|
||
const deleteTask = async (task: PlainleafTask) => {
|
||
try {
|
||
const block = await onDeleteTask(task);
|
||
const descendants = new Set([task.ref]);
|
||
let changed = true;
|
||
while (changed) {
|
||
changed = false;
|
||
for (const candidate of tasks) {
|
||
if (
|
||
candidate.parent &&
|
||
descendants.has(candidate.parent) &&
|
||
!descendants.has(candidate.ref)
|
||
) {
|
||
descendants.add(candidate.ref);
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
const removed = tasks.filter((candidate) =>
|
||
descendants.has(candidate.ref),
|
||
);
|
||
setTasks((previous) =>
|
||
previous.filter((candidate) => !descendants.has(candidate.ref)),
|
||
);
|
||
setSelectedTaskRef(undefined);
|
||
setDeletedTask({ tasks: removed, block });
|
||
} catch (error) {
|
||
setTaskError(error instanceof Error ? error.message : "删除待办失败");
|
||
}
|
||
};
|
||
|
||
const batchUpdate = async (patch: PlainleafTaskPatch) => {
|
||
const selected = tasks.filter((task) => selectedTaskRefs.has(task.ref));
|
||
if (!selected.length) return;
|
||
const repeatedTasks: PlainleafTask[] = [];
|
||
try {
|
||
if (patch.state === "x") {
|
||
for (const task of selected.filter((task) => !task.done)) {
|
||
const repeated = nextRepeatedTask(task);
|
||
if (!repeated) continue;
|
||
const ref = await onCreateTask(repeated, task.page);
|
||
repeatedTasks.push({
|
||
ref,
|
||
page: task.page,
|
||
name: repeated.name,
|
||
state: " ",
|
||
done: false,
|
||
scheduled: repeated.scheduled ?? undefined,
|
||
deadline: repeated.deadline ?? undefined,
|
||
repeat: repeated.repeat ?? undefined,
|
||
reminder: repeated.reminder ?? undefined,
|
||
links: repeated.project ? [repeated.project] : undefined,
|
||
tags: repeated.tags,
|
||
});
|
||
}
|
||
}
|
||
await onBatchUpdateTasks(selected, patch);
|
||
setTasks((previous) => [
|
||
...previous.map((task) =>
|
||
selectedTaskRefs.has(task.ref)
|
||
? ({
|
||
...task,
|
||
...patch,
|
||
done:
|
||
patch.state === "x"
|
||
? true
|
||
: patch.state === " "
|
||
? false
|
||
: task.done,
|
||
} as PlainleafTask)
|
||
: task,
|
||
),
|
||
...repeatedTasks,
|
||
]);
|
||
setSelectedTaskRefs(new Set());
|
||
setTaskSelectionMode(false);
|
||
} catch (error) {
|
||
let cleanupFailed = false;
|
||
for (const repeatedTask of [...repeatedTasks].reverse()) {
|
||
try {
|
||
await onDeleteTask(repeatedTask);
|
||
} catch (cleanupError) {
|
||
cleanupFailed = true;
|
||
console.error("Failed to roll back repeated task", cleanupError);
|
||
}
|
||
}
|
||
await refreshTasks();
|
||
setTaskError(
|
||
cleanupFailed
|
||
? "批量更新失败,自动清理未完全成功,请核对重复任务"
|
||
: error instanceof Error
|
||
? error.message
|
||
: "批量更新失败",
|
||
);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!("Notification" in globalThis) ||
|
||
Notification.permission !== "granted"
|
||
) {
|
||
return;
|
||
}
|
||
const check = () => {
|
||
let notified = new Set<string>();
|
||
try {
|
||
notified = new Set(
|
||
JSON.parse(localStorage.getItem("plainleaf.taskReminders") ?? "[]"),
|
||
);
|
||
} catch {
|
||
// A malformed local record should not stop reminder checks.
|
||
}
|
||
const due = dueReminderTasks(tasks, new Date(), notified);
|
||
for (const task of due) {
|
||
const key = `${task.ref}:${task.reminder}`;
|
||
new Notification(plainleafTaskTitle(task), {
|
||
body: `${displayTaskDate(task.scheduled ?? task.deadline) || "待办"} · ${projectForTask(task) ?? task.page}`,
|
||
icon: ".client/favicon-96x96.png",
|
||
tag: key,
|
||
});
|
||
notified.add(key);
|
||
}
|
||
if (due.length) {
|
||
localStorage.setItem(
|
||
"plainleaf.taskReminders",
|
||
JSON.stringify([...notified].slice(-500)),
|
||
);
|
||
}
|
||
};
|
||
check();
|
||
const timer = globalThis.setInterval(check, 60_000);
|
||
return () => globalThis.clearInterval(timer);
|
||
}, [tasks]);
|
||
|
||
useEffect(() => {
|
||
void refreshTasks();
|
||
}, [allPages, showSystemContent]);
|
||
|
||
useEffect(() => {
|
||
if (view !== "search") return;
|
||
const query = searchQuery.trim();
|
||
const run = ++searchRun.current;
|
||
if (!query) {
|
||
setSearchResults(
|
||
recentPages.map((page) => ({
|
||
page: page.name,
|
||
title: plainleafPageLabel(page.name, indexPage),
|
||
path: page.name,
|
||
excerpt: page.description ?? "",
|
||
updated: formatUpdated(page),
|
||
})),
|
||
);
|
||
setSearchLoading(false);
|
||
return;
|
||
}
|
||
setSearchLoading(true);
|
||
const timeout = globalThis.setTimeout(() => {
|
||
onSearchPages(query, searchScope, showSystemContent)
|
||
.then((results) => {
|
||
if (run === searchRun.current) setSearchResults(results);
|
||
})
|
||
.catch((error) => {
|
||
console.error("Failed to search Plainleaf pages", error);
|
||
if (run === searchRun.current) setSearchResults([]);
|
||
})
|
||
.finally(() => {
|
||
if (run === searchRun.current) setSearchLoading(false);
|
||
});
|
||
}, 180);
|
||
return () => globalThis.clearTimeout(timeout);
|
||
}, [view, searchQuery, searchScope, recentPages, showSystemContent]);
|
||
|
||
const toggleSystemContent = () => {
|
||
setShowSystemContent((current) => {
|
||
const next = !current;
|
||
storeShowSystemContent(next);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const toggleDeveloperTools = () => {
|
||
setDeveloperTools((previous) => {
|
||
const next = !previous;
|
||
storeDeveloperTools(next);
|
||
if (!next && showSystemContent) {
|
||
setShowSystemContent(false);
|
||
storeShowSystemContent(false);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const rootFolders = useMemo(() => {
|
||
const roots = new Set<string>(["收件箱"]);
|
||
for (const page of pages) {
|
||
const root = page.name.split("/")[0];
|
||
if (page.name.includes("/") && root) roots.add(root);
|
||
}
|
||
return [...roots].sort((a, b) => a.localeCompare(b, "zh-CN"));
|
||
}, [pages]);
|
||
|
||
const toggleFolder = (path: string) => {
|
||
setExpanded((previous) => {
|
||
const next = new Set(previous);
|
||
if (next.has(path)) next.delete(path);
|
||
else next.add(path);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const openPage = (page: string) => {
|
||
onOpenPage(page);
|
||
setMobileOpen(false);
|
||
};
|
||
|
||
const openView = (next: PlainleafView) => {
|
||
setView(next);
|
||
setMobileOpen(false);
|
||
};
|
||
|
||
const openCreate = (mode: "blank" | "capture" = "blank") => {
|
||
setCreateError("");
|
||
setNewNoteFolder("收件箱");
|
||
const now = new Date();
|
||
const captureTime = [
|
||
String(now.getMonth() + 1).padStart(2, "0"),
|
||
String(now.getDate()).padStart(2, "0"),
|
||
String(now.getHours()).padStart(2, "0"),
|
||
String(now.getMinutes()).padStart(2, "0"),
|
||
];
|
||
setNewNoteTitle(
|
||
mode === "capture"
|
||
? `随手记 ${captureTime[0]}-${captureTime[1]} ${captureTime[2]}-${captureTime[3]}`
|
||
: "",
|
||
);
|
||
setNewNoteOpen(true);
|
||
};
|
||
|
||
const createPage = async () => {
|
||
const title = newNoteTitle.trim();
|
||
if (!title) {
|
||
setCreateError("请输入笔记标题");
|
||
return;
|
||
}
|
||
const pageName = title.includes("/") ? title : `${newNoteFolder}/${title}`;
|
||
setCreating(true);
|
||
setCreateError("");
|
||
try {
|
||
await onCreatePage(pageName);
|
||
setNewNoteOpen(false);
|
||
setMobileOpen(false);
|
||
} catch (error) {
|
||
setCreateError(error instanceof Error ? error.message : "创建笔记失败");
|
||
} finally {
|
||
setCreating(false);
|
||
}
|
||
};
|
||
|
||
const tagPages = selectedTag
|
||
? pages.filter((page) => page.tags?.includes(selectedTag))
|
||
: [];
|
||
const todayTasks = sortTodayTasks(tasks);
|
||
const inboxTasks = tasksForInbox(tasks);
|
||
const plannedGroups = groupScheduledTasks(tasks);
|
||
const plannedTasks = plannedGroups.flatMap((group) => group.tasks);
|
||
const anytimeTasks = tasksForAnytime(tasks);
|
||
const projectGroups = groupTasksByProject(tasks);
|
||
const projectProgress = projectSummaries(tasks);
|
||
const projectTasks = projectGroups.flatMap((group) => group.tasks);
|
||
const completedTasks = logbookTasks(tasks);
|
||
const taskTags = [...new Set(tasks.flatMap((task) => task.tags ?? []))].sort(
|
||
(a, b) => a.localeCompare(b, "zh-CN"),
|
||
);
|
||
const taskProjects = projectGroups.map((group) => group.project);
|
||
const staleProjectNames = staleProjects(tasks);
|
||
const healthIssues = taskHealthIssues(tasks);
|
||
const taskListCounts: Record<PlainleafTaskList, number> = {
|
||
inbox: inboxTasks.length,
|
||
today: todayTasks.length,
|
||
planned: plannedTasks.length,
|
||
anytime: anytimeTasks.length,
|
||
projects: projectTasks.length,
|
||
logbook: completedTasks.length,
|
||
};
|
||
const selectedTask = tasks.find((task) => task.ref === selectedTaskRef);
|
||
const showingCustomSurface =
|
||
currentPage === indexPage || view === "settings" || view === "tasks";
|
||
|
||
const baseTaskGroups: Array<{
|
||
key: string;
|
||
label?: string;
|
||
tasks: PlainleafTask[];
|
||
}> =
|
||
taskList === "planned"
|
||
? plannedGroups.map((group) => ({
|
||
key: group.date,
|
||
label: displayTaskDate(group.date),
|
||
tasks: group.tasks,
|
||
}))
|
||
: taskList === "projects"
|
||
? projectGroups.map((group) => ({
|
||
key: group.project,
|
||
label: `${group.project}${staleProjectNames.has(group.project) ? " · 久未更新" : ""}${(() => {
|
||
const summary = projectProgress.find(
|
||
(item) => item.project === group.project,
|
||
);
|
||
return summary ? ` · ${summary.completed}/${summary.total}` : "";
|
||
})()}`,
|
||
tasks: group.tasks,
|
||
}))
|
||
: taskList === "logbook"
|
||
? groupLogbookTasks(tasks, logbookPeriod).map((group) => ({
|
||
key: group.period,
|
||
label: group.period,
|
||
tasks: group.tasks,
|
||
}))
|
||
: [
|
||
{
|
||
key: taskList,
|
||
tasks:
|
||
taskList === "inbox"
|
||
? inboxTasks
|
||
: taskList === "today"
|
||
? todayTasks
|
||
: taskList === "anytime"
|
||
? anytimeTasks
|
||
: completedTasks,
|
||
},
|
||
];
|
||
const taskGroups = baseTaskGroups
|
||
.map((group) => ({
|
||
...group,
|
||
tasks: group.tasks.filter((task) =>
|
||
taskMatchesFilters(task, {
|
||
query: taskQuery,
|
||
tag: taskTagFilter,
|
||
project: taskProjectFilter,
|
||
}),
|
||
),
|
||
}))
|
||
.filter((group) => group.tasks.length > 0);
|
||
const visibleTaskCount = taskGroups.reduce(
|
||
(total, group) => total + group.tasks.length,
|
||
0,
|
||
);
|
||
const calendarBase = new Date(`${calendarDate}T12:00:00`);
|
||
const calendarDays = calendarMonth(
|
||
calendarBase.getFullYear(),
|
||
calendarBase.getMonth(),
|
||
);
|
||
|
||
const renderSidebarContent = () => {
|
||
if (view === "search") {
|
||
return (
|
||
<>
|
||
<header class="pl-sidebar-heading">
|
||
<strong>搜索笔记</strong>
|
||
<span>
|
||
{searchLoading ? "正在搜索" : `${searchResults.length} 条`}
|
||
</span>
|
||
</header>
|
||
<div class="pl-search-field">
|
||
<Search size={15} />
|
||
<input
|
||
value={searchQuery}
|
||
placeholder="输入标题或正文"
|
||
onInput={(event) => setSearchQuery(event.currentTarget.value)}
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
type="button"
|
||
aria-label="清空搜索"
|
||
onClick={() => setSearchQuery("")}
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div class="pl-search-scopes">
|
||
{(["all", "title", "content"] as const).map((scope) => (
|
||
<button
|
||
key={scope}
|
||
type="button"
|
||
class={searchScope === scope ? "is-active" : ""}
|
||
onClick={() => setSearchScope(scope)}
|
||
>
|
||
{scope === "all" ? "全部" : scope === "title" ? "标题" : "正文"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div class="pl-result-list">
|
||
{!searchLoading &&
|
||
searchResults.map((result) => (
|
||
<SearchResultRow
|
||
key={result.page}
|
||
result={result}
|
||
onOpen={() => openPage(result.page)}
|
||
/>
|
||
))}
|
||
{!searchLoading && searchResults.length === 0 && (
|
||
<div class="pl-empty-state">
|
||
<Search size={22} />
|
||
<strong>没有找到“{searchQuery}”</strong>
|
||
<span>可以换个关键词,或创建一篇同名笔记。</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setNewNoteTitle(searchQuery);
|
||
setNewNoteOpen(true);
|
||
}}
|
||
>
|
||
<Plus size={14} />
|
||
新建笔记
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (view === "tasks") {
|
||
return (
|
||
<>
|
||
<header class="pl-sidebar-heading">
|
||
<strong>待办</strong>
|
||
<span>{tasks.filter((task) => !task.done).length} 项</span>
|
||
</header>
|
||
<div class="pl-task-sidebar-lists">
|
||
{(Object.keys(taskListLabels) as PlainleafTaskList[]).map(
|
||
(list) => (
|
||
<button
|
||
key={list}
|
||
type="button"
|
||
class={taskList === list ? "is-active" : ""}
|
||
onClick={() => {
|
||
setTaskList(list);
|
||
setSelectedTaskRef(undefined);
|
||
setMobileOpen(false);
|
||
}}
|
||
>
|
||
{taskListIcon(list)}
|
||
<span>{taskListLabels[list]}</span>
|
||
<small>{taskListCounts[list]}</small>
|
||
</button>
|
||
),
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (view === "tags") {
|
||
return (
|
||
<>
|
||
<header class="pl-sidebar-heading">
|
||
<strong>标签</strong>
|
||
<span>{tags.length} 个</span>
|
||
</header>
|
||
<div class="pl-tag-list">
|
||
{tags.map((tag, index) => (
|
||
<button
|
||
key={tag.name}
|
||
type="button"
|
||
class={selectedTag === tag.name ? "is-active" : ""}
|
||
onClick={() => setSelectedTag(tag.name)}
|
||
>
|
||
<span class={`pl-tag-dot color-${index % 4}`} />
|
||
<span>{tag.name}</span>
|
||
<small>{tag.count}</small>
|
||
</button>
|
||
))}
|
||
</div>
|
||
{selectedTag && (
|
||
<section class="pl-tag-results">
|
||
<h2>#{selectedTag}</h2>
|
||
{tagPages.map((page) => (
|
||
<button
|
||
type="button"
|
||
key={page.name}
|
||
onClick={() => openPage(page.name)}
|
||
>
|
||
<FileText size={14} />
|
||
<span>{plainleafPageLabel(page.name, indexPage)}</span>
|
||
<small>{formatUpdated(page)}</small>
|
||
</button>
|
||
))}
|
||
</section>
|
||
)}
|
||
{tags.length === 0 && (
|
||
<div class="pl-empty-state">
|
||
<Tag size={22} />
|
||
<strong>还没有标签</strong>
|
||
<span>在 Markdown 中输入 #标签 即可整理笔记。</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (view === "settings" || view === "more") {
|
||
return (
|
||
<>
|
||
<header class="pl-sidebar-heading">
|
||
<strong>{view === "settings" ? "设置" : "更多"}</strong>
|
||
</header>
|
||
{view === "more" && (
|
||
<div class="pl-more-links">
|
||
<button type="button" onClick={() => setView("tags")}>
|
||
<Hash size={16} />
|
||
<span>标签</span>
|
||
</button>
|
||
<button type="button" onClick={() => openView("settings")}>
|
||
<Settings size={16} />
|
||
<span>设置</span>
|
||
</button>
|
||
{accountManaged && (
|
||
<a href="/.spaces/account">
|
||
<User size={16} />
|
||
<span>我的账号</span>
|
||
</a>
|
||
)}
|
||
</div>
|
||
)}
|
||
<section class="pl-settings-section">
|
||
<h2>常用设置</h2>
|
||
<button
|
||
type="button"
|
||
class="pl-setting-row"
|
||
onClick={onToggleTheme}
|
||
>
|
||
<span>
|
||
<strong>界面主题</strong>
|
||
<small>当前为{darkMode ? "深色" : "明亮"}界面</small>
|
||
</span>
|
||
<Moon size={15} />
|
||
</button>
|
||
<div class="pl-setting-row">
|
||
<span>
|
||
<strong>文件同步</strong>
|
||
<small>{isOnline ? "当前空间已连接" : "离线工作中"}</small>
|
||
</span>
|
||
<Cloud size={15} />
|
||
</div>
|
||
</section>
|
||
{developerTools && (
|
||
<section class="pl-settings-section">
|
||
<h2>开发者选项已开启</h2>
|
||
<div class="pl-setting-row">
|
||
<span>
|
||
<strong>系统内容</strong>
|
||
<small>{showSystemContent ? "当前显示" : "当前隐藏"}</small>
|
||
</span>
|
||
<Sliders size={15} />
|
||
</div>
|
||
</section>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<header class="pl-sidebar-heading">
|
||
<strong>你的笔记</strong>
|
||
</header>
|
||
<div class="pl-note-shortcuts">
|
||
<button
|
||
type="button"
|
||
class={currentPage === indexPage ? "is-active" : ""}
|
||
onClick={() => openPage(indexPage)}
|
||
>
|
||
<Home size={15} />
|
||
<span>首页</span>
|
||
</button>
|
||
<button type="button">
|
||
<Clock size={15} />
|
||
<span>最近编辑</span>
|
||
<small>{recentPages.length}</small>
|
||
</button>
|
||
<button type="button">
|
||
<Star size={15} />
|
||
<span>收藏</span>
|
||
<small>{favoritePages.length}</small>
|
||
</button>
|
||
</div>
|
||
<section class="pl-files-section">
|
||
<header>
|
||
<span>文件夹</span>
|
||
</header>
|
||
{tree.length ? (
|
||
<PageTree
|
||
nodes={tree}
|
||
currentPage={currentPage}
|
||
expanded={expanded}
|
||
onToggleFolder={toggleFolder}
|
||
onOpenPage={openPage}
|
||
/>
|
||
) : (
|
||
<div class="pl-empty-state">
|
||
<FileText size={22} />
|
||
<strong>还没有 Markdown 文件</strong>
|
||
<button type="button" onClick={() => openCreate()}>
|
||
<Plus size={14} />
|
||
新建第一篇笔记
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div id="pl-workspace-shell">
|
||
<aside
|
||
class={`pl-sidebar ${mobileOpen ? "is-mobile-open" : ""}`}
|
||
aria-label="Plainleaf 导航"
|
||
>
|
||
<header class="pl-brand-row">
|
||
<button
|
||
type="button"
|
||
class="pl-brand"
|
||
onClick={() => openPage(indexPage)}
|
||
>
|
||
<span class="pl-brand-mark">
|
||
<Feather size={17} />
|
||
</span>
|
||
<span>Plainleaf</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="pl-close-mobile"
|
||
aria-label="关闭导航"
|
||
onClick={() => setMobileOpen(false)}
|
||
>
|
||
<X size={18} />
|
||
</button>
|
||
</header>
|
||
<button
|
||
type="button"
|
||
class="pl-create-button"
|
||
disabled={isReadOnly}
|
||
onClick={() => openCreate()}
|
||
>
|
||
<Plus size={16} />
|
||
<span>新建笔记</span>
|
||
</button>
|
||
<nav class="pl-primary-nav">
|
||
<NavButton
|
||
view="notes"
|
||
activeView={view}
|
||
label="笔记"
|
||
onClick={() => openView("notes")}
|
||
>
|
||
<BookOpen size={17} />
|
||
</NavButton>
|
||
<NavButton
|
||
view="search"
|
||
activeView={view}
|
||
label="搜索"
|
||
onClick={() => openView("search")}
|
||
>
|
||
<Search size={17} />
|
||
</NavButton>
|
||
<NavButton
|
||
view="tasks"
|
||
activeView={view}
|
||
label="待办"
|
||
count={tasks.length}
|
||
onClick={() => openView("tasks")}
|
||
>
|
||
<CheckSquare size={17} />
|
||
</NavButton>
|
||
<NavButton
|
||
view="tags"
|
||
activeView={view}
|
||
label="标签"
|
||
onClick={() => openView("tags")}
|
||
>
|
||
<Hash size={17} />
|
||
</NavButton>
|
||
</nav>
|
||
<div class="pl-sidebar-divider" />
|
||
<div class="pl-sidebar-scroll">{renderSidebarContent()}</div>
|
||
<footer class="pl-sidebar-footer">
|
||
<button
|
||
type="button"
|
||
class={view === "settings" ? "is-active" : ""}
|
||
onClick={() => openView("settings")}
|
||
>
|
||
<Settings size={16} />
|
||
<span>设置</span>
|
||
</button>
|
||
</footer>
|
||
</aside>
|
||
|
||
{mobileOpen && (
|
||
<button
|
||
type="button"
|
||
class="pl-mobile-scrim"
|
||
aria-label="关闭导航"
|
||
onClick={() => setMobileOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
<main class="pl-editor-workspace">
|
||
<button
|
||
type="button"
|
||
class="pl-mobile-menu-button"
|
||
aria-label="打开导航"
|
||
onClick={() => setMobileOpen(true)}
|
||
>
|
||
<Menu size={18} />
|
||
</button>
|
||
<div
|
||
class={`pl-editor-layer ${showingCustomSurface ? "is-hidden" : ""}`}
|
||
>
|
||
{children}
|
||
{currentPage && currentPage !== indexPage && (
|
||
<MarkdownToolbar readOnly={isReadOnly} onAction={onEditorAction} />
|
||
)}
|
||
</div>
|
||
{currentPage === indexPage &&
|
||
view !== "settings" &&
|
||
view !== "tasks" && (
|
||
<section class="pl-home-surface">
|
||
<div class="pl-home-inner">
|
||
<p class="pl-home-date">
|
||
{new Date().toLocaleDateString("zh-CN", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
weekday: "long",
|
||
})}
|
||
</p>
|
||
<h1>今天想记点什么?</h1>
|
||
<p class="pl-home-subtitle">
|
||
从一条想法开始,剩下的可以晚点再整理。
|
||
</p>
|
||
<div class="pl-home-actions">
|
||
<button type="button" onClick={() => openCreate("capture")}>
|
||
<span class="is-green">
|
||
<Zap size={16} />
|
||
</span>
|
||
<span>
|
||
<strong>随手记</strong>
|
||
<small>快速记下一条想法</small>
|
||
</span>
|
||
</button>
|
||
<button type="button" onClick={onOpenDaily}>
|
||
<span class="is-coral">
|
||
<Calendar size={16} />
|
||
</span>
|
||
<span>
|
||
<strong>今天的日记</strong>
|
||
<small>继续今天的记录</small>
|
||
</span>
|
||
</button>
|
||
<button type="button" onClick={() => openCreate()}>
|
||
<span class="is-blue">
|
||
<FilePlus size={16} />
|
||
</span>
|
||
<span>
|
||
<strong>新建笔记</strong>
|
||
<small>选择标题和保存位置</small>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div class="pl-home-grid">
|
||
<section>
|
||
<header>
|
||
<h2>最近编辑</h2>
|
||
<button type="button" onClick={() => setView("notes")}>
|
||
查看全部
|
||
</button>
|
||
</header>
|
||
{recentPages.slice(0, 5).map((page) => (
|
||
<button
|
||
type="button"
|
||
class="pl-home-recent"
|
||
key={page.name}
|
||
onClick={() => openPage(page.name)}
|
||
>
|
||
<strong>
|
||
{plainleafPageLabel(page.name, indexPage)}
|
||
</strong>
|
||
<time>{formatUpdated(page)}</time>
|
||
<span>{page.name}</span>
|
||
</button>
|
||
))}
|
||
</section>
|
||
<section>
|
||
<header>
|
||
<h2>今天待办</h2>
|
||
<button type="button" onClick={() => openView("tasks")}>
|
||
查看全部
|
||
</button>
|
||
</header>
|
||
{todayTasks.slice(0, 4).map((task) => (
|
||
<div class="pl-home-task" key={task.ref}>
|
||
<button
|
||
type="button"
|
||
disabled={isReadOnly}
|
||
aria-label="完成待办"
|
||
onClick={() => void completeTask(task)}
|
||
>
|
||
<Circle size={17} />
|
||
</button>
|
||
<button type="button" onClick={() => onOpenTask(task)}>
|
||
<strong>{task.name}</strong>
|
||
<small>{task.page}</small>
|
||
</button>
|
||
</div>
|
||
))}
|
||
{!tasksLoading && todayTasks.length === 0 && (
|
||
<p class="pl-home-empty">今天没有未完成待办</p>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
{view === "tasks" && (
|
||
<section class="pl-task-surface">
|
||
<div class="pl-task-content">
|
||
<header class="pl-task-content-header">
|
||
<button
|
||
type="button"
|
||
class="pl-task-mobile-lists"
|
||
aria-label="切换待办清单"
|
||
onClick={() => setMobileOpen(true)}
|
||
>
|
||
<Menu size={19} />
|
||
</button>
|
||
<span class={`pl-task-title-icon is-${taskList}`}>
|
||
{taskListIcon(taskList)}
|
||
</span>
|
||
<div>
|
||
<h1>{taskListLabels[taskList]}</h1>
|
||
<p>{taskListCounts[taskList]} 项</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="pl-task-add-button"
|
||
disabled={isReadOnly}
|
||
onClick={() => setNewTaskOpen(true)}
|
||
>
|
||
<Plus size={17} />
|
||
<span>添加待办</span>
|
||
</button>
|
||
</header>
|
||
<div class="pl-task-toolbar">
|
||
<label class="pl-task-toolbar-search">
|
||
<Search size={14} />
|
||
<input
|
||
value={taskQuery}
|
||
placeholder="搜索待办"
|
||
onInput={(event) => setTaskQuery(event.currentTarget.value)}
|
||
/>
|
||
{taskQuery && (
|
||
<button
|
||
type="button"
|
||
aria-label="清空待办搜索"
|
||
onClick={() => setTaskQuery("")}
|
||
>
|
||
<X size={13} />
|
||
</button>
|
||
)}
|
||
</label>
|
||
<select
|
||
aria-label="按标签筛选"
|
||
value={taskTagFilter}
|
||
onChange={(event) =>
|
||
setTaskTagFilter(event.currentTarget.value)
|
||
}
|
||
>
|
||
<option value="">全部标签</option>
|
||
{taskTags.map((tag) => (
|
||
<option key={tag} value={tag}>
|
||
#{tag}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
aria-label="按项目筛选"
|
||
value={taskProjectFilter}
|
||
onChange={(event) =>
|
||
setTaskProjectFilter(event.currentTarget.value)
|
||
}
|
||
>
|
||
<option value="">全部项目</option>
|
||
{taskProjects.map((project) => (
|
||
<option key={project} value={project}>
|
||
{project}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{taskList === "planned" && (
|
||
<button
|
||
type="button"
|
||
class={taskCalendar ? "is-active" : ""}
|
||
aria-label={taskCalendar ? "切换到列表" : "切换到月历"}
|
||
onClick={() => setTaskCalendar((value) => !value)}
|
||
>
|
||
<Grid size={15} />
|
||
<span>{taskCalendar ? "列表" : "月历"}</span>
|
||
</button>
|
||
)}
|
||
{taskList === "logbook" && (
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setLogbookPeriod((value) =>
|
||
value === "week" ? "month" : "week",
|
||
)
|
||
}
|
||
>
|
||
<Calendar size={15} />
|
||
<span>{logbookPeriod === "week" ? "按月" : "按周"}</span>
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
class={taskSelectionMode ? "is-active" : ""}
|
||
onClick={() => {
|
||
setTaskSelectionMode((value) => !value);
|
||
setSelectedTaskRefs(new Set());
|
||
}}
|
||
>
|
||
<CheckSquare size={15} />
|
||
<span>{taskSelectionMode ? "取消选择" : "选择"}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setTaskToolsOpen((value) => !value)}
|
||
>
|
||
<Sliders size={15} />
|
||
<span>工具</span>
|
||
</button>
|
||
</div>
|
||
{taskSelectionMode && (
|
||
<div class="pl-task-batch-bar">
|
||
<strong>{selectedTaskRefs.size} 项已选</strong>
|
||
<button
|
||
type="button"
|
||
disabled={!selectedTaskRefs.size}
|
||
onClick={() =>
|
||
void batchUpdate({ scheduled: localDateKey() })
|
||
}
|
||
>
|
||
安排到今天
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!selectedTaskRefs.size}
|
||
onClick={() =>
|
||
void batchUpdate({ scheduled: null, deadline: null })
|
||
}
|
||
>
|
||
清除日期
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!selectedTaskRefs.size}
|
||
onClick={() =>
|
||
void batchUpdate({
|
||
state: "x",
|
||
completed: localDateKey(),
|
||
})
|
||
}
|
||
>
|
||
标记完成
|
||
</button>
|
||
</div>
|
||
)}
|
||
{taskToolsOpen && (
|
||
<section class="pl-task-tools-panel">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (!("Notification" in globalThis)) {
|
||
setTaskError("当前浏览器不支持通知");
|
||
return;
|
||
}
|
||
void Notification.requestPermission().then(
|
||
(permission) => {
|
||
if (permission !== "granted")
|
||
setTaskError("通知权限未开启");
|
||
},
|
||
);
|
||
}}
|
||
>
|
||
<Bell size={16} />
|
||
<span>
|
||
<strong>开启提醒</strong>
|
||
<small>应用打开或已安装时检查到期提醒</small>
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setTaskError("");
|
||
void onExportMarkdown()
|
||
.then((count) =>
|
||
setTaskNotice(`已导出 ${count} 个 Markdown 文件`),
|
||
)
|
||
.catch((error) =>
|
||
setTaskError(
|
||
error instanceof Error ? error.message : "备份失败",
|
||
),
|
||
);
|
||
}}
|
||
>
|
||
<Download size={16} />
|
||
<span>
|
||
<strong>备份 Markdown</strong>
|
||
<small>下载全部笔记文件的 JSON 备份包</small>
|
||
</span>
|
||
</button>
|
||
<label class="pl-task-import-tool">
|
||
<Download size={16} />
|
||
<span>
|
||
<strong>恢复 Markdown</strong>
|
||
<small>从 Plainleaf 备份包写回页面,不删除其他文件</small>
|
||
</span>
|
||
<input
|
||
type="file"
|
||
accept="application/json,.json"
|
||
onChange={(event) => {
|
||
const file = event.currentTarget.files?.[0];
|
||
if (!file) return;
|
||
void onImportMarkdown(file)
|
||
.then(async (count) => {
|
||
if (!count) return;
|
||
await refreshTasks();
|
||
setTaskNotice(`已恢复 ${count} 个 Markdown 文件`);
|
||
})
|
||
.catch((error) =>
|
||
setTaskError(
|
||
error instanceof Error
|
||
? error.message
|
||
: "恢复失败",
|
||
),
|
||
);
|
||
event.currentTarget.value = "";
|
||
}}
|
||
/>
|
||
</label>
|
||
<details class="pl-task-health">
|
||
<summary>
|
||
<CheckCircle size={16} />
|
||
<span>
|
||
<strong>数据检查</strong>
|
||
<small>
|
||
{healthIssues.length
|
||
? `发现 ${healthIssues.length} 个问题`
|
||
: "任务数据结构正常"}
|
||
</small>
|
||
</span>
|
||
{healthIssues.length > 0 && <ChevronDown size={14} />}
|
||
</summary>
|
||
{healthIssues.length > 0 && (
|
||
<div>
|
||
{healthIssues.map((issue, index) => {
|
||
const task = tasks.find(
|
||
(candidate) => candidate.ref === issue.ref,
|
||
);
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={`${issue.ref}:${issue.type}:${index}`}
|
||
disabled={!task}
|
||
onClick={() => task && onOpenTask(task)}
|
||
>
|
||
<strong>
|
||
{task ? plainleafTaskTitle(task) : issue.ref}
|
||
</strong>
|
||
<small>{issue.message}</small>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</details>
|
||
{accountManaged ? (
|
||
<a href="/.spaces/account">
|
||
<User size={16} />
|
||
<span>
|
||
<strong>我的账号</strong>
|
||
<small>修改密码与管理 API 令牌</small>
|
||
</span>
|
||
</a>
|
||
) : (
|
||
<div aria-disabled="true">
|
||
<User size={16} />
|
||
<span>
|
||
<strong>账号管理</strong>
|
||
<small>部署切换到多用户模式后启用</small>
|
||
</span>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
<div class="pl-task-scroll">
|
||
{tasksLoading && (
|
||
<div class="pl-task-loading">正在整理待办...</div>
|
||
)}
|
||
{!tasksLoading && taskList === "planned" && taskCalendar && (
|
||
<section class="pl-task-calendar">
|
||
<header>
|
||
<button
|
||
type="button"
|
||
aria-label="上个月"
|
||
onClick={() => {
|
||
const date = new Date(`${calendarDate}T12:00:00`);
|
||
date.setMonth(date.getMonth() - 1, 1);
|
||
setCalendarDate(localDateKey(date));
|
||
}}
|
||
>
|
||
<ChevronDown size={16} />
|
||
</button>
|
||
<strong>
|
||
{calendarBase.toLocaleDateString("zh-CN", {
|
||
year: "numeric",
|
||
month: "long",
|
||
})}
|
||
</strong>
|
||
<button
|
||
type="button"
|
||
aria-label="下个月"
|
||
onClick={() => {
|
||
const date = new Date(`${calendarDate}T12:00:00`);
|
||
date.setMonth(date.getMonth() + 1, 1);
|
||
setCalendarDate(localDateKey(date));
|
||
}}
|
||
>
|
||
<ChevronDown size={16} />
|
||
</button>
|
||
</header>
|
||
<div class="pl-task-calendar-weekdays">
|
||
{["日", "一", "二", "三", "四", "五", "六"].map((day) => (
|
||
<span key={day}>{day}</span>
|
||
))}
|
||
</div>
|
||
<div class="pl-task-calendar-grid">
|
||
{calendarDays.map((day) => {
|
||
const dayTasks = tasksForCalendarDate(tasks, day.date);
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={day.date}
|
||
class={`${day.inMonth ? "" : "is-outside"} ${day.date === calendarDate ? "is-selected" : ""}`}
|
||
onClick={() => setCalendarDate(day.date)}
|
||
>
|
||
<span>{Number(day.date.slice(-2))}</span>
|
||
{dayTasks.length > 0 && (
|
||
<small>{dayTasks.length}</small>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<section class="pl-task-calendar-day">
|
||
<h2>{displayTaskDate(calendarDate)}</h2>
|
||
{tasksForCalendarDate(tasks, calendarDate).map((task) => (
|
||
<TaskRow
|
||
key={task.ref}
|
||
task={task}
|
||
depth={taskDepth(task, tasks)}
|
||
selected={selectedTaskRef === task.ref}
|
||
selectionMode={taskSelectionMode}
|
||
checked={selectedTaskRefs.has(task.ref)}
|
||
readOnly={isReadOnly}
|
||
progress={subtaskProgress(task, tasks)}
|
||
onSelect={() => setSelectedTaskRef(task.ref)}
|
||
onToggleSelection={() =>
|
||
setSelectedTaskRefs((previous) => {
|
||
const next = new Set(previous);
|
||
if (next.has(task.ref)) next.delete(task.ref);
|
||
else next.add(task.ref);
|
||
return next;
|
||
})
|
||
}
|
||
onComplete={() => void completeTask(task)}
|
||
/>
|
||
))}
|
||
</section>
|
||
</section>
|
||
)}
|
||
{!tasksLoading &&
|
||
!(taskList === "planned" && taskCalendar) &&
|
||
taskGroups.map((group) => (
|
||
<section class="pl-task-group" key={group.key}>
|
||
{group.label && (
|
||
<header>
|
||
<h2>{group.label}</h2>
|
||
<span>{group.tasks.length}</span>
|
||
</header>
|
||
)}
|
||
{group.tasks.map((task) => (
|
||
<TaskRow
|
||
key={task.ref}
|
||
task={task}
|
||
depth={taskDepth(task, tasks)}
|
||
selected={selectedTaskRef === task.ref}
|
||
selectionMode={taskSelectionMode}
|
||
checked={selectedTaskRefs.has(task.ref)}
|
||
readOnly={isReadOnly}
|
||
progress={subtaskProgress(task, tasks)}
|
||
onSelect={() => setSelectedTaskRef(task.ref)}
|
||
onToggleSelection={() =>
|
||
setSelectedTaskRefs((previous) => {
|
||
const next = new Set(previous);
|
||
if (next.has(task.ref)) next.delete(task.ref);
|
||
else next.add(task.ref);
|
||
return next;
|
||
})
|
||
}
|
||
onComplete={() => void completeTask(task)}
|
||
/>
|
||
))}
|
||
</section>
|
||
))}
|
||
{!tasksLoading && visibleTaskCount === 0 && (
|
||
<div class="pl-task-empty">
|
||
<span>{taskListIcon(taskList)}</span>
|
||
<strong>
|
||
{taskList === "today"
|
||
? "今天没有待办"
|
||
: taskList === "logbook"
|
||
? "还没有完成记录"
|
||
: "这里还是空的"}
|
||
</strong>
|
||
<p>
|
||
{taskList === "inbox"
|
||
? "先把想到的事情记下来,稍后再安排。"
|
||
: "新的 Markdown 待办会自动整理到合适的清单。"}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="pl-task-mobile-add"
|
||
disabled={isReadOnly}
|
||
aria-label="添加待办"
|
||
onClick={() => setNewTaskOpen(true)}
|
||
>
|
||
<Plus size={22} />
|
||
</button>
|
||
</div>
|
||
{selectedTask && (
|
||
<TaskEditor
|
||
key={selectedTask.ref}
|
||
task={selectedTask}
|
||
readOnly={isReadOnly}
|
||
onClose={() => setSelectedTaskRef(undefined)}
|
||
onSave={(patch) => updateTask(selectedTask, patch)}
|
||
onAddSubtask={(input) => createSubtask(selectedTask, input)}
|
||
onDelete={() => deleteTask(selectedTask)}
|
||
onOpenSource={() => {
|
||
onOpenTask(selectedTask);
|
||
setSelectedTaskRef(undefined);
|
||
}}
|
||
/>
|
||
)}
|
||
</section>
|
||
)}
|
||
{view === "settings" && (
|
||
<section class="pl-settings-surface">
|
||
<div class="pl-settings-inner">
|
||
<h1>设置</h1>
|
||
<p>日常写作选项放在前面,高级能力需要时再打开。</p>
|
||
<section>
|
||
<h2>写作体验</h2>
|
||
<div class="pl-settings-main-row pl-settings-writing-note">
|
||
<span>
|
||
<strong>Markdown 编辑器</strong>
|
||
<small>
|
||
已启用格式工具栏、实时渲染、双向链接和自动保存
|
||
</small>
|
||
</span>
|
||
<span class="pl-status-label">已启用</span>
|
||
</div>
|
||
<div class="pl-settings-main-row">
|
||
<span>
|
||
<strong>界面主题</strong>
|
||
<small>当前为{darkMode ? "深色" : "明亮"}界面</small>
|
||
</span>
|
||
<button type="button" onClick={onToggleTheme}>
|
||
切换为{darkMode ? "明亮" : "深色"}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h2>文件与同步</h2>
|
||
<div class="pl-settings-main-row">
|
||
<span>
|
||
<strong>当前空间</strong>
|
||
<small>
|
||
{isOnline
|
||
? "Markdown 文件已同步"
|
||
: "离线修改将保存在本机"}
|
||
</small>
|
||
</span>
|
||
<span
|
||
class={`pl-online-dot ${isOnline ? "is-online" : ""}`}
|
||
/>
|
||
</div>
|
||
{installPrompt && (
|
||
<div class="pl-settings-main-row">
|
||
<span>
|
||
<strong>安装到设备</strong>
|
||
<small>作为独立应用打开,手机端使用更接近原生 App</small>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
void installPrompt
|
||
.prompt()
|
||
.then(() => installPrompt.userChoice)
|
||
.then(() => setInstallPrompt(undefined));
|
||
}}
|
||
>
|
||
安装
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
{accountManaged && (
|
||
<section>
|
||
<h2>账号与空间</h2>
|
||
<div class="pl-settings-main-row">
|
||
<span>
|
||
<strong>我的账号</strong>
|
||
<small>修改密码与管理 API 令牌</small>
|
||
</span>
|
||
<a class="pl-settings-link" href="/.spaces/account">
|
||
打开
|
||
</a>
|
||
</div>
|
||
</section>
|
||
)}
|
||
<section>
|
||
<h2>开发者选项</h2>
|
||
<div class="pl-settings-main-row">
|
||
<span>
|
||
<strong>启用开发者选项</strong>
|
||
<small>命令、系统页面和社区扩展仅适合高级用户</small>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
class="pl-switch-button"
|
||
role="switch"
|
||
aria-checked={developerTools}
|
||
onClick={toggleDeveloperTools}
|
||
>
|
||
<span>{developerTools ? "开启" : "关闭"}</span>
|
||
<span
|
||
class={`pl-switch ${developerTools ? "is-on" : ""}`}
|
||
aria-hidden="true"
|
||
>
|
||
<span />
|
||
</span>
|
||
</button>
|
||
</div>
|
||
{developerTools && (
|
||
<>
|
||
<div class="pl-settings-main-row">
|
||
<span>
|
||
<strong>显示系统内容</strong>
|
||
<small>扩展库、仓库、内置组件与系统配置</small>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
class="pl-switch-button"
|
||
role="switch"
|
||
aria-checked={showSystemContent}
|
||
onClick={toggleSystemContent}
|
||
>
|
||
<span>{showSystemContent ? "显示" : "隐藏"}</span>
|
||
<span
|
||
class={`pl-switch ${showSystemContent ? "is-on" : ""}`}
|
||
aria-hidden="true"
|
||
>
|
||
<span />
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div class="pl-developer-warning">
|
||
社区扩展从 GitHub 下载,不属于 Plainleaf
|
||
核心功能。安装前请确认来源可信。
|
||
</div>
|
||
<AdvancedTools onOpen={onOpenAdvancedTool} />
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</main>
|
||
|
||
{undoTask && (
|
||
<div class="pl-task-undo" role="status">
|
||
<span>已完成“{undoTask.task.name}”</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const undo = undoTask;
|
||
setUndoTask(undefined);
|
||
void (async () => {
|
||
let repeatedBlock: PlainleafDeletedTaskBlock | undefined;
|
||
try {
|
||
if (undo.repeatedTask) {
|
||
repeatedBlock = await onDeleteTask(undo.repeatedTask);
|
||
}
|
||
await onToggleTask(undo.task, {
|
||
state: " ",
|
||
completed: null,
|
||
});
|
||
setTasks((previous) =>
|
||
previous
|
||
.filter(
|
||
(candidate) => candidate.ref !== undo.repeatedTask?.ref,
|
||
)
|
||
.map((candidate) =>
|
||
candidate.ref === undo.task.ref
|
||
? {
|
||
...candidate,
|
||
state: " ",
|
||
done: false,
|
||
completed: undefined,
|
||
}
|
||
: candidate,
|
||
),
|
||
);
|
||
} catch (error) {
|
||
if (repeatedBlock) {
|
||
try {
|
||
await onRestoreTask(repeatedBlock);
|
||
} catch (restoreError) {
|
||
console.error(
|
||
"Failed to restore repeated task after undo failure",
|
||
restoreError,
|
||
);
|
||
}
|
||
}
|
||
await refreshTasks();
|
||
setTaskError(
|
||
error instanceof Error ? error.message : "撤销完成失败",
|
||
);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
撤销
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{deletedTask && (
|
||
<div class="pl-task-undo" role="status">
|
||
<span>已删除“{plainleafTaskTitle(deletedTask.tasks[0])}”</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
void onRestoreTask(deletedTask.block)
|
||
.then(() => {
|
||
setTasks((previous) => [...previous, ...deletedTask.tasks]);
|
||
setDeletedTask(undefined);
|
||
})
|
||
.catch((error) =>
|
||
setTaskError(
|
||
error instanceof Error ? error.message : "恢复待办失败",
|
||
),
|
||
);
|
||
}}
|
||
>
|
||
撤销
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{taskNotice && (
|
||
<div class="pl-task-undo pl-task-notice" role="status">
|
||
<span>{taskNotice}</span>
|
||
</div>
|
||
)}
|
||
|
||
{taskError && (
|
||
<div class="pl-task-error" role="alert">
|
||
<span>{taskError}</span>
|
||
<button
|
||
type="button"
|
||
aria-label="关闭错误"
|
||
onClick={() => setTaskError("")}
|
||
>
|
||
<X size={15} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{newTaskOpen && (
|
||
<NewTaskDialog
|
||
readOnly={isReadOnly}
|
||
onClose={() => setNewTaskOpen(false)}
|
||
onCreate={createTask}
|
||
/>
|
||
)}
|
||
|
||
{newNoteOpen && (
|
||
<div
|
||
class="pl-new-note-backdrop"
|
||
onClick={(event) => {
|
||
if (event.currentTarget === event.target) setNewNoteOpen(false);
|
||
}}
|
||
>
|
||
<section
|
||
class="pl-new-note-dialog"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="新建笔记"
|
||
>
|
||
<header>
|
||
<h2>新建笔记</h2>
|
||
<button
|
||
type="button"
|
||
aria-label="关闭"
|
||
onClick={() => setNewNoteOpen(false)}
|
||
>
|
||
<X size={18} />
|
||
</button>
|
||
</header>
|
||
<div class="pl-quick-create">
|
||
<button type="button" onClick={() => openCreate("capture")}>
|
||
<Zap size={15} />
|
||
随手记
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setNewNoteOpen(false);
|
||
onOpenDaily();
|
||
}}
|
||
>
|
||
<Calendar size={15} />
|
||
今天的日记
|
||
</button>
|
||
<button type="button" class="is-active">
|
||
<FileText size={15} />
|
||
空白笔记
|
||
</button>
|
||
</div>
|
||
<label>
|
||
标题
|
||
<input
|
||
autoFocus
|
||
value={newNoteTitle}
|
||
placeholder="输入笔记标题"
|
||
onInput={(event) => setNewNoteTitle(event.currentTarget.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") void createPage();
|
||
}}
|
||
/>
|
||
</label>
|
||
<label>
|
||
保存到
|
||
<select
|
||
value={newNoteFolder}
|
||
onChange={(event) =>
|
||
setNewNoteFolder(event.currentTarget.value)
|
||
}
|
||
>
|
||
{rootFolders.map((folder) => (
|
||
<option key={folder} value={folder}>
|
||
{folder}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{createError && <p class="pl-create-error">{createError}</p>}
|
||
<footer>
|
||
<button type="button" onClick={() => setNewNoteOpen(false)}>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="is-primary"
|
||
disabled={creating}
|
||
onClick={() => void createPage()}
|
||
>
|
||
{creating ? "正在创建" : "创建笔记"}
|
||
</button>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|