веб-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:
@@ -75,6 +75,25 @@ tasks:
|
|||||||
echo "$sum $file" | sha256sum -c -
|
echo "$sum $file" | sha256sum -c -
|
||||||
done < web/assets.manifest
|
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:
|
image:
|
||||||
desc: 'Docker-образ из готового бинаря. Тег из $BUILD_ID (по умолчанию dev; роль app_image umbar передаёт свой). См. docs/adr local-image-build'
|
desc: 'Docker-образ из готового бинаря. Тег из $BUILD_ID (по умолчанию dev; роль app_image umbar передаёт свой). См. docs/adr local-image-build'
|
||||||
deps: [build]
|
deps: [build]
|
||||||
|
|||||||
@@ -189,3 +189,19 @@ htmx по умолчанию **не свопит DOM на ответы 4xx/5xx**
|
|||||||
`task build`/`task run` зависят от `task assets`.
|
`task build`/`task run` зависят от `task assets`.
|
||||||
- Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь самодостаточен,
|
- Шрифты и скрипты — **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`). Хост приходит из недоверенного заголовка, поэтому
|
||||||
|
форма проверяется, а на непохожем значении база пустеет до относительной.
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ type bulkRow struct {
|
|||||||
// bulkSelectView — страница выбора (`GET /delete`) и она же ответ на отказ
|
// bulkSelectView — страница выбора (`GET /delete`) и она же ответ на отказ
|
||||||
// разбора: отметки при этом сохраняются, иначе проверка стирает всю работу.
|
// разбора: отметки при этом сохраняются, иначе проверка стирает всю работу.
|
||||||
type bulkSelectView struct {
|
type bulkSelectView struct {
|
||||||
|
Meta pageMeta
|
||||||
Error string
|
Error string
|
||||||
Max int
|
Max int
|
||||||
Rows []bulkRow
|
Rows []bulkRow
|
||||||
@@ -71,12 +72,14 @@ type bulkSelectView struct {
|
|||||||
|
|
||||||
// bulkConfirmView — страница подтверждения: выбранные названы поимённо.
|
// bulkConfirmView — страница подтверждения: выбранные названы поимённо.
|
||||||
type bulkConfirmView struct {
|
type bulkConfirmView struct {
|
||||||
|
Meta pageMeta
|
||||||
Rows []bulkRow
|
Rows []bulkRow
|
||||||
}
|
}
|
||||||
|
|
||||||
// bulkResultView — отчёт: обе половины исхода поимённо плюс остаток, если
|
// bulkResultView — отчёт: обе половины исхода поимённо плюс остаток, если
|
||||||
// проход остановлен системным отказом.
|
// проход остановлен системным отказом.
|
||||||
type bulkResultView struct {
|
type bulkResultView struct {
|
||||||
|
Meta pageMeta
|
||||||
Deleted []bulkRow
|
Deleted []bulkRow
|
||||||
Failed []bulkRow
|
Failed []bulkRow
|
||||||
Skipped []bulkRow
|
Skipped []bulkRow
|
||||||
@@ -114,6 +117,7 @@ func (s *server) renderBulkSelect(w http.ResponseWriter, r *http.Request, msg st
|
|||||||
LastCopy: d.State == store.StateOrphaned,
|
LastCopy: d.State == store.StateOrphaned,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
view.Meta = s.pageMetaFor(r, "jellybit — удаление")
|
||||||
s.render(w, "delete.html", view)
|
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)
|
s.renderBulkSelect(w, r, bulkErrMsg(err), ids)
|
||||||
return
|
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 — исполнение пачки. Признак подтверждения проверяется ДО
|
// handleBulkDelete — исполнение пачки. Признак подтверждения проверяется ДО
|
||||||
@@ -204,6 +211,7 @@ func (s *server) handleBulkDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
"failed", len(res.Failed), "skipped", len(res.Skipped),
|
"failed", len(res.Failed), "skipped", len(res.Skipped),
|
||||||
"stopped", res.StopReason != "")
|
"stopped", res.StopReason != "")
|
||||||
|
|
||||||
|
res.Meta = s.pageMetaFor(r, "jellybit — результат удаления")
|
||||||
s.render(w, "delete_result.html", res)
|
s.render(w, "delete_result.html", res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
// --- Страница просмотра одной загрузки ---
|
// --- Страница просмотра одной загрузки ---
|
||||||
|
|
||||||
type downloadDetailView struct {
|
type downloadDetailView struct {
|
||||||
|
Meta pageMeta
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
|
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
|
||||||
@@ -107,7 +108,9 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||||
return
|
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 собирает представление страницы загрузки из доменных данных
|
// buildDownloadView собирает представление страницы загрузки из доменных данных
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ func handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|||||||
const pageSize = 25
|
const pageSize = 25
|
||||||
|
|
||||||
type indexView struct {
|
type indexView struct {
|
||||||
|
Meta pageMeta
|
||||||
Error string
|
Error string
|
||||||
Downloads []downloadView
|
Downloads []downloadView
|
||||||
|
|
||||||
@@ -318,6 +319,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, d := range downloads {
|
for _, d := range downloads {
|
||||||
view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID]))
|
view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID]))
|
||||||
}
|
}
|
||||||
|
view.Meta = s.pageMetaFor(r, "jellybit — загрузки")
|
||||||
s.render(w, "index.html", view)
|
s.render(w, "index.html", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 "(тега нет)"
|
||||||
|
}
|
||||||
@@ -10,11 +10,15 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/web"
|
"git.vakhrushev.me/av/jellybit/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// assetVersion — короткий хеш изменяемых ассетов (css/js) для cache-busting.
|
// assetVersion — короткий хеш изменяемых ассетов (css/js, знак и карточка
|
||||||
// Шрифты и вендор адресуются по неизменному имени, их версионировать не нужно.
|
// превью) для cache-busting. Шрифты и вендор адресуются по неизменному имени,
|
||||||
|
// их версионировать не нужно.
|
||||||
func assetVersion() (string, error) {
|
func assetVersion() (string, error) {
|
||||||
h := sha256.New()
|
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)
|
b, err := web.FS.ReadFile(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type Reviewer interface {
|
|||||||
// --- Представление страницы ревью ---
|
// --- Представление страницы ревью ---
|
||||||
|
|
||||||
type reviewView struct {
|
type reviewView struct {
|
||||||
|
Meta pageMeta
|
||||||
ID string
|
ID string
|
||||||
Source string
|
Source string
|
||||||
Context string
|
Context string
|
||||||
@@ -99,7 +100,9 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 собирает представление страницы ревью из доменных данных.
|
// buildReviewView собирает представление страницы ревью из доменных данных.
|
||||||
|
|||||||
@@ -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 |
@@ -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);
|
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:var(--fs-md);
|
||||||
letter-spacing:-.01em;color:var(--text)}
|
letter-spacing:-.01em;color:var(--text)}
|
||||||
.brand:hover{text-decoration:none}
|
.brand:hover{text-decoration:none}
|
||||||
.brand .logo{width:22px;height:22px;border-radius:6px;background:var(--accent);
|
.brand .logo{width:22px;height:22px;flex:none;display:block}
|
||||||
display:grid;place-items:center;color:var(--on-accent);font-size:13px;flex:none}
|
|
||||||
.nav{display:flex;gap:2px;margin-left:var(--sp-2)}
|
.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;
|
.nav a{padding:6px 11px;border-radius:var(--r-sm);font-size:var(--fs-sm);font-weight:500;
|
||||||
color:var(--text-dim)}
|
color:var(--text-dim)}
|
||||||
|
|||||||
@@ -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 |
@@ -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,11 +1,7 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
{{template "head" .Meta}}
|
||||||
<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"}}">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
{{template "head" .Meta}}
|
||||||
<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"}}">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
{{template "head" .Meta}}
|
||||||
<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"}}">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
{{template "head" .Meta}}
|
||||||
<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"}}">
|
|
||||||
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
||||||
<script src="{{asset "js/app.js"}}" defer></script>
|
<script src="{{asset "js/app.js"}}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
{{template "head" .Meta}}
|
||||||
<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"}}">
|
|
||||||
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
||||||
<script src="{{asset "js/app.js"}}" defer></script>
|
<script src="{{asset "js/app.js"}}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
{{define "header"}}
|
{{define "header"}}
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="bar">
|
<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">
|
<nav class="nav">
|
||||||
<a href="/"{{if eq . "downloads"}} class="active"{{end}}>Загрузки</a>
|
<a href="/"{{if eq . "downloads"}} class="active"{{end}}>Загрузки</a>
|
||||||
<a href="/delete"{{if eq . "delete"}} class="active"{{end}}>Удаление</a>
|
<a href="/delete"{{if eq . "delete"}} class="active"{{end}}>Удаление</a>
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
{{template "head" .Meta}}
|
||||||
<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"}}">
|
|
||||||
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
||||||
<script src="{{asset "js/app.js"}}" defer></script>
|
<script src="{{asset "js/app.js"}}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -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="">
|
||||||
Reference in New Issue
Block a user