余白 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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user