Files
beavergram/frontend/src/lib/components/disk/StoragePanel.svelte
T

879 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onDestroy, onMount, tick } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { mediaUrl } from "#lib/api/client.js";
import { downloadMedia } from "#lib/api/download.js";
import {
getStorageChats,
getStorageFiles,
getStorageSummary,
moveToTrash,
restoreFromTrash,
} from "#lib/api/endpoints.js";
import { forgetMedia, type ViewerItem } from "#lib/api/media.js";
import type {
StorageChat,
StorageFile,
StorageGroupName,
StorageSummary,
TrashOutcome,
} from "#lib/api/types.js";
import ScopeFiles from "#lib/components/disk/ScopeFiles.svelte";
import SelectionBar from "#lib/components/disk/SelectionBar.svelte";
import StorageChatRow from "#lib/components/disk/StorageChatRow.svelte";
import StorageRow from "#lib/components/disk/StorageRow.svelte";
import MediaViewer from "#lib/components/MediaViewer.svelte";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { formatBytes } from "#lib/format/media.js";
import {
filesLabel,
GROUP_LABELS,
messagesLabel,
} from "#lib/format/storage.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import {
type StorageListing,
storageUi,
} from "#lib/stores/storage.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
interface Props {
chatId?: number | null;
}
let { chatId = null }: Props = $props();
const PAGE = 30;
const UNDO_MS = 12_000;
const UNDO_GUARD_MS = 600;
const LISTINGS: { label: string; value: StorageListing }[] = [
{ label: "Файлы", value: "files" },
{ label: "Чаты", value: "chats" },
];
let summary = $state<StorageSummary | null>(null);
let files = $state<StorageFile[]>([]);
let heavyChats = $state<StorageChat[]>([]);
let scope = $state<ReturnType<typeof ScopeFiles>>();
let group = $state<StorageGroupName | null>(null);
let loading = $state(false);
let failed = $state(false);
let done = $state(false);
let busy = $state(false);
let undo = $state<{ files: StorageFile[]; outcome: TrashOutcome } | null>(
null
);
let problem = $state<string | null>(null);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const selected = new SvelteSet<number>();
let token = 0;
let scroller: HTMLElement | null = null;
let undoTimer: ReturnType<typeof setTimeout> | undefined;
let undoShownAt = 0;
const listing = $derived<StorageListing>(
chatId === null ? storageUi.listing : "files"
);
const shown = $derived(
listing === "chats" ? heavyChats.length : files.length
);
const heaviest = $derived(files[0]?.file_size ?? 0);
const heaviestChat = $derived(heavyChats[0]?.bytes ?? 0);
const emptiness = $derived.by(() => {
if (group !== null) {
return "В этой группе нет файлов.";
}
return chatId === null
? "На диске нет скачанных файлов."
: "В этом чате нет скачанных файлов.";
});
const chosen = $derived(files.filter((file) => selected.has(file.media_id)));
const chosenBytes = $derived(
chosen.reduce((sum, file) => sum + file.file_size, 0)
);
const chosenGone = $derived(
chosen.filter((file) => !file.refetchable).length
);
const groups = $derived(
summary?.groups.filter((item) => item.files > 0 || item.group === group) ??
[]
);
const viewerItems = $derived<ViewerItem[]>(
files.map((file) => ({
caption: `${formatBytes(file.file_size)} · ${chatTitle(file.chat_id)}`,
downloaded: true,
fileName: file.file_name,
fileSize: file.file_size,
kind: file.kind,
mediaId: file.media_id,
messageId: file.message_id,
mime: file.mime,
}))
);
function chatTitle(id: number): string {
return chats.byId(id)?.title ?? `Чат ${id}`;
}
async function loadSummary() {
try {
summary = await getStorageSummary(chatId);
} catch {
failed = true;
}
}
async function loadPage(current: number, limit: number): Promise<number> {
if (listing === "chats") {
const batch = await getStorageChats(group, {
limit,
offset: heavyChats.length,
});
if (current === token) {
chats.ensureMany(batch.map((chat) => chat.chat_id));
heavyChats = [...heavyChats, ...batch];
}
return batch.length;
}
const batch = await getStorageFiles(
group,
{ limit, offset: files.length },
chatId
);
if (current === token) {
chats.ensureMany(batch.map((file) => file.chat_id));
files = [...files, ...batch];
}
return batch.length;
}
async function loadMore(limit = PAGE) {
if (loading || done) {
return;
}
const current = token;
loading = true;
failed = false;
try {
const loaded = await loadPage(current, limit);
if (current === token) {
done = loaded < limit;
}
} catch {
if (current === token) {
failed = true;
}
} finally {
if (current === token) {
loading = false;
}
}
}
function reload(limit = PAGE) {
token += 1;
files = [];
heavyChats = [];
done = false;
loading = false;
selected.clear();
return Promise.all([loadSummary(), loadMore(limit)]);
}
async function resume() {
const from = chatId === null ? storageUi.takeResume() : null;
await reload(Math.ceil(Math.max(1, from?.count ?? 0) / PAGE) * PAGE);
if (from && scroller) {
await tick();
scroller.scrollTop = from.top;
}
}
function openChat(id: number) {
storageUi.openChat(id, {
count: heavyChats.length,
top: scroller?.scrollTop ?? 0,
});
}
function hold(node: HTMLElement) {
scroller = node.parentElement;
}
function toggle(mediaId: number) {
problem = null;
if (!selected.delete(mediaId)) {
selected.add(mediaId);
}
}
function hideUndo() {
clearTimeout(undoTimer);
undo = null;
}
function showUndo(outcome: TrashOutcome, trashed: StorageFile[]) {
hideUndo();
if (outcome.files > 0) {
undo = { files: trashed, outcome };
undoShownAt = Date.now();
undoTimer = setTimeout(hideUndo, UNDO_MS);
}
}
async function trash(mediaIds: number[]) {
if (busy || mediaIds.length === 0) {
return;
}
busy = true;
problem = null;
try {
const outcome = await moveToTrash(mediaIds);
const moved = new Set(
mediaIds.filter((id) => !outcome.blocked.includes(id))
);
const trashed = files.filter((file) => moved.has(file.media_id));
for (const file of trashed) {
forgetMedia(file.chat_id, file.message_id, file.media_id);
}
files = files.filter((file) => !moved.has(file.media_id));
selected.clear();
scope?.refresh();
showUndo(outcome, trashed);
if (outcome.blocked.length > 0) {
problem = "Файл занят в сторис или аватаре и остался на диске";
}
await loadSummary();
} catch {
problem = "Не удалось переместить в корзину";
} finally {
busy = false;
}
}
async function restoreLast() {
if (undo === null || Date.now() - undoShownAt < UNDO_GUARD_MS) {
return;
}
const restored = undo;
hideUndo();
try {
await restoreFromTrash(restored.outcome.keys);
files = [...files, ...restored.files].sort(
(a, b) => b.file_size - a.file_size
);
scope?.refresh();
await loadSummary();
} catch {
problem = "Не удалось вернуть файлы";
}
}
async function save(file: StorageFile) {
try {
await downloadMedia(file.media_id);
} catch {
problem = "Не удалось скачать файл";
}
}
function open(index: number) {
viewerIndex = index;
viewerOpen = true;
}
function jump(file: StorageFile) {
ui.requestJump(file.chat_id, file.message_id);
goto(`/app/${file.chat_id}`);
if (isMobile()) {
ui.closePanel();
}
}
function pick(next: StorageGroupName) {
group = group === next ? null : next;
problem = null;
reload();
}
function show(next: StorageListing) {
if (next === storageUi.listing) {
return;
}
storageUi.listing = next;
problem = null;
hideUndo();
reload();
}
function visit(chat: StorageChat) {
goto(`/app/${chat.chat_id}`);
if (isMobile()) {
ui.closePanel();
}
}
onMount(resume);
onDestroy(() => clearTimeout(undoTimer));
</script>
<div class="storage" {@attach hold}>
{#if summary}
{#if summary.files > 0 || chatId !== null}
<section class="summary">
<div class="hero">
<span class="total">{formatBytes(summary.bytes) || "0 B"}</span>
<span class="count">
{filesLabel(summary.files)}
{#if chatId !== null}
в этом чате
{:else if summary.trash_bytes > 0}
· ещё {formatBytes(summary.trash_bytes)} в корзине
{:else}
на диске
{/if}
</span>
</div>
<div class="stack" class:bare={groups.length === 0}>
{#each groups as item (item.group)}
<span
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
style:background-color="var(--color-storage-{item.group})"
style:flex-grow={item.bytes}
></span>
{/each}
{#if chatId === null && summary.trash_bytes > 0}
<span
class="trashed"
title="Корзина: {formatBytes(summary.trash_bytes)}"
style:flex-grow={summary.trash_bytes}
></span>
{/if}
</div>
<div class="legend">
{#each groups as item (item.group)}
<button
aria-pressed={group === item.group}
class="chip"
onclick={() => pick(item.group)}
type="button"
class:on={group === item.group}
>
<span
class="dot"
style:background-color="var(--color-storage-{item.group})"
></span>
{GROUP_LABELS[item.group]}
<span class="value">{formatBytes(item.bytes)}</span>
</button>
{/each}
</div>
</section>
{/if}
{#if chatId === null}
<button
class="trash-link"
onclick={() => ui.openPanel("storage-trash")}
type="button"
>
<Icon name="delete" size="1.375rem" />
<span class="label">Корзина</span>
<span class="value">
{#if summary.trash_files > 0}
<span class="swatch"></span>
{filesLabel(summary.trash_files)}
· {formatBytes(summary.trash_bytes)}
{:else}
пусто
{/if}
</span>
<Icon name="next" size="1.125rem" />
</button>
<div class="tabs">
{#each LISTINGS as item (item.value)}
<button
aria-pressed={listing === item.value}
class="tab"
onclick={() => show(item.value)}
type="button"
class:on={listing === item.value}
>
{item.label}
</button>
{/each}
</div>
{:else}
<ScopeFiles
label="Все файлы чата"
onchange={() => {
hideUndo();
reload();
}}
target={{ chatId }}
bind:this={scope}
/>
{/if}
{/if}
{#if listing === "files" && files.length > 0}
<div class="section-title">
<h3>Самые тяжёлые</h3>
{#if summary && group === null && summary.heavy_files > 0}
<span>
{filesLabel(summary.heavy_files)}
крупнее {formatBytes(summary.heavy_threshold)}
= {formatBytes(summary.heavy_bytes)}
</span>
{/if}
</div>
{/if}
<div class="list" class:chats={listing === "chats"}>
{#each heavyChats as chat (chat.chat_id)}
<StorageChatRow
{chat}
hasAvatar={chats.byId(chat.chat_id)?.has_avatar ?? false}
onopen={() => openChat(chat.chat_id)}
title={chatTitle(chat.chat_id)}
weight={heaviestChat > 0 ? chat.bytes / heaviestChat : 0}
>
{#snippet menu()}
<ContextMenuItem icon="data" onselect={() => openChat(chat.chat_id)}>
Файлы чата
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => visit(chat)}>
Перейти в чат
</ContextMenuItem>
<ContextMenuItem
icon="settings"
onselect={() =>
policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))}
>
Политика захвата
</ContextMenuItem>
{/snippet}
</StorageChatRow>
{/each}
{#each files as file, index (file.media_id)}
<StorageRow
fileName={file.file_name}
kind={file.kind}
mime={file.mime}
note={file.refs > 1 ? messagesLabel(file.refs) : undefined}
onopen={() => open(index)}
ontoggle={() => toggle(file.media_id)}
refetchable={file.refetchable}
selected={selected.has(file.media_id)}
size={file.file_size}
src={mediaUrl(file.media_id)}
sub={chatId === null
? chatTitle(file.chat_id)
: formatListDate(file.date)}
tail={chatId === null ? formatListDate(file.date) : undefined}
weight={heaviest > 0 ? file.file_size / heaviest : 0}
>
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(file)}>
Перейти к сообщению
</ContextMenuItem>
<ContextMenuItem icon="download" onselect={() => save(file)}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem
icon="delete"
onselect={() => trash([file.media_id])}
>
В корзину
</ContextMenuItem>
{/snippet}
</StorageRow>
{/each}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить хранилище.</p>
<Button onclick={() => reload()} smaller variant="text">
Повторить
</Button>
</div>
{:else if shown === 0}
<div class="state"><p>{emptiness}</p></div>
{:else if !done}
<button class="more" onclick={() => loadMore()} type="button">
Показать ещё
</button>
{/if}
</div>
{#if chosen.length > 0}
<SelectionBar
failure={problem}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}"
warn
>
<Button onclick={() => selected.clear()} smaller variant="text">
Снять
</Button>
<Button
disabled={busy}
loading={busy}
onclick={() => trash([...selected])}
pill
smaller
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
</Button>
</SelectionBar>
{:else if undo}
<SelectionBar
failure={problem}
note="{formatBytes(undo.outcome.bytes)} освободится через 7 дней"
title="{filesLabel(undo.outcome.files)} в корзине"
>
<Button onclick={restoreLast} smaller variant="text">
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
</Button>
</SelectionBar>
{:else if problem}
<SelectionBar failure={problem} title="Не получилось">
<Button
onclick={() => {
problem = null;
}}
smaller
variant="text"
>
Понятно
</Button>
</SelectionBar>
{/if}
</div>
<MediaViewer
chatId={files[viewerIndex]?.chat_id ?? 0}
items={viewerItems}
ontrash={(item) => {
if (item.mediaId !== null) {
trash([item.mediaId]);
}
}}
bind:index={viewerIndex}
bind:open={viewerOpen}
/>
<style lang="scss">
.storage {
display: flex;
flex-direction: column;
min-height: 100%;
}
.summary {
padding: 1rem 1rem 0.875rem;
}
.hero {
display: flex;
flex-wrap: wrap;
gap: 0 0.625rem;
align-items: baseline;
}
.total {
font-size: 1.875rem;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
line-height: 1.2;
letter-spacing: -0.01em;
}
.count {
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.stack {
display: flex;
gap: 2px;
height: 0.625rem;
margin-top: 0.875rem;
&.bare {
border-radius: 0.3125rem;
background-color: rgba(var(--color-text-secondary-rgb), 0.16);
}
span {
min-width: 0.1875rem;
border-radius: 2px;
&.trashed {
background-color: var(--color-storage-trash);
}
&:first-child {
border-start-start-radius: 0.3125rem;
border-end-start-radius: 0.3125rem;
}
&:last-child {
border-start-end-radius: 0.3125rem;
border-end-end-radius: 0.3125rem;
}
}
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.375rem 0.25rem;
margin-top: 0.75rem;
}
.chip {
cursor: pointer;
display: inline-flex;
gap: 0.375rem;
align-items: center;
height: 1.75rem;
padding: 0 0.5625rem 0 0.4375rem;
border: 0;
border-radius: 0.875rem;
font: inherit;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text);
background-color: var(--color-chat-hover);
transition:
background-color 0.15s ease-out,
box-shadow 0.15s ease-out;
&.on {
background-color: color-mix(
in srgb,
var(--color-primary) 12%,
transparent
);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
@media (pointer: coarse) {
height: 2.25rem;
padding: 0 0.75rem 0 0.625rem;
border-radius: 1.125rem;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
@media (hover: hover) {
&:not(.on):hover {
background-color: var(--color-background-secondary-accent);
}
}
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.value {
color: var(--color-text-secondary);
}
.trash-link {
cursor: pointer;
display: flex;
flex-shrink: 0;
gap: 1rem;
align-items: center;
width: 100%;
height: 3.25rem;
padding: 0 1rem 0 1.125rem;
border: 0;
border-top: 1px solid var(--color-borders);
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
text-align: start;
background: none;
:global(.icon) {
color: var(--color-text-secondary);
}
.label {
flex: 1;
}
.value {
display: flex;
gap: 0.375rem;
align-items: center;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
}
.swatch {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: var(--color-storage-trash);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.tabs {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid var(--color-borders);
}
.tab {
cursor: pointer;
flex: 1;
height: 2.75rem;
border: 0;
border-bottom: 2px solid transparent;
font: inherit;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
background: none;
transition: color 0.15s ease-out;
&.on {
border-bottom-color: var(--color-primary);
color: var(--color-primary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:not(.on):hover {
color: var(--color-text);
}
}
}
.section-title {
display: flex;
gap: 0.75rem;
align-items: baseline;
justify-content: space-between;
padding: 0.875rem 1rem 0.375rem;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
h3 {
margin: 0;
font-size: inherit;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
white-space: nowrap;
}
}
.list {
flex: 1;
padding-bottom: 0.5rem;
&.chats {
padding-top: 0.375rem;
}
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
.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;
}
}
</style>