web-ui: добавлена страница группового удаления загрузок

- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
av
2026-08-10 17:43:36 +03:00
parent a7c1efd8eb
commit 288be8ec34
35 changed files with 3185 additions and 31 deletions
+21 -1
View File
@@ -473,7 +473,27 @@ table.tbl .size{font-family:var(--mono);color:var(--text-dim);white-space:nowrap
.tl-item time{color:var(--text-faint);font-size:var(--fs-xs);font-variant-numeric:tabular-nums}
.tl-item .tl-note{color:var(--text-dim);margin-top:2px}
/* ---------- 18. Адаптив ---------- */
/* ---------- 18. Страница группового удаления ---------- */
.del-list{display:flex;flex-direction:column;gap:var(--sp-2);margin-bottom:var(--sp-4)}
.del-row{display:flex;align-items:flex-start;gap:var(--sp-3);padding:var(--sp-3);
border:1px solid var(--border);border-radius:var(--r-md);background:var(--surface);cursor:pointer}
.del-row:hover{border-color:var(--accent-border)}
.del-row input[type=checkbox]{margin-top:3px;width:16px;height:16px;flex:none;accent-color:var(--accent)}
.del-row .body{min-width:0;flex:1}
.del-row .name{font-weight:600;overflow-wrap:anywhere}
.del-row .meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;
color:var(--text-dim);font-size:var(--fs-sm)}
.del-row .meta .id{font-family:var(--mono);font-size:var(--fs-xs);color:var(--text-faint)}
.last-copy{color:var(--st-err);font-size:var(--fs-sm);font-weight:600}
.del-limit{color:var(--text-dim);font-size:var(--fs-sm);margin-top:var(--sp-2)}
.del-bar{display:flex;align-items:center;gap:var(--sp-3);flex-wrap:wrap}
.del-warn{background:var(--st-err-bg);border:1px solid color-mix(in srgb,var(--st-err) 35%,transparent);
border-radius:var(--r-md);padding:var(--sp-4);margin-bottom:var(--sp-4)}
.del-warn h2{margin:0 0 var(--sp-2);font-size:var(--fs-md);color:var(--st-err)}
.del-group{margin-bottom:var(--sp-5)}
.del-group h2{font-size:var(--fs-md);margin:0 0 var(--sp-3)}
/* ---------- 19. Адаптив ---------- */
@media (max-width:640px){
.main{padding:var(--sp-4) var(--sp-3) var(--sp-6)}
.page-title{font-size:var(--fs-lg)}
+63
View File
@@ -0,0 +1,63 @@
<!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"}}">
</head>
<body>
{{template "header" "delete"}}
<main class="main">
<h1 class="page-title">Удаление загрузок</h1>
<p class="page-sub">
Отметь раздачи и нажми «Удалить выбранные» — на следующем экране они будут
названы поимённо, и только оттуда уходит удаление. Оно снимает библиотечные
хардлинки <strong>и</strong> сносит раздачу с файлами из qBittorrent.
Действие необратимо.
</p>
{{if .Error}}
<div class="del-warn"><h2>{{.Error}}</h2></div>
{{end}}
{{if .Rows}}
<!-- Обычная POST-форма: страница работает без JavaScript, htmx здесь не нужен.
Самообновления страница не несёт сознательно — своп разметки стёр бы
отметки, и человек подтвердил бы удаление по выбору, которого не видит. -->
<form method="post" action="/ui/delete/confirm">
<div class="del-list">
{{range .Rows}}
<label class="del-row">
<input type="checkbox" name="id" value="{{.ID}}"{{if .Selected}} checked{{end}}>
<span class="body">
<span class="name">{{.Title}}</span>
<span class="meta">
{{template "status_badge" .State}}
<span class="id">{{.ID}}</span>
{{if .LastCopy}}<span class="last-copy">⚠ последняя копия данных</span>{{end}}
</span>
</span>
</label>
{{end}}
</div>
<div class="del-bar">
<button class="btn btn-danger btn-lg" type="submit">Удалить выбранные</button>
<span class="del-limit">За один раз — не больше {{.Max}} загрузок.</span>
</div>
</form>
{{else}}
<div class="empty-state">
<div class="big">🧹</div>
Удалять нечего: ни одной загрузки в состоянии, где удаление с файлами
разрешено.
</div>
{{end}}
</main>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
<!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"}}">
</head>
<body>
{{template "header" "delete"}}
<main class="main">
<h1 class="page-title">Подтверждение удаления</h1>
<div class="del-warn">
<h2>Будут снесены раздачи с файлами и сняты библиотечные хардлинки</h2>
<p class="muted" style="margin:0">
Действие необратимо: восстановить снесённые файлы нечем. Проверь список
ниже — удалено будет ровно то, что в нём названо.
</p>
</div>
<div class="del-list">
{{range .Rows}}
<div class="del-row" style="cursor:default">
<span class="body">
<span class="name">{{.Title}}</span>
<span class="meta">
{{if .Missing}}<span class="last-copy">записи нет — удалять нечего</span>
{{else if .Unread}}<span class="last-copy">⚠ состояние прочитать не удалось — что именно будет снесено, неизвестно</span>
{{else}}{{template "status_badge" .State}}{{end}}
<span class="id">{{.ID}}</span>
{{if .LastCopy}}<span class="last-copy">⚠ источник пропал: библиотечная ссылка — последняя копия данных</span>{{end}}
</span>
</span>
</div>
{{end}}
</div>
<!-- Признак подтверждения едет полем формы и проверяется сервером до единого
вызова удаления. Идентификаторы отправляются заново: исполняющий запрос —
самостоятельная входная граница, состояний между шагами сервис не хранит. -->
<form method="post" action="/ui/delete" class="del-bar">
{{range .Rows}}<input type="hidden" name="id" value="{{.ID}}">{{end}}
<input type="hidden" name="confirm" value="1">
<button class="btn btn-danger btn-lg" type="submit">Да, удалить</button>
<a class="btn" href="/delete">Отмена</a>
</form>
</main>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
<!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"}}">
</head>
<body>
{{template "header" "delete"}}
<main class="main">
<h1 class="page-title">Результат удаления</h1>
{{if .StopReason}}
<div class="del-warn"><h2>{{.StopReason}}</h2></div>
{{end}}
{{if .Deleted}}
<section class="del-group">
<h2>Удалено — {{len .Deleted}}</h2>
<div class="del-list">
{{range .Deleted}}
<div class="del-row" style="cursor:default">
<span class="body">
<span class="name">{{.Title}}</span>
<span class="meta"><span class="id">{{.ID}}</span></span>
</span>
</div>
{{end}}
</div>
</section>
{{end}}
{{if .Failed}}
<section class="del-group">
<h2>Отказ — {{len .Failed}}</h2>
<div class="del-list">
{{range .Failed}}
<div class="del-row" style="cursor:default">
<span class="body">
<span class="name">{{.Title}}</span>
<span class="meta">
<span class="last-copy">{{.Reason}}</span>
<span class="id">{{.ID}}</span>
</span>
</span>
</div>
{{end}}
</div>
</section>
{{end}}
{{if .Skipped}}
<section class="del-group">
<h2>Не выполнено — {{len .Skipped}}</h2>
<div class="del-list">
{{range .Skipped}}
<div class="del-row" style="cursor:default">
<span class="body">
<span class="name">{{.Title}}</span>
<span class="meta">
<span class="muted">проход остановлен до этой загрузки</span>
<span class="id">{{.ID}}</span>
</span>
</span>
</div>
{{end}}
</div>
</section>
{{end}}
{{if not (or .Deleted .Failed .Skipped)}}
<div class="empty-state"><div class="big">🤷</div>Удалять было нечего.</div>
{{end}}
<p><a class="btn" href="/delete">К списку удаления</a></p>
</main>
</body>
</html>
+1
View File
@@ -4,6 +4,7 @@
<a class="brand" href="/"><span class="logo">jb</span>jellybit</a>
<nav class="nav">
<a href="/"{{if eq . "downloads"}} class="active"{{end}}>Загрузки</a>
<a href="/delete"{{if eq . "delete"}} class="active"{{end}}>Удаление</a>
</nav>
<span class="header-spacer"></span>
</div>