Перенос дизайна в 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:
av
2026-06-30 19:59:19 +03:00
co-authored by Claude Opus 4.8
parent d190072647
commit 4e2593ac31
26 changed files with 1960 additions and 303 deletions
+90
View File
@@ -0,0 +1,90 @@
<!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="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>
{{template "header" ""}}
<main class="main">
<a class="back-link" href="/">← ко всем загрузкам</a>
{{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}}
<!-- Шапка -->
<div class="review-head" style="margin-bottom:var(--sp-4)">
<div>
<div class="review-title" style="font-size:var(--fs-xl)">{{.Title}}</div>
<div class="hash-row" style="margin-top:9px">
{{template "status_badge" .State}}
{{if .Infohash}}<span class="hash">{{.InfohashShort}}</span>
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.Infohash}}')">⧉ копировать</button>{{end}}
</div>
{{if .Note}}<p class="muted" style="margin:var(--sp-2) 0 0">{{.Note}}</p>{{end}}
</div>
<div class="card-acts" style="align-items:center">
{{if .Reviewable}}<a class="btn btn-primary" href="/review/{{.ID}}">Ревью →</a>{{end}}
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo"><button class="btn btn-danger" type="submit">↩ Откатить</button></form>{{end}}
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink"><button class="btn" type="submit">Привязать заново</button></form>{{end}}
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry"><button class="btn" type="submit">Повторить</button></form>{{end}}
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-ghost" type="submit">Отменить</button></form>{{end}}
</div>
</div>
<!-- Распознано как -->
<div class="section">
<div class="section-head"><h2>Распознано как</h2></div>
{{if .HasPlan}}
<div class="meta-card">
<div class="meta-body">
<p class="h">{{.RecTitle}}</p>
{{if .OriginalTitle}}<p class="o">{{.OriginalTitle}}</p>{{end}}
<dl class="kv" style="max-width:360px">
<dt>Тип</dt><dd>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</dd>
{{if .Year}}<dt>Год</dt><dd>{{.Year}}</dd>{{end}}
<dt>База</dt><dd>{{if .Provider}}{{.Provider}} · <span class="mono">{{.ProviderID}}</span>{{else if .NoBase}}без базы{{else}}—{{end}}</dd>
{{if .Confidence}}<dt>Уверенность</dt><dd class="mono">{{.Confidence}}</dd>{{end}}
</dl>
</div>
</div>
{{else}}
<p class="muted" style="margin:0">Ещё не распознано.</p>
{{end}}
</div>
<!-- Файлы и раскладка -->
{{if .HasPlan}}
<div class="section">
<div class="section-head"><h2>Файлы и раскладка</h2><span class="hint">файл источника → целевой хардлинк</span></div>
{{template "layout_widget" .Files}}
<p class="faint" style="font-size:var(--fs-xs);margin:var(--sp-3) 0 0">
Целевые файлы связаны хардлинками — место не дублируется, исходник остаётся на раздаче.
</p>
</div>
{{end}}
<!-- История -->
<div class="section">
<div class="section-head"><h2>История</h2></div>
<ul class="timeline">
{{if .CreatedAt}}<li class="tl-item is-done"><div class="tl-t">Добавлено</div><time>{{.CreatedAt}}</time></li>{{end}}
<li class="tl-item is-now"><div class="tl-t">{{badgeLabel .State}} <span class="badge st-{{.State}}">текущее</span></div>{{if .UpdatedAt}}<time>{{.UpdatedAt}}</time>{{end}}</li>
</ul>
</div>
<!-- Контекст -->
{{if .Context}}
<div class="section" style="margin-bottom:0">
<div class="section-head"><h2>Контекст</h2><span class="hint">сообщение при добавлении</span></div>
<p style="margin:0;color:var(--text-dim)">«{{.Context}}»</p>
</div>
{{end}}
</main>
</body>
</html>
+86 -84
View File
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
{{define "header"}}
<header class="app-header">
<div class="bar">
<a class="brand" href="/"><span class="logo">jb</span>jellybit</a>
<nav class="nav">
<a href="/"{{if eq . "downloads"}} class="active"{{end}}>Загрузки</a>
</nav>
<span class="header-spacer"></span>
</div>
</header>
{{end}}
+22
View File
@@ -0,0 +1,22 @@
{{define "layout_widget"}}
<div class="table-wrap">
<table class="tbl">
<thead><tr><th class="cell-num">#</th><th>файл источника → раскладка</th><th>роль</th></tr></thead>
<tbody>
{{range $i, $f := .}}
<tr class="{{if $f.Ignored}}ignored{{end}}">
<td class="cell-num">{{add $i 1}}</td>
<td>
<div class="fname">{{$f.Src}}</div>
<div class="link-to">
<span class="arr"></span>
{{if $f.Linked}}<span class="src">{{$f.Dst}}</span>{{else}}<span class="no-link">не разложен</span>{{end}}
</div>
</td>
<td><span class="badge {{if $f.Ignored}}st-cancelled{{else}}st-done{{end}}">{{$f.RoleLabel}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
+1
View File
@@ -0,0 +1 @@
{{define "status_badge"}}<span class="badge st-{{.}}">{{badgeLabel .}}</span>{{end}}
+125 -143
View File
@@ -3,169 +3,151 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit · ревью #{{.ID}}</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 70rem; margin: 2rem auto; padding: 0 1rem; }
h1 { margin-bottom: .25rem; }
a { color: inherit; }
input, button, select, textarea { padding: .4rem .6rem; font-size: 1rem; font-family: inherit; }
table { width: 100%; border-collapse: collapse; margin: .5rem 0 1rem; }
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid #8884; vertical-align: top; }
td.src, li.path { 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; }
.state-review { background: #f1c40f88; }
.state-recognizing { background: #9b59b655; }
.state-deferred { background: #f39c1255; }
section { border: 1px solid #8883; border-radius: .5rem; padding: .75rem 1rem; margin: 1rem 0; }
.reasons li { color: #b9770e; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
.actions { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1rem; }
.actions form { display: inline; }
.ignored td { opacity: .5; text-decoration: line-through; }
ul.preview { margin: 0; padding-left: 1.2rem; }
small { color: #8888; }
textarea { width: 100%; box-sizing: border-box; }
</style>
<title>jellybit ревью #{{.ID}}</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>
<p><a href="/">← к списку</a></p>
<h1>Ревью #{{.ID}} <span class="state state-{{.State}}">{{.State}}</span></h1>
<p class="src"><small>{{.Source}}</small></p>
{{if .Context}}<p>Контекст: «{{.Context}}»</p>{{end}}
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .StateError}}<p class="err">{{.StateError}}</p>{{end}}
{{template "header" ""}}
<main class="main">
<a class="back-link" href="/">← ко всем загрузкам</a>
{{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}}
{{if .StateError}}<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)">{{.StateError}}</h2></div>{{end}}
<!-- Шапка ревью -->
<div class="review-head">
<div>
<div class="review-title">{{.Source}}</div>
<div class="reason-line">
{{template "status_badge" .State}}
{{if .Reasons}}<span>Причины: {{range $i, $r := .Reasons}}{{if $i}} · {{end}}<b>{{$r}}</b>{{end}}</span>{{end}}
{{if .Confidence}}<span class="conf">уверенность {{.Confidence}}</span>{{end}}
</div>
</div>
</div>
{{if eq .State "recognizing"}}
<p>⏳ Идёт распознавание, обновите страницу через несколько секунд…</p>
<p><a href="/review/{{.ID}}">Обновить</a></p>
<div class="section">
<p style="margin:0">⏳ Идёт распознавание — обновите страницу через несколько секунд.</p>
<p style="margin:var(--sp-2) 0 0"><a href="/review/{{.ID}}">Обновить</a></p>
</div>
{{end}}
{{if .Reasons}}
<section>
<strong>Причины ревью</strong>{{if .Confidence}} · уверенность {{.Confidence}}{{end}}
<ul class="reasons">{{range .Reasons}}<li>{{.}}</li>{{end}}</ul>
</section>
<!-- Контекст -->
{{if .Context}}
<div class="section">
<div class="section-head"><h2>Контекст</h2><span class="hint">исходное сообщение при добавлении</span></div>
<p style="margin:0;color:var(--text-dim)">«{{.Context}}»</p>
</div>
{{end}}
{{if .HasPlan}}
<section>
<strong>Догадка</strong>
<p class="row">
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}}
{{if .Year}}· Год: <b>{{.Year}}</b>{{end}}
{{if .Provider}}· База: <b>{{.Provider}}</b> {{.ProviderID}}{{else if .NoBase}}· База: <b>без базы</b>{{end}}
</p>
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
Переключить тип:
<button name="type" value="movie" {{if not .IsSeries}}disabled{{end}}>фильм</button>
<button name="type" value="series" {{if .IsSeries}}disabled{{end}}>сериал</button>
<small>(пересоберёт план)</small>
</form>
</section>
<!-- Догадка -->
<div class="section">
<div class="section-head"><h2>Догадка</h2></div>
<div style="margin-bottom:var(--sp-4)">
<span class="label">Тип</span>
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type" style="display:flex;gap:var(--sp-2);align-items:center">
<button class="btn btn-sm{{if not .IsSeries}} btn-primary{{end}}" name="type" value="movie"{{if not .IsSeries}} disabled{{end}}>фильм</button>
<button class="btn btn-sm{{if .IsSeries}} btn-primary{{end}}" name="type" value="series"{{if .IsSeries}} disabled{{end}}>сериал</button>
<span class="hint">переключение пересоберёт план</span>
</form>
</div>
<div class="guess-grid">
<div class="field"><span class="label">Название</span><div>{{.Title}}</div></div>
{{if .OriginalTitle}}<div class="field"><span class="label">Ориг. название</span><div class="mono">{{.OriginalTitle}}</div></div>{{end}}
{{if .Year}}<div class="field"><span class="label">Год</span><div>{{.Year}}</div></div>{{end}}
</div>
</div>
<section>
<strong>База метаданных</strong>
{{if .Provider}}<p>Выбрано: <b>{{.Provider}}</b> {{.ProviderID}}</p>
{{else if .NoBase}}<p>Выбрано: <b>без базы</b> (тег папки не ставится)</p>
{{else}}<p><small>Матч не подтверждён — выберите кандидата, введите id или «без базы».</small></p>{{end}}
<!-- Источник совпадения -->
<div class="section">
<div class="section-head"><h2>Источник совпадения</h2><span class="hint">выбери базу или оставь распознавание нейронкой</span></div>
{{if .Provider}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>{{.Provider}}</b> <span class="mono">{{.ProviderID}}</span></p>
{{else if .NoBase}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>без базы</b> <span class="faint">(тег папки не ставится)</span></p>
{{else}}<p style="margin:0 0 var(--sp-3)"><span class="faint">Матч не подтверждён — выбери кандидата, введи id или «без базы».</span></p>{{end}}
{{if .Candidates}}
<table>
<thead><tr><th>провайдер</th><th>название</th><th>год</th><th>id</th><th>ссылка</th><th></th></tr></thead>
<tbody>
<div class="cand-list">
{{range .Candidates}}
<tr>
<td>{{.Provider}}</td>
<td>{{.Title}}</td>
<td>{{if .Year}}{{.Year}}{{end}}</td>
<td class="src">{{.ProviderID}}</td>
<td>{{if .URL}}<a href="{{.URL}}" target="_blank" rel="noopener">{{.Provider}}</a>{{end}}</td>
<td>
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
<input type="hidden" name="candidate_id" value="{{.ID}}">
<button type="submit" {{if .Chosen}}disabled{{end}}>{{if .Chosen}}выбрано{{else}}выбрать{{end}}</button>
</form>
</td>
</tr>
<div class="cand{{if .Chosen}} selected{{end}}">
<span class="radio"></span>
<div class="body">
<div class="name"><span class="prov {{.Provider}}">{{.Provider}}</span> {{.Title}}{{if .Year}} <span class="muted">· {{.Year}}</span>{{end}}</div>
<div class="sub">id {{.ProviderID}}</div>
</div>
{{if .URL}}<a class="ext-link" href="{{.URL}}" target="_blank" rel="noopener">ссылка ↗</a>{{end}}
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
<input type="hidden" name="candidate_id" value="{{.ID}}">
<button class="btn btn-sm{{if not .Chosen}} btn-primary{{end}}" type="submit"{{if .Chosen}} disabled{{end}}>{{if .Chosen}}выбрано{{else}}выбрать{{end}}</button>
</form>
</div>
{{end}}
</tbody>
</table>
</div>
{{end}}
<form class="row" method="post" action="/ui/downloads/{{.ID}}/provider">
Вручную:
<select name="provider">
<option value="tvdb">tvdb</option>
<option value="tmdb">tmdb</option>
<option value="imdb">imdb</option>
<form class="cand-extra" method="post" action="/ui/downloads/{{.ID}}/provider">
<select class="select" name="provider" style="max-width:120px">
<option value="tmdb">TMDB</option>
<option value="tvdb">TVDB</option>
<option value="imdb">IMDb</option>
</select>
<input type="text" name="provider_id" placeholder="id (напр. 269613)" required>
<button type="submit">задать id</button>
<input class="input mono" name="provider_id" placeholder="ввести id вручную, напр. 60622" required>
<button class="btn btn-sm" type="submit">Задать id</button>
</form>
<form method="post" action="/ui/downloads/{{.ID}}/nobase" style="margin-top:.4rem">
<button type="submit">Без базы</button>
<form method="post" action="/ui/downloads/{{.ID}}/nobase" style="margin-top:var(--sp-2)">
<button class="btn btn-sm btn-ghost" type="submit">Без базы</button>
</form>
</section>
<section>
<strong>Файлы → роль</strong>
<table>
<thead><tr><th>#</th><th>файл</th><th>роль</th><th>S</th><th>E</th><th></th></tr></thead>
<tbody>
{{range $i, $f := .Files}}
<tr {{if $f.Ignored}}class="ignored"{{end}}>
<td>{{add $i 1}}</td>
<td class="src">{{$f.Src}}</td>
<td>{{$f.Role}}</td>
<td>{{$f.Season}}</td>
<td>{{$f.Episode}}</td>
<td>
{{if not $f.Ignored}}
<form method="post" action="/ui/downloads/{{$.ID}}/ignore">
<input type="hidden" name="src" value="{{$f.Src}}">
<button type="submit">игнор</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{if .Preview}}
<section>
<strong>Превью раскладки</strong> <small>(будут созданы хардлинки)</small>
<ul class="preview">{{range .Preview}}<li class="path">{{.}}</li>{{end}}</ul>
</section>
{{end}}
{{end}}
<section>
<strong>Уточнить и перераспознать</strong>
<form method="post" action="/ui/downloads/{{.ID}}/refine">
<p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p>
<button type="submit">🔁 Уточнить</button>
</form>
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:.4rem">
<button type="submit">🔄 Распознать заново</button>
<small>(без новой подсказки — по уже накопленному контексту)</small>
</form>
{{if .Hints}}
<p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p>
{{end}}
</section>
<div class="actions">
{{if .Preview}}
<form method="post" action="/ui/downloads/{{.ID}}/apply"><button type="submit">✅ Применить</button></form>
{{end}}
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button type="submit">🕗 Позже</button></form>
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button type="submit">❌ Отклонить</button></form>
</div>
<!-- Раскладка -->
<div class="section">
<div class="section-head">
<h2>Раскладка</h2>
<span class="hint">файл источника → целевой хардлинк</span>
</div>
{{template "layout_widget" .Files}}
<p class="faint" style="font-size:var(--fs-xs);margin:var(--sp-3) 0 0">
Буквальные целевые пути. Применяются как хардлинки — исходник остаётся на раздаче.
</p>
</div>
{{end}}
<!-- Уточнить и перераспознать -->
<div class="section">
<div class="section-head"><h2>Уточнить и перераспознать</h2></div>
{{if .Hints}}
<div class="hint-list">
{{range $i, $h := .Hints}}
<div class="hint-item"><span class="idx">#{{add $i 1}}</span><span class="txt">{{$h}}</span></div>
{{end}}
</div>
{{end}}
<form method="post" action="/ui/downloads/{{.ID}}/refine">
<textarea class="textarea" name="hint" placeholder="Например: «это Fargo, год сериала 2014, не 2015» или «sample.mkv не считать эпизодом»"></textarea>
<div class="refine-row">
<button class="btn btn-primary btn-sm" type="submit">🔁 Уточнить</button>
</div>
</form>
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:var(--sp-2)">
<button class="btn btn-sm" type="submit">🔄 Распознать заново</button>
<span class="hint">без новой подсказки — по уже накопленному контексту</span>
</form>
</div>
<!-- Панель действий -->
<div class="action-bar">
<span class="grow">{{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}}</span>
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button class="btn btn-lg" type="submit">🕗 Позже</button></form>
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-lg btn-danger" type="submit">❌ Отклонить</button></form>
{{if .HasLinks}}<form method="post" action="/ui/downloads/{{.ID}}/apply"><button class="btn btn-lg btn-primary" type="submit">✅ Применить</button></form>{{end}}
</div>
</main>
</body>
</html>