chore: sync local changes to Gitea

This commit is contained in:
shumengya
2026-06-24 22:10:27 +08:00
parent 10ff96f32b
commit ad8997a5ec
105 changed files with 11146 additions and 1207 deletions

32
client/index.html Normal file
View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>萌芽小窝</title>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&family=Source+Code+Pro:wght@400;500;600&display=swap"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css"
crossorigin="anonymous"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css"
crossorigin="anonymous"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
client/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

25
client/src/App.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ConfigProvider } from "./ConfigContext";
import { Layout } from "./Layout";
import { Home } from "./pages/Home";
import { PostPage } from "./pages/PostPage";
import { AdminDashboard, AdminEditor } from "./pages/Admin";
export function App() {
return (
<BrowserRouter>
<ConfigProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="post/:slug" element={<PostPage />} />
<Route path="admin" element={<AdminDashboard />} />
<Route path="admin/new" element={<AdminEditor />} />
<Route path="admin/edit/:id" element={<AdminEditor />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</ConfigProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,51 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode
} from "react";
import type { SiteConfig } from "./api";
import { fetchConfig } from "./api";
const Ctx = createContext<SiteConfig | null>(null);
export function ConfigProvider({ children }: { children: ReactNode }) {
const [cfg, setCfg] = useState<SiteConfig | null>(null);
const load = useCallback(async () => {
try {
setCfg(await fetchConfig());
} catch {
setCfg({
siteTitle: "萌芽小窝",
siteTitleEn: "SproutBlog",
cwdApiBase: "https://cwd.api.smyhub.com"
});
}
}, []);
useEffect(() => {
load();
}, [load]);
const value = useMemo(() => cfg, [cfg]);
if (!value) {
return (
<main style={{ padding: 24 }}>
<p></p>
</main>
);
}
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useSiteConfig(): SiteConfig {
const v = useContext(Ctx);
if (!v) throw new Error("useSiteConfig");
return v;
}

40
client/src/Layout.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { Link, Outlet } from "react-router-dom";
import { useSiteConfig } from "./ConfigContext";
import { AdminGate } from "./components/AdminGate";
import { GeoFooter } from "./components/GeoFooter";
export function Layout() {
const { siteTitle, siteTitleEn } = useSiteConfig();
return (
<>
<main>
<header className="site-header">
<div className="site-brand">
<AdminGate>
<img
className="site-logo"
src="/logo.png"
alt=""
width={40}
height={40}
decoding="async"
/>
</AdminGate>
<div className="site-titles">
<Link to="/" className="site-title-link">
{siteTitle}
</Link>
<span className="site-title-en">{siteTitleEn}</span>
</div>
</div>
</header>
<Outlet />
</main>
<footer className="site-footer">
<p>- @2026-</p>
<GeoFooter />
</footer>
</>
);
}

123
client/src/api.ts Normal file
View File

@@ -0,0 +1,123 @@
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? "";
function apiUrl(path: string): string {
return API_BASE ? `${API_BASE}${path}` : path;
}
export type SiteConfig = {
siteTitle: string;
siteTitleEn: string;
cwdApiBase: string;
cwdSiteId?: string;
};
export type Post = {
id: number;
title: string;
slug: string;
excerpt: string;
content: string;
published: number;
created_at: string;
updated_at: string;
view_count?: number;
};
export async function fetchConfig(): Promise<SiteConfig> {
const r = await fetch(apiUrl("/api/config"));
const j = (await r.json()) as {
ok?: boolean;
config?: SiteConfig;
};
if (!j.ok || !j.config) throw new Error("config");
const viteId = import.meta.env.VITE_CWD_SITE_ID;
if (viteId && !j.config.cwdSiteId) {
j.config.cwdSiteId = viteId;
}
return j.config;
}
export async function fetchPosts(
q?: string,
opts?: { credentials?: RequestCredentials }
): Promise<Post[]> {
const u = new URL(apiUrl("/api/posts"), window.location.origin);
if (q) u.searchParams.set("q", q);
const r = await fetch(u.toString(), {
credentials: opts?.credentials ?? "same-origin"
});
const j = (await r.json()) as { ok?: boolean; posts?: Post[] };
if (!r.ok || !j.ok) throw new Error("posts");
return j.posts || [];
}
export async function fetchPost(
idOrSlug: string,
bump: boolean,
opts?: { credentials?: RequestCredentials }
): Promise<Post> {
const u = new URL(apiUrl(`/api/posts/${encodeURIComponent(idOrSlug)}`), window.location.origin);
if (bump) u.searchParams.set("bump", "1");
const r = await fetch(u.toString(), {
credentials: opts?.credentials ?? "same-origin"
});
const j = (await r.json()) as { ok?: boolean; post?: Post; error?: string };
if (!r.ok || !j.ok || !j.post) throw new Error(j.error || "post");
return j.post;
}
export async function postJson<T>(
path: string,
body: unknown,
opts?: { method?: string }
): Promise<T> {
const r = await fetch(apiUrl(path), {
method: opts?.method || "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body)
});
return (await r.json()) as T;
}
export async function adminLogin(token: string): Promise<boolean> {
const j = await postJson<{ ok?: boolean }>("/api/admin/session", { token });
return !!j.ok;
}
export async function savePost(
data: Record<string, unknown>,
id?: number | null
): Promise<Post> {
if (id) {
const r = await fetch(apiUrl(`/api/posts/${id}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(data)
});
const j = (await r.json()) as { ok?: boolean; post?: Post; error?: string };
if (!r.ok || !j.ok || !j.post) throw new Error(j.error || "save");
return j.post;
}
const { id: _omit, ...body } = data;
void _omit;
const r = await fetch(apiUrl("/api/posts"), {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body)
});
const j = (await r.json()) as { ok?: boolean; post?: Post; error?: string };
if (!r.ok || !j.ok || !j.post) throw new Error(j.error || "save");
return j.post;
}
export async function deletePostApi(id: number): Promise<void> {
const r = await fetch(apiUrl(`/api/posts/${id}`), {
method: "DELETE",
credentials: "include"
});
const j = (await r.json()) as { ok?: boolean };
if (!r.ok || !j.ok) throw new Error("delete");
}

View File

@@ -0,0 +1,78 @@
import { useCallback, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { adminLogin } from "../api";
export function AdminGate({ children }: { children: ReactNode }) {
const [n, setN] = useState(0);
const [open, setOpen] = useState(false);
const [token, setToken] = useState("");
const navigate = useNavigate();
const onLogoClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
const next = n + 1;
if (next >= 5) {
setN(0);
setOpen(true);
return;
}
setN(next);
window.setTimeout(() => setN(0), 4000);
},
[n]
);
const submit = useCallback(async () => {
const ok = await adminLogin(token);
if (ok) {
setOpen(false);
setToken("");
navigate("/admin");
return;
}
alert("Token 无效");
}, [token, navigate]);
return (
<>
<button
type="button"
className="admin-gate-logo-btn"
onClick={onLogoClick}
aria-label="站点标志"
>
{children}
</button>
<div id="admin-gate" className="admin-gate" hidden={!open}>
<div className="admin-gate-panel">
<input
id="admin-token"
type="password"
autoComplete="off"
placeholder="Token"
aria-label="Token"
value={token}
onChange={(e) => setToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
/>
<button type="button" id="admin-token-go" onClick={submit}>
</button>
<button
type="button"
id="admin-token-cancel"
onClick={() => setOpen(false)}
>
</button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,134 @@
import { useEffect } from "react";
import { useSiteConfig } from "../ConfigContext";
declare global {
interface Window {
CWDComments?: new (opts: {
el: string;
apiBaseUrl: string;
postSlug: string;
lang?: string;
siteId?: string;
}) => { mount: () => void };
__cwdLikeStatusQueryPatched?: boolean;
}
}
/** 修正 cwd-widget 点赞 GET 使用完整 URL 导致计数为 0 的问题(沿用旧脚本逻辑) */
function patchCwdLikeGetQuery(apiBaseNorm: string, postSlug: string): void {
if (window.__cwdLikeStatusQueryPatched) return;
window.__cwdLikeStatusQueryPatched = true;
const base = apiBaseNorm;
const orig = window.fetch.bind(window);
window.fetch = function fetchPatched(input: RequestInfo | URL, init?: RequestInit) {
let url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
let method = "GET";
if (init?.method) method = String(init.method).toUpperCase();
else if (typeof Request !== "undefined" && input instanceof Request) {
method = String(input.method || "GET").toUpperCase();
}
if (
url &&
url.indexOf(base) === 0 &&
url.indexOf("/api/like?") !== -1 &&
method === "GET"
) {
try {
const u = new URL(url);
const ps = u.searchParams.get("post_slug") || "";
if (ps.indexOf("://") !== -1) {
u.searchParams.set("post_slug", postSlug);
if (typeof input === "string") {
input = u.toString();
} else if (typeof Request !== "undefined" && input instanceof Request) {
input = new Request(u.toString(), input);
} else if (input instanceof URL) {
input = u;
}
}
} catch {
/* ignore */
}
}
return orig(input as RequestInfo, init);
};
}
export function CwdComments({ postSlug }: { postSlug: string }) {
const { cwdApiBase, cwdSiteId } = useSiteConfig();
useEffect(() => {
const apiNorm = (cwdApiBase || "").replace(/\/$/, "");
patchCwdLikeGetQuery(apiNorm, postSlug);
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.async = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error(src));
document.head.appendChild(s);
});
}
let cancelled = false;
const el = document.getElementById("comments");
if (el) el.innerHTML = "";
(async () => {
try {
if (!window.CWDComments) {
await loadScript(
"https://cdn.jsdelivr.net/npm/cwd-widget@0.1.11/dist/cwd.umd.js"
);
}
if (cancelled || typeof window.CWDComments === "undefined") return;
const opts: {
el: string;
apiBaseUrl: string;
postSlug: string;
lang?: string;
siteId?: string;
} = {
el: "#comments",
apiBaseUrl: cwdApiBase.replace(/\/$/, ""),
postSlug,
lang: "zh-CN"
};
if (cwdSiteId) opts.siteId = cwdSiteId;
new window.CWDComments(opts).mount();
} catch {
const node = document.getElementById("comments");
if (node) node.textContent = "评论插件加载失败。";
}
})();
return () => {
cancelled = true;
const node = document.getElementById("comments");
if (node) node.innerHTML = "";
};
}, [postSlug, cwdApiBase, cwdSiteId]);
return (
<section className="post-comments" aria-labelledby="comments-heading">
<h2 id="comments-heading" className="post-comments__title">
</h2>
<div
id="comments"
data-api-base={cwdApiBase}
data-post-slug={postSlug}
data-lang="zh-CN"
{...(cwdSiteId ? { "data-site-id": cwdSiteId } : {})}
/>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
const GEO_API = "https://geoip.api.smyhub.com/api";
function formatGeo(g: Record<string, string> | null): string {
if (!g || typeof g !== "object") return "";
const parts: string[] = [];
if (g.countryName) parts.push(g.countryName);
if (g.regionName) parts.push(g.regionName);
if (g.cityName) parts.push(g.cityName);
return parts.join(" ");
}
export function GeoFooter() {
const [text, setText] = useState("正在获取访问信息…");
useEffect(() => {
fetch(GEO_API, { credentials: "omit", cache: "no-store" })
.then((r) => {
if (!r.ok) throw new Error("http " + r.status);
return r.json() as Promise<{ ip?: string; geo?: Record<string, string> }>;
})
.then((data) => {
const ip = data && data.ip ? String(data.ip) : "—";
let loc = formatGeo(data.geo || null);
if (!loc) loc = "未知位置";
setText(`您的访问 IP${ip} · ${loc}`);
})
.catch(() => setText("无法获取访问位置信息"));
}, []);
return (
<p className="site-footer-geo" id="site-footer-geo" aria-live="polite">
{text}
</p>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useRef } from "react";
import { renderMarkdownInto } from "../lib/markdown";
import { initPostToc, teardownPostTocUi } from "../lib/postToc";
export function MarkdownBody({ markdown }: { markdown: string }) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
teardownPostTocUi();
if (!markdown.trim()) {
el.innerHTML = "";
return;
}
el.classList.add("markdown-body", "markdown-pending");
el.innerHTML = '<p class="markdown-loading">加载中…</p>';
let cancelled = false;
(async () => {
try {
await renderMarkdownInto(el, markdown);
if (!cancelled) initPostToc(el);
} catch {
if (!cancelled) {
el.innerHTML = "<p>渲染失败。</p>";
el.classList.remove("markdown-pending");
}
}
})();
return () => {
cancelled = true;
teardownPostTocUi();
};
}, [markdown]);
return <div ref={ref} className="markdown-body" />;
}

View File

@@ -0,0 +1,73 @@
import { marked } from "marked";
import markedKatex from "marked-katex-extension";
import hljs from "highlight.js";
marked.use(
markedKatex({
throwOnError: false,
nonStandard: true
})
);
marked.setOptions({ gfm: true, breaks: true });
function decodeHtmlEntities(str: string): string {
return String(str)
.replace(/&nbsp;/g, "\u00a0")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#(\d+);/g, (_, n) => {
const code = Number(n);
return code >= 0 && code <= 0x10ffff ? String.fromCharCode(code) : "";
})
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => {
const code = parseInt(h, 16);
return code >= 0 && code <= 0x10ffff ? String.fromCharCode(code) : "";
});
}
function escapeHtml(s: string): string {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function mermaidFencedToDiv(html: string): string {
return html.replace(
/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/gi,
(_, inner: string) => {
const raw = decodeHtmlEntities(inner);
return `<div class="mermaid">${escapeHtml(raw)}</div>`;
}
);
}
/** 将 Markdown 渲染进元素(含 KaTeX、高亮、Mermaid */
export async function renderMarkdownInto(
el: HTMLElement,
markdown: string
): Promise<void> {
let html = marked.parse(markdown, { async: false }) as string;
if (typeof html !== "string") html = "";
html = mermaidFencedToDiv(html);
el.innerHTML = html;
el.classList.remove("markdown-pending");
el.querySelectorAll("pre code").forEach((block) => {
try {
hljs.highlightElement(block as HTMLElement);
} catch {
/* ignore */
}
});
const mNodes = el.querySelectorAll(".mermaid");
if (mNodes.length) {
const mermaid = (await import("mermaid")).default;
mermaid.initialize({ startOnLoad: false, theme: "neutral" });
await mermaid.run({ nodes: [...mNodes] as HTMLElement[] });
}
}

246
client/src/lib/postToc.ts Normal file
View File

@@ -0,0 +1,246 @@
/**
* 文章目录(由旧 post-toc.js 迁移)
*/
const HEADING_SEL = "h2, h3, h4";
function headingText(h: Element): string {
return (h.textContent || "").replace(/\s+/g, " ").trim();
}
function makeHeadingIds(headings: Element[]): void {
const used = new Set<string>();
headings.forEach((h) => {
if (h.id && !used.has(h.id)) {
used.add(h.id);
return;
}
const raw = headingText(h);
let base =
raw
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9\u2e80-\u9fff-]/gi, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || "section";
let id = base;
let n = 0;
while (used.has(id) || document.getElementById(id)) id = `${base}-${++n}`;
h.id = id;
used.add(id);
});
}
function buildTocOl(headings: Element[]) {
const ol = document.createElement("ol");
ol.className = "toc-tree";
const links: HTMLAnchorElement[] = [];
const path: { level: number; li: HTMLLIElement }[] = [];
for (const h of headings) {
const level = parseInt(h.tagName[1], 10);
if (level < 2 || level > 6) continue;
const li = document.createElement("li");
li.className = "toc-item";
const a = document.createElement("a");
a.className = "toc-link";
a.href = "#" + h.id;
a.textContent = headingText(h);
a.dataset.targetId = h.id;
li.appendChild(a);
links.push(a);
while (path.length && path[path.length - 1].level >= level) path.pop();
if (path.length === 0) {
ol.appendChild(li);
} else {
const parentLi = path[path.length - 1].li;
let sub = parentLi.querySelector(":scope > ol.toc-sub");
if (!sub) {
sub = document.createElement("ol");
sub.className = "toc-sub";
parentLi.appendChild(sub);
}
sub.appendChild(li);
}
path.push({ level, li });
}
return { ol, links };
}
function setActive(
linkGroups: HTMLAnchorElement[][],
activeId: string | null
): void {
for (const links of linkGroups) {
for (const a of links) {
const on = a.dataset.targetId === activeId;
a.classList.toggle("toc-link--active", !!on);
}
}
}
function bindScrollSpy(
headings: Element[],
linkGroups: HTMLAnchorElement[][],
desktopAside: HTMLElement | null,
mobileFab: HTMLElement | null,
scope: Element | null | undefined
): void {
const OFFSET = 100;
function update() {
const inArticle = scope ? scope.getBoundingClientRect().bottom > 120 : true;
if (desktopAside)
desktopAside.classList.toggle("post-toc--hidden", !inArticle);
if (mobileFab) mobileFab.classList.toggle("toc-fab--hidden", !inArticle);
const y = window.scrollY + OFFSET;
let current: string | null = headings[0]?.id || null;
for (const h of headings) {
if (h.getBoundingClientRect().top + window.scrollY <= y)
current = h.id || null;
}
if (current) setActive(linkGroups, current);
}
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
update();
ticking = false;
});
}
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
update();
}
function bindTocLinks(
container: HTMLElement,
onNavigate?: (() => void) | null
): void {
container.addEventListener("click", (e) => {
const t = e.target as HTMLElement | null;
const a = t?.closest("a.toc-link") as HTMLAnchorElement | null;
if (!a) return;
const id = a.getAttribute("href")?.slice(1);
if (!id) return;
const target = document.getElementById(id);
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", "#" + id);
onNavigate?.();
}
});
}
function initDesktop(
aside: HTMLElement,
nav: HTMLElement,
headings: Element[]
): HTMLAnchorElement[] {
const { ol, links } = buildTocOl(headings);
nav.innerHTML = "";
nav.appendChild(ol);
aside.hidden = false;
bindTocLinks(nav, null);
return links;
}
function initMobile(headings: Element[]) {
if (document.getElementById("toc-fab")) return { links: [] as HTMLAnchorElement[], fab: null as HTMLElement | null };
const { ol, links } = buildTocOl(headings);
const panel = document.createElement("div");
panel.id = "toc-panel";
panel.setAttribute("aria-label", "文章目录");
panel.setAttribute("role", "navigation");
const panelTitle = document.createElement("p");
panelTitle.className = "toc-panel__title";
panelTitle.textContent = "目录";
panel.appendChild(panelTitle);
panel.appendChild(ol);
document.body.appendChild(panel);
const fab = document.createElement("button");
fab.id = "toc-fab";
fab.type = "button";
fab.setAttribute("aria-label", "打开文章目录");
fab.setAttribute("aria-controls", "toc-panel");
fab.setAttribute("aria-expanded", "false");
fab.innerHTML = `
<span class="toc-fab__icon" aria-hidden="true">
<span></span><span></span><span></span>
</span>`;
document.body.appendChild(fab);
function openPanel() {
panel.classList.add("toc-panel--open");
fab.setAttribute("aria-expanded", "true");
fab.setAttribute("aria-label", "关闭文章目录");
fab.classList.add("toc-fab--open");
}
function closePanel() {
panel.classList.remove("toc-panel--open");
fab.setAttribute("aria-expanded", "false");
fab.setAttribute("aria-label", "打开文章目录");
fab.classList.remove("toc-fab--open");
}
fab.addEventListener("click", () => {
panel.classList.contains("toc-panel--open") ? closePanel() : openPanel();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closePanel();
});
bindTocLinks(panel, closePanel);
return { links, fab };
}
export function teardownPostTocUi(): void {
document.getElementById("toc-fab")?.remove();
document.getElementById("toc-panel")?.remove();
}
export function initPostToc(markdownBody: HTMLElement | null): void {
const aside = document.getElementById("post-toc");
const nav = document.getElementById("post-toc-nav");
if (!markdownBody) return;
const headings = [...markdownBody.querySelectorAll(HEADING_SEL)];
if (headings.length === 0) {
if (aside) aside.hidden = true;
return;
}
makeHeadingIds(headings);
const scope = markdownBody.closest(".post-with-toc");
let desktopLinks: HTMLAnchorElement[] = [];
if (aside && nav) {
desktopLinks = initDesktop(aside, nav, headings);
}
const { links: mobileLinks, fab: mobileElem } = initMobile(headings);
const fab = mobileElem || document.getElementById("toc-fab");
bindScrollSpy(headings, [desktopLinks, mobileLinks], aside, fab, scope);
if (location.hash.length > 1) {
const id = decodeURIComponent(location.hash.slice(1));
if (headings.some((h) => h.id === id)) {
setActive([desktopLinks, mobileLinks], id);
}
}
}

27
client/src/lib/text.ts Normal file
View File

@@ -0,0 +1,27 @@
export function stripMarkdown(text: unknown): string {
return String(text ?? "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/\$\$[\s\S]*?\$\$/g, " ")
.replace(/\$[^$\n]+\$/g, " ")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/^\s{0,3}#{1,6}\s+/gm, "")
.replace(/^\s{0,3}>\s?/gm, "")
.replace(/^\s{0,3}[-*+]\s+/gm, "")
.replace(/^\s{0,3}\d+[.)]\s+/gm, "")
.replace(/[*_`~#]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export function formatDateTime(value: unknown): string {
if (!value) return "";
const date = new Date(String(value));
if (Number.isNaN(date.getTime())) return String(value);
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}

11
client/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/app.css";
import "./styles/markdown.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

234
client/src/pages/Admin.tsx Normal file
View File

@@ -0,0 +1,234 @@
import { FormEvent, useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
deletePostApi,
fetchPost,
fetchPosts,
savePost,
type Post
} from "../api";
import { useSiteConfig } from "../ConfigContext";
export function AdminDashboard() {
const { siteTitle } = useSiteConfig();
const [q, setQ] = useState("");
const [inputQ, setInputQ] = useState("");
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
document.title = `后台 - ${siteTitle}`;
}, [siteTitle]);
useEffect(() => {
const id = window.setTimeout(() => setQ(inputQ.trim()), 300);
return () => clearTimeout(id);
}, [inputQ]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const list = await fetchPosts(q || undefined, {
credentials: "include"
});
if (!cancelled) setPosts(list);
} catch {
if (!cancelled) setPosts([]);
}
})();
return () => {
cancelled = true;
};
}, [q]);
return (
<>
<p>
<Link to="/"></Link>
</p>
<h1></h1>
<p>
<Link to="/admin/new"></Link>
</p>
<section>
<h2></h2>
<form
onSubmit={(e) => e.preventDefault()}
>
<p>
<label>
<input value={inputQ} onChange={(e) => setInputQ(e.target.value)} />
</label>
</p>
</form>
{posts.length ? (
posts.map((post) => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>
ID: {post.id} | Slug: {post.slug} |{" "}
{post.published ? "已发布" : "草稿"}
</p>
<div>
<Link to={`/admin/edit/${post.id}`}></Link>
<DeleteButton postId={post.id} onDone={() => setPosts((p) => p.filter((x) => x.id !== post.id))} />
</div>
</article>
))
) : (
<p></p>
)}
</section>
</>
);
}
function DeleteButton({
postId,
onDone
}: {
postId: number;
onDone: () => void;
}) {
return (
<form
style={{ display: "inline", marginLeft: 8 }}
onSubmit={async (e) => {
e.preventDefault();
if (!confirm("确定删除?")) return;
try {
await deletePostApi(postId);
onDone();
} catch {
alert("删除失败");
}
}}
>
<button type="submit"></button>
</form>
);
}
export function AdminEditor() {
const { id: idParam } = useParams();
const [search] = useSearchParams();
const navigate = useNavigate();
const { siteTitle } = useSiteConfig();
const id = idParam ? Number(idParam) : Number(search.get("id") || "") || null;
const [title, setTitle] = useState("");
const [slug, setSlug] = useState("");
const [excerpt, setExcerpt] = useState("");
const [content, setContent] = useState("");
const [published, setPublished] = useState(true);
useEffect(() => {
document.title = `后台 - ${siteTitle}`;
}, [siteTitle]);
useEffect(() => {
if (!id) {
setTitle("");
setSlug("");
setExcerpt("");
setContent("");
setPublished(true);
return;
}
let cancelled = false;
(async () => {
try {
const post = await fetchPost(String(id), false, {
credentials: "include"
});
if (cancelled) return;
setTitle(post.title);
setSlug(post.slug);
setExcerpt(post.excerpt || "");
setContent(post.content || "");
setPublished(!!post.published);
} catch {
if (!cancelled) alert("加载文章失败");
}
})();
return () => {
cancelled = true;
};
}, [id]);
async function onSubmit(e: FormEvent) {
e.preventDefault();
try {
const payload = {
title,
slug,
excerpt,
content,
published: published ? 1 : 0,
id: id || undefined
};
const saved = await savePost(payload, id);
navigate(`/admin/edit/${saved.id}?saved=1`, { replace: true });
} catch (er) {
alert(er instanceof Error ? er.message : "保存失败");
}
}
return (
<>
<p>
<Link to="/admin"></Link>
</p>
<h1>{id ? `编辑 #${id}` : "新建文章"}</h1>
<form onSubmit={onSubmit}>
<p>
<label>
<br />
<input value={title} onChange={(e) => setTitle(e.target.value)} required />
</label>
</p>
<p>
<label>
Slug
<br />
<input value={slug} onChange={(e) => setSlug(e.target.value)} />
</label>
</p>
<p>
<label>
<br />
<input value={excerpt} onChange={(e) => setExcerpt(e.target.value)} />
</label>
</p>
<p>
<label>
<br />
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
required
rows={18}
style={{ width: "100%", maxWidth: "100%" }}
/>
</label>
</p>
<p>
<label>
<input
type="checkbox"
checked={published}
onChange={(e) => setPublished(e.target.checked)}
/>{" "}
</label>
</p>
<p>
<button type="submit"></button>
</p>
</form>
</>
);
}

95
client/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,95 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { fetchPosts, type Post } from "../api";
import { useSiteConfig } from "../ConfigContext";
import { formatDateTime, stripMarkdown } from "../lib/text";
export function Home() {
const { siteTitle } = useSiteConfig();
const [inputQ, setInputQ] = useState("");
const [q, setQ] = useState("");
const [posts, setPosts] = useState<Post[]>([]);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
document.title = siteTitle;
}, [siteTitle]);
useEffect(() => {
const id = window.setTimeout(() => setQ(inputQ.trim()), 300);
return () => clearTimeout(id);
}, [inputQ]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
setErr(null);
const list = await fetchPosts(q || undefined);
if (!cancelled) setPosts(list);
} catch {
if (!cancelled) setErr("加载失败");
}
})();
return () => {
cancelled = true;
};
}, [q]);
return (
<>
<form
className="home-search"
onSubmit={(e) => e.preventDefault()}
style={{ marginBottom: 16 }}
>
<label>
{" "}
<input
name="q"
value={inputQ}
onChange={(e) => setInputQ(e.target.value)}
placeholder="标题 / 正文"
/>
</label>
</form>
{q ? (
<p>
<strong>{q}</strong>
</p>
) : null}
{err ? <p>{err}</p> : null}
{posts.length ? (
posts.map((post) => {
const excerpt =
post.excerpt || stripMarkdown(post.content).slice(0, 180);
const views = Number(post.view_count) || 0;
return (
<article key={post.id} className="post-card">
<h2>
<Link to={`/post/${encodeURIComponent(post.slug)}`}>
{post.title}
</Link>
</h2>
<p className="post-excerpt">
{excerpt}
{excerpt.length >= 180 ? "…" : ""}
</p>
<p className="post-meta">
<span className="post-card-time">
{formatDateTime(post.created_at)}
</span>
<span className="post-card-time">
{formatDateTime(post.updated_at)}
</span>
<span className="post-card-views"> {views}</span>
</p>
</article>
);
})
) : (
<p></p>
)}
</>
);
}

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { fetchPost } from "../api";
import { useSiteConfig } from "../ConfigContext";
import { CwdComments } from "../components/CwdComments";
import { MarkdownBody } from "../components/MarkdownBody";
import { formatDateTime } from "../lib/text";
export function PostPage() {
const { slug = "" } = useParams();
const { siteTitle } = useSiteConfig();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [updatedAt, setUpdatedAt] = useState("");
const [views, setViews] = useState(0);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
setErr(null);
const post = await fetchPost(decodeURIComponent(slug), true);
if (cancelled) return;
setTitle(post.title);
setContent(post.content || "");
setUpdatedAt(post.updated_at);
setViews(Number(post.view_count) || 0);
document.title = `${post.title} - ${siteTitle}`;
} catch {
if (!cancelled) {
setErr("文章不存在或加载失败");
document.title = `404 - ${siteTitle}`;
}
}
})();
return () => {
cancelled = true;
};
}, [slug, siteTitle]);
if (err) {
return (
<>
<p>
<Link to="/"></Link>
</p>
<h1>404</h1>
<p>{err}</p>
</>
);
}
return (
<>
<p>
<Link to="/"></Link>
</p>
<div className="post-with-toc">
<div className="post-with-toc__main">
<article>
<h1>{title}</h1>
<p className="post-detail-meta">
<span className="post-detail-time">{formatDateTime(updatedAt)}</span>
<span className="post-detail-views"> {views}</span>
</p>
<MarkdownBody markdown={content} />
</article>
</div>
<aside
className="post-toc"
id="post-toc"
aria-label="文章目录"
hidden
>
<div className="post-toc__inner">
<p className="post-toc__title"></p>
<nav className="post-toc__nav" id="post-toc-nav" />
</div>
</aside>
</div>
<CwdComments key={slug} postSlug={decodeURIComponent(slug)} />
</>
);
}

612
client/src/styles/app.css Normal file
View File

@@ -0,0 +1,612 @@
:root {
--page-max: 920px;
--page-pad-x: 24px;
--page-pad-y: 24px;
--page-gap: 24px;
--text: #2a1f0e;
--muted: #6b5a42;
--line: #d9cdb8;
/* 笔记纸底色与格线 */
--paper-bg: #fdf8ec;
--paper-rule: rgba(160, 140, 100, 0.18);
--paper-grid: rgba(160, 140, 100, 0.10);
--paper-rule-spacing: 28px;
/**
* 有网Inter拉丁+ Noto Sans SC补中文外链见 render 中 Google Fonts
* 无网/外链失败:不加载 webfont 时,浏览器跳过未解析名称,用后续系统无衬线
*/
--font-sans: "Inter", "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
/* 行内/块 <code> 等;无 Source Code Pro 时走系统等宽 */
--font-mono: "Source Code Pro", Menlo, Monaco, Consolas, "Courier New", monospace;
}
html {
margin: 0;
padding: 0;
background: var(--paper-bg);
color: var(--text);
font-family: var(--font-sans);
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
padding: 0;
color: var(--text);
font-family: var(--font-sans);
line-height: 1.7;
overflow-x: hidden;
/* 笔记纸纹理:竖向 + 横向格线,横线更淡 */
background-color: var(--paper-bg);
background-image:
repeating-linear-gradient(
90deg,
transparent,
transparent calc(var(--paper-rule-spacing) - 1px),
var(--paper-grid) calc(var(--paper-rule-spacing) - 1px),
var(--paper-grid) var(--paper-rule-spacing)
),
repeating-linear-gradient(
180deg,
transparent,
transparent calc(var(--paper-rule-spacing) - 1px),
rgba(160, 140, 100, 0.07) calc(var(--paper-rule-spacing) - 1px),
rgba(160, 140, 100, 0.07) var(--paper-rule-spacing)
);
}
code,
kbd,
samp {
font-family: var(--font-mono);
}
main {
width: min(100%, var(--page-max));
margin: 0 auto;
padding: var(--page-pad-y) var(--page-pad-x) 32px;
box-sizing: border-box;
}
.site-footer {
width: min(100%, var(--page-max));
margin: 0 auto;
padding: 20px var(--page-pad-x) 28px;
box-sizing: border-box;
border-top: 1px solid var(--line);
text-align: center;
font-size: 0.88rem;
color: var(--muted);
}
.site-footer p {
margin: 0;
line-height: 1.6;
}
.site-footer p.site-footer-geo {
margin-top: 10px;
font-size: 0.82rem;
color: var(--muted);
word-break: break-word;
}
header.site-header {
margin-bottom: var(--page-gap);
padding-bottom: 16px;
border-bottom: 1px solid var(--line);
}
.site-brand {
display: flex;
align-items: center;
gap: 12px;
}
.site-titles {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.site-title-link {
text-decoration: none;
color: inherit;
font-weight: 700;
font-size: 1.25rem;
line-height: 1.2;
}
.site-title-en {
font-size: 0.82rem;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.02em;
}
.site-logo {
flex-shrink: 0;
width: 40px;
height: 40px;
object-fit: contain;
cursor: pointer;
/* 让 logo 白底融入纸张背景 */
mix-blend-mode: multiply;
}
.admin-gate {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 16px;
box-sizing: border-box;
}
.admin-gate[hidden] {
display: none !important;
}
.admin-gate-panel {
background: var(--paper-bg);
padding: 16px 18px;
border-radius: 6px;
border: 1px solid #c8b99a;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
width: min(100%, 420px);
box-sizing: border-box;
}
.admin-gate-panel input[type="password"] {
width: 100%;
box-sizing: border-box;
min-width: 0;
}
h1,
h2,
h3 {
line-height: 1.3;
margin: 0 0 12px;
word-break: break-word;
}
article,
section {
margin: 0 0 24px;
}
p {
margin: 0 0 12px;
word-break: break-word;
}
a {
color: inherit;
}
input,
textarea,
button {
font: inherit;
max-width: 100%;
}
input[type="text"],
input[type="password"],
textarea {
width: 100%;
box-sizing: border-box;
min-width: 0;
}
textarea {
min-height: 320px;
resize: vertical;
}
pre {
font-family: var(--font-mono);
font-size: 0.92em;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
img {
max-width: 100%;
height: auto;
}
hr {
border: 0;
border-top: 1px solid #d4c5a8;
margin: 24px 0;
}
.post-card {
padding-bottom: 16px;
border-bottom: 1px solid #e4d9c4;
}
.post-card:last-of-type {
padding-bottom: 0;
border-bottom: 0;
}
.post-card .post-excerpt {
margin: 0 0 10px;
}
.post-card .post-meta {
font-size: 0.9em;
color: #555;
margin: 0 0 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
}
.post-detail-meta {
font-size: 0.95em;
color: #555;
margin: 0 0 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
}
.post-detail-views {
color: #666;
}
.post-comments {
margin-top: 36px;
padding-top: 28px;
border-top: 1px solid var(--line);
}
.post-comments__title {
font-size: 1.35rem;
margin: 0 0 16px;
}
form {
margin: 0;
}
@media (max-width: 1024px) {
:root {
--page-max: 840px;
--page-pad-x: 20px;
--page-pad-y: 20px;
--page-gap: 20px;
}
}
@media (max-width: 768px) {
:root {
--page-max: 100%;
--page-pad-x: 16px;
--page-pad-y: 16px;
--page-gap: 18px;
}
.site-logo {
width: 36px;
height: 36px;
}
.site-title-link {
font-size: 1.1rem;
}
.post-card .post-meta,
.post-detail-meta {
gap: 6px 12px;
}
}
@media (max-width: 480px) {
:root {
--page-pad-x: 14px;
--page-pad-y: 14px;
--page-gap: 16px;
}
body {
line-height: 1.68;
}
header.site-header {
padding-bottom: 12px;
}
textarea {
min-height: 240px;
}
.site-brand {
gap: 10px;
align-items: flex-start;
}
.site-logo {
width: 32px;
height: 32px;
}
.site-title-link {
font-size: 1rem;
}
.post-card .post-meta,
.post-detail-meta {
font-size: 0.82em;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
row-gap: 4px;
}
.post-card .post-meta span,
.post-detail-meta span {
white-space: nowrap;
}
.admin-gate-panel {
padding: 14px 14px;
}
}
/* ----- 文章页布局 ----- */
main:has(.post-with-toc),
body:has(.post-with-toc) .site-footer {
width: min(100%, 920px);
max-width: 920px;
}
.post-with-toc {
display: block;
width: 100%;
}
.post-with-toc__main {
min-width: 0;
width: min(100%, 720px);
margin: 0 auto;
}
/* ═══ 桌面端目录≥1181px═══════════════════════════════════ */
.post-toc {
position: fixed;
top: 96px;
right: max(14px, calc((100vw - 1400px) / 2));
z-index: 20;
width: 260px;
max-width: min(30vw, 260px);
transition: opacity 0.18s ease, visibility 0.18s ease;
}
.post-toc--hidden {
visibility: hidden;
opacity: 0;
pointer-events: none;
}
.post-toc__inner {
box-sizing: border-box;
max-height: calc(100vh - 128px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.post-toc__title {
margin: 0 0 10px;
font-size: 0.75rem;
font-weight: 500;
color: #888;
}
.post-toc__nav {
position: relative;
margin: 0;
border-left: 1px solid #d4c5a8;
padding-left: 14px;
}
/* 桌面≤1180px 完全隐藏 aside */
@media (max-width: 1180px) {
.post-toc {
display: none;
}
main:has(.post-with-toc),
body:has(.post-with-toc) .site-footer {
width: min(100%, var(--page-max));
max-width: var(--page-max);
}
}
/* ═══ 公共目录列表样式(桌面 & 移动端共用类名)══════════════ */
.toc-tree,
.toc-sub {
list-style: none;
margin: 0;
padding: 0;
}
.toc-sub {
margin-top: 2px;
margin-bottom: 2px;
padding-left: 10px;
}
.toc-item {
margin: 0;
}
.toc-link {
display: block;
position: relative;
padding: 5px 0 6px;
font-size: 0.88rem;
line-height: 1.5;
text-decoration: none;
color: #777;
}
.toc-sub .toc-link {
color: #999;
font-size: 0.82rem;
}
.toc-link:hover,
.toc-sub .toc-link:hover {
color: #111;
}
.toc-link--active {
color: #111;
font-weight: 500;
}
/* 桌面目录:当前项左侧指示条 */
.post-toc__nav .toc-link--active::before {
content: "";
position: absolute;
left: -16px;
top: 0.15em;
bottom: 0.15em;
width: 2px;
background: #111;
border-radius: 0;
}
/* ═══ 移动端 FABJS append 到 body≤1180px 显示)═════════ */
#toc-fab {
display: none;
}
@media (max-width: 1180px) {
#toc-fab {
display: inline-flex;
position: fixed;
right: 20px;
bottom: calc(22px + env(safe-area-inset-bottom));
z-index: 50;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
border: 1px solid rgba(160, 130, 80, 0.25);
border-radius: 50%;
background: rgba(253, 248, 236, 0.97);
box-shadow: 0 6px 20px rgba(100, 80, 40, 0.16);
color: #333;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: opacity 0.18s, visibility 0.18s, transform 0.18s;
}
#toc-fab.toc-fab--hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: scale(0.85);
}
/* 汉堡线 */
.toc-fab__icon {
display: flex;
flex-direction: column;
gap: 4px;
width: 18px;
transition: gap 0.18s;
}
.toc-fab__icon span {
display: block;
height: 2px;
width: 18px;
background: currentColor;
border-radius: 2px;
transition: transform 0.18s, opacity 0.18s, width 0.18s;
transform-origin: center;
}
/* 打开状态 → X 形 */
#toc-fab.toc-fab--open .toc-fab__icon {
gap: 0;
}
#toc-fab.toc-fab--open .toc-fab__icon span:nth-child(1) {
transform: translateY(2px) rotate(45deg);
}
#toc-fab.toc-fab--open .toc-fab__icon span:nth-child(2) {
opacity: 0;
width: 0;
}
#toc-fab.toc-fab--open .toc-fab__icon span:nth-child(3) {
transform: translateY(-2px) rotate(-45deg);
}
}
/* ═══ 移动端弹出面板JS append 到 body≤1180px 显示)══════ */
#toc-panel {
display: none;
}
@media (max-width: 1180px) {
#toc-panel {
display: block;
position: fixed;
right: 16px;
bottom: calc(76px + env(safe-area-inset-bottom));
z-index: 49;
width: min(300px, calc(100vw - 32px));
max-height: min(60vh, 440px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
padding: 14px 16px 16px;
background: rgba(253, 248, 236, 0.98);
border: 1px solid rgba(160, 130, 80, 0.2);
border-radius: 12px;
box-shadow: 0 12px 36px rgba(100, 80, 40, 0.18);
/* 默认隐藏 */
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(10px) scale(0.97);
transform-origin: bottom right;
transition: opacity 0.16s ease, visibility 0.16s ease, transform 0.16s ease;
}
#toc-panel.toc-panel--open {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: none;
}
.toc-panel__title {
margin: 0 0 8px;
font-size: 0.75rem;
font-weight: 500;
color: #888;
}
/* 移动端面板:当前项左侧指示条 */
#toc-panel .toc-link--active::before {
content: "";
position: absolute;
left: -14px;
top: 0.15em;
bottom: 0.15em;
width: 2px;
background: #111;
border-radius: 0;
}
#toc-panel .toc-tree {
border-left: 1px solid #d4c5a8;
padding-left: 14px;
}
#toc-panel .toc-link {
font-size: 0.9rem;
padding: 6px 0 7px;
}
#toc-panel .toc-sub .toc-link {
font-size: 0.84rem;
}
}
/* React可点击 Logo 用于后台入口 */
.admin-gate-logo-btn {
all: unset;
display: flex;
align-items: center;
cursor: pointer;
flex-shrink: 0;
}
/* ═══ cwd-widget 评论区覆盖:融入纸张主题 ═══════════════════ */
#comments > div,
#comments .cwd-widget,
#comments [class*="card"],
#comments [class*="form-wrap"],
#comments [class*="comment-form"],
#comments [class*="CommentForm"],
#comments form {
background: var(--paper-bg) !important;
border-color: var(--line) !important;
box-shadow: 0 2px 12px rgba(100, 80, 40, 0.1) !important;
}
#comments input[type="text"],
#comments input[type="email"],
#comments input[type="url"],
#comments textarea {
background: rgba(255, 252, 240, 0.8) !important;
border-color: #c8b99a !important;
color: var(--text) !important;
}
#comments input[type="text"]:focus,
#comments input[type="email"]:focus,
#comments input[type="url"]:focus,
#comments textarea:focus {
border-color: #a08060 !important;
outline: none !important;
box-shadow: 0 0 0 2px rgba(160, 128, 96, 0.2) !important;
}
#comments [class*="comment-item"],
#comments [class*="CommentItem"],
#comments [class*="comment-card"] {
background: rgba(255, 252, 240, 0.6) !important;
border-color: var(--line) !important;
}
/* 兜底widget 内所有白色背景块 */
#comments > div[style*="background"],
#comments > div > div[style*="background:#fff"],
#comments > div > div[style*="background: #fff"],
#comments > div > div[style*="background:white"],
#comments > div > div[style*="background: white"] {
background: var(--paper-bg) !important;
}

View File

@@ -0,0 +1,246 @@
.markdown-body {
box-sizing: border-box;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.8;
font-size: 16px;
color: #111;
}
.markdown-body > *:first-child {
margin-top: 0;
}
.markdown-body > *:last-child {
margin-bottom: 0;
}
.markdown-body h1 {
font-size: 1.9rem;
margin: 24px 0 12px;
}
.markdown-body h2 {
font-size: 1.5rem;
margin: 22px 0 10px;
scroll-margin-top: 1.25em;
}
.markdown-body h3 {
font-size: 1.25rem;
margin: 18px 0 8px;
scroll-margin-top: 1.15em;
}
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
font-size: 1.05rem;
margin: 16px 0 8px;
scroll-margin-top: 1.05em;
}
.markdown-body p {
margin: 0 0 12px;
}
.markdown-body ul,
.markdown-body ol {
margin: 0 0 12px;
padding-left: 1.6em;
}
.markdown-body li {
margin: 0 0 4px;
}
.markdown-body li > p {
margin: 0 0 6px;
}
.markdown-body blockquote {
border-left: 4px solid #d0d0d0;
margin: 0 0 16px;
padding: 2px 0 2px 16px;
color: #444;
}
.markdown-body blockquote > :last-child {
margin-bottom: 0;
}
.markdown-body pre {
padding: 0;
margin: 0 0 16px;
border-radius: 0;
overflow-x: auto;
background: #fafafa;
border: 1px solid #e6e6e6;
}
.markdown-body pre code.hljs {
display: block;
padding: 12px 14px;
overflow-x: auto;
background: transparent;
}
.markdown-body code {
font-family: var(--font-mono);
font-size: 0.92em;
}
.markdown-body pre code {
background: none;
padding: 0;
font-size: inherit;
}
.markdown-body :not(pre) > code {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
}
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin: 0 0 16px;
font-size: 0.95em;
}
.markdown-body th,
.markdown-body td {
border: 1px solid #d4d4d4;
padding: 8px 10px;
vertical-align: top;
}
.markdown-body th {
background: #f6f6f6;
font-weight: 600;
}
.markdown-body hr {
margin: 20px 0;
border: 0;
border-top: 1px solid #ddd;
}
.markdown-body a {
text-decoration: underline;
}
.markdown-body li:has(> input[type="checkbox"]) {
list-style: none;
margin-left: -1.5em;
padding-left: 0;
}
.markdown-body input[type="checkbox"] {
margin-right: 6px;
vertical-align: middle;
}
.markdown-body img {
max-width: 100%;
height: auto;
}
.markdown-body .katex-display {
overflow-x: auto;
overflow-y: hidden;
max-width: 100%;
padding: 8px 0;
}
.markdown-body .katex {
max-width: 100%;
}
.markdown-body .mermaid {
margin: 16px 0;
overflow-x: auto;
text-align: center;
}
.markdown-pending .markdown-loading {
color: #666;
margin: 0;
}
@media (max-width: 1024px) {
.markdown-body {
font-size: 15.5px;
}
.markdown-body h1 {
font-size: 1.75rem;
}
.markdown-body h2 {
font-size: 1.4rem;
}
}
@media (max-width: 768px) {
.markdown-body {
font-size: 15px;
line-height: 1.75;
}
.markdown-body h1 {
font-size: 1.55rem;
}
.markdown-body h2 {
font-size: 1.28rem;
}
.markdown-body h3 {
font-size: 1.1rem;
}
.markdown-body table {
display: block;
overflow-x: auto;
font-size: 0.92em;
}
.markdown-body blockquote {
padding-left: 14px;
}
}
@media (max-width: 480px) {
.markdown-body {
font-size: 14.5px;
line-height: 1.72;
}
.markdown-body h1 {
font-size: 1.35rem;
}
.markdown-body h2 {
font-size: 1.18rem;
}
.markdown-body h3 {
font-size: 1.02rem;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 1.35em;
}
.markdown-body pre code.hljs {
padding: 10px 12px;
}
.markdown-body th,
.markdown-body td {
padding: 7px 8px;
}
}

9
client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CWD_SITE_ID?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

24
client/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["vite.config.ts"]
}

33
client/vite.config.ts Normal file
View File

@@ -0,0 +1,33 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const root = path.resolve(__dirname);
export default defineConfig(({ mode }) => {
const isDesktop = mode === "desktop";
return {
plugins: [react()],
root,
publicDir: path.join(root, "public"),
define: {
"import.meta.env.VITE_API_BASE": JSON.stringify(
isDesktop ? "https://blog.smyhub.com" : ""
)
},
build: {
outDir: path.resolve(__dirname, isDesktop ? "../dist-desktop" : "../dist"),
emptyOutDir: true,
chunkSizeWarningLimit: 1200
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:8787",
changeOrigin: true
}
}
}
};
});