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

890 lines
22 KiB
Svelte

<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,
groupLabel,
messagesLabel,
} from "#lib/format/storage.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.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: MessageKey; value: StorageListing }[] = [
{ label: "storage.listing.files", value: "files" },
{ label: "storage.listing.chats", 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<MessageKey | 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 t("storage.empty.group");
}
return chatId === null ? t("storage.empty.all") : t("storage.empty.chat");
});
const failure = $derived(problem && t(problem));
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 ?? t("ui.chat", { 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 = "storage.blocked";
}
await loadSummary();
} catch {
problem = "storage.trashFailed";
} 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 = "storage.restoreFailed";
}
}
async function save(file: StorageFile) {
try {
await downloadMedia(file.media_id);
} catch {
problem = "ui.downloadFailed";
}
}
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) || t("storage.zero")}
</span>
<span class="count">
{#if chatId !== null}
{t("storage.hero.chat", { files: filesLabel(summary.files) })}
{:else if summary.trash_bytes > 0}
{t("storage.hero.trash", {
files: filesLabel(summary.files),
size: formatBytes(summary.trash_bytes),
})}
{:else}
{t("storage.hero.disk", { files: filesLabel(summary.files) })}
{/if}
</span>
</div>
<div class="stack" class:bare={groups.length === 0}>
{#each groups as item (item.group)}
<span
title="{groupLabel(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={t("storage.trash.title", {
size: 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>
{groupLabel(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">{t("storage.trash")}</span>
<span class="value">
{#if summary.trash_files > 0}
<span class="swatch"></span>
{filesLabel(summary.trash_files)}
· {formatBytes(summary.trash_bytes)}
{:else}
{t("storage.trash.nothing")}
{/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}
>
{t(item.label)}
</button>
{/each}
</div>
{:else}
<ScopeFiles
label={t("storage.chatFiles")}
onchange={() => {
hideUndo();
reload();
}}
target={{ chatId }}
bind:this={scope}
/>
{/if}
{/if}
{#if listing === "files" && files.length > 0}
<div class="section-title">
<h3>{t("storage.heaviest")}</h3>
{#if summary && group === null && summary.heavy_files > 0}
<span>
{t("storage.heavy", {
files: filesLabel(summary.heavy_files),
size: formatBytes(summary.heavy_bytes),
threshold: formatBytes(summary.heavy_threshold),
})}
</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)}>
{t("storage.menu.chatFiles")}
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => visit(chat)}>
{t("storage.menu.openChat")}
</ContextMenuItem>
<ContextMenuItem
icon="settings"
onselect={() =>
policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))}
>
{t("storage.menu.policy")}
</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)}>
{t("ui.open")}
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(file)}>
{t("ui.jumpToMessage")}
</ContextMenuItem>
<ContextMenuItem icon="download" onselect={() => save(file)}>
{t("ui.downloadFile")}
</ContextMenuItem>
<ContextMenuItem
icon="delete"
onselect={() => trash([file.media_id])}
>
{t("storage.toTrash")}
</ContextMenuItem>
{/snippet}
</StorageRow>
{/each}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>{t("storage.loadFailed")}</p>
<Button onclick={() => reload()} smaller variant="text">
{t("ui.retry")}
</Button>
</div>
{:else if shown === 0}
<div class="state"><p>{emptiness}</p></div>
{:else if !done}
<button class="more" onclick={() => loadMore()} type="button">
{t("ui.showMore")}
</button>
{/if}
</div>
{#if chosen.length > 0}
<SelectionBar
{failure}
note={chosenGone > 0
? t("storage.cantRefetch", { count: chosenGone })
: undefined}
title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}"
warn
>
<Button onclick={() => selected.clear()} smaller variant="text">
{t("storage.deselect")}
</Button>
<Button
disabled={busy}
loading={busy}
onclick={() => trash([...selected])}
pill
smaller
>
<Icon name="delete" size="1.25rem" />
<span>{t("storage.toTrash")}</span>
</Button>
</SelectionBar>
{:else if undo}
<SelectionBar
{failure}
note={t("storage.freed", { size: formatBytes(undo.outcome.bytes) })}
title={t("storage.inTrash", { files: filesLabel(undo.outcome.files) })}
>
<Button onclick={restoreLast} smaller variant="text">
<Icon name="undo" size="1.25rem" />
<span>{t("storage.restore")}</span>
</Button>
</SelectionBar>
{:else if failure}
<SelectionBar {failure} title={t("storage.failed")}>
<Button
onclick={() => {
problem = null;
}}
smaller
variant="text"
>
{t("ui.gotIt")}
</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>