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:
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;
|
||||
|
||||
Reference in new issue
Block a user