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

623 lines
15 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 } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { mediaUrl } from "#lib/api/client.js";
import { downloadMedia } from "#lib/api/download.js";
import {
getStorageFiles,
getStorageSummary,
moveToTrash,
restoreFromTrash,
} from "#lib/api/endpoints.js";
import { forgetMedia, type ViewerItem } from "#lib/api/media.js";
import type {
StorageFile,
StorageGroupName,
StorageSummary,
TrashOutcome,
} from "#lib/api/types.js";
import SelectionBar from "#lib/components/disk/SelectionBar.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 { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
const PAGE = 30;
const UNDO_MS = 12_000;
let summary = $state<StorageSummary | null>(null);
let files = $state<StorageFile[]>([]);
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<TrashOutcome | null>(null);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const selected = new SvelteSet<number>();
let token = 0;
let undoTimer: ReturnType<typeof setTimeout> | undefined;
const heaviest = $derived(files[0]?.file_size ?? 0);
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 segments = $derived(
summary?.groups.filter((item) => item.bytes > 0) ?? []
);
const viewerItems = $derived<ViewerItem[]>(
files.map((file) => ({
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(chatId: number): string {
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
}
function tail(file: StorageFile): string {
return file.refs > 1 ? messagesLabel(file.refs) : formatListDate(file.date);
}
async function loadSummary() {
try {
summary = await getStorageSummary();
} catch {
failed = true;
}
}
async function loadMore() {
if (loading || done) {
return;
}
const current = token;
loading = true;
failed = false;
try {
const batch = await getStorageFiles(group, {
limit: PAGE,
offset: files.length,
});
if (current !== token) {
return;
}
chats.ensureMany(batch.map((file) => file.chat_id));
files = [...files, ...batch];
done = batch.length < PAGE;
} catch {
if (current === token) {
failed = true;
}
} finally {
if (current === token) {
loading = false;
}
}
}
function reload() {
token += 1;
files = [];
done = false;
loading = false;
selected.clear();
loadSummary();
loadMore();
}
function toggle(mediaId: number) {
hideUndo();
if (!selected.delete(mediaId)) {
selected.add(mediaId);
}
}
function hideUndo() {
clearTimeout(undoTimer);
undo = null;
}
function showUndo(outcome: TrashOutcome) {
hideUndo();
if (outcome.files > 0) {
undo = outcome;
undoTimer = setTimeout(hideUndo, UNDO_MS);
}
}
async function trash(mediaIds: number[]) {
if (busy || mediaIds.length === 0) {
return;
}
busy = true;
try {
const outcome = await moveToTrash(mediaIds);
const moved = new Set(
mediaIds.filter((id) => !outcome.blocked.includes(id))
);
for (const file of files) {
if (moved.has(file.media_id)) {
forgetMedia(file.chat_id, file.message_id, file.media_id);
}
}
files = files.filter((file) => !moved.has(file.media_id));
selected.clear();
showUndo(outcome);
if (outcome.blocked.length > 0) {
toasts.error("Файл занят в сторис или аватаре и остался на диске");
}
await loadSummary();
} catch {
toasts.error("Не удалось переместить в корзину");
} finally {
busy = false;
}
}
async function restoreLast() {
if (undo === null) {
return;
}
const { keys } = undo;
hideUndo();
try {
await restoreFromTrash(keys);
reload();
} catch {
toasts.error("Не удалось вернуть файлы");
}
}
async function save(file: StorageFile) {
try {
await downloadMedia(file.media_id);
} catch {
toasts.error("Не удалось скачать файл");
}
}
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;
reload();
}
onMount(reload);
onDestroy(() => clearTimeout(undoTimer));
</script>
<div class="storage">
{#if summary}
<section class="summary">
<div class="hero">
<span class="total">{formatBytes(summary.bytes) || "0 B"}</span>
<span class="count">{filesLabel(summary.files)} на диске</span>
</div>
{#if segments.length > 0}
<div class="stack">
{#each segments 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}
</div>
{/if}
<div class="legend">
{#each summary.groups as item (item.group)}
<button
aria-pressed={group === item.group}
class="chip"
disabled={item.files === 0}
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) || "0"}</span>
</button>
{/each}
</div>
</section>
<button
class="trash-link"
onclick={() => ui.openPanel("storage-trash")}
type="button"
>
<Icon name="delete" size="1.375rem" />
<span class="label">Корзина</span>
<span class="value">
{summary.trash_files > 0
? `${filesLabel(summary.trash_files)} · ${formatBytes(summary.trash_bytes)}`
: "пусто"}
</span>
<Icon name="next" size="1.125rem" />
</button>
{/if}
<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>
<div class="list">
{#each files as file, index (file.media_id)}
<StorageRow
fileName={file.file_name}
kind={file.kind}
mime={file.mime}
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={chatTitle(file.chat_id)}
tail={tail(file)}
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 files.length === 0}
<div class="state">
<p>
{group === null
? "На диске нет скачанных файлов."
: "В этой группе нет файлов."}
</p>
</div>
{:else if !done}
<button class="more" onclick={loadMore} type="button">
Показать ещё
</button>
{/if}
</div>
{#if chosen.length > 0}
<SelectionBar
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
note="{formatBytes(undo.bytes)} освободится через 7 дней"
title="{filesLabel(undo.files)} в корзине"
>
<Button onclick={restoreLast} smaller variant="text">
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
</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;
span {
min-width: 0.1875rem;
border-radius: 2px;
&: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: var(--color-primary-tint);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
&:disabled {
cursor: default;
opacity: 0.5;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
@media (hover: hover) {
&:not(:disabled, .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 {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.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;
}
.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>