前端(新增 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/)
1397 lines
57 KiB
HTML
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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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>
|