feat(frontend): storage shows the heaviest chats, a chat opens its own files with selective cleanup and a whole-chat move to trash

This commit is contained in:
hh committed 2026-10-14 05:02:06 +02:00
1 parent 5eacabd101
commit 7f124059be
9 files changed
+671 -232

No files matched your search

@@ -0,0 +1,226 @@
<script lang="ts">
import { onMount } from "svelte";
import {
getStorageScope,
restoreFromTrash,
trashScope,
} from "#lib/api/endpoints.js";
import type {
ScopeTarget,
StorageScope,
TrashOutcome,
} from "#lib/api/types.js";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js";
import { plural } from "#lib/format/shares.js";
import { filesLabel } from "#lib/format/storage.js";
interface Props {
label?: string;
onchange?: () => void;
target: ScopeTarget;
}
let { target, label, onchange }: Props = $props();
let files = $state<StorageScope | null>(null);
let confirming = $state(false);
let moving = $state(false);
let moved = $state<TrashOutcome | null>(null);
let problem = $state<string | null>(null);
function where(scope: StorageScope): string {
return "folderId" in target
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки`
: "в этом чате";
}
async function load() {
try {
files = await getStorageScope(target);
} catch {
files = null;
}
}
async function move() {
if (moving) {
return;
}
moving = true;
problem = null;
try {
moved = await trashScope(target);
confirming = false;
await load();
onchange?.();
} catch {
problem = "Не удалось переместить файлы в корзину";
} finally {
moving = false;
}
}
async function undo() {
if (moved === null || moving) {
return;
}
moving = true;
problem = null;
try {
await restoreFromTrash(moved.keys);
moved = null;
await load();
onchange?.();
} catch {
problem = "Не удалось вернуть файлы";
} finally {
moving = false;
}
}
onMount(load);
</script>
{#if moved && moved.files > 0}
<div class="files">
<div class="text">
<span>{filesLabel(moved.files)} в корзине</span>
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
{#if problem}
<small class="warn">{problem}</small>
{/if}
</div>
<Button disabled={moving} onclick={undo} smaller variant="text">
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
</Button>
</div>
{:else if files && files.files > 0}
<div class="files">
{#if confirming}
<div class="text">
<span>
{filesLabel(files.files)}, {formatBytes(files.bytes)}, уйдут в корзину
и перестанут скачиваться сами.
</span>
{#if files.gone > 0}
<small class="warn">
{files.gone}
из них потом не скачать заново
</small>
{/if}
{#if problem}
<small class="warn">{problem}</small>
{/if}
<div class="actions">
<Button
onclick={() => {
confirming = false;
}}
smaller
variant="text"
>
Отмена
</Button>
<Button
disabled={moving}
loading={moving}
onclick={move}
pill
smaller
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
</Button>
</div>
</div>
{:else}
<div class="text">
{#if label}
<span>{label}</span>
{#if files.kept > 0}
<small>
{filesLabel(files.files)}
· {formatBytes(files.bytes)}
</small>
{/if}
{:else}
<span>{formatBytes(files.bytes)} уже скачано</span>
<small>{filesLabel(files.files)} {where(files)}</small>
{/if}
{#if files.kept > 0}
<small>
Ещё {filesLabel(files.kept)} есть и в других чатах,
{plural(
files.kept,
"он останется",
"они останутся",
"они останутся"
)}
</small>
{/if}
</div>
<Button
onclick={() => {
confirming = true;
}}
pill
smaller
variant="gray"
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
</Button>
{/if}
</div>
{/if}
<style lang="scss">
.files {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin: 0.5rem 1rem 0.25rem;
padding: 0.625rem 0.5rem 0.625rem 0.875rem;
border-radius: 0.75rem;
background-color: var(--color-background-secondary);
:global(.Button) {
gap: 0.375rem;
padding-inline: 0.875rem;
}
}
.text {
display: flex;
flex: 1 1 10rem;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
small {
font-size: 0.8125rem;
color: var(--color-text-secondary);
&.warn {
color: var(--color-storage-warning);
}
}
}
.actions {
display: flex;
gap: 0.25rem;
justify-content: flex-end;
margin-top: 0.375rem;
}
</style>