Перенос дизайна в server-rendered веб-UI (web-ui)
Новая capability web-ui: презентационный перенос готового дизайна
(семантический HTML + единый jellybit.css, тёмная тема по настройке ОС)
в html/template без шага сборки.
- Встроенная (go:embed) отдача статики под /static с Cache-Control и
cache-busting (?v=<hash> по содержимому css/js).
- Шрифты IBM Plex self-hosted (@font-face, cyrillic+latin), без CDN.
- Наколеночный менеджер зависимостей: вендор (htmx + шрифты) не хранится
в репо (gitignore), идемпотентно добывается `task assets` по
web/assets.manifest с проверкой sha256; task build/run зависят от assets.
- Общие партиалы: шапка, бейдж статуса (карта всех 14 состояний),
виджет «файл источника → раскладка» (общий для review и download).
- Страницы: список с фильтром/поиском, ревью, новая страница просмотра
загрузки (/download/{id}). deleted скрыт по умолчанию.
- Превью раскладки берётся из единой логики internal/layout
(buildFileRows), без дублирования правил имён в шаблонах.
- Убраны meta-refresh и инлайн-стили; copyHash на vanilla с fallback
на execCommand и честной индикацией (целевой деплой — HTTP LAN).
Вне scope (отдельный change): живые обновления прогресса и раздел
раздачи, клиентский режим ручной раскладки файл→серия (с Alpine.js).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/web"
|
||||
)
|
||||
|
||||
// assetVersion — короткий хеш изменяемых ассетов (css/js) для cache-busting.
|
||||
// Шрифты и вендор адресуются по неизменному имени, их версионировать не нужно.
|
||||
func assetVersion() (string, error) {
|
||||
h := sha256.New()
|
||||
for _, p := range []string{"static/css/jellybit.css", "static/js/app.js"} {
|
||||
b, err := web.FS.ReadFile(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, _ = h.Write(b)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))[:12], nil
|
||||
}
|
||||
|
||||
// staticHandler отдаёт встроенную статику с длинным иммутабельным кэшем —
|
||||
// URL версионируются через ?v=<assetVersion> в шаблонах, поэтому свежий
|
||||
// деплой не отдаёт устаревший файл.
|
||||
func staticHandler(fsys fs.FS) http.Handler {
|
||||
fileServer := http.FileServer(http.FS(fsys))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// badgeLabel — человекочитаемая подпись состояния для бейджа (см. handoff п.4).
|
||||
// CSS-класс берётся отдельно как st-<state>; неизвестное состояние показываем
|
||||
// как есть, чтобы не терять его в UI.
|
||||
func badgeLabel(state string) string {
|
||||
switch state {
|
||||
case "downloading":
|
||||
return "⬇ качается"
|
||||
case "completed":
|
||||
return "⬇ скачано"
|
||||
case "recognizing":
|
||||
return "🧠 распознаётся"
|
||||
case "linking":
|
||||
return "🔗 раскладка"
|
||||
case "review":
|
||||
return "🟡 на ревью"
|
||||
case "deferred":
|
||||
return "🕗 отложено"
|
||||
case "done":
|
||||
return "✅ готово"
|
||||
case "stuck":
|
||||
return "⏳ застряло"
|
||||
case "target_missing":
|
||||
return "⚠ нет цели"
|
||||
case "failed":
|
||||
return "⛔ ошибка"
|
||||
case "orphaned":
|
||||
return "⚠ потеряно"
|
||||
case "cancelled":
|
||||
return "✖ отменено"
|
||||
case "reverted":
|
||||
return "↩ откат"
|
||||
case "deleted":
|
||||
return "🗑 удалено"
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// stateGroup — группа фильтра на главной (чипы): ждут меня / в работе / готово
|
||||
// / проблемы / прочее. Завязано на смысл состояния, не на цвет.
|
||||
func stateGroup(state string) string {
|
||||
switch state {
|
||||
case "review", "deferred":
|
||||
return "review"
|
||||
case "downloading", "completed", "recognizing", "linking":
|
||||
return "active"
|
||||
case "done":
|
||||
return "done"
|
||||
case "stuck", "failed", "target_missing", "orphaned":
|
||||
return "problem"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// render отрисовывает именованный шаблон в буфер и только затем пишет ответ —
|
||||
// при ошибке шаблона клиент не получит «полустраницу».
|
||||
func (s *server) render(w http.ResponseWriter, name string, data any) {
|
||||
var buf bytes.Buffer
|
||||
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
||||
s.deps.Logger.Error("render", "template", name, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = buf.WriteTo(w)
|
||||
}
|
||||
Reference in New Issue
Block a user