余白 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/)
This commit is contained in:
Sakurasan
2026-09-23 15:37:42 +08:00
parent 3238175ef0
commit fbda293072
46 changed files with 4266 additions and 35 deletions
+155 -2
View File
@@ -5,6 +5,7 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
@@ -190,6 +191,8 @@ func settingsFromMap(m map[string]string) model.Settings {
// Mirror the value into ThemeID so legacy API consumers still see it
// in the JSON response.
st.ThemeID = st.LightSkinID
st.UIID = sanitizeUI(m["ui_id"])
st.CustomCSS = decodeCSSMap(m["custom_css"])
if n := atoi(m["posts_per_page"]); n > 0 {
st.PostsPerPage = n
}
@@ -215,6 +218,107 @@ var ValidLightSkins = map[string]bool{
"rose": true,
}
const (
// UIDefault is the UI used when nothing valid is stored.
UIDefault = "classic"
// UIClassic is the original minimalist front-end.
UIClassic = "classic"
// UIVivid is the livelier front-end, which also owns custom-CSS support.
UIVivid = "vivid"
)
// ValidUIs is the whitelist of front-end UI ids. Anything else falls back to
// "classic" on both read and write.
var ValidUIs = map[string]bool{
UIClassic: true,
UIVivid: true,
}
// ValidCSSSections whitelists the page sections an owner may target with
// custom CSS. Keep in sync with frontend/src/ui/sections.js.
var ValidCSSSections = map[string]bool{
"global": true,
"home": true,
"post": true,
"archive": true,
"tags": true,
"projects": true,
"about": true,
}
const (
// maxCustomCSSBytes caps the whole stylesheet set; maxSectionCSSBytes caps
// any single section. Both are generous for hand-written CSS but keep a
// runaway paste from bloating every /api/site response.
maxCustomCSSBytes = 64 << 10
maxSectionCSSBytes = 16 << 10
)
func sanitizeUI(id string) string {
id = strings.TrimSpace(id)
if ValidUIs[id] {
return id
}
return UIDefault
}
// decodeCSSMap turns the stored JSON blob into a section→CSS map. It never
// returns nil, and it drops unknown sections and over-long values even if the
// row was hand-edited, so callers can trust the result.
func decodeCSSMap(raw string) map[string]string {
out := map[string]string{}
if strings.TrimSpace(raw) == "" {
return out
}
var parsed map[string]string
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return out
}
total := 0
for section, css := range parsed {
if !ValidCSSSections[section] {
continue
}
css = strings.ReplaceAll(css, "\x00", "")
if strings.TrimSpace(css) == "" || len(css) > maxSectionCSSBytes {
continue
}
if total+len(css) > maxCustomCSSBytes {
continue
}
total += len(css)
out[section] = css
}
return out
}
// encodeCSSMap is the write-side counterpart: unknown sections are dropped and
// oversized values are refused, then the map is serialized. A nil or empty map
// encodes to "{}" so a full-replace PUT clears everything deterministically.
func encodeCSSMap(in map[string]string) string {
clean := map[string]string{}
total := 0
for section, css := range in {
if !ValidCSSSections[section] {
continue
}
css = strings.ReplaceAll(css, "\x00", "")
if strings.TrimSpace(css) == "" || len(css) > maxSectionCSSBytes {
continue
}
if total+len(css) > maxCustomCSSBytes {
continue
}
total += len(css)
clean[section] = css
}
b, err := json.Marshal(clean)
if err != nil {
return "{}"
}
return string(b)
}
func (s *Store) UpdateSettings(st model.Settings) error {
if st.PostsPerPage <= 0 {
st.PostsPerPage = 10
@@ -222,6 +326,7 @@ func (s *Store) UpdateSettings(st model.Settings) error {
if !ValidLightSkins[st.LightSkinID] {
st.LightSkinID = "paper"
}
st.UIID = sanitizeUI(st.UIID)
sets := map[string]string{
"site_title": st.SiteTitle,
"site_desc": st.SiteDesc,
@@ -233,6 +338,9 @@ func (s *Store) UpdateSettings(st model.Settings) error {
"light_skin_id": st.LightSkinID,
// Mirror to theme_id so any older client still sees something.
"theme_id": st.LightSkinID,
"ui_id": st.UIID,
// Full replace: an omitted/empty map clears every section's CSS.
"custom_css": encodeCSSMap(st.CustomCSS),
}
for k, v := range sets {
if s.db.Dialect == db.Postgres {
@@ -410,6 +518,51 @@ func (s *Store) Get(id int64) (model.Post, error) {
return items[0], nil
}
// Neighbors 返回某篇已发布文章在时间线上的前后邻居(详情页侧栏用)。
// 返回顺序与时间线一致(published_at DESC):最旧在最前、最新在最后,
// 目标文章夹在中间,前后各取最多 2 篇。
func (s *Store) Neighbors(slug string) ([]model.Post, error) {
rows, err := s.db.Query(s.db.Q(`SELECT ` + listCols + ` FROM posts
WHERE status = ? ORDER BY published_at DESC`), model.StatusPublished)
if err != nil {
return nil, err
}
defer rows.Close()
all := []model.Post{}
for rows.Next() {
p, err := scanPost(rows)
if err != nil {
return nil, err
}
all = append(all, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
idx := -1
for i, p := range all {
if p.Slug == slug {
idx = i
break
}
}
if idx < 0 {
return []model.Post{}, nil
}
lo, hi := idx-2, idx+2
if lo < 0 {
lo = 0
}
if hi > len(all)-1 {
hi = len(all) - 1
}
out := all[lo : hi+1]
if err := s.attachTags(out); err != nil {
return nil, err
}
return out, nil
}
func (s *Store) GetBySlug(slug string) (model.Post, error) {
var p model.Post
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE slug = ?`), slug)
@@ -925,7 +1078,7 @@ func (s *Store) CreateTagFull(name, color string) (model.Tag, error) {
return model.Tag{}, err
}
if err == nil {
// already exists — update color and return
// already exists · update color and return
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET color=? WHERE id=?`), color, id); err != nil {
return model.Tag{}, err
}
@@ -1152,7 +1305,7 @@ func (s *Store) Dashboard() (model.Dashboard, error) {
}
tagRows.Close()
// published_by_month — last 12 months -----------------------------------
// published_by_month · last 12 months -----------------------------------
monthRows, err := s.db.Query(s.db.Q(`SELECT substr(published_at,1,7) AS m, COUNT(*) AS c
FROM posts WHERE status=? AND length(published_at) >= 7
GROUP BY m ORDER BY m DESC LIMIT 12`), model.StatusPublished)