余白 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:
@@ -141,3 +141,67 @@ func TestOrderParamInjectionIsNeutralized(t *testing.T) {
|
||||
t.Fatalf("posts table damaged: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置里的 ui_id / custom_css 必须能经 PUT→GET 往返,且非法分区被挡掉。
|
||||
func TestSettingsUIRoundTrip(t *testing.T) {
|
||||
_, h := newTestAPI(t)
|
||||
token := login(t, h, "admin", "s3cret")
|
||||
var sess struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.NewDecoder(token.Body).Decode(&sess)
|
||||
if sess.Token == "" {
|
||||
t.Fatal("login failed")
|
||||
}
|
||||
|
||||
put := func(body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+sess.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
rec := put(`{"site_title":"ONE","posts_per_page":10,"light_skin_id":"paper",
|
||||
"ui_id":"vivid","custom_css":{"home":"body{--v-accent:#f0f}","bogus":"x{}"}}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("put settings: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
get := httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil)
|
||||
get.Header.Set("Authorization", "Bearer "+sess.Token)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, get)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get settings: got %d", rec.Code)
|
||||
}
|
||||
var got model.Settings
|
||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.UIID != "vivid" {
|
||||
t.Errorf("ui_id = %q, want vivid", got.UIID)
|
||||
}
|
||||
if got.CustomCSS["home"] != "body{--v-accent:#f0f}" {
|
||||
t.Errorf("home css lost: %+v", got.CustomCSS)
|
||||
}
|
||||
if _, ok := got.CustomCSS["bogus"]; ok {
|
||||
t.Error("unknown section should not be persisted")
|
||||
}
|
||||
|
||||
// An invalid ui_id falls back rather than being stored verbatim.
|
||||
rec = put(`{"site_title":"ONE","posts_per_page":10,"light_skin_id":"paper","ui_id":"neon","custom_css":{}}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("put invalid ui: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, get)
|
||||
_ = json.NewDecoder(rec.Body).Decode(&got)
|
||||
if got.UIID != "classic" {
|
||||
t.Errorf("invalid ui_id should fall back to classic, got %q", got.UIID)
|
||||
}
|
||||
if got.CustomCSS == nil {
|
||||
t.Error("custom_css should be non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,15 @@ func (a *API) getPost(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, p)
|
||||
// 邻居给详情页侧栏的时间线用;取不到不该让详情页挂掉。
|
||||
nb, err := a.Store.Neighbors(slug)
|
||||
if err != nil {
|
||||
nb = nil
|
||||
}
|
||||
httpx.OK(w, struct {
|
||||
model.Post
|
||||
Neighbors []model.Post `json:"neighbors"`
|
||||
}{p, nb})
|
||||
}
|
||||
|
||||
func (a *API) archive(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -137,4 +137,14 @@ type Settings struct {
|
||||
// only know about a single skin. settingsFromMap falls back to it
|
||||
// when LightSkinID is empty.
|
||||
ThemeID string `json:"theme_id,omitempty"`
|
||||
// UIID selects which front-end UI the whole site renders. Valid values
|
||||
// are "classic" (the original minimalist layout) and "vivid" (the
|
||||
// livelier one). The admin UI is unaffected by this — it always uses the
|
||||
// --admin-* tokens.
|
||||
UIID string `json:"ui_id"`
|
||||
// CustomCSS holds owner-authored stylesheets keyed by page section
|
||||
// ("global", "home", "post", "archive", "tags", "projects", "about").
|
||||
// Only injected for the vivid UI, and never on /admin. Always non-nil so
|
||||
// the JSON response is {} rather than null.
|
||||
CustomCSS map[string]string `json:"custom_css"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -113,6 +113,122 @@ func TestSettingsLightSkinFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsUIFallback(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
|
||||
st, err := s.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if st.UIID != UIClassic {
|
||||
t.Errorf("default ui_id should be %q, got %q", UIClassic, st.UIID)
|
||||
}
|
||||
if st.CustomCSS == nil {
|
||||
t.Error("CustomCSS should never be nil")
|
||||
}
|
||||
|
||||
// An unknown ui_id is coerced to the default on write.
|
||||
st.UIID = "bogus"
|
||||
if err := s.UpdateSettings(st); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, _ := s.GetSettings()
|
||||
if got.UIID != UIClassic {
|
||||
t.Errorf("bogus ui_id should fall back to %q, got %q", UIClassic, got.UIID)
|
||||
}
|
||||
|
||||
// A valid one persists.
|
||||
st.UIID = UIVivid
|
||||
if err := s.UpdateSettings(st); err != nil {
|
||||
t.Fatalf("update again: %v", err)
|
||||
}
|
||||
got, _ = s.GetSettings()
|
||||
if got.UIID != UIVivid {
|
||||
t.Errorf("ui_id should persist, got %q", got.UIID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsUIFallbackOnRead(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
// A hand-edited / legacy row must not leak an unknown UI to clients.
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`),
|
||||
"ui_id", "neon"); err != nil {
|
||||
t.Fatalf("seed ui_id: %v", err)
|
||||
}
|
||||
st, err := s.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if st.UIID != UIClassic {
|
||||
t.Errorf("expected read-side fallback to %q, got %q", UIClassic, st.UIID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsCustomCSSRoundTrip(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
|
||||
// Seeding a raw row with an unknown section + a valid one.
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`),
|
||||
"custom_css", `{"home":"body{--v-accent:#f0f}","bogus":"x{}"}`); err != nil {
|
||||
t.Fatalf("seed custom_css: %v", err)
|
||||
}
|
||||
st, _ := s.GetSettings()
|
||||
if st.CustomCSS["home"] != "body{--v-accent:#f0f}" {
|
||||
t.Errorf("home css not read back: %+v", st.CustomCSS)
|
||||
}
|
||||
if _, ok := st.CustomCSS["bogus"]; ok {
|
||||
t.Error("unknown section should be dropped on read")
|
||||
}
|
||||
|
||||
// Write side: unknown sections dropped, XML-hostile chars survive, nil clears.
|
||||
st.CustomCSS = map[string]string{
|
||||
"home": `a::after{content:"<&>"}`,
|
||||
"bogus": "x{}",
|
||||
}
|
||||
if err := s.UpdateSettings(st); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, _ := s.GetSettings()
|
||||
if got.CustomCSS["home"] != `a::after{content:"<&>"}` {
|
||||
t.Errorf("css mangled in round trip: %q", got.CustomCSS["home"])
|
||||
}
|
||||
if len(got.CustomCSS) != 1 {
|
||||
t.Errorf("unknown section should be dropped on write, got %+v", got.CustomCSS)
|
||||
}
|
||||
|
||||
// A nil map is a full clear, and still comes back as a non-nil empty map.
|
||||
got.CustomCSS = nil
|
||||
if err := s.UpdateSettings(got); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
after, _ := s.GetSettings()
|
||||
if after.CustomCSS == nil {
|
||||
t.Error("CustomCSS should be non-nil after clearing")
|
||||
}
|
||||
if len(after.CustomCSS) != 0 {
|
||||
t.Errorf("expected cleared map, got %+v", after.CustomCSS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsCustomCSSOverLongSectionDropped(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
st, _ := s.GetSettings()
|
||||
st.CustomCSS = map[string]string{
|
||||
"home": strings.Repeat("a", maxSectionCSSBytes+1),
|
||||
"about": "b{color:red}",
|
||||
}
|
||||
if err := s.UpdateSettings(st); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, _ := s.GetSettings()
|
||||
if _, ok := got.CustomCSS["home"]; ok {
|
||||
t.Error("over-long section should be dropped")
|
||||
}
|
||||
if got.CustomCSS["about"] != "b{color:red}" {
|
||||
t.Errorf("valid section should survive, got %+v", got.CustomCSS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreSQLite runs a small integration pass on top of an in-memory SQLite
|
||||
// database to exercise the new fields (cover_url, tag color) and the merge
|
||||
// + bulk + dashboard paths.
|
||||
|
||||
+11
-1
@@ -70,7 +70,7 @@ func main() {
|
||||
log.Printf("admin login: %s / (from ONE_ADMIN_PASSWORD)", cfg.AdminUser)
|
||||
}
|
||||
if !dirExists(cfg.WebDist) {
|
||||
log.Printf("frontend build not found at %s — run `make web` or `make dev` (API still available)", cfg.WebDist)
|
||||
log.Printf("frontend build not found at %s · run `make web` or `make dev` (API still available)", cfg.WebDist)
|
||||
}
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("listen: %v", err)
|
||||
@@ -105,9 +105,19 @@ func spaHandler(dist string) http.Handler {
|
||||
p = "index.html"
|
||||
}
|
||||
if _, err := fs.Open(p); err == nil && hasExt(p) {
|
||||
// 缓存策略分两类:
|
||||
// assets/ 下的文件名带内容 hash(Vite 产物),内容变了文件名就变,
|
||||
// 所以可以长期强缓存;其余(主要是 index.html)必须每次回源校验,
|
||||
// 否则新构建不会生效 : 页面「改了但看着还是旧的」通常就是它。
|
||||
if strings.HasPrefix(p, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
|
||||
http.ServeFile(w, r, index)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user