UI/UX списка и карточки загрузки: серверные фильтр/поиск/пагинация, матч-ссылка, имя раздачи (web-ui-list-detail)
- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
(added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -261,12 +261,19 @@ details.spoiler{margin-top:var(--sp-3)}
|
||||
.toggle-line{display:flex;align-items:center;gap:8px;font-size:var(--fs-sm);color:var(--text-dim);
|
||||
margin-bottom:var(--sp-4)}
|
||||
.toggle-line label{display:inline-flex;align-items:center;gap:7px;cursor:pointer}
|
||||
.toggle-line a{display:inline-flex;align-items:center;gap:7px;color:var(--text-dim)}
|
||||
.toggle-line a:hover{color:var(--text)}
|
||||
|
||||
/* пагинация */
|
||||
.pager{display:flex;align-items:center;justify-content:center;gap:var(--sp-2);
|
||||
margin-top:var(--sp-5);font-size:var(--fs-sm);color:var(--text-dim)}
|
||||
.pager .btn{min-width:36px}
|
||||
|
||||
/* блок «Информация о торренте» — сырой источник + копирование */
|
||||
.src-block{display:flex;align-items:flex-start;gap:var(--sp-2);margin-top:var(--sp-3)}
|
||||
.src-full{flex:1;font-family:var(--mono);font-size:var(--fs-xs);color:var(--text-dim);
|
||||
word-break:break-all}
|
||||
|
||||
/* ---------- 10. Секция ревью ---------- */
|
||||
.section{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);
|
||||
padding:var(--sp-5);margin-bottom:var(--sp-4);box-shadow:var(--shadow-sm)}
|
||||
@@ -338,9 +345,11 @@ table.tbl tr:last-child td{border-bottom:none}
|
||||
table.tbl tr.ignored td{opacity:.45}
|
||||
table.tbl tr.invalid{background:var(--st-err-bg)}
|
||||
table.tbl .fname{font-family:var(--mono);font-size:var(--fs-xs);word-break:break-all}
|
||||
table.tbl .link-to{display:flex;align-items:baseline;gap:6px;margin-top:5px;
|
||||
font-family:var(--mono);font-size:var(--fs-xs);flex-wrap:wrap}
|
||||
table.tbl .link-to .arr{color:var(--text-faint);flex:none}
|
||||
/* Стрелка приклеена к строке источника — источник и цель идут строго друг под
|
||||
другом (цель не смещена ведущей стрелкой). */
|
||||
table.tbl .fname .arr{color:var(--text-faint)}
|
||||
table.tbl .link-to{margin-top:5px;font-family:var(--mono);font-size:var(--fs-xs);
|
||||
word-break:break-all}
|
||||
table.tbl .link-to .dir{color:var(--text-faint)}
|
||||
table.tbl .link-to .src{color:var(--st-ok)}
|
||||
table.tbl .link-to .no-link{color:var(--text-faint);font-style:italic;font-family:var(--font)}
|
||||
|
||||
+2
-33
@@ -40,36 +40,5 @@ function flashCopy(btn, ok) {
|
||||
setTimeout(() => { btn.textContent = old; btn.classList.remove('copied'); }, 1400);
|
||||
}
|
||||
|
||||
// --- Фильтр/поиск по списку загрузок (по уже отрисованным карточкам) ---
|
||||
|
||||
let activeFilter = 'all';
|
||||
|
||||
function setFilter(el) {
|
||||
document.querySelectorAll('#chips .chip').forEach((c) => c.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
activeFilter = el.dataset.f;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const searchEl = document.querySelector('#list-toolbar input[type=search]');
|
||||
const q = (searchEl ? searchEl.value : '').toLowerCase().trim();
|
||||
const showAllEl = document.getElementById('showAll');
|
||||
const showAll = showAllEl ? showAllEl.checked : false;
|
||||
let visible = 0;
|
||||
document.querySelectorAll('#list .card').forEach((card) => {
|
||||
const grp = card.dataset.group;
|
||||
const isDeleted = card.dataset.deleted === '1';
|
||||
let ok = true;
|
||||
if (isDeleted && !showAll) ok = false;
|
||||
if (ok && activeFilter !== 'all' && grp !== activeFilter) ok = false;
|
||||
if (ok && q && !(card.dataset.text || '').includes(q)) ok = false;
|
||||
card.style.display = ok ? '' : 'none';
|
||||
if (ok) visible += 1;
|
||||
});
|
||||
const empty = document.getElementById('empty');
|
||||
if (empty) empty.style.display = visible ? 'none' : '';
|
||||
}
|
||||
|
||||
// Применить начальный фильтр (скрыть удалённые) после загрузки.
|
||||
document.addEventListener('DOMContentLoaded', applyFilters);
|
||||
// Фильтр, поиск и пагинация списка — серверные (GET-параметры f/q/page/all),
|
||||
// работают без JS. Здесь клиентской фильтрации нет намеренно.
|
||||
|
||||
@@ -45,10 +45,10 @@
|
||||
<div class="meta-body">
|
||||
<p class="h">{{.RecTitle}}</p>
|
||||
{{if .OriginalTitle}}<p class="o">{{.OriginalTitle}}</p>{{end}}
|
||||
<dl class="kv" style="max-width:360px">
|
||||
<dl class="kv">
|
||||
<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>
|
||||
<dt>База</dt><dd>{{if .Provider}}{{if .MatchURL}}<a class="ext-link" href="{{.MatchURL}}" target="_blank" rel="noopener">{{.Provider}} · {{.ProviderID}} ↗</a>{{else}}{{.Provider}} · <span class="mono">{{.ProviderID}}</span>{{end}}{{else if .NoBase}}без базы{{else}}—{{end}}</dd>
|
||||
{{if .Confidence}}<dt>Уверенность</dt><dd class="mono">{{.Confidence}}</dd>{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
@@ -83,11 +83,26 @@
|
||||
|
||||
<!-- Контекст -->
|
||||
{{if .Context}}
|
||||
<div class="section" style="margin-bottom:0">
|
||||
<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}}
|
||||
|
||||
<!-- Информация о торренте -->
|
||||
<div class="section" style="margin-bottom:0">
|
||||
<div class="section-head"><h2>Информация о торренте</h2><span class="hint">сырой источник и infohash</span></div>
|
||||
<dl class="kv">
|
||||
<dt>Тип источника</dt><dd>{{.SourceType}}</dd>
|
||||
{{if .Infohash}}<dt>infohash</dt><dd class="mono">{{.Infohash}}</dd>{{end}}
|
||||
</dl>
|
||||
{{if .SourceFull}}
|
||||
<div class="src-block">
|
||||
<code class="src-full">{{.SourceFull}}</code>
|
||||
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.SourceFull}}')">⧉ копировать</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+22
-17
@@ -28,30 +28,31 @@
|
||||
<p class="add-hint">Можно вставить целиком сообщение трекер-бота — magnet и контекст разберутся автоматически.</p>
|
||||
</form>
|
||||
|
||||
{{if .Downloads}}
|
||||
<!-- Панель фильтров -->
|
||||
{{if or .Downloads .Searching}}
|
||||
<!-- Панель фильтров (серверные: фильтр/поиск/пагинация в query) -->
|
||||
<div class="toolbar" id="list-toolbar">
|
||||
<div class="search">
|
||||
<form class="search" method="get" action="/">
|
||||
<span class="ico">⌕</span>
|
||||
<input class="input" type="search" placeholder="Поиск по названию или infohash…" oninput="applyFilters()">
|
||||
</div>
|
||||
<input class="input" type="search" name="q" value="{{.Query}}"
|
||||
placeholder="Поиск по названию или infohash…">
|
||||
{{if ne .Filter "all"}}<input type="hidden" name="f" value="{{.Filter}}">{{end}}
|
||||
{{if .ShowAll}}<input type="hidden" name="all" value="1">{{end}}
|
||||
</form>
|
||||
<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>
|
||||
{{range .Chips}}<a class="chip{{if .Active}} active{{end}}" href="{{.URL}}">{{.Label}}</a>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-line">
|
||||
<label><input type="checkbox" id="showAll" onchange="applyFilters()"> Показать всё, включая удалённые</label>
|
||||
<a href="{{.ShowURL}}">{{if .ShowAll}}☑{{else}}☐{{end}} Показать всё, включая удалённые</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Downloads}}
|
||||
<!-- Список карточек -->
|
||||
<div id="list">
|
||||
{{range .Downloads}}
|
||||
<article class="card" data-group="{{.Group}}" data-text="{{.SearchText}}"{{if .Deleted}} data-deleted="1"{{end}}>
|
||||
<article class="card">
|
||||
<div class="card-top">
|
||||
<div>
|
||||
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
||||
@@ -88,14 +89,18 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="empty" style="display:none">
|
||||
<div class="big">🍿</div>
|
||||
Ничего не найдено. Поменяй фильтр или запрос.
|
||||
</div>
|
||||
{{if gt .Pages 1}}
|
||||
<nav class="pager">
|
||||
{{if .PrevURL}}<a class="btn btn-sm" href="{{.PrevURL}}">←</a>{{end}}
|
||||
{{range .PageLinks}}<a class="btn btn-sm{{if .Active}} btn-primary{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
|
||||
{{if .NextURL}}<a class="btn btn-sm" href="{{.NextURL}}">→</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="big">🍿</div>
|
||||
Пока пусто. Добавь первую загрузку выше.
|
||||
{{if .Searching}}Ничего не найдено. Поменяй фильтр или запрос.{{else}}Пока пусто. Добавь первую загрузку выше.{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
<tr class="{{if $f.Ignored}}ignored{{end}}">
|
||||
<td class="cell-num">{{add $i 1}}</td>
|
||||
<td>
|
||||
<div class="fname">{{$f.Src}}</div>
|
||||
<div class="fname">{{$f.Src}} <span class="arr">→</span></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>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<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>
|
||||
{{if .Provider}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>{{.Provider}}</b> {{if .MatchURL}}<a class="ext-link" href="{{.MatchURL}}" target="_blank" rel="noopener">{{.ProviderID}} ↗</a>{{else}}<span class="mono">{{.ProviderID}}</span>{{end}}</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}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user