chore: sync local changes to Gitea
This commit is contained in:
32
client/index.html
Normal file
32
client/index.html
Normal 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
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
BIN
client/public/logo.png
Normal file
BIN
client/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
25
client/src/App.tsx
Normal file
25
client/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
client/src/ConfigContext.tsx
Normal file
51
client/src/ConfigContext.tsx
Normal 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
40
client/src/Layout.tsx
Normal 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
123
client/src/api.ts
Normal 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");
|
||||
}
|
||||
78
client/src/components/AdminGate.tsx
Normal file
78
client/src/components/AdminGate.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
client/src/components/CwdComments.tsx
Normal file
134
client/src/components/CwdComments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
client/src/components/GeoFooter.tsx
Normal file
37
client/src/components/GeoFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
client/src/components/MarkdownBody.tsx
Normal file
37
client/src/components/MarkdownBody.tsx
Normal 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" />;
|
||||
}
|
||||
73
client/src/lib/markdown.ts
Normal file
73
client/src/lib/markdown.ts
Normal 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(/ /g, "\u00a0")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
246
client/src/lib/postToc.ts
Normal 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
27
client/src/lib/text.ts
Normal 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
11
client/src/main.tsx
Normal 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
234
client/src/pages/Admin.tsx
Normal 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
95
client/src/pages/Home.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
client/src/pages/PostPage.tsx
Normal file
85
client/src/pages/PostPage.tsx
Normal 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
612
client/src/styles/app.css
Normal 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;
|
||||
}
|
||||
|
||||
/* ═══ 移动端 FAB(JS 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;
|
||||
}
|
||||
246
client/src/styles/markdown.css
Normal file
246
client/src/styles/markdown.css
Normal 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
9
client/src/vite-env.d.ts
vendored
Normal 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
24
client/tsconfig.json
Normal 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
33
client/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user