веб-ui: знак «jb» стал единым для шапки, иконки вкладки и превью ссылки

- логотип сведён в web/static/logo.svg (буквы геометрией, оптическая
  центровка); прежний favicon с «play» убран
- <head> шести страниц свёрнут в партиал head с og-мета; адреса абсолютные,
  база берётся из запроса, хост из недоверенного заголовка проверяется
- растровые ассеты og-cover.jpg и apple-touch-icon.png собирает `task brand`
  из исходников web/*.html
This commit is contained in:
av
2026-09-02 10:55:45 +03:00
parent 3bce73fc34
commit 2971a520c7
24 changed files with 339 additions and 51 deletions
+19
View File
@@ -75,6 +75,25 @@ tasks:
echo "$sum $file" | sha256sum -c -
done < web/assets.manifest
brand:
desc: 'Пересборка растровых брендовых ассетов из web/*.html (нужны chromium и magick; вручную, не в сборке)'
deps: [assets]
cmds:
- |
set -e
command -v chromium >/dev/null || { echo "нет chromium — растр не собрать"; exit 1; }
command -v magick >/dev/null || { echo "нет magick — растр не сжать"; exit 1; }
mkdir -p tmp/brand
chromium --headless=new --disable-gpu --hide-scrollbars \
--screenshot=tmp/brand/og-cover.png --window-size=1200,630 \
"file://$PWD/web/og-cover.html"
magick tmp/brand/og-cover.png -strip -quality 90 web/static/og-cover.jpg
chromium --headless=new --disable-gpu --hide-scrollbars \
--screenshot=tmp/brand/touch-icon.png --window-size=180,180 \
"file://$PWD/web/touch-icon.html"
magick tmp/brand/touch-icon.png -strip web/static/apple-touch-icon.png
echo "✓ web/static/og-cover.jpg, web/static/apple-touch-icon.png"
image:
desc: 'Docker-образ из готового бинаря. Тег из $BUILD_ID (по умолчанию dev; роль app_image umbar передаёт свой). См. docs/adr local-image-build'
deps: [build]
+16
View File
@@ -189,3 +189,19 @@ htmx по умолчанию **не свопит DOM на ответы 4xx/5xx**
`task build`/`task run` зависят от `task assets`.
- Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь самодостаточен,
внешних ресурсов времени выполнения нет.
## Знак и превью ссылки
- Знак один на все места — `web/static/logo.svg`: иконка вкладки (`rel="icon"`),
логотип в шапке (`<img class="logo">`) и карточка превью собираются из него.
Второй картинки «для favicon» не заводим: она молча разъезжается с шапкой.
- Буквы «jb» заданы геометрией, а не текстом: favicon рендерится браузером без
наших шрифтов, и `<text>` в нём взял бы чужой.
- Растровые ассеты (`og-cover.jpg` 1200×630 для превью ссылки,
`apple-touch-icon.png` 180×180) в PNG/JPEG не правятся руками: исходники —
`web/og-cover.html` и `web/touch-icon.html`, пересборка — `task brand`
(нужны `chromium` и `magick`, в сборку и гейт не входит).
- `<head>` страниц собирает единственный партиал `head` (`partials/head.html`);
адреса для `og:*` абсолютные, база берётся из запроса — `pageMeta`
(`internal/httpapi/page.go`). Хост приходит из недоверенного заголовка, поэтому
форма проверяется, а на непохожем значении база пустеет до относительной.
+9 -1
View File
@@ -64,6 +64,7 @@ type bulkRow struct {
// bulkSelectView — страница выбора (`GET /delete`) и она же ответ на отказ
// разбора: отметки при этом сохраняются, иначе проверка стирает всю работу.
type bulkSelectView struct {
Meta pageMeta
Error string
Max int
Rows []bulkRow
@@ -71,12 +72,14 @@ type bulkSelectView struct {
// bulkConfirmView — страница подтверждения: выбранные названы поимённо.
type bulkConfirmView struct {
Meta pageMeta
Rows []bulkRow
}
// bulkResultView — отчёт: обе половины исхода поимённо плюс остаток, если
// проход остановлен системным отказом.
type bulkResultView struct {
Meta pageMeta
Deleted []bulkRow
Failed []bulkRow
Skipped []bulkRow
@@ -114,6 +117,7 @@ func (s *server) renderBulkSelect(w http.ResponseWriter, r *http.Request, msg st
LastCopy: d.State == store.StateOrphaned,
})
}
view.Meta = s.pageMetaFor(r, "jellybit — удаление")
s.render(w, "delete.html", view)
}
@@ -125,7 +129,10 @@ func (s *server) handleBulkDeleteConfirm(w http.ResponseWriter, r *http.Request)
s.renderBulkSelect(w, r, bulkErrMsg(err), ids)
return
}
s.render(w, "delete_confirm.html", bulkConfirmView{Rows: s.bulkRows(r.Context(), ids)})
s.render(w, "delete_confirm.html", bulkConfirmView{
Meta: s.pageMetaFor(r, "jellybit — подтверждение удаления"),
Rows: s.bulkRows(r.Context(), ids),
})
}
// handleBulkDelete — исполнение пачки. Признак подтверждения проверяется ДО
@@ -204,6 +211,7 @@ func (s *server) handleBulkDelete(w http.ResponseWriter, r *http.Request) {
"failed", len(res.Failed), "skipped", len(res.Skipped),
"stopped", res.StopReason != "")
res.Meta = s.pageMetaFor(r, "jellybit — результат удаления")
s.render(w, "delete_result.html", res)
}
+4 -1
View File
@@ -14,6 +14,7 @@ import (
// --- Страница просмотра одной загрузки ---
type downloadDetailView struct {
Meta pageMeta
ID string
Title string
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
@@ -107,7 +108,9 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
s.render(w, "download.html", s.buildDownloadView(id, rd))
view := s.buildDownloadView(id, rd)
view.Meta = s.pageMetaFor(r, "jellybit — загрузка #"+id)
s.render(w, "download.html", view)
}
// buildDownloadView собирает представление страницы загрузки из доменных данных
+2
View File
@@ -175,6 +175,7 @@ func handleHealthz(w http.ResponseWriter, _ *http.Request) {
const pageSize = 25
type indexView struct {
Meta pageMeta
Error string
Downloads []downloadView
@@ -318,6 +319,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
for _, d := range downloads {
view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID]))
}
view.Meta = s.pageMetaFor(r, "jellybit — загрузки")
s.render(w, "index.html", view)
}
+77
View File
@@ -0,0 +1,77 @@
package httpapi
import (
"net/http"
"strings"
)
// pageMeta — мета-шапка страницы: заголовок вкладки и адреса для превью
// ссылки (og:*). Заполняется только на страничных путях; htmx-фрагменты
// рендерят те же view с пустым pageMeta и <head> не отдают.
type pageMeta struct {
Title string // <title> и og:title
URL string // адрес страницы (og:url)
Image string // адрес карточки превью (og:image)
}
// pageMetaFor собирает мету страницы. Адреса делаются абсолютными: краулер
// мессенджера не обязан разрешать относительный og:image, а без картинки
// превью ссылки вырождается в голый заголовок.
func (s *server) pageMetaFor(r *http.Request, title string) pageMeta {
base := absBase(r)
return pageMeta{
Title: title,
URL: base + r.URL.EscapedPath(),
Image: base + "/static/og-cover.jpg?v=" + s.assetVer,
}
}
// absBase — база адреса («https://host»), какой её видит клиент: за обратным
// прокси схему и хост несут X-Forwarded-Proto/X-Forwarded-Host, иначе берём
// соединение и Host запроса. Значение уходит только в мета-теги превью,
// доменных решений на нём не строится. Хост непохожей формы отбрасываем
// целиком: пустая база оставляет в мете корректный относительный адрес.
func absBase(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
switch v := firstForwarded(r.Header.Get("X-Forwarded-Proto")); v {
case "http", "https":
scheme = v
}
host := r.Host
if v := firstForwarded(r.Header.Get("X-Forwarded-Host")); v != "" {
host = v
}
if !validHost(host) {
return ""
}
return scheme + "://" + host
}
// firstForwarded берёт первое значение списка «a, b, c»: цепочка прокси
// дописывает свои значения справа, клиенту ближе левое.
func firstForwarded(v string) string {
if i := strings.IndexByte(v, ','); i >= 0 {
v = v[:i]
}
return strings.TrimSpace(v)
}
// validHost — проверка хоста из недоверенного заголовка: допустимы только
// знаки имени, порта и скобок IPv6.
func validHost(host string) bool {
if host == "" || len(host) > 255 {
return false
}
for _, c := range host {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
case c == '.', c == '-', c == '_', c == ':', c == '[', c == ']':
default:
return false
}
}
return true
}
+106
View File
@@ -0,0 +1,106 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// getWith — GET с заголовками (за обратным прокси мету считает не Host).
func getWith(t *testing.T, h http.Handler, path string, hdr map[string]string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
// TestPageMetaAbsolute — превью ссылки: адреса картинки и страницы абсолютные,
// иначе краулер мессенджера картинку не заберёт.
func TestPageMetaAbsolute(t *testing.T) {
h := testRouter(t, stubReader{}, stubReviewer{})
body := get(t, h, "/").Body.String()
if !strings.Contains(body, `<meta property="og:image" content="http://example.com/static/og-cover.jpg?v=`) {
t.Errorf("og:image не абсолютный: %s", metaLine(body, "og:image"))
}
if !strings.Contains(body, `<meta property="og:url" content="http://example.com/">`) {
t.Errorf("og:url не абсолютный: %s", metaLine(body, "og:url"))
}
if !strings.Contains(body, `<meta property="og:title" content="jellybit — загрузки">`) {
t.Errorf("og:title не заголовок страницы: %s", metaLine(body, "og:title"))
}
if !strings.Contains(body, `<title>jellybit — загрузки</title>`) {
t.Error("заголовок вкладки потерян")
}
}
// TestPageMetaBehindProxy — за прокси схему и хост несут X-Forwarded-*;
// из списка значений берётся первое (ближнее к клиенту).
func TestPageMetaBehindProxy(t *testing.T) {
h := testRouter(t, stubReader{}, stubReviewer{})
body := getWith(t, h, "/", map[string]string{
"X-Forwarded-Proto": "https, http",
"X-Forwarded-Host": "jellybit.example.org, internal",
}).Body.String()
if !strings.Contains(body, `content="https://jellybit.example.org/static/og-cover.jpg?v=`) {
t.Errorf("og:image мимо прокси: %s", metaLine(body, "og:image"))
}
if !strings.Contains(body, `<meta property="og:url" content="https://jellybit.example.org/">`) {
t.Errorf("og:url мимо прокси: %s", metaLine(body, "og:url"))
}
}
// TestPageMetaBadHost — хост непохожей формы отбрасывается целиком: в мете
// остаётся относительный адрес, чужой хост в превью не подставляется.
func TestPageMetaBadHost(t *testing.T) {
h := testRouter(t, stubReader{}, stubReviewer{})
body := getWith(t, h, "/", map[string]string{
"X-Forwarded-Host": `evil.example.org"><script>`,
}).Body.String()
if !strings.Contains(body, `<meta property="og:image" content="/static/og-cover.jpg?v=`) {
t.Errorf("порченый хост попал в мету: %s", metaLine(body, "og:image"))
}
if strings.Contains(body, "<script>") {
t.Error("заголовок запроса дотёк в разметку неэкранированным")
}
}
// TestBrandAssets — знак один на все места: иконка вкладки и шапка ссылаются на
// тот же /static/logo.svg, и он отдаётся.
func TestBrandAssets(t *testing.T) {
h := testRouter(t, stubReader{}, stubReviewer{})
body := get(t, h, "/").Body.String()
if strings.Count(body, "/static/logo.svg") != 2 {
t.Errorf("ожидались две ссылки на знак (icon + шапка), получено %d",
strings.Count(body, "/static/logo.svg"))
}
if strings.Contains(body, "favicon.svg") {
t.Error("осталась ссылка на прежний favicon.svg")
}
for _, p := range []string{"/static/logo.svg", "/static/og-cover.jpg", "/static/apple-touch-icon.png"} {
if code := get(t, h, p).Code; code != http.StatusOK {
t.Errorf("GET %s = %d, want 200", p, code)
}
}
}
// metaLine — строка ответа с искомым мета-тегом (для внятного отчёта теста).
func metaLine(body, prop string) string {
for _, l := range strings.Split(body, "\n") {
if strings.Contains(l, prop) {
return strings.TrimSpace(l)
}
}
return "(тега нет)"
}
+7 -3
View File
@@ -10,11 +10,15 @@ import (
"git.vakhrushev.me/av/jellybit/web"
)
// assetVersion — короткий хеш изменяемых ассетов (css/js) для cache-busting.
// Шрифты и вендор адресуются по неизменному имени, их версионировать не нужно.
// assetVersion — короткий хеш изменяемых ассетов (css/js, знак и карточка
// превью) для cache-busting. Шрифты и вендор адресуются по неизменному имени,
// их версионировать не нужно.
func assetVersion() (string, error) {
h := sha256.New()
for _, p := range []string{"static/css/jellybit.css", "static/js/app.js"} {
for _, p := range []string{
"static/css/jellybit.css", "static/js/app.js",
"static/logo.svg", "static/og-cover.jpg",
} {
b, err := web.FS.ReadFile(p)
if err != nil {
return "", err
+4 -1
View File
@@ -37,6 +37,7 @@ type Reviewer interface {
// --- Представление страницы ревью ---
type reviewView struct {
Meta pageMeta
ID string
Source string
Context string
@@ -99,7 +100,9 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
return
}
s.render(w, "review.html", buildReviewView(id, rd, r.URL.Query().Get("err")))
view := buildReviewView(id, rd, r.URL.Query().Get("err"))
view.Meta = s.pageMetaFor(r, "jellybit — ревью #"+id)
s.render(w, "review.html", view)
}
// buildReviewView собирает представление страницы ревью из доменных данных.
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<!-- Исходник карточки превью ссылки (og:image). Растр собирается командой
`task brand` в web/static/og-cover.png; правим здесь, а не в PNG.
Размер 1200×630 — канонический для Open Graph. -->
<html lang="ru">
<meta charset="utf-8">
<style>
@font-face{font-family:'IBM Plex Sans';font-weight:400;
src:url(static/vendor/fonts/ibm-plex-sans-cyrillic-400.woff2) format('woff2');
unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}
@font-face{font-family:'IBM Plex Sans';font-weight:400;
src:url(static/vendor/fonts/ibm-plex-sans-latin-400.woff2) format('woff2')}
@font-face{font-family:'IBM Plex Sans';font-weight:700;
src:url(static/vendor/fonts/ibm-plex-sans-cyrillic-700.woff2) format('woff2');
unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}
@font-face{font-family:'IBM Plex Sans';font-weight:700;
src:url(static/vendor/fonts/ibm-plex-sans-latin-700.woff2) format('woff2')}
html,body{margin:0;padding:0}
body{width:1200px;height:630px;overflow:hidden;
font-family:'IBM Plex Sans',system-ui,sans-serif;
background:radial-gradient(900px 520px at 50% 8%,#1d2b4a 0%,#101724 58%,#0b0f18 100%);
color:#eef2f8;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:34px}
img{width:176px;height:176px;filter:drop-shadow(0 18px 46px rgba(47,111,237,.45))}
h1{margin:0;font-size:96px;font-weight:700;letter-spacing:-.035em;line-height:1}
p{margin:0;font-size:33px;font-weight:400;color:#94a3b8;letter-spacing:-.01em}
b{color:#7aa5f7;font-weight:400}
</style>
<img src="static/logo.svg" alt="">
<h1>jellybit</h1>
<p>раздача <b></b> распознавание <b></b> библиотека Jellyfin</p>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+1 -2
View File
@@ -129,8 +129,7 @@ a:hover{text-decoration:underline}
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:var(--fs-md);
letter-spacing:-.01em;color:var(--text)}
.brand:hover{text-decoration:none}
.brand .logo{width:22px;height:22px;border-radius:6px;background:var(--accent);
display:grid;place-items:center;color:var(--on-accent);font-size:13px;flex:none}
.brand .logo{width:22px;height:22px;flex:none;display:block}
.nav{display:flex;gap:2px;margin-left:var(--sp-2)}
.nav a{padding:6px 11px;border-radius:var(--r-sm);font-size:var(--fs-sm);font-weight:500;
color:var(--text-dim)}
-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="jellybit">
<defs>
<linearGradient id="j" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8b5cf6"/>
<stop offset="1" stop-color="#6d28d9"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#j)"/>
<path d="M26 21 L45 32 L26 43 Z" fill="#fff"/>
<rect x="17" y="21" width="5" height="5" rx="1" fill="#fff" opacity=".85"/>
<rect x="17" y="38" width="5" height="5" rx="1" fill="#fff" opacity=".85"/>
</svg>

Before

Width:  |  Height:  |  Size: 563 B

+21
View File
@@ -0,0 +1,21 @@
<!-- Знак jellybit: буквы «jb» в скруглённом квадрате акцентного цвета.
Единственный источник логотипа — шапка веб-UI, favicon и карточка превью
ссылки (og-cover.jpg собирается из него же, `task brand`). Буквы заданы
геометрией, а не текстом: favicon рендерится без наших шрифтов.
Знак оптически центрирован — центр масс белого лежит на середине холста. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="jellybit">
<defs>
<linearGradient id="jb-bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#4a86f5"/>
<stop offset="1" stop-color="#2453c8"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="15" fill="url(#jb-bg)"/>
<g fill="#fff" fill-rule="evenodd" transform="translate(0,-0.3)">
<!-- b: стержень и правая половина чаши одним контуром, дырка — вторым -->
<path d="M32.8 13.5h6.4V24.5A10.2 10.2 0 0 1 39.2 44.9H32.8ZM39.2 39.9A5.2 5.2 0 0 0 39.2 29.5Z"/>
<!-- j: стержень с хвостом влево -->
<path d="M20.3 24.5h6.4v17.2a7.6 7.6 0 0 1-7.6 7.6h-1.6a3.2 3.2 0 0 1 0-6.4h1.6a1.2 1.2 0 0 0 1.2-1.2z"/>
<circle cx="23.5" cy="17" r="3.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+1 -5
View File
@@ -1,11 +1,7 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit — удаление</title>
<link rel="icon" href="{{asset "favicon.svg"}}" type="image/svg+xml">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
{{template "head" .Meta}}
</head>
<body>
+1 -5
View File
@@ -1,11 +1,7 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit — подтверждение удаления</title>
<link rel="icon" href="{{asset "favicon.svg"}}" type="image/svg+xml">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
{{template "head" .Meta}}
</head>
<body>
+1 -5
View File
@@ -1,11 +1,7 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit — результат удаления</title>
<link rel="icon" href="{{asset "favicon.svg"}}" type="image/svg+xml">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
{{template "head" .Meta}}
</head>
<body>
+1 -5
View File
@@ -1,11 +1,7 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit — загрузка #{{.ID}}</title>
<link rel="icon" href="{{asset "favicon.svg"}}" type="image/svg+xml">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
{{template "head" .Meta}}
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
<script src="{{asset "js/app.js"}}" defer></script>
</head>
+1 -5
View File
@@ -1,11 +1,7 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit — загрузки</title>
<link rel="icon" href="{{asset "favicon.svg"}}" type="image/svg+xml">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
{{template "head" .Meta}}
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
<script src="{{asset "js/app.js"}}" defer></script>
</head>
+22
View File
@@ -0,0 +1,22 @@
{{define "head"}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<meta name="description" content="Качает раздачу через qBittorrent, распознаёт фильм или сериал и раскладывает файлы для Jellyfin хардлинками.">
<link rel="icon" href="{{asset "logo.svg"}}" type="image/svg+xml">
<link rel="apple-touch-icon" href="{{asset "apple-touch-icon.png"}}">
<meta name="theme-color" content="#2f6fed">
{{/* Превью ссылки в мессенджере: карточка со знаком jb. Адреса абсолютные —
краулер не обязан разрешать относительные (см. pageMeta). */}}
<meta property="og:type" content="website">
<meta property="og:site_name" content="jellybit">
<meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="Качает раздачу через qBittorrent, распознаёт фильм или сериал и раскладывает файлы для Jellyfin хардлинками.">
<meta property="og:url" content="{{.URL}}">
<meta property="og:image" content="{{.Image}}">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
{{end}}
+1 -1
View File
@@ -1,7 +1,7 @@
{{define "header"}}
<header class="app-header">
<div class="bar">
<a class="brand" href="/"><span class="logo">jb</span>jellybit</a>
<a class="brand" href="/"><img class="logo" src="{{asset "logo.svg"}}" width="22" height="22" alt="">jellybit</a>
<nav class="nav">
<a href="/"{{if eq . "downloads"}} class="active"{{end}}>Загрузки</a>
<a href="/delete"{{if eq . "delete"}} class="active"{{end}}>Удаление</a>
+1 -5
View File
@@ -1,11 +1,7 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit — ревью #{{.ID}}</title>
<link rel="icon" href="{{asset "favicon.svg"}}" type="image/svg+xml">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
{{template "head" .Meta}}
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
<script src="{{asset "js/app.js"}}" defer></script>
</head>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<!-- Исходник иконки для домашнего экрана iOS (apple-touch-icon). Знак кладётся
на непрозрачную подложку того же градиента: iOS скругляет углы сама, и
прозрачные углы SVG стали бы чёрными. Растр собирает `task brand`. -->
<html lang="ru">
<meta charset="utf-8">
<style>
html,body{margin:0;padding:0}
body{width:180px;height:180px;overflow:hidden;background:linear-gradient(#4a86f5,#2453c8)}
img{width:180px;height:180px;display:block}
</style>
<img src="static/logo.svg" alt="">