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:
1 parent
5eacabd101
commit
7f124059be
9 files changed
+671
-232
No files matched your search
@@ -4,6 +4,7 @@
|
||||
import { mediaUrl } from "#lib/api/client.js";
|
||||
import { downloadMedia } from "#lib/api/download.js";
|
||||
import {
|
||||
getStorageChats,
|
||||
getStorageFiles,
|
||||
getStorageSummary,
|
||||
moveToTrash,
|
||||
@@ -11,12 +12,15 @@
|
||||
} 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";
|
||||
@@ -31,16 +35,33 @@
|
||||
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 scopeRevision = $state(0);
|
||||
let group = $state<StorageGroupName | null>(null);
|
||||
let loading = $state(false);
|
||||
let failed = $state(false);
|
||||
@@ -57,7 +78,22 @@
|
||||
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)
|
||||
@@ -82,18 +118,42 @@
|
||||
}))
|
||||
);
|
||||
|
||||
function chatTitle(chatId: number): string {
|
||||
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
|
||||
function chatTitle(id: number): string {
|
||||
return chats.byId(id)?.title ?? `Чат ${id}`;
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
try {
|
||||
summary = await getStorageSummary();
|
||||
summary = await getStorageSummary(chatId);
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPage(current: number): Promise<number> {
|
||||
if (listing === "chats") {
|
||||
const batch = await getStorageChats(group, {
|
||||
limit: PAGE,
|
||||
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: PAGE, offset: files.length },
|
||||
chatId
|
||||
);
|
||||
if (current === token) {
|
||||
chats.ensureMany(batch.map((file) => file.chat_id));
|
||||
files = [...files, ...batch];
|
||||
}
|
||||
return batch.length;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || done) {
|
||||
return;
|
||||
@@ -102,16 +162,10 @@
|
||||
loading = true;
|
||||
failed = false;
|
||||
try {
|
||||
const batch = await getStorageFiles(group, {
|
||||
limit: PAGE,
|
||||
offset: files.length,
|
||||
});
|
||||
if (current !== token) {
|
||||
return;
|
||||
const loaded = await loadPage(current);
|
||||
if (current === token) {
|
||||
done = loaded < PAGE;
|
||||
}
|
||||
chats.ensureMany(batch.map((file) => file.chat_id));
|
||||
files = [...files, ...batch];
|
||||
done = batch.length < PAGE;
|
||||
} catch {
|
||||
if (current === token) {
|
||||
failed = true;
|
||||
@@ -126,6 +180,7 @@
|
||||
function reload() {
|
||||
token += 1;
|
||||
files = [];
|
||||
heavyChats = [];
|
||||
done = false;
|
||||
loading = false;
|
||||
selected.clear();
|
||||
@@ -171,6 +226,7 @@
|
||||
}
|
||||
files = files.filter((file) => !moved.has(file.media_id));
|
||||
selected.clear();
|
||||
scopeRevision += 1;
|
||||
showUndo(outcome, trashed);
|
||||
if (outcome.blocked.length > 0) {
|
||||
problem = "Файл занят в сторис или аватаре и остался на диске";
|
||||
@@ -194,6 +250,7 @@
|
||||
files = [...files, ...restored.files].sort(
|
||||
(a, b) => b.file_size - a.file_size
|
||||
);
|
||||
scopeRevision += 1;
|
||||
await loadSummary();
|
||||
} catch {
|
||||
problem = "Не удалось вернуть файлы";
|
||||
@@ -227,6 +284,23 @@
|
||||
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(reload);
|
||||
onDestroy(() => clearTimeout(undoTimer));
|
||||
</script>
|
||||
@@ -239,9 +313,13 @@
|
||||
<span class="total">{formatBytes(summary.bytes)}</span>
|
||||
<span class="count">
|
||||
{filesLabel(summary.files)}
|
||||
{summary.trash_bytes > 0
|
||||
? `· ещё ${formatBytes(summary.trash_bytes)} в корзине`
|
||||
: "на диске"}
|
||||
{#if chatId !== null}
|
||||
в этом чате
|
||||
{:else if summary.trash_bytes > 0}
|
||||
· ещё {formatBytes(summary.trash_bytes)} в корзине
|
||||
{:else}
|
||||
на диске
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stack">
|
||||
@@ -252,7 +330,7 @@
|
||||
style:flex-grow={item.bytes}
|
||||
></span>
|
||||
{/each}
|
||||
{#if summary.trash_bytes > 0}
|
||||
{#if chatId === null && summary.trash_bytes > 0}
|
||||
<span
|
||||
class="trashed"
|
||||
title="Корзина: {formatBytes(summary.trash_bytes)}"
|
||||
@@ -281,27 +359,53 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
{#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}
|
||||
{#key scopeRevision}
|
||||
<ScopeFiles
|
||||
label="Все файлы чата"
|
||||
onchange={() => {
|
||||
hideUndo();
|
||||
reload();
|
||||
}}
|
||||
target={{ chatId }}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if files.length > 0}
|
||||
{#if listing === "files" && files.length > 0}
|
||||
<div class="section-title">
|
||||
<h3>Самые тяжёлые</h3>
|
||||
{#if summary && group === null && summary.heavy_files > 0}
|
||||
@@ -314,7 +418,36 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="list">
|
||||
<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={() => storageUi.openChat(chat.chat_id)}
|
||||
title={chatTitle(chat.chat_id)}
|
||||
weight={heaviestChat > 0 ? chat.bytes / heaviestChat : 0}
|
||||
>
|
||||
{#snippet menu()}
|
||||
<ContextMenuItem
|
||||
icon="data"
|
||||
onselect={() => storageUi.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}
|
||||
@@ -327,8 +460,10 @@
|
||||
selected={selected.has(file.media_id)}
|
||||
size={file.file_size}
|
||||
src={mediaUrl(file.media_id)}
|
||||
sub={chatTitle(file.chat_id)}
|
||||
tail={formatListDate(file.date)}
|
||||
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()}
|
||||
@@ -358,14 +493,8 @@
|
||||
<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 shown === 0}
|
||||
<div class="state"><p>{emptiness}</p></div>
|
||||
{:else if !done}
|
||||
<button class="more" onclick={loadMore} type="button">
|
||||
Показать ещё
|
||||
@@ -615,6 +744,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -639,6 +809,10 @@
|
||||
.list {
|
||||
flex: 1;
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
&.chats {
|
||||
padding-top: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.state {
|
||||
|
||||
Reference in new issue
Block a user