Files
plainleaf/client/components/plainleaf_workspace.tsx
T
wushenghuaandCodex fdd51f01a2
CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s
feat: localize settings and smooth theme switching
Add a persistent system-content switch, translate the extension manager,
and apply theme changes in place without reloading the browser UI.

Co-Authored-By: Codex <noreply@anthropic.com>
2026-08-12 17:42:44 +08:00

1399 lines
41 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,
Box,
Calendar,
CheckCircle,
CheckSquare,
ChevronDown,
ChevronRight,
Circle,
Clock,
Cloud,
Command,
Feather,
FilePlus,
FileText,
Folder,
GitBranch,
Hash,
Home,
Menu,
Moon,
Plus,
Search,
Settings,
Sliders,
Star,
Tag,
X,
Zap,
} from "preact-feather";
import { parsePageMetaLastModified } from "../lib/page_meta.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 type PlainleafTask = {
ref: string;
page: string;
name: string;
state: string;
done: boolean;
deadline?: 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";
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";
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.
}
}
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: "管理兼容扩展",
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>
);
}
export function PlainleafWorkspace({
allPages,
currentPage,
indexPage,
isOnline,
isReadOnly,
darkMode,
onOpenPage,
onCreatePage,
onOpenDaily,
onSearchPages,
onLoadTasks,
onToggleTask,
onOpenTask,
onOpenAdvancedTool,
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) => Promise<void>;
onOpenTask: (task: PlainleafTask) => void;
onOpenAdvancedTool: (tool: PlainleafAdvancedTool) => void;
onToggleTheme: () => void;
children: ComponentChildren;
}) {
const [showSystemContent, setShowSystemContent] = useState(
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 [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) => !task.done && visibleNames.has(task.page),
),
);
} catch (error) {
console.error("Failed to load Plainleaf tasks", error);
setTasks([]);
} finally {
setTasksLoading(false);
}
};
const completeTask = async (task: PlainleafTask) => {
try {
await onToggleTask(task);
setTasks((previous) =>
previous.filter((candidate) => candidate.ref !== task.ref),
);
} catch (error) {
console.error("Failed to complete Plainleaf task", error);
}
};
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 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 openMobileView = (next: PlainleafView) => {
setView(next);
setMobileOpen(true);
};
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 showingCustomSurface = currentPage === indexPage || view === "settings";
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.length} 项</span>
</header>
<div class="pl-task-list">
{tasksLoading && (
<div class="pl-loading-state">正在整理待办...</div>
)}
{!tasksLoading &&
tasks.map((task) => (
<div class="pl-task-row" key={task.ref}>
<button
type="button"
class="pl-task-check"
disabled={isReadOnly}
aria-label="完成待办"
onClick={() => void completeTask(task)}
>
<Circle size={17} />
</button>
<button
type="button"
class="pl-task-copy"
onClick={() => {
onOpenTask(task);
setMobileOpen(false);
}}
>
<strong>{task.name}</strong>
<small>
{task.page}
{task.deadline ? ` · ${task.deadline}` : ""}
</small>
</button>
</div>
))}
{!tasksLoading && tasks.length === 0 && (
<div class="pl-empty-state">
<CheckCircle size={23} />
<strong>待办都完成了</strong>
<span>Markdown 中新的未完成项会出现在这里。</span>
</div>
)}
</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={() => setView("settings")}>
<Settings size={16} />
<span>设置</span>
</button>
</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>
<button
type="button"
class="pl-setting-row"
role="switch"
aria-checked={showSystemContent}
onClick={toggleSystemContent}
>
<span>
<strong>显示系统内容</strong>
<small>扩展库、仓库与配置页面</small>
</span>
<span
class={`pl-switch ${showSystemContent ? "is-on" : ""}`}
aria-hidden="true"
>
<span />
</span>
</button>
<div class="pl-setting-row">
<span>
<strong>文件同步</strong>
<small>{isOnline ? "当前空间已连接" : "离线工作中"}</small>
</span>
<Cloud size={15} />
</div>
</section>
<section class="pl-settings-section">
<h2>高级工具</h2>
<AdvancedTools onOpen={onOpenAdvancedTool} />
</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}
</div>
{currentPage === indexPage && view !== "settings" && (
<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={() => openMobileView("tasks")}
>
查看全部
</button>
</header>
{tasks.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 && tasks.length === 0 && (
<p class="pl-home-empty">今天没有未完成待办</p>
)}
</section>
</div>
</div>
</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">
<span>
<strong>界面主题</strong>
<small>当前为{darkMode ? "深色" : "明亮"}界面</small>
</span>
<button type="button" onClick={onToggleTheme}>
切换为{darkMode ? "明亮" : "深色"}
</button>
</div>
<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>
</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>
</section>
<section>
<h2>高级工具</h2>
<AdvancedTools onOpen={onOpenAdvancedTool} />
</section>
</div>
</section>
)}
</main>
<nav class="pl-mobile-navigation" aria-label="手机导航">
<button type="button" onClick={() => openMobileView("notes")}>
<BookOpen size={19} />
<span>笔记</span>
</button>
<button type="button" onClick={() => openMobileView("search")}>
<Search size={19} />
<span>搜索</span>
</button>
<button
type="button"
class="pl-mobile-create"
disabled={isReadOnly}
aria-label="新建笔记"
onClick={() => openCreate("capture")}
>
<Plus size={21} />
</button>
<button type="button" onClick={() => openMobileView("tasks")}>
<CheckSquare size={19} />
<span>待办</span>
</button>
<button type="button" onClick={() => openMobileView("more")}>
<Menu size={19} />
<span>更多</span>
</button>
</nav>
{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>
);
}