Перенос дизайна в 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:
+86
-84
@@ -3,98 +3,100 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="5">
|
||||
<title>jellybit</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font-family: system-ui, sans-serif; max-width: 60rem; margin: 2rem auto; padding: 0 1rem; }
|
||||
h1 { margin-bottom: .25rem; }
|
||||
form.add { display: flex; gap: .5rem; flex-wrap: wrap; margin: 1rem 0; }
|
||||
form.add input[name=source] { flex: 1 1 24rem; }
|
||||
input, button { padding: .4rem .6rem; font-size: 1rem; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||
th, td { text-align: left; padding: .4rem .5rem; border-bottom: 1px solid #8884; vertical-align: top; }
|
||||
td.src { font-family: monospace; font-size: .85rem; word-break: break-all; }
|
||||
.err { color: #c0392b; }
|
||||
.state { font-size: .8rem; padding: .1rem .5rem; border-radius: 1rem; background: #8883; white-space: nowrap; }
|
||||
.state-completed { background: #2ecc7155; }
|
||||
.state-downloading { background: #3498db55; }
|
||||
.state-recognizing { background: #9b59b655; }
|
||||
.state-review { background: #f1c40f88; }
|
||||
.state-deferred { background: #f39c1255; }
|
||||
.state-linking { background: #1abc9c55; }
|
||||
.state-done { background: #2ecc7188; }
|
||||
.state-stuck { background: #f39c1255; }
|
||||
.state-failed { background: #e74c3c55; }
|
||||
.state-cancelled { background: #95a5a655; }
|
||||
.state-reverted { background: #95a5a655; }
|
||||
.state-target_missing { background: #e67e2255; }
|
||||
.state-orphaned { background: #e74c3c88; }
|
||||
.state-deleted { background: #95a5a655; }
|
||||
.actions { display: flex; gap: .4rem; flex-wrap: wrap; }
|
||||
a.button { display: inline-block; padding: .35rem .6rem; border: 1px solid #8886; border-radius: .3rem; text-decoration: none; }
|
||||
small { color: #8888; }
|
||||
</style>
|
||||
<title>jellybit — загрузки</title>
|
||||
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
|
||||
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
||||
<script src="{{asset "js/app.js"}}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>jellybit</h1>
|
||||
<p><small>обновляется каждые 5 с</small></p>
|
||||
|
||||
<form class="add" method="post" action="/ui/downloads">
|
||||
<input type="text" name="source" placeholder="magnet:?xt=urn:btih:…" required>
|
||||
<input type="text" name="context" placeholder="контекст (опц.)">
|
||||
<button type="submit">Добавить</button>
|
||||
{{template "header" "downloads"}}
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Загрузки</h1>
|
||||
<p class="page-sub">Кинь magnet, ссылку, .torrent или сообщение бота — остальное jellybit сделает сам.</p>
|
||||
|
||||
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
||||
|
||||
<!-- Единое окно добавления -->
|
||||
<form class="add-box" method="post" action="/ui/downloads">
|
||||
<div class="add-row">
|
||||
<input class="input" type="text" name="source" autocomplete="off" required
|
||||
placeholder="magnet:?xt=urn:btih:… · ссылка · .torrent · или вставь текст бота">
|
||||
<button class="btn btn-primary btn-lg" type="submit">Добавить</button>
|
||||
</div>
|
||||
<p class="add-hint">Можно вставить целиком сообщение трекер-бота — magnet и контекст разберутся автоматически.</p>
|
||||
</form>
|
||||
|
||||
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
|
||||
{{if .Downloads}}
|
||||
<!-- Панель фильтров -->
|
||||
<div class="toolbar" id="list-toolbar">
|
||||
<div class="search">
|
||||
<span class="ico">⌕</span>
|
||||
<input class="input" type="search" placeholder="Поиск по названию или infohash…" oninput="applyFilters()">
|
||||
</div>
|
||||
<div class="filter-chips" id="chips">
|
||||
<button class="chip active" type="button" data-f="all" onclick="setFilter(this)">Все</button>
|
||||
<button class="chip" type="button" data-f="review" onclick="setFilter(this)">Ждут меня</button>
|
||||
<button class="chip" type="button" data-f="active" onclick="setFilter(this)">В работе</button>
|
||||
<button class="chip" type="button" data-f="done" onclick="setFilter(this)">Готово</button>
|
||||
<button class="chip" type="button" data-f="problem" onclick="setFilter(this)">Проблемы</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>#</th><th>Источник</th><th>Контекст</th><th>Состояние</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div class="toggle-line">
|
||||
<label><input type="checkbox" id="showAll" onchange="applyFilters()"> Показать всё, включая удалённые</label>
|
||||
</div>
|
||||
|
||||
<!-- Список карточек -->
|
||||
<div id="list">
|
||||
{{range .Downloads}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td class="src" title="{{.Infohash}}">{{.Source}}</td>
|
||||
<td>{{.Context}}</td>
|
||||
<td>
|
||||
<span class="state state-{{.State}}">{{.State}}</span>
|
||||
{{if .Note}}<br><small>{{.Note}}</small>{{end}}
|
||||
{{if .Error}}<br><small>{{.Error}}</small>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
{{if .Reviewable}}
|
||||
<a class="button" href="/review/{{.ID}}">Ревью →</a>
|
||||
{{end}}
|
||||
{{if .Undoable}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/undo">
|
||||
<button type="submit">Откатить</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Relinkable}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/relink">
|
||||
<button type="submit">Привязать заново</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Retriable}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/retry">
|
||||
<button type="submit">Повторить</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if not .Terminal}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/cancel">
|
||||
<button type="submit">Отклонить</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<article class="card" data-group="{{.Group}}" data-text="{{.SearchText}}"{{if .Deleted}} data-deleted="1"{{end}}>
|
||||
<div class="card-top">
|
||||
<div>
|
||||
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
||||
{{if .Infohash}}
|
||||
<div class="hash-row">
|
||||
<span class="hash">{{.InfohashShort}}</span>
|
||||
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.Infohash}}')">⧉ копировать</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5"><small>пока пусто</small></td></tr>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "status_badge" .State}}
|
||||
</div>
|
||||
|
||||
{{if .Context}}
|
||||
<details class="spoiler">
|
||||
<summary>контекст</summary>
|
||||
<div class="spoiler-body">«{{.Context}}»</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
<div class="card-foot">
|
||||
<span class="card-meta">{{if .Note}}{{.Note}}{{else if .Error}}{{.Error}}{{end}}</span>
|
||||
<div class="card-acts">
|
||||
{{if .Reviewable}}<a class="btn btn-primary btn-sm" href="/review/{{.ID}}">Ревью →</a>{{end}}
|
||||
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo"><button class="btn btn-sm btn-ghost" type="submit">Откатить</button></form>{{end}}
|
||||
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink"><button class="btn btn-sm" type="submit">Привязать заново</button></form>{{end}}
|
||||
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry"><button class="btn btn-sm" type="submit">Повторить</button></form>{{end}}
|
||||
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-sm btn-danger" type="submit">{{if .Reviewable}}Отклонить{{else}}Отменить{{end}}</button></form>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="empty" style="display:none">
|
||||
<div class="big">🍿</div>
|
||||
Ничего не найдено. Поменяй фильтр или запрос.
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="big">🍿</div>
|
||||
Пока пусто. Добавь первую загрузку выше.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user