Update WBBR frontend template collection

This commit is contained in:
Codex
2026-08-27 20:15:24 +08:00
parent bd4d54a110
commit e444605e5d
85 changed files with 6322 additions and 115 deletions

185
app/markdown/page.tsx Normal file
View File

@@ -0,0 +1,185 @@
"use client";
import Link from "next/link";
import { Fragment, ReactNode, useEffect, useMemo, useRef, useState } from "react";
import data from "../data/content-templates.json";
import styles from "./MarkdownEditor.module.css";
type ViewMode = "edit" | "split" | "preview";
type DocumentItem = (typeof data.documents)[number];
export default function MarkdownEditorPage() {
const [documents, setDocuments] = useState<DocumentItem[]>(data.documents);
const [activeId, setActiveId] = useState(data.documents[0].id);
const [contents, setContents] = useState<Record<string, string>>(() => Object.fromEntries(data.documents.map((doc) => [doc.id, doc.content])));
const [view, setView] = useState<ViewMode>("split");
const [query, setQuery] = useState("");
const [saved, setSaved] = useState(true);
const [toast, setToast] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
try {
const cached = window.localStorage.getItem("orbit-markdown-workspace");
if (cached) {
const parsed = JSON.parse(cached);
if (parsed.contents) setContents((current) => ({ ...current, ...parsed.contents }));
if (Array.isArray(parsed.documents)) setDocuments(parsed.documents);
if (typeof parsed.activeId === "string") setActiveId(parsed.activeId);
}
} catch { /* use JSON defaults */ }
}, []);
useEffect(() => {
setSaved(false);
const timer = window.setTimeout(() => {
window.localStorage.setItem("orbit-markdown-workspace", JSON.stringify({ documents, contents, activeId }));
setSaved(true);
}, 500);
return () => window.clearTimeout(timer);
}, [documents, contents, activeId]);
const activeDocument = documents.find((doc) => doc.id === activeId) ?? documents[0];
const content = contents[activeId] ?? "";
const filteredDocuments = documents.filter((doc) => doc.title.toLowerCase().includes(query.toLowerCase()));
const stats = useMemo(() => ({
chars: content.replace(/\s/g, "").length,
lines: content.split("\n").length,
words: content.trim() ? content.trim().split(/\s+/).length : 0,
}), [content]);
const notify = (message: string) => {
setToast(message);
window.setTimeout(() => setToast(""), 2000);
};
const updateContent = (value: string) => setContents((current) => ({ ...current, [activeId]: value }));
const insert = (before: string, placeholder = "文本", after = "") => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = content.slice(start, end) || placeholder;
const next = `${content.slice(0, start)}${before}${selected}${after}${content.slice(end)}`;
updateContent(next);
window.requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
});
};
const createDocument = () => {
const id = `md-${Date.now()}`;
const newDocument: DocumentItem = { id, title: "未命名文档", category: "草稿", updated: "刚刚", content: "# 未命名文档\n\n开始写作……" };
setDocuments((current) => [newDocument, ...current]);
setContents((current) => ({ ...current, [id]: newDocument.content }));
setActiveId(id);
notify("已创建新文档");
};
const renameDocument = (title: string) => setDocuments((current) => current.map((doc) => doc.id === activeId ? { ...doc, title, updated: "刚刚" } : doc));
const downloadDocument = () => {
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = window.document.createElement("a");
link.href = url;
link.download = `${activeDocument?.title || "document"}.md`;
link.click();
URL.revokeObjectURL(url);
notify("Markdown 文件已导出");
};
return (
<div className={styles.page}>
<header className={styles.topbar}>
<Link className={styles.brand} href="/"><span>M</span><strong>Paper</strong></Link>
<nav><Link href="/simple-list"></Link><Link href="/character"></Link><Link className={styles.active} href="/markdown">Markdown </Link></nav>
<div className={styles.saveState}><i className={saved ? styles.saved : ""} />{saved ? "已自动保存" : "正在保存…"}</div>
<div className={styles.headerActions}><button onClick={() => notify("协作成员面板为模板占位")}> </button><button className={styles.exportButton} onClick={downloadDocument}> .md</button><span></span></div>
</header>
<div className={styles.workspace}>
<aside className={styles.sidebar}>
<div className={styles.sidebarHeader}><div><strong></strong><small>{documents.length} </small></div><button onClick={createDocument}></button></div>
<label className={styles.search}><span></span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索文档" /></label>
<div className={styles.documentList}>
<p></p>
{filteredDocuments.map((doc) => <button key={doc.id} className={activeId === doc.id ? styles.documentActive : ""} onClick={() => setActiveId(doc.id)}><span>{doc.category === "草稿" ? "○" : "M"}</span><div><strong>{doc.title}</strong><small>{doc.category} · {doc.updated}</small></div><i></i></button>)}
{filteredDocuments.length === 0 && <div className={styles.noDocument}></div>}
</div>
<div className={styles.sidebarFooter}><Link href="/simple-list"> </Link><button onClick={() => notify("设置面板为模板占位")}> </button></div>
</aside>
<main className={styles.editorArea}>
<div className={styles.documentHeader}>
<div><span>{activeDocument?.category || "文档"}</span><input value={activeDocument?.title || ""} onChange={(event) => renameDocument(event.target.value)} aria-label="文档标题" /></div>
<div className={styles.viewSwitch}><button className={view === "edit" ? styles.viewActive : ""} onClick={() => setView("edit")}></button><button className={view === "split" ? styles.viewActive : ""} onClick={() => setView("split")}></button><button className={view === "preview" ? styles.viewActive : ""} onClick={() => setView("preview")}></button></div>
</div>
<div className={styles.toolbar}>
<div><button title="一级标题" onClick={() => insert("# ", "标题")}>H1</button><button title="二级标题" onClick={() => insert("## ", "标题")}>H2</button><button title="三级标题" onClick={() => insert("### ", "标题")}>H3</button></div>
<i />
<div><button title="粗体" onClick={() => insert("**", "粗体文本", "**")}><b>B</b></button><button title="斜体" onClick={() => insert("*", "斜体文本", "*")}><em>I</em></button><button title="删除线" onClick={() => insert("~~", "删除文本", "~~")}><s>S</s></button></div>
<i />
<div><button title="无序列表" onClick={() => insert("- ", "列表项")}></button><button title="有序列表" onClick={() => insert("1. ", "列表项")}>1</button><button title="引用" onClick={() => insert("> ", "引用内容")}></button><button title="行内代码" onClick={() => insert("`", "代码", "`")}>&lt;/&gt;</button><button title="" onClick={() => insert("[", "", "](https://example.com)")}></button><button title="线" onClick={() => insert("\n---\n", "")}></button></div>
<span className={styles.toolbarHint}>Markdown · UTF-8</span>
</div>
<div className={`${styles.panes} ${styles[`view_${view}`]}`}>
{view !== "preview" && <section className={styles.editorPane}>
<div className={styles.lineNumbers}>{content.split("\n").map((_, index) => <span key={index}>{index + 1}</span>)}</div>
<textarea ref={textareaRef} value={content} onChange={(event) => updateContent(event.target.value)} spellCheck={false} aria-label="Markdown 编辑区" />
</section>}
{view !== "edit" && <section className={styles.previewPane}><MarkdownPreview content={content} /></section>}
</div>
<footer className={styles.statusbar}><div><span>Ln {textareaRef.current ? content.slice(0, textareaRef.current.selectionStart).split("\n").length : 1}</span><span>{stats.lines} </span><span>{stats.chars} </span><span>{stats.words} </span></div><div><span>Markdown</span><span>UTF-8</span><button onClick={() => notify("快捷键Ctrl / Cmd + S 保存")}> </button></div></footer>
</main>
</div>
{toast && <div className={styles.toast}> {toast}</div>}
</div>
);
}
function MarkdownPreview({ content }: { content: string }) {
const lines = content.split("\n");
let codeMode = false;
const blocks: ReactNode[] = [];
lines.forEach((line, index) => {
if (line.trim().startsWith("```")) {
codeMode = !codeMode;
blocks.push(<div className={styles.codeFence} key={index}>{codeMode ? "CODE" : ""}</div>);
return;
}
if (codeMode) { blocks.push(<code className={styles.codeLine} key={index}>{line || " "}</code>); return; }
if (/^###\s/.test(line)) blocks.push(<h3 key={index}>{renderInline(line.slice(4))}</h3>);
else if (/^##\s/.test(line)) blocks.push(<h2 key={index}>{renderInline(line.slice(3))}</h2>);
else if (/^#\s/.test(line)) blocks.push(<h1 key={index}>{renderInline(line.slice(2))}</h1>);
else if (/^>\s?/.test(line)) blocks.push(<blockquote key={index}>{renderInline(line.replace(/^>\s?/, ""))}</blockquote>);
else if (/^---+$/.test(line.trim())) blocks.push(<hr key={index} />);
else if (/^-\s/.test(line)) blocks.push(<div className={styles.unordered} key={index}><span></span><p>{renderInline(line.slice(2))}</p></div>);
else if (/^\d+\.\s/.test(line)) blocks.push(<div className={styles.ordered} key={index}><span>{line.match(/^\d+/)?.[0]}.</span><p>{renderInline(line.replace(/^\d+\.\s/, ""))}</p></div>);
else if (!line.trim()) blocks.push(<div className={styles.blank} key={index} />);
else blocks.push(<p key={index}>{renderInline(line)}</p>);
});
return <article className={styles.markdownBody}>{blocks}</article>;
}
function renderInline(text: string): ReactNode {
const token = /(\*\*.+?\*\*|~~.+?~~|`.+?`|\*.+?\*|\[[^\]]+\]\([^\)]+\))/g;
return text.split(token).map((part, index) => {
if (part.startsWith("**") && part.endsWith("**")) return <strong key={index}>{part.slice(2, -2)}</strong>;
if (part.startsWith("~~") && part.endsWith("~~")) return <del key={index}>{part.slice(2, -2)}</del>;
if (part.startsWith("`") && part.endsWith("`")) return <code key={index}>{part.slice(1, -1)}</code>;
if (part.startsWith("*") && part.endsWith("*")) return <em key={index}>{part.slice(1, -1)}</em>;
const link = part.match(/^\[([^\]]+)\]\(([^\)]+)\)$/);
if (link) {
const href = /^(https?:\/\/|#)/.test(link[2]) ? link[2] : "#";
return <a key={index} href={href} target={href.startsWith("http") ? "_blank" : undefined} rel="noreferrer">{link[1]}</a>;
}
return <Fragment key={index}>{part}</Fragment>;
});
}