Files
ONE/index.html
Sakurasan fbda293072 余白 UI:按设计稿模块化还原 + 站主自定义 CSS + 详情 neighbors + 静态缓存头
前端(新增 ui/yohaku/,各视图按 ui_id=vivid 分支渲染;classic 与后台不受影响)
- 令牌层:设计稿数值原样落地,28 个 --v-* 为站主接口(自定义 CSS 可覆盖,
  放 @layer 让出优先级);另加防线,中和 styles.css 裸选择器
  (h1 衬线 / body 行高 / 后台 .stat/.toolbar/.chip 卡片)漏进余白元素
- 顶栏(滚动收缩态)/ 阅读进度(顶部 2px + READ n%)/ 页脚
- 首页:hero(统计条)/ 筛选(类型分段 + 标签 chips + 搜索)/
  长文卡片与短文纸条 / 分页 / 作品展示区(3 件 + 查看所有,参照 diygod.cc);
  面板栅格改 1.6fr 1fr 1fr 且高度自适应内容(标签云明显大一圈)
- 文章页:三栏「纸」—— 左时间线(前后各两篇)/ 中纸张 / 右目录与阅读进度;
  短文详情不显示标题(h1 视觉隐藏保留无障碍)
- 全部规则收敛在 html[data-ui='vivid'] 前缀下;无 !important

后端
- GET /api/posts/:slug 增 neighbors(前后各两篇,详情页侧栏时间线用)
- SPA 缓存头:index.html no-cache,assets/ 带 hash 长期 immutable
  (此前无缓存头,浏览器会一直拿旧构建)

其他
- 站主自定义 CSS 管线(customCss.js + ui/sections.js,按分区注入,仅 vivid)
- 删除旧 vivid 覆盖层实现(VividNav/Hero/Footer/Timeline/Toc、ui/vivid.css)
- 设计稿原型附于仓库根 index.html;作品表清掉外链 diygod.cc 的示例封面,
  改用本地占位图(public/covers/)
2026-09-23 15:37:42 +08:00

1397 lines
57 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ONE · 余白</title>
<style>
/* ============================================================
余白 — 纸的纯净,雪的清新
浅中性色 + 单一强调色 + 噪点 + 弹簧动效
============================================================ */
:root{
--paper:#faf7f0;
--card:#fffefb;
--ink:#1c1c1e;
--text:#2c2c2e;
--soft:#5c5c66;
--muted:#8e8e99;
--line:rgba(28,28,30,.1);
--line-strong:rgba(28,28,30,.16);
--accent:#69a6cc;
--accent-deep:#4a8bb3;
--accent-soft:rgba(105,166,204,.14);
--accent-glow:rgba(105,166,204,.35);
--shadow:0 1px 2px rgba(28,28,30,.04),0 8px 28px -8px rgba(28,28,30,.08);
--shadow-lg:0 2px 4px rgba(28,28,30,.05),0 20px 50px -16px rgba(28,28,30,.14);
--radius:14px;
--radius-lg:20px;
--spring:cubic-bezier(.34,1.56,.64,1);
--spring-soft:cubic-bezier(.22,1.2,.36,1);
--ease:cubic-bezier(.22,.61,.36,1);
--sans:"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans SC",system-ui,-apple-system,sans-serif;
--serif:"Songti SC","Noto Serif SC","SimSun",serif;
--mono:"SF Mono","JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;
--measure:680px;
}
:root[data-theme="dark"]{
--paper:#1c1c1e;
--card:#232326;
--ink:#f2f2f7;
--text:#e8e8ed;
--soft:#a1a1aa;
--muted:#6e6e78;
--line:rgba(242,242,247,.1);
--line-strong:rgba(242,242,247,.16);
--accent:#a0a7d4;
--accent-deep:#b8bee6;
--accent-soft:rgba(160,167,212,.16);
--accent-glow:rgba(160,167,212,.3);
--shadow:0 1px 2px rgba(0,0,0,.2),0 8px 28px -8px rgba(0,0,0,.35);
--shadow-lg:0 2px 4px rgba(0,0,0,.25),0 20px 50px -16px rgba(0,0,0,.5);
}
*{box-sizing:border-box}
html{scroll-behavior:auto}
body{
margin:0;font-family:var(--sans);color:var(--text);
background:var(--paper);min-height:100vh;
-webkit-font-smoothing:antialiased;
transition:background .4s var(--ease),color .4s var(--ease);
}
a{color:inherit;text-decoration:none}
button{font:inherit;cursor:pointer;border:0;background:none;color:inherit}
::selection{background:var(--accent-glow);color:var(--ink)}
/* 噪点:强调色染色的细颗粒 */
body::before{
content:"";position:fixed;inset:0;pointer-events:none;z-index:9999;
opacity:.45;mix-blend-mode:overlay;
background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0.42 0 0 0 0 0.65 0 0 0 0 0.8 0 0 0 0.55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size:180px 180px;
}
:root[data-theme="dark"] body::before{opacity:.3;mix-blend-mode:soft-light}
/* ---- 顶部导航:四态里的第一态(悬浮常规条) ---- */
.top{
position:sticky;top:0;z-index:100;
backdrop-filter:blur(16px) saturate(1.3);
-webkit-backdrop-filter:blur(16px) saturate(1.3);
background:color-mix(in srgb,var(--paper) 78%,transparent);
border-bottom:1px solid transparent;
transition:border-color .3s var(--ease),background .3s var(--ease),box-shadow .3s var(--ease);
}
.top.scrolled{
border-bottom-color:var(--line);
box-shadow:0 1px 0 var(--line);
}
.top-in{
max-width:1100px;margin:0 auto;
padding:0 28px;height:60px;
display:flex;align-items:center;gap:20px;
}
.brand{
display:flex;align-items:center;gap:10px;
font-weight:700;font-size:15px;letter-spacing:.02em;
transition:transform .5s var(--spring);
}
.brand:hover{transform:scale(1.04)}
.brand .av{
width:30px;height:30px;border-radius:50%;
background:linear-gradient(135deg,var(--accent),var(--accent-deep));
display:grid;place-items:center;color:#fff;font-size:13px;font-weight:800;
box-shadow:0 0 0 2px var(--paper),0 0 0 3.5px var(--accent-soft);
position:relative;
}
.brand .av::after{
content:"";position:absolute;right:-1px;bottom:-1px;
width:9px;height:9px;border-radius:50%;
background:#34c759;border:2px solid var(--paper);
}
.nav{display:flex;gap:2px;flex:1;justify-content:center}
.nav a{
position:relative;font-size:13.5px;font-weight:600;color:var(--soft);
padding:8px 14px;border-radius:999px;
transition:color .25s var(--ease),background .25s var(--ease),transform .5s var(--spring);
}
.nav a:hover{color:var(--ink);background:var(--accent-soft);transform:translateY(-1px)}
.nav a.on{color:var(--accent)}
.nav a.on::after{
content:"";position:absolute;left:50%;bottom:4px;
width:4px;height:4px;border-radius:50%;
background:var(--accent);transform:translateX(-50%);
}
.top-acts{display:flex;align-items:center;gap:6px}
.icon-btn{
width:36px;height:36px;border-radius:50%;
display:grid;place-items:center;color:var(--soft);font-size:15px;
transition:background .25s var(--ease),color .25s var(--ease),transform .5s var(--spring);
}
.icon-btn:hover{background:var(--accent-soft);color:var(--accent);transform:scale(1.08)}
.rss-btn{
font-size:12.5px;font-weight:700;color:var(--accent);
padding:7px 14px;border-radius:999px;
background:var(--accent-soft);
transition:background .25s,transform .5s var(--spring),box-shadow .3s;
}
.rss-btn:hover{background:var(--accent);color:#fff;transform:translateY(-2px);box-shadow:0 8px 20px var(--accent-glow)}
@media (max-width:720px){.nav{display:none}}
/* ---- 阅读进度(详情页) ---- */
.progress{
position:fixed;top:0;left:0;right:0;height:2px;z-index:200;
background:transparent;pointer-events:none;
}
.progress i{
display:block;height:100%;width:0%;
background:linear-gradient(90deg,var(--accent),var(--accent-deep));
transition:width .1s linear;
}
.progress-pct{
position:fixed;right:22px;top:50%;transform:translateY(-50%);
z-index:90;font-family:var(--mono);font-size:11px;font-weight:700;
color:var(--muted);letter-spacing:.06em;
writing-mode:vertical-rl;
opacity:0;transition:opacity .4s var(--ease);
pointer-events:none;
}
.progress-pct.show{opacity:1}
.progress-pct b{color:var(--accent);font-weight:800}
/* ---- Hero ---- */
.hero{
max-width:1100px;margin:0 auto;padding:72px 28px 48px;
position:relative;
}
.hero .kicker{
display:inline-flex;align-items:center;gap:8px;
font-size:12px;font-weight:700;letter-spacing:.14em;
color:var(--accent);text-transform:uppercase;
margin-bottom:20px;
}
.hero .kicker::before{
content:"";width:20px;height:1.5px;background:var(--accent);border-radius:2px;
}
.hero h1{
margin:0;font-size:clamp(36px,5.5vw,56px);font-weight:800;
letter-spacing:-.03em;line-height:1.15;max-width:16ch;
color:var(--ink);
}
.hero h1 .hl{
color:var(--accent);
position:relative;
}
.hero h1 .hl::after{
content:"";position:absolute;left:0;right:0;bottom:4px;height:8px;
background:var(--accent-soft);z-index:-1;border-radius:2px;
}
.hero .lead{
margin:18px 0 0;max-width:480px;font-size:16px;
color:var(--soft);line-height:1.8;
}
.hero .lead strong{color:var(--text);font-weight:700}
.stats{display:flex;gap:28px;margin-top:32px;flex-wrap:wrap}
.stat b{
display:block;font-size:22px;font-weight:800;letter-spacing:-.02em;
color:var(--ink);font-variant-numeric:tabular-nums;
}
.stat span{font-size:12px;color:var(--muted);font-weight:600}
/* ---- 筛选 ---- */
.wrap{max-width:1100px;margin:0 auto;padding:8px 28px 48px}
.toolbar{
display:flex;gap:10px;flex-wrap:wrap;align-items:center;
padding-bottom:20px;margin-bottom:8px;
border-bottom:1px solid var(--line);
}
.seg{
display:inline-flex;background:color-mix(in srgb,var(--ink) 5%,transparent);
border-radius:999px;padding:3px;gap:2px;
}
.seg button{
font-size:13px;font-weight:600;color:var(--soft);
padding:7px 16px;border-radius:999px;
transition:background .3s var(--ease),color .3s var(--ease),transform .5s var(--spring);
}
.seg button:hover{color:var(--ink)}
.seg button.on{
background:var(--card);color:var(--accent);
box-shadow:var(--shadow);
transform:scale(1.02);
}
.chips{display:flex;gap:6px;flex-wrap:wrap;flex:1}
.chip{
font-size:12.5px;font-weight:600;color:var(--soft);
padding:7px 14px;border-radius:999px;
border:1px solid var(--line);
transition:all .35s var(--spring);
}
.chip:hover{border-color:var(--accent);color:var(--accent);transform:translateY(-2px)}
.chip.on{
background:var(--accent);border-color:var(--accent);color:#fff;
box-shadow:0 6px 16px var(--accent-glow);
transform:translateY(-1px);
}
.search{
display:flex;align-items:center;gap:6px;
background:color-mix(in srgb,var(--ink) 4%,transparent);
border-radius:999px;padding:2px 4px 2px 14px;
border:1px solid transparent;
transition:border-color .3s,background .3s,box-shadow .3s;
}
.search:focus-within{
border-color:var(--accent);background:var(--card);
box-shadow:0 0 0 3px var(--accent-soft);
}
.search input{
border:0;background:transparent;outline:none;font:inherit;font-size:13.5px;
width:150px;padding:7px 0;color:var(--ink);
}
.search input::placeholder{color:var(--muted)}
.search button{
width:32px;height:32px;border-radius:50%;
background:var(--accent);color:#fff;font-size:13px;
transition:transform .5s var(--spring),box-shadow .3s;
}
.search button:hover{transform:scale(1.1);box-shadow:0 4px 12px var(--accent-glow)}
/* ---- 列表:长文卡片 / 短文纸条 ---- */
.feed{display:flex;flex-direction:column;gap:18px;padding-top:24px}
/* 长文:干净卡片 */
.card{
display:block;background:var(--card);
border:1px solid var(--line);
border-radius:var(--radius-lg);
overflow:hidden;
box-shadow:var(--shadow);
transition:transform .55s var(--spring),box-shadow .45s var(--ease),border-color .3s;
position:relative;
}
.card:hover{
transform:translateY(-4px) scale(1.005);
box-shadow:var(--shadow-lg);
border-color:color-mix(in srgb,var(--accent) 35%,transparent);
}
.card:active{transform:translateY(-1px) scale(.998);transition-duration:.15s}
.card .cov{
height:180px;background:linear-gradient(
135deg,
color-mix(in srgb,var(--accent) 25%,var(--paper)),
color-mix(in srgb,var(--accent) 8%,var(--paper))
);
position:relative;overflow:hidden;
}
.card .cov img{width:100%;height:100%;object-fit:cover;transition:transform .8s var(--ease)}
.card:hover .cov img{transform:scale(1.04)}
.card .body{padding:22px 26px 24px}
.card .meta{
display:flex;gap:10px;align-items:center;flex-wrap:wrap;
font-size:12px;color:var(--muted);font-weight:600;margin-bottom:10px;
}
.badge{
font-size:10.5px;font-weight:800;letter-spacing:.06em;
padding:3px 9px;border-radius:999px;
}
.badge.long{background:var(--accent-soft);color:var(--accent)}
.badge.short{background:rgba(52,199,89,.14);color:#28a745}
.card h2{
margin:0 0 10px;font-size:clamp(19px,2.2vw,23px);font-weight:800;
letter-spacing:-.02em;line-height:1.35;color:var(--ink);
transition:color .25s;
}
.card:hover h2{color:var(--accent)}
.card .sum{
margin:0;font-size:14.5px;line-height:1.75;color:var(--soft);
display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;
}
.card .tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:14px}
.tg{
font-size:11.5px;font-weight:700;color:var(--soft);
padding:4px 11px;border-radius:999px;
background:color-mix(in srgb,var(--ink) 5%,transparent);
transition:all .4s var(--spring);
}
.tg:hover{background:var(--accent-soft);color:var(--accent);transform:translateY(-2px)}
/* 短文:纸条,无框更轻 */
.note{
display:block;background:transparent;
border-left:3px solid var(--accent);
border-radius:0 var(--radius) var(--radius) 0;
padding:18px 24px;
margin-left:12px;
position:relative;
transition:background .35s var(--ease),transform .55s var(--spring),border-color .3s;
}
.note:hover{
background:var(--card);
transform:translateX(6px);
box-shadow:var(--shadow);
}
.note .meta{
display:flex;gap:10px;align-items:center;
font-size:12px;color:var(--muted);font-weight:600;margin-bottom:8px;
}
.note .body{
margin:0;font-size:15.5px;line-height:1.8;color:var(--text);
}
.note .body a{
color:var(--accent);font-weight:600;
border-bottom:1px solid color-mix(in srgb,var(--accent) 40%,transparent);
transition:border-color .25s;
}
.note .body a:hover{border-bottom-color:var(--accent)}
.note .tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:12px}
/* ---- 分页 ---- */
.pager{
display:flex;gap:8px;align-items:center;justify-content:center;
margin-top:36px;flex-wrap:wrap;
}
.pager button{
font-size:13px;font-weight:700;padding:9px 16px;border-radius:999px;
color:var(--soft);border:1px solid var(--line);background:var(--card);
transition:all .45s var(--spring);
}
.pager button:hover:not(:disabled){
border-color:var(--accent);color:var(--accent);transform:translateY(-2px);
box-shadow:0 6px 16px var(--accent-soft);
}
.pager button:disabled{opacity:.4;cursor:not-allowed}
.pager .cur{
background:var(--accent);border-color:var(--accent);color:#fff;
box-shadow:0 6px 16px var(--accent-glow);
}
.pager .info{border:0;background:none;color:var(--muted);font-weight:600;font-size:12.5px}
/* ---- 底部:标签 / 作品 / 关于 ---- */
.panels{
max-width:1100px;margin:0 auto;padding:20px 28px 20px;
display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:20px;
}
@media (max-width:860px){.panels{grid-template-columns:1fr}}
.panel{
background:var(--card);border:1px solid var(--line);
border-radius:var(--radius-lg);padding:24px;
box-shadow:var(--shadow);
transition:transform .55s var(--spring),box-shadow .4s;
}
.panel:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg)}
.panel h3{
margin:0 0 16px;font-size:11px;font-weight:800;
letter-spacing:.16em;text-transform:uppercase;color:var(--muted);
display:flex;align-items:center;gap:10px;
}
.panel h3::after{content:"";flex:1;height:1px;background:var(--line)}
.cloud{display:flex;flex-wrap:wrap;gap:8px}
.cloud a{
font-size:13px;font-weight:700;padding:7px 14px;border-radius:999px;
background:color-mix(in srgb,var(--accent) 10%,transparent);
color:var(--accent);
transition:all .5s var(--spring);
}
.cloud a:hover{
background:var(--accent);color:#fff;
transform:translateY(-3px) scale(1.05);
box-shadow:0 8px 20px var(--accent-glow);
}
.cloud a span{opacity:.7;font-size:11px;margin-left:4px;font-weight:600}
.plist{display:flex;flex-direction:column;gap:10px}
.plist a{
display:block;padding:13px 15px;border-radius:var(--radius);
background:color-mix(in srgb,var(--ink) 3%,transparent);
transition:all .5s var(--spring);
}
.plist a:hover{
background:var(--accent-soft);transform:translateX(6px);
}
.plist b{display:block;font-size:14px;font-weight:700;color:var(--ink)}
.plist small{color:var(--muted);font-size:12px}
.about-box .name{
font-size:18px;font-weight:800;color:var(--ink);margin:0 0 8px;letter-spacing:-.02em;
}
.about-box p{margin:0;font-size:13.5px;line-height:1.8;color:var(--soft)}
/* ---- 页脚 ---- */
.foot{
max-width:1100px;margin:40px auto 0;padding:28px 28px 120px;
border-top:1px solid var(--line);
text-align:center;color:var(--muted);font-size:12.5px;line-height:2;
}
.foot a{color:var(--accent);font-weight:600;transition:opacity .25s}
.foot a:hover{opacity:.75}
.foot .sep{margin:0 8px;opacity:.4}
.foot .quote{
font-family:var(--serif);font-style:italic;
color:var(--soft);margin-bottom:8px;font-size:13.5px;
}
/* ============================================================
详情:三栏「纸」— 左时间线 / 中纸张 / 右目录+进度
============================================================ */
.article-layout{
max-width:1100px;margin:0 auto;padding:36px 28px 60px;
display:grid;
grid-template-columns:140px minmax(0,1fr) 180px;
gap:32px;align-items:start;
}
@media (max-width:960px){
.article-layout{grid-template-columns:1fr;gap:0}
.side-l,.side-r{display:none}
.progress-pct{display:none}
}
/* 左:时间线 */
.side-l{position:sticky;top:80px;padding-top:8px}
.tl{position:relative;padding-left:14px}
.tl::before{
content:"";position:absolute;left:3px;top:6px;bottom:6px;width:1.5px;
background:linear-gradient(180deg,var(--accent),transparent);
border-radius:2px;
}
.tl-item{
position:relative;padding:0 0 18px 0;font-size:12px;color:var(--muted);
}
.tl-item::before{
content:"";position:absolute;left:-14px;top:5px;
width:7px;height:7px;border-radius:50%;
background:var(--card);border:2px solid var(--accent);
}
.tl-item.now::before{
background:var(--accent);
box-shadow:0 0 0 4px var(--accent-soft);
}
.tl-item b{display:block;color:var(--soft);font-weight:700;font-size:12px}
.tl-item.now b{color:var(--accent)}
/* 中:纸张 */
.paper{
background:var(--card);
border:1px solid var(--line);
border-radius:var(--radius-lg);
box-shadow:var(--shadow-lg);
padding:44px 48px 52px;
position:relative;
overflow:hidden;
animation:paperIn .7s var(--spring) both;
}
@keyframes paperIn{
from{opacity:0;transform:translateY(24px) scale(.985)}
to{opacity:1;transform:none}
}
/* 纸的纹理感:右上角一抹强调色 */
.paper::before{
content:"";position:absolute;top:0;right:0;
width:120px;height:120px;
background:radial-gradient(circle at 100% 0,var(--accent-soft),transparent 70%);
pointer-events:none;
}
.paper .meta{
display:flex;gap:12px;align-items:center;flex-wrap:wrap;
font-size:12.5px;color:var(--muted);font-weight:600;margin-bottom:18px;
}
.paper h1{
margin:0 0 20px;font-size:clamp(28px,4vw,40px);font-weight:800;
letter-spacing:-.03em;line-height:1.25;color:var(--ink);
}
.paper .dek{
margin:0 0 32px;font-size:17px;line-height:1.8;color:var(--soft);
padding-left:16px;border-left:3px solid var(--accent);
font-family:var(--serif);
}
.prose{font-size:16.5px;line-height:1.95;color:var(--text);max-width:var(--measure)}
.prose p{margin:0 0 1.35em}
.prose h2{
font-size:21px;font-weight:800;margin:2.2em 0 .8em;
letter-spacing:-.02em;color:var(--ink);
padding-bottom:10px;border-bottom:1px solid var(--line);
}
.prose a{color:var(--accent);font-weight:600;border-bottom:1.5px solid var(--accent-soft);transition:border-color .25s}
.prose a:hover{border-bottom-color:var(--accent)}
.prose code{
font-family:var(--mono);font-size:.88em;
background:var(--accent-soft);color:var(--accent);
padding:2px 7px;border-radius:6px;
}
.prose pre{
background:color-mix(in srgb,var(--ink) 92%,var(--accent));
color:#e8e8f0;padding:18px 20px;border-radius:12px;
overflow-x:auto;font-size:13.5px;line-height:1.7;
box-shadow:var(--shadow-lg);
}
.prose pre code{background:none;padding:0;color:inherit;font-size:inherit}
.prose blockquote{
margin:1.8em 0;padding:16px 20px;
background:var(--accent-soft);
border-left:3px solid var(--accent);
border-radius:0 12px 12px 0;
font-family:var(--serif);font-size:1.05em;
color:var(--soft);font-style:italic;
}
.prose ul{padding-left:1.3em}
.prose li{margin-bottom:.55em}
.prose hr{border:0;border-top:1px dashed var(--line-strong);margin:2.5em 0}
.paper .end{
margin-top:40px;padding-top:24px;border-top:1px dashed var(--line-strong);
display:flex;gap:8px;flex-wrap:wrap;align-items:center;
}
.paper .end .lbl{font-size:12px;color:var(--muted);font-weight:700;margin-right:4px}
.back{
display:inline-flex;align-items:center;gap:6px;
font-size:13px;font-weight:700;color:var(--accent);
margin-bottom:20px;padding:6px 0;
transition:gap .4s var(--spring);
}
.back:hover{gap:12px}
/* 右:目录 + 进度 */
.side-r{position:sticky;top:80px;padding-top:8px}
.toc h4{
margin:0 0 12px;font-size:11px;font-weight:800;
letter-spacing:.14em;text-transform:uppercase;color:var(--muted);
}
.toc ol{list-style:none;margin:0;padding:0;counter-reset:toc}
.toc li{counter-increment:toc;margin-bottom:2px}
.toc a{
display:flex;gap:8px;font-size:12.5px;color:var(--soft);
padding:6px 8px;border-radius:8px;line-height:1.4;
transition:all .4s var(--spring);
}
.toc a::before{
content:counter(toc,decimal-leading-zero);
font-family:var(--mono);font-size:10px;color:var(--muted);
flex:none;padding-top:2px;
}
.toc a:hover,.toc a.on{
background:var(--accent-soft);color:var(--accent);transform:translateX(4px);
}
.toc a:hover::before,.toc a.on::before{color:var(--accent)}
.read-meter{
margin-top:20px;padding-top:16px;border-top:1px solid var(--line);
}
.read-meter .bar{
height:3px;background:color-mix(in srgb,var(--ink) 8%,transparent);
border-radius:999px;overflow:hidden;
}
.read-meter .bar i{
display:block;height:100%;width:0%;
background:linear-gradient(90deg,var(--accent),var(--accent-deep));
border-radius:999px;transition:width .15s linear;
}
.read-meter .num{
display:flex;justify-content:space-between;
font-family:var(--mono);font-size:11px;color:var(--muted);
margin-top:8px;font-weight:600;
}
.read-meter .num b{color:var(--accent)}
/* ---- 归档 / 标签 / 作品 / 关于 ---- */
.page-head{
max-width:1100px;margin:0 auto;padding:56px 28px 32px;
}
.page-head .kicker{
display:inline-flex;align-items:center;gap:8px;
font-size:12px;font-weight:700;letter-spacing:.14em;
color:var(--accent);text-transform:uppercase;margin-bottom:16px;
}
.page-head .kicker::before{content:"";width:20px;height:1.5px;background:var(--accent)}
.page-head h1{
margin:0;font-size:clamp(32px,5vw,48px);font-weight:800;
letter-spacing:-.03em;color:var(--ink);
}
.page-head h1 em{font-style:normal;color:var(--accent)}
.page-head p{margin:12px 0 0;max-width:480px;color:var(--soft);font-size:15px;line-height:1.75}
.year-block{margin-bottom:36px}
.year-head{display:flex;align-items:baseline;gap:14px;margin-bottom:14px}
.year-head .y{
font-size:clamp(36px,5vw,52px);font-weight:800;letter-spacing:-.03em;
color:var(--accent);font-variant-numeric:tabular-nums;
}
.year-head .c{
font-size:12px;font-weight:700;color:var(--muted);
padding:5px 12px;border-radius:999px;
background:color-mix(in srgb,var(--ink) 5%,transparent);
}
.month{
background:var(--card);border:1px solid var(--line);
border-radius:var(--radius-lg);margin-bottom:12px;overflow:hidden;
box-shadow:var(--shadow);
transition:box-shadow .35s,transform .55s var(--spring);
}
.month:hover{box-shadow:var(--shadow-lg);transform:translateY(-2px)}
.month h3{
margin:0;padding:14px 22px 10px;font-size:12px;font-weight:800;
letter-spacing:.1em;color:var(--accent);
}
.month ul{list-style:none;margin:0;padding:0 10px 10px}
.month li a{
display:flex;gap:14px;padding:10px 14px;border-radius:10px;
font-size:14px;align-items:baseline;
transition:background .3s,transform .5s var(--spring);
}
.month li a:hover{background:var(--accent-soft);transform:translateX(6px)}
.month .d{font-family:var(--mono);font-size:12px;color:var(--muted);flex:none;font-weight:600}
.month .kd{font-size:10.5px;font-weight:800;padding:2px 8px;border-radius:999px;flex:none}
.month .kd.s{background:rgba(52,199,89,.14);color:#28a745}
.month .kd.l{background:var(--accent-soft);color:var(--accent)}
.month .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.month li a:hover .t{color:var(--ink)}
.tag-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px}
.tag-tile{
background:var(--card);border:1px solid var(--line);
border-radius:var(--radius-lg);padding:22px;
box-shadow:var(--shadow);position:relative;overflow:hidden;
transition:all .55s var(--spring);
}
.tag-tile:hover{
transform:translateY(-6px);
box-shadow:var(--shadow-lg);
border-color:color-mix(in srgb,var(--tc,var(--accent)) 40%,transparent);
}
.tag-tile .sw{
width:32px;height:32px;border-radius:10px 10px 10px 4px;
background:var(--tc,var(--accent));margin-bottom:14px;
box-shadow:0 6px 14px -4px var(--tc,var(--accent));
transition:transform .55s var(--spring);
}
.tag-tile:hover .sw{transform:rotate(-8deg) scale(1.1)}
.tag-tile .nm{font-size:18px;font-weight:800;display:block;margin-bottom:4px;color:var(--ink)}
.tag-tile .ct{font-size:12.5px;color:var(--muted);font-weight:600}
.tag-tile .big{
position:absolute;right:14px;top:10px;font-size:42px;font-weight:800;
color:color-mix(in srgb,var(--tc,var(--accent)) 12%,transparent);
letter-spacing:-.04em;line-height:1;
}
.proj-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px}
.proj{
background:var(--card);border:1px solid var(--line);
border-radius:var(--radius-lg);overflow:hidden;
box-shadow:var(--shadow);display:flex;flex-direction:column;
transition:all .55s var(--spring);
}
.proj:hover{transform:translateY(-6px);box-shadow:var(--shadow-lg);border-color:var(--accent-soft)}
.proj .cov{height:150px;position:relative;overflow:hidden;background:var(--accent-soft)}
.proj .cov img{width:100%;height:100%;object-fit:cover;transition:transform .8s var(--ease)}
.proj:hover .cov img{transform:scale(1.05)}
.proj .st{
position:absolute;top:12px;left:12px;font-size:10.5px;font-weight:800;
background:color-mix(in srgb,var(--card) 90%,transparent);
backdrop-filter:blur(8px);color:var(--accent);
padding:5px 11px;border-radius:999px;
}
.proj .body{padding:18px 20px 20px;flex:1;display:flex;flex-direction:column;gap:8px}
.proj h3{margin:0;font-size:17px;font-weight:800;color:var(--ink);letter-spacing:-.01em}
.proj p{margin:0;font-size:13.5px;line-height:1.7;color:var(--soft);flex:1}
.proj .acts{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
.proj .acts span{
font-size:11.5px;font-weight:700;padding:6px 12px;border-radius:999px;
background:color-mix(in srgb,var(--ink) 5%,transparent);color:var(--soft);
transition:all .45s var(--spring);
}
.proj:hover .acts span{background:var(--accent-soft);color:var(--accent)}
.about-card{
max-width:680px;margin:0 auto;background:var(--card);
border:1px solid var(--line);border-radius:var(--radius-lg);
padding:48px 44px;box-shadow:var(--shadow-lg);
animation:paperIn .7s var(--spring) both;
}
.about-card h1{margin:0 0 8px;font-size:clamp(32px,5vw,44px);font-weight:800;letter-spacing:-.03em;color:var(--ink)}
.about-card .role{
font-size:13px;font-weight:700;letter-spacing:.12em;color:var(--accent);
text-transform:uppercase;margin-bottom:22px;
}
.about-card .bio{
font-size:16.5px;line-height:1.95;color:var(--soft);
max-width:46ch;margin:0 0 32px;font-family:var(--serif);
}
.about-facts{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px}
.about-facts div{
background:color-mix(in srgb,var(--accent) 8%,transparent);
border-radius:var(--radius);padding:16px;
}
.about-facts b{
display:block;font-size:24px;font-weight:800;color:var(--accent);
letter-spacing:-.02em;font-variant-numeric:tabular-nums;
}
.about-facts span{font-size:12px;color:var(--muted);font-weight:700}
.empty{
max-width:480px;margin:40px auto;background:var(--card);
border:1px dashed var(--line-strong);border-radius:var(--radius-lg);
padding:48px 32px;text-align:center;box-shadow:var(--shadow);
}
.empty h3{margin:0 0 8px;font-size:20px;font-weight:800;color:var(--ink)}
.empty p{margin:0 0 18px;color:var(--muted);font-size:14px}
/* ---- 切换器 ---- */
.switch{
position:fixed;z-index:99;left:50%;bottom:18px;transform:translateX(-50%);
display:flex;gap:6px;padding:6px;border-radius:999px;
background:rgba(28,28,30,.88);backdrop-filter:blur(14px);
box-shadow:0 10px 36px rgba(0,0,0,.35);
border:1px solid rgba(255,255,255,.08);
}
.switch button{
padding:9px 15px;border-radius:999px;font-size:12.5px;font-weight:700;
color:rgba(255,255,255,.55);transition:.2s;
}
.switch button:hover{color:#fff;background:rgba(255,255,255,.1)}
.switch button.on{background:#fff;color:#111}
.switch .lbl{color:rgba(255,255,255,.35);font-size:11px;align-self:center;padding:0 8px;letter-spacing:.08em}
@media (max-width:640px){
.hero{padding:48px 20px 32px}
.wrap{padding:8px 18px 36px}
.paper{padding:28px 22px 36px;border-radius:16px}
.about-card{padding:32px 22px}
.panels{padding:16px 18px 12px}
.toolbar{flex-direction:column;align-items:stretch}
.search{width:100%}
.search input{flex:1;width:auto}
.top-in{padding:0 16px;gap:12px}
.rss-btn{display:none}
}
</style>
</head>
<body>
<div class="progress" id="progressBar" hidden><i></i></div>
<div class="progress-pct" id="progressPct" hidden>READ <b>0</b>%</div>
<header class="top" id="top">
<div class="top-in">
<a class="brand" href="#/" data-nav>
<span class="av">O</span>
<span>ONE</span>
</a>
<nav class="nav" id="navLinks">
<a href="#/" data-page="home">时间线</a>
<a href="#/archive" data-page="archive">归档</a>
<a href="#/tags" data-page="tags">标签</a>
<a href="#/projects" data-page="projects">作品</a>
<a href="#/about" data-page="about">关于</a>
</nav>
<div class="top-acts">
<button class="icon-btn" id="themeBtn" type="button" title="切换明暗" aria-label="切换明暗">◐</button>
<a class="rss-btn" href="#" title="RSS">RSS</a>
</div>
</div>
</header>
<div id="app"></div>
<footer class="foot" id="foot"></footer>
<div class="switch" role="tablist">
<span class="lbl">余白</span>
<button class="on" type="button">当前主题</button>
</div>
<script>
"use strict";
/* ========== Mock · 对应公开接口 ========== */
const SITE={
site_title:"ONE · 一个博客",
site_desc:"长文与短文,同一种节奏。在余白处书写,把做过的、想明白的留下来。",
author_name:"ONE",
author_bio:"写点长的,也写点短的。不追热点,只记路径。相信留白本身也是内容。",
footer_note:"© ONE · 一个博客",
icp:"京 ICP 备 0000000 号",
posts_per_page:6
};
const TAGS=[
{id:1,name:"Go",slug:"go",color:"#e07a3d",count:4},
{id:2,name:"前端",slug:"frontend",color:"#69a6cc",count:3},
{id:3,name:"设计",slug:"design",color:"#c56473",count:2},
{id:4,name:"写作",slug:"writing",color:"#5aaf8a",count:4},
{id:5,name:"SQLite",slug:"sqlite",color:"#5b9bd5",count:1},
{id:6,name:"Markdown",slug:"markdown",color:"#8b7ec8",count:1},
{id:7,name:"安全",slug:"security",color:"#c9a227",count:1}
];
const PROJECTS=[
{id:1,title:"ONE",slug:"one",summary:"写给自己的博客。Go 单二进制 + SQLite,长文短文混排,两套前台界面。",cover_url:"https://picsum.photos/seed/yohakuone/900/560",url:"https://example.com",repo_url:"https://example.com/repo/one",status:"published",position:0},
{id:2,title:"rsscope",slug:"rsscope",summary:"RSS 输出的命令行校验器,发布前跑一遍,坏日期和空 description 躲不掉。",cover_url:"https://picsum.photos/seed/yohakuss/900/560",url:"",repo_url:"https://example.com/repo/rsscope",status:"published",position:1},
{id:3,title:"inkplate",slug:"inkplate",summary:"把 Markdown 目录排成电子书的脚本集,中英混排断行是主要工作量。",cover_url:"https://picsum.photos/seed/yohakuink/900/560",url:"https://example.com/inkplate",repo_url:"",status:"published",position:2}
];
const POSTS=[
{id:1,kind:"long",title:"把博客拆成两半:长文与短文的节奏",slug:"long-short-rhythm",
summary:"长文负责把事情讲完,短文负责把念头留下。同一套时间线里,两种密度如何共存而不互相稀释。",
cover_url:"https://picsum.photos/seed/yohakurm/1200/720",
content_html:"<p>起因很简单:有些话需要两千字才说得清,有些话只有一句。把它们塞进同一种版式里,短的会被拉长,长的会被压扁。</p><h2>密度决定版式</h2><p>长文有标题、摘要、封面、阅读时长;短文只有一段话和时间。列表里如果短文也强行展示标题,读起来像被截断的长文;如果长文不给摘要,时间线又会变成标题墙。</p><p>所以接口里只有一个 <code>kind</code> 字段,版式却分成两种渲染:长文走「标题 + 摘要 + 元信息」,短文走「正文直接铺开」。筛选参数 <code>?kind=long</code> 留给只想读其中一种的人。</p><h2>同一条时间线</h2><p>也考虑过分成两个页面,最后放弃了。混排的价值在于节奏:认真读完一篇长文之后,下一条短文像一次换气。归档和标签页也因此保持单一数据源。</p><blockquote>留白不是空,是还没落笔的地方。加法都要付说明的代价,所以多数时候选择不加。</blockquote><p>唯一要小心的是分页。短文高度不定,固定条数的分页在视觉上会忽松忽紧。目前接受这个代价,先把内容写起来。</p>",
status:"published",published_at:"2026-09-20T03:14:15Z",reading_minutes:6,content_len:4200,tags:["写作","设计"]},
{id:2,kind:"short",title:"修 RSS 时区",slug:"fix-rss-tz",
summary:"今天把 RSS 的 lastBuildDate 修了,原来是时区没转 UTC。浏览器里看不出来,阅读器里整页日期都错了一天。",
content_html:"<p>今天把 RSS 的 lastBuildDate 修了,原来是时区没转 UTC。浏览器里看不出来,阅读器里整页日期都错了一天。</p>",
status:"published",published_at:"2026-09-18T08:41:00Z",reading_minutes:1,content_len:64,tags:["Go"]},
{id:3,kind:"long",title:"SQLite 够用:一个博客的存储选型",slug:"sqlite-is-enough",
summary:"单机、低并发、备份等于拷文件,这三条同时成立时,选型只需要三小时,而不是三天。",
cover_url:"https://picsum.photos/seed/yohakusql/1200/720",
content_html:"<p>数据库选型在一个博客上不值得花三天,但值得花三小时把边界想清楚。</p><h2>为什么先是 SQLite</h2><p>单机、低并发、备份等于拷文件,这三条同时成立时,SQLite 几乎是默认答案。WAL 模式打开之后,读写并发也够用。</p><h2>把方言挡在 store 层</h2><p>接口层不关心 SQL 长什么样。<code>?</code> 占位符在 Postgres 驱动里重写成 <code>$n</code>,差异收在同一处。换驱动时业务代码一行不动。</p><h2>什么时候该换</h2><p>出现多实例部署、或者写入变成瓶颈的那天再换。schema 迁移已经写成启动时自动跑。</p>",
status:"published",published_at:"2026-09-05T02:10:00Z",reading_minutes:5,content_len:3600,tags:["Go","SQLite"]},
{id:4,kind:"short",title:"自动保存间隔",slug:"autosave-interval",
summary:"编辑器 1.5 秒自动保存。再快会跟打字抢,再慢会让人盯着「未保存」心慌。",
content_html:"<p>编辑器 1.5 秒自动保存。再快会跟打字抢,再慢会让人盯着「未保存」心慌。</p>",
status:"published",published_at:"2026-08-30T13:05:00Z",reading_minutes:1,content_len:48,tags:["前端"]},
{id:5,kind:"long",title:"服务端渲染 Markdown 的几个坑",slug:"server-side-markdown",
summary:"渲染放在服务端,是因为列表要摘要、RSS 要描述、详情要成品 HTML,三处现算不划算。",
cover_url:"https://picsum.photos/seed/yohakumd/1200/720",
content_html:"<p>把渲染放在服务端,是因为列表页要摘要、RSS 要描述、详情页要成品 HTML,三处都现算一遍 Markdown 不划算。</p><h2>先堵住注入</h2><p>原始 HTML 默认转义,链接协议白名单只放 http 与 mailto。<code>javascript:</code> 与 <code>data:</code> 在渲染阶段就剥掉。</p><h2>摘要与阅读时长</h2><p>摘要取正文前若干字,短文则整段即摘要。阅读时长按中日韩字符除以四百估算。</p><pre><code>if contentMd != \"\" {\n html = render(contentMd)\n minutes = estimate(html)\n}</code></pre><h2>改了正文要重算</h2><p>自动保存走部分更新:只要 <code>content_md</code> 非空就重渲染 HTML 并重算时长。</p>",
status:"published",published_at:"2026-08-12T09:30:00Z",reading_minutes:5,content_len:3900,tags:["Go","Markdown"]},
{id:6,kind:"short",title:"标签合并的事务",slug:"tag-merge-tx",
summary:"标签合并比想象中难:先清中间表关联,再迁文章,再删源标签,一个事务包全。",
content_html:"<p>标签合并比想象中难:先清中间表关联,再迁文章,再删源标签,一个事务包全。中途失败最怕留下悬空关联。</p>",
status:"published",published_at:"2026-07-29T06:12:00Z",reading_minutes:1,content_len:62,tags:["Go"]},
{id:7,kind:"long",title:"给站点做第二套皮肤:classic 与 vivid 共存",slug:"two-skins",
summary:"经典版是纸面的,有人喜欢;也有人想要更跳一点的东西。于是有了第二套,而不是替换。",
cover_url:"https://picsum.photos/seed/yohakusk/1200/720",
content_html:"<p>经典版是纸面的:分区线、衬线标题、三栏骨架。有人喜欢,也有人想要更跳一点的东西。于是有了第二套。</p><h2>共存而不是替换</h2><p>切换写在站点设置里,前台按 <code>ui_id</code> 决定挂哪套。两套皮肤的调色板互不共享。</p><h2>样式必须可被覆盖</h2><p>站主自定义 CSS 按分区分段注入:全局、首页、文章、归档、标签、作品、关于。写坏了也只影响前台。</p><p>变量是这套机制的接口。换主色、改圆角、关动效,都通过改几个自定义属性完成。</p>",
status:"published",published_at:"2026-07-03T04:00:00Z",reading_minutes:5,content_len:3400,tags:["前端","设计"]},
{id:8,kind:"short",title:"归档页有用的一天",slug:"archive-useful",
summary:"写到第四十篇,归档页终于不是摆设。找旧文靠的不是搜索,是「大概是那年夏天」。",
content_html:"<p>写到第四十篇,归档页终于不是摆设。按年月分组之后,找旧文靠的不是搜索,是「大概是那年夏天」。</p>",
status:"published",published_at:"2026-05-16T11:20:00Z",reading_minutes:1,content_len:56,tags:["写作"]},
{id:9,kind:"long",title:"从零实现 HMAC 会话:够用的登录",slug:"hmac-sessions",
summary:"单管理员的博客不需要全家桶。账号密码来自环境变量,会话是一段签名字符串。",
cover_url:"https://picsum.photos/seed/yohakuhm/1200/720",
content_html:"<p>单管理员的博客,登录不需要全家桶。账号密码来自环境变量,会话是一段签名字符串。</p><h2>无状态的取舍</h2><p>token 里只有用户与过期时间,签名校验靠 HMAC-SHA256。好处是重启不掉线;代价是无法单条吊销。七天过期,够用。</p><h2>双通道携带</h2><p>浏览器走 httpOnly cookie,脚本走 <code>Authorization: Bearer</code>。同源部署下不需要 CSRF token。</p><h2>限速按来源</h2><p>失败计数只信 <code>RemoteAddr</code>。十分钟窗口十次,成功即清零。</p>",
status:"published",published_at:"2026-03-08T07:45:00Z",reading_minutes:6,content_len:4100,tags:["Go","安全"]},
{id:10,kind:"short",title:"中文阅读时长",slug:"cjk-reading-time",
summary:"中文没有空格,断行和阅读时长都得按字数算。估算不必精确,但不能离谱。",
content_html:"<p>中文没有空格,断行和阅读时长都得按字数算。估算不必精确,但不能离谱,写「约 1 分钟」也比显示 0 好。</p>",
status:"published",published_at:"2025-12-21T15:30:00Z",reading_minutes:1,content_len:58,tags:["写作"]}
];
/* ========== helpers ========== */
const app=document.getElementById("app");
const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const fmtDate=iso=>{
const d=new Date(iso);
return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");
};
const fmtDateTime=iso=>{
const d=new Date(iso);
return fmtDate(iso)+" "+String(d.getHours()).padStart(2,"0")+":"+String(d.getMinutes()).padStart(2,"0");
};
const plain=html=>{const t=document.createElement("div");t.innerHTML=html||"";return (t.textContent||"").trim();};
const kindLabel=k=>k==="long"?"长文":"短文";
function setPageNav(page){
document.querySelectorAll("#navLinks a").forEach(a=>{
a.classList.toggle("on",a.dataset.page===page);
});
}
function panels(){
return `
<aside class="panels">
<div class="panel">
<h3>标签云</h3>
<div class="cloud">
${TAGS.map(t=>`<a href="#/tags">${esc(t.name)}<span>${t.count}</span></a>`).join("")}
</div>
</div>
<div class="panel">
<h3>作品</h3>
<div class="plist">
${PROJECTS.map(p=>`<a href="#/projects"><b>${esc(p.title)}</b><small>${esc(p.summary).slice(0,40)}…</small></a>`).join("")}
</div>
</div>
<div class="panel about-box">
<h3>关于</h3>
<div class="name">${esc(SITE.author_name)}</div>
<p>${esc(SITE.author_bio)}</p>
</div>
</aside>`;
}
/* ========== 弹簧滚动 ========== */
function springScrollTo(y){
const start=window.scrollY;
const dist=y-start;
if(Math.abs(dist)<2)return;
const dur=Math.min(700,Math.max(350,Math.abs(dist)*0.4));
const t0=performance.now();
const overshoot=1.15;
function frame(t){
const p=Math.min(1,(t-t0)/dur);
// 近似 spring:带一点过冲再回弹
const e=1-Math.pow(1-p,3);
const wobble=p<1?Math.sin(p*Math.PI)*overshoot*0.06*(1-p):0;
window.scrollTo(0,start+dist*(e+wobble));
if(p<1)requestAnimationFrame(frame);
else window.scrollTo(0,y);
}
requestAnimationFrame(frame);
}
/* ========== 主题 ========== */
const THEME_KEY="one.yohaku.theme";
function applyTheme(t){
document.documentElement.setAttribute("data-theme",t);
try{localStorage.setItem(THEME_KEY,t)}catch(_){}
const btn=document.getElementById("themeBtn");
if(btn)btn.textContent=t==="dark"?"◑":"◐";
}
(function initTheme(){
let t=null;
try{t=localStorage.getItem(THEME_KEY)}catch(_){}
if(!t)t=window.matchMedia&&window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";
applyTheme(t);
})();
document.getElementById("themeBtn").addEventListener("click",()=>{
const cur=document.documentElement.getAttribute("data-theme");
applyTheme(cur==="dark"?"light":"dark");
});
/* 顶栏滚动态 */
const topEl=document.getElementById("top");
function onScrollTop(){topEl.classList.toggle("scrolled",window.scrollY>8)}
window.addEventListener("scroll",onScrollTop,{passive:true});
onScrollTop();
/* ========== views ========== */
let filterKind="all";
let filterTag="";
let query="";
let page=1;
function viewHome(){
setPageNav("home");
hideProgress();
let items=POSTS.slice();
if(filterKind!=="all") items=items.filter(p=>p.kind===filterKind);
if(filterTag) items=items.filter(p=>p.tags.includes(filterTag));
if(query){
const q=query.toLowerCase();
items=items.filter(p=>
(p.title||"").toLowerCase().includes(q)||
(p.summary||"").toLowerCase().includes(q)||
plain(p.content_html).toLowerCase().includes(q)
);
}
const size=SITE.posts_per_page;
const total=items.length;
const maxPage=Math.max(1,Math.ceil(total/size));
if(page>maxPage)page=maxPage;
const slice=items.slice((page-1)*size,page*size);
const kindSeg=`
<div class="seg" role="tablist">
<button type="button" data-f="all" class="${filterKind==="all"&&!filterTag?"on":""}">全部</button>
<button type="button" data-f="long" class="${filterKind==="long"?"on":""}">长文</button>
<button type="button" data-f="short" class="${filterKind==="short"?"on":""}">短文</button>
</div>`;
const tagChips=TAGS.map(t=>{
const on=filterTag===t.name;
return `<button class="chip${on?" on":""}" data-f="tag:${esc(t.name)}" type="button">${esc(t.name)}</button>`;
}).join("");
const cards=slice.map(p=>{
if(p.kind==="long"){
return `
<a class="card" href="#/post/${esc(p.slug)}">
${p.cover_url?`<div class="cov"><img src="${esc(p.cover_url)}" alt="" loading="lazy"></div>`:""}
<div class="body">
<div class="meta">
<span class="badge long">LONG</span>
<time>${fmtDate(p.published_at)}</time>
<span>· 约 ${p.reading_minutes} 分钟</span>
<span>· ${p.content_len.toLocaleString()} 字</span>
</div>
<h2>${esc(p.title)}</h2>
<p class="sum">${esc(p.summary)}</p>
<div class="tags">${p.tags.map(t=>`<span class="tg">${esc(t)}</span>`).join("")}</div>
</div>
</a>`;
}
return `
<a class="note" href="#/post/${esc(p.slug)}">
<div class="meta">
<span class="badge short">SHORT</span>
<time>${fmtDateTime(p.published_at)}</time>
</div>
<div class="body">${p.content_html}</div>
<div class="tags">${p.tags.map(t=>`<span class="tg">${esc(t)}</span>`).join("")}</div>
</a>`;
}).join("");
const empty=!slice.length?`
<div class="empty">
<h3>这里还没有落笔</h3>
<p>换个筛选或搜索词试试。</p>
<button class="chip on" type="button" data-f="all">看全部</button>
</div>`:"";
const pager=total>size?`
<div class="pager">
<button type="button" data-pg="${page-1}" ${page<=1?"disabled":""}>← 上一页</button>
<span class="cur">${page} / ${maxPage}</span>
<button type="button" data-pg="${page+1}" ${page>=maxPage?"disabled":""}>下一页 →</button>
<span class="info">共 ${total} 篇</span>
</div>`:total?`<div class="pager"><span class="info">共 ${total} 篇</span></div>`:"";
app.innerHTML=`
<section class="hero">
<div class="kicker">Personal Site · 在余白处书写</div>
<h1>把想法写下来,<br/><span class="hl">留白也是内容</span>。</h1>
<p class="lead">${esc(SITE.site_desc)}<br/><strong>全部内容自托管</strong>,没有推荐算法,只有时间。</p>
<div class="stats">
<div class="stat"><b>${POSTS.length}</b><span>篇文章</span></div>
<div class="stat"><b>${POSTS.reduce((s,p)=>s+(p.content_len||0),0).toLocaleString()}</b><span>累计字数</span></div>
<div class="stat"><b>${TAGS.length}</b><span>个标签</span></div>
<div class="stat"><b>${PROJECTS.length}</b><span>件作品</span></div>
</div>
</section>
<div class="wrap">
<div class="toolbar">
${kindSeg}
<div class="chips">${tagChips}</div>
<label class="search">
<input id="q" type="search" placeholder="搜索…" value="${esc(query)}" />
<button type="button" id="qbtn" aria-label="搜索">⌕</button>
</label>
</div>
<div class="feed">${cards}</div>
${empty}
${pager}
</div>
${panels()}
`;
app.querySelectorAll("[data-f]").forEach(btn=>{
btn.addEventListener("click",()=>{
const k=btn.dataset.f;
filterKind="all";filterTag="";page=1;query="";
if(k==="long"||k==="short")filterKind=k;
else if(k.startsWith("tag:"))filterTag=k.slice(4);
viewHome();
});
});
app.querySelectorAll("[data-pg]").forEach(btn=>{
btn.addEventListener("click",()=>{
const n=+btn.dataset.pg;
if(n>=1){page=n;viewHome();springScrollTo(0);}
});
});
const qi=app.querySelector("#q");
const run=()=>{query=(qi&&qi.value||"").trim();page=1;viewHome();
const nq=app.querySelector("#q");if(nq){nq.focus();nq.setSelectionRange(nq.value.length,nq.value.length);}};
app.querySelector("#qbtn")?.addEventListener("click",run);
qi?.addEventListener("keydown",e=>{if(e.key==="Enter")run();});
}
/* ---- 阅读进度 ---- */
const progressBar=document.getElementById("progressBar");
const progressPct=document.getElementById("progressPct");
function hideProgress(){
progressBar.hidden=true;progressPct.hidden=true;
progressBar.querySelector("i").style.width="0%";
}
function showProgress(){
progressBar.hidden=false;progressPct.hidden=false;
progressPct.classList.add("show");
}
function updateProgress(){
if(progressBar.hidden)return;
const h=document.documentElement.scrollHeight-window.innerHeight;
const p=h>0?Math.min(100,Math.max(0,(window.scrollY/h)*100)):0;
progressBar.querySelector("i").style.width=p.toFixed(1)+"%";
progressPct.querySelector("b").textContent=Math.round(p);
// 目录高亮
const heads=document.querySelectorAll(".prose h2");
let active=null;
heads.forEach(h2=>{
if(h2.getBoundingClientRect().top<120)active=h2;
});
document.querySelectorAll(".toc a").forEach(a=>{
a.classList.toggle("on",active&&a.getAttribute("href")==="#"+active.id);
});
}
window.addEventListener("scroll",updateProgress,{passive:true});
function viewPost(slug){
setPageNav("home");
const p=POSTS.find(x=>x.slug===slug);
if(!p){
hideProgress();
app.innerHTML=`<div class="wrap"><div class="empty"><h3>没有这篇</h3><p>链接可能过期了。</p><a class="chip on" href="#/">回时间线</a></div></div>`;
return;
}
showProgress();
// 给 h2 加 id 供 TOC
const tmp=document.createElement("div");
tmp.innerHTML=p.content_html||"";
const heads=[...tmp.querySelectorAll("h2")];
heads.forEach((h,i)=>{h.id="h-"+i});
const contentHTML=tmp.innerHTML;
const tocItems=heads.map((h,i)=>
`<li><a href="#h-${i}">${esc(h.textContent)}</a></li>`
).join("");
const title=p.kind==="short"?plain(p.content_html).slice(0,40)+"…":p.title;
const d=new Date(p.published_at);
// 时间线:围绕这篇的前后
const idx=POSTS.findIndex(x=>x.id===p.id);
const tl=[
{p:POSTS[idx+2],label:"更早"},
{p:POSTS[idx+1],label:"上一篇"},
{p:POSTS[idx],label:"当前",now:true},
{p:POSTS[idx-1],label:"下一篇"},
{p:POSTS[idx-2],label:"更新"}
].filter(x=>x.p);
app.innerHTML=`
<div class="article-layout">
<aside class="side-l">
<div class="tl">
${tl.map(x=>`
<div class="tl-item${x.now?" now":""}">
<b>${x.label}</b>
<time>${fmtDate(x.p.published_at)}</time>
<div>${esc(x.p.kind==="long"?x.p.title:plain(x.p.content_html).slice(0,20))}…</div>
</div>
`).join("")}
</div>
</aside>
<main>
<a class="back" href="#/">← 返回时间线</a>
<article class="paper">
<div class="meta">
<span class="badge ${p.kind}">${p.kind.toUpperCase()}</span>
<time>${fmtDate(p.published_at)}</time>
<span>· 约 ${p.reading_minutes} 分钟</span>
<span>· ${p.content_len.toLocaleString()} 字</span>
</div>
${p.kind==="long"
?`<h1>${esc(p.title)}</h1><p class="dek">${esc(p.summary)}</p>`
:`<h1 style="font-size:clamp(20px,2.5vw,26px);color:var(--muted);font-weight:700">${esc(title)}</h1>`}
<div class="prose">${contentHTML}</div>
<div class="end">
<span class="lbl">标签</span>
${p.tags.map(t=>`<a class="tg" href="#/tags">${esc(t)}</a>`).join("")}
</div>
</article>
</main>
<aside class="side-r">
<nav class="toc">
<h4>目录</h4>
<ol>${tocItems||`<li><a href="#">(本文无小标题)</a></li>`}</ol>
</nav>
<div class="read-meter">
<div class="bar"><i id="meterFill"></i></div>
<div class="num"><span>阅读进度</span><b id="meterNum">0%</b></div>
</div>
</aside>
</div>
${panels()}
`;
// TOC 点击弹性滚动
app.querySelectorAll(".toc a").forEach(a=>{
a.addEventListener("click",e=>{
const id=a.getAttribute("href");
if(!id||!id.startsWith("#h-"))return;
e.preventDefault();
const el=document.getElementById(id.slice(1));
if(el)springScrollTo(el.getBoundingClientRect().top+window.scrollY-80);
});
});
// 阅读 meter 同步
const meterFill=document.getElementById("meterFill");
const meterNum=document.getElementById("meterNum");
const _upd=updateProgress;
window.removeEventListener("scroll",window.__meter||(()=>{}));
function meter(){
if(!meterFill)return;
const h=document.documentElement.scrollHeight-window.innerHeight;
const pct=h>0?Math.min(100,Math.max(0,(window.scrollY/h)*100)):0;
meterFill.style.width=pct.toFixed(1)+"%";
meterNum.textContent=Math.round(pct)+"%";
}
window.__meter=meter;
window.addEventListener("scroll",meter,{passive:true});
meter();updateProgress();
}
function viewArchive(){
setPageNav("archive");hideProgress();
const years={};
POSTS.forEach(p=>{
const d=new Date(p.published_at);
const y=String(d.getFullYear());
const m=String(d.getMonth()+1).padStart(2,"0");
(years[y]=years[y]||{count:0,months:{}});
years[y].count++;
(years[y].months[m]=years[y].months[m]||[]).push(p);
});
const ys=Object.keys(years).sort((a,b)=>b-a);
app.innerHTML=`
<div class="page-head">
<div class="kicker">Archive</div>
<h1>沿着时间<em>往回走</em></h1>
<p>按年 → 月分组,来自 <code style="font-family:var(--mono);font-size:.9em">GET /api/archive</code>。</p>
</div>
<div class="wrap" style="padding-top:0">
${ys.map(y=>`
<div class="year-block">
<div class="year-head">
<span class="y">${y}</span>
<span class="c">${years[y].count} 篇</span>
</div>
${Object.keys(years[y].months).sort((a,b)=>b-a).map(m=>{
const list=years[y].months[m];
return `
<div class="month">
<h3>${y} / ${m}</h3>
<ul>
${list.map(p=>`
<li>
<a href="#/post/${esc(p.slug)}">
<span class="d">${fmtDate(p.published_at).slice(5)}</span>
<span class="kd ${p.kind}">${kindLabel(p.kind)}</span>
<span class="t">${esc(p.kind==="long"?p.title:plain(p.content_html).slice(0,48))}</span>
</a>
</li>`).join("")}
</ul>
</div>`;
}).join("")}
</div>
`).join("")}
</div>
${panels()}
`;
}
function viewTags(){
setPageNav("tags");hideProgress();
app.innerHTML=`
<div class="page-head">
<div class="kicker">Tags</div>
<h1>标签<em>如常</em></h1>
<p>颜色来自接口的 <code style="font-family:var(--mono);font-size:.9em">color</code>,计数来自 <code style="font-family:var(--mono);font-size:.9em">count</code>。</p>
</div>
<div class="wrap" style="padding-top:0">
<div class="tag-grid">
${TAGS.map(t=>`
<a class="tag-tile" href="#/" data-tag="${esc(t.name)}" style="--tc:${esc(t.color)}">
<span class="big">${t.count}</span>
<div class="sw"></div>
<span class="nm">${esc(t.name)}</span>
<span class="ct">${t.count} 篇 · /tag/${esc(t.slug)}</span>
</a>
`).join("")}
</div>
</div>
${panels()}
`;
app.querySelectorAll("[data-tag]").forEach(a=>{
a.addEventListener("click",e=>{
e.preventDefault();
filterKind="all";filterTag=a.dataset.tag;query="";page=1;
location.hash="#/";
if(location.hash==="#/")viewHome();
});
});
}
function viewProjects(){
setPageNav("projects");hideProgress();
app.innerHTML=`
<div class="page-head">
<div class="kicker">Projects</div>
<h1>做过的<em>东西</em></h1>
<p>来自 <code style="font-family:var(--mono);font-size:.9em">GET /api/projects</code>,可挂外链与仓库。</p>
</div>
<div class="wrap" style="padding-top:0">
<div class="proj-grid">
${PROJECTS.map(p=>`
<article class="proj">
<div class="cov">
${p.cover_url?`<img src="${esc(p.cover_url)}" alt="" loading="lazy">`:""}
<span class="st">${esc(p.status)}</span>
</div>
<div class="body">
<h3>${esc(p.title)}</h3>
<p>${esc(p.summary)}</p>
<div class="acts">
${p.url?`<span>↗ 主页</span>`:""}
${p.repo_url?`<span>⌥ 仓库</span>`:""}
</div>
</div>
</article>
`).join("")}
</div>
</div>
${panels()}
`;
}
function viewAbout(){
setPageNav("about");hideProgress();
app.innerHTML=`
<div class="wrap" style="padding-top:40px">
<div class="about-card">
<div class="kicker" style="margin-bottom:14px">About</div>
<h1>${esc(SITE.author_name)}</h1>
<div class="role">Writer · Builder</div>
<p class="bio">${esc(SITE.author_bio)}</p>
<div class="about-facts">
<div><b>${POSTS.length}</b><span>篇文章</span></div>
<div><b>${TAGS.length}</b><span>个标签</span></div>
<div><b>${PROJECTS.length}</b><span>件作品</span></div>
<div><b>${POSTS.filter(p=>p.kind==="long").length}</b><span>长文</span></div>
</div>
</div>
</div>
${panels()}
`;
}
/* ========== footer ========== */
document.getElementById("foot").innerHTML=`
<div class="quote">What's left unsaid holds the most weight.</div>
<div>${esc(SITE.footer_note)} · ${esc(SITE.icp)}</div>
<div>
<a href="#/">时间线</a><span class="sep">·</span>
<a href="#/archive">归档</a><span class="sep">·</span>
<a href="#/tags">标签</a><span class="sep">·</span>
<a href="#/projects">作品</a><span class="sep">·</span>
<a href="#/about">关于</a><span class="sep">·</span>
<a href="#">RSS</a>
</div>
`;
/* ========== router ========== */
function route(){
const h=(location.hash||"#/").replace(/^#/,"");
window.scrollTo(0,0);
if(h.startsWith("/post/"))return viewPost(decodeURIComponent(h.slice(6)));
if(h==="/archive")return viewArchive();
if(h==="/tags")return viewTags();
if(h==="/projects")return viewProjects();
if(h==="/about")return viewAbout();
viewHome();
}
window.addEventListener("hashchange",route);
route();
</script>
</body>
</html>