feat(frontend): capture policy shows what each rule changes, rule card opens from folder and chat menus and can trash downloaded files, trash is paged

This commit is contained in:
hh committed 2026-10-14 04:20:38 +02:00
1 parent f646f29ff9
commit cb01d7dff8
16 files changed
+1536 -369

No files matched your search

@@ -4,6 +4,7 @@
import { SvelteSet } from "svelte/reactivity";
import { trashedUrl } from "#lib/api/client.js";
import {
getStorageSummary,
getTrash,
purgeTrash,
restoreFromTrash,
@@ -29,7 +30,12 @@
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
const PAGE = 30;
let items = $state<TrashItem[]>([]);
let total = $state({ bytes: 0, files: 0 });
let done = $state(false);
let everything = $state(false);
let loading = $state(true);
let failed = $state(false);
let busy = $state(false);
@@ -42,7 +48,7 @@
const heaviest = $derived(
Math.max(0, ...items.map((item) => item.file_size ?? 0))
);
const totalBytes = $derived(bytesOf(items));
const dialogOpen = $derived(doomed !== null || everything);
const chosen = $derived(
items.filter((item) => selected.has(item.storage_key))
);
@@ -74,12 +80,19 @@
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
}
async function load() {
async function loadTotal() {
const summary = await getStorageSummary();
total = { bytes: summary.trash_bytes, files: summary.trash_files };
}
async function loadMore() {
loading = true;
failed = false;
try {
items = await getTrash();
chats.ensureMany(items.map((item) => item.chat_id));
const batch = await getTrash({ limit: PAGE, offset: items.length });
chats.ensureMany(batch.map((item) => item.chat_id));
items = [...items, ...batch];
done = batch.length < PAGE;
} catch {
failed = true;
} finally {
@@ -87,6 +100,13 @@
}
}
async function load() {
items = [];
done = false;
selected.clear();
await Promise.all([loadMore(), loadTotal().catch(() => undefined)]);
}
function toggle(key: string) {
problem = null;
if (!selected.delete(key)) {
@@ -98,6 +118,7 @@
const gone = new Set(keys);
items = items.filter((item) => !gone.has(item.storage_key));
selected.clear();
loadTotal().catch(() => undefined);
}
async function restore(list: TrashItem[]) {
@@ -117,17 +138,34 @@
}
}
async function purge() {
if (busy || doomed === null) {
async function restoreAll() {
if (busy) {
return;
}
busy = true;
problem = null;
const keys = doomed.map((item) => item.storage_key);
try {
await restoreFromTrash(null);
await load();
} catch {
problem = "Не удалось вернуть файлы";
} finally {
busy = false;
}
}
async function purge() {
if (busy || (doomed === null && !everything)) {
return;
}
busy = true;
problem = null;
const keys = doomed?.map((item) => item.storage_key) ?? null;
try {
await purgeTrash(keys);
drop(keys);
doomed = null;
everything = false;
await load();
} catch {
problem = "Не удалось удалить файлы";
} finally {
@@ -157,17 +195,22 @@
вернуть.
</p>
{#if items.length > 0}
{#if total.files > 0}
<button class="act" disabled={busy} onclick={restoreAll} type="button">
<Icon name="undo" size="1.375rem" />
<span class="label">Вернуть всё</span>
<span class="value">{filesLabel(total.files)}</span>
</button>
<button
class="clear"
class="act danger"
onclick={() => {
doomed = items;
everything = true;
}}
type="button"
>
<Icon name="delete" size="1.375rem" />
<span class="label">Очистить корзину</span>
<span class="value">{formatBytes(totalBytes)}</span>
<span class="value">{formatBytes(total.bytes)}</span>
</button>
{/if}
@@ -219,12 +262,16 @@
</div>
{:else if items.length === 0}
<div class="state"><p>Корзина пуста.</p></div>
{:else if !done}
<button class="more" onclick={loadMore} type="button">
Показать ещё
</button>
{/if}
</div>
{#if chosen.length > 0}
<SelectionBar
failure={doomed === null ? problem : null}
failure={dialogOpen ? null : problem}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
warn
@@ -250,7 +297,7 @@
<span>Вернуть</span>
</Button>
</SelectionBar>
{:else if problem && doomed === null}
{:else if problem && !dialogOpen}
<SelectionBar failure={problem} title="Не получилось">
<Button
onclick={() => {
@@ -269,26 +316,40 @@
onOpenChange={(next) => {
if (!next) {
doomed = null;
everything = false;
problem = null;
}
}}
open={doomed !== null}
open={doomed !== null || everything}
>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">
{doomed?.length === items.length
? "Очистить корзину?"
: "Удалить насовсем?"}
{everything ? "Очистить корзину?" : "Удалить насовсем?"}
</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
{#if doomed}
<div class="dialog-body">
<div class="dialog-body">
{#if everything}
<p class="confirm">
{filesLabel(total.files)}, {formatBytes(total.bytes)},
{plural(
total.files,
"будет удалён",
"будут удалены",
"будут удалены"
)}
с диска.
</p>
<p class="confirm rest">
То, что ещё есть в Telegram, потом можно скачать заново. Файлы из
удалённых там сообщений пропадут насовсем.
</p>
{:else if doomed}
<p class="confirm">
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))},
{plural(
@@ -316,11 +377,11 @@
можно скачать из Telegram заново.
</p>
{/if}
{#if problem}
<p class="confirm failed">{problem}.</p>
{/if}
</div>
{/if}
{/if}
{#if problem}
<p class="confirm failed">{problem}.</p>
{/if}
</div>
<div class="dialog-actions">
<Dialog.Close>
{#snippet child({
@@ -366,7 +427,7 @@
color: var(--color-text-secondary);
}
.clear {
.act {
cursor: pointer;
display: flex;
@@ -382,11 +443,20 @@
font: inherit;
font-size: 0.9375rem;
color: var(--color-error);
color: var(--color-primary);
text-align: start;
background: none;
&.danger {
color: var(--color-error);
}
&:disabled {
cursor: default;
opacity: 0.5;
}
.label {
flex: 1;
}
@@ -398,17 +468,38 @@
}
&:focus-visible {
outline: 2px solid var(--color-error);
outline: 2px solid currentcolor;
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
&:not(:disabled):hover {
background-color: var(--color-chat-hover);
}
}
}
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0.5rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.list {
flex: 1;
padding: 0.25rem 0 0.5rem;