diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index d57336a..a754581 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -34,6 +34,7 @@ import type { ResponseStats, ScopeTarget, SearchHit, + StorageChat, StorageFile, StorageGroupName, StorageScope, @@ -608,15 +609,31 @@ export function removeWatchedBot(botId: number): Promise { return request(`/bots/${botId}`, { method: "DELETE" }); } -export function getStorageSummary(): Promise { - return request("/storage/summary", { account: true }); +export function getStorageSummary( + chatId: number | null = null +): Promise { + return request("/storage/summary", { + account: true, + query: { chat_id: chatId }, + }); } export function getStorageFiles( group: StorageGroupName | null, - page: Page = {} + page: Page = {}, + chatId: number | null = null ): Promise { return request("/storage/files", { + account: true, + query: { chat_id: chatId, group, ...page }, + }); +} + +export function getStorageChats( + group: StorageGroupName | null, + page: Page = {} +): Promise { + return request("/storage/chats", { account: true, query: { group, ...page }, }); diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 7c42060..5048b53 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -598,6 +598,13 @@ export interface StorageSummary { trash_files: number; } +export interface StorageChat { + bytes: number; + chat_id: number; + files: number; + groups: StorageGroup[]; +} + export interface StorageFile { chat_id: number; date: string | null; diff --git a/frontend/src/lib/components/RightColumn.svelte b/frontend/src/lib/components/RightColumn.svelte index c0cf5f8..32aa54c 100644 --- a/frontend/src/lib/components/RightColumn.svelte +++ b/frontend/src/lib/components/RightColumn.svelte @@ -19,7 +19,9 @@ import Icon from "#lib/components/ui/Icon.svelte"; import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte"; import { accounts } from "#lib/stores/accounts.svelte.js"; + import { chats } from "#lib/stores/chats.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js"; + import { storageUi } from "#lib/stores/storage.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; const titles: Record = { @@ -36,6 +38,7 @@ search: "Поиск", shares: "Файлы по ссылке", storage: "Хранилище", + "storage-chat": "Файлы чата", "storage-trash": "Корзина", stories: "Сторис", "stories-all": "Все сторис", @@ -44,10 +47,21 @@ }; const parents: Partial> = { + "storage-chat": "storage", "storage-trash": "storage", }; const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined); + const title = $derived.by(() => { + if (ui.rightPanel === null) { + return ""; + } + const chat = + ui.rightPanel === "storage-chat" && storageUi.chatId !== null + ? chats.byId(storageUi.chatId) + : undefined; + return chat?.title ?? titles[ui.rightPanel]; + });
@@ -72,7 +86,7 @@ {/if} -

{ui.rightPanel ? titles[ui.rightPanel] : ""}

+

{title}

@@ -112,6 +126,10 @@ {#key accounts.selectedId} {/key} + {:else if ui.rightPanel === "storage-chat"} + {#key `${accounts.selectedId}:${storageUi.chatId}`} + + {/key} {:else if ui.rightPanel === "storage-trash"} {#key accounts.selectedId} @@ -131,9 +149,15 @@ border-bottom: 1px solid var(--color-borders); h2 { + overflow: hidden; + + min-width: 0; margin: 0; + font-size: 1.25rem; font-weight: var(--font-weight-medium); + text-overflow: ellipsis; + white-space: nowrap; } } diff --git a/frontend/src/lib/components/disk/ScopeFiles.svelte b/frontend/src/lib/components/disk/ScopeFiles.svelte new file mode 100644 index 0000000..749fff9 --- /dev/null +++ b/frontend/src/lib/components/disk/ScopeFiles.svelte @@ -0,0 +1,226 @@ + + +{#if moved && moved.files > 0} +
+
+ {filesLabel(moved.files)} в корзине + {formatBytes(moved.bytes)} освободится через 7 дней + {#if problem} + {problem} + {/if} +
+ +
+{:else if files && files.files > 0} +
+ {#if confirming} +
+ + {filesLabel(files.files)}, {formatBytes(files.bytes)}, уйдут в корзину + и перестанут скачиваться сами. + + {#if files.gone > 0} + + {files.gone} + из них потом не скачать заново + + {/if} + {#if problem} + {problem} + {/if} +
+ + +
+
+ {:else} +
+ {#if label} + {label} + {#if files.kept > 0} + + {filesLabel(files.files)} + · {formatBytes(files.bytes)} + + {/if} + {:else} + {formatBytes(files.bytes)} уже скачано + {filesLabel(files.files)} {where(files)} + {/if} + {#if files.kept > 0} + + Ещё {filesLabel(files.kept)} есть и в других чатах, + {plural( + files.kept, + "он останется", + "они останутся", + "они останутся" + )} + + {/if} +
+ + {/if} +
+{/if} + + diff --git a/frontend/src/lib/components/disk/StorageChatRow.svelte b/frontend/src/lib/components/disk/StorageChatRow.svelte new file mode 100644 index 0000000..11786b1 --- /dev/null +++ b/frontend/src/lib/components/disk/StorageChatRow.svelte @@ -0,0 +1,143 @@ + + + + {#snippet children({ + props, + })} + + {/snippet} + + + diff --git a/frontend/src/lib/components/disk/StoragePanel.svelte b/frontend/src/lib/components/disk/StoragePanel.svelte index 0a82056..e8e7a43 100644 --- a/frontend/src/lib/components/disk/StoragePanel.svelte +++ b/frontend/src/lib/components/disk/StoragePanel.svelte @@ -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(null); let files = $state([]); + let heavyChats = $state([]); + let scopeRevision = $state(0); let group = $state(null); let loading = $state(false); let failed = $state(false); @@ -57,7 +78,22 @@ let undoTimer: ReturnType | undefined; let undoShownAt = 0; + const listing = $derived( + 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 { + 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)); @@ -239,9 +313,13 @@ {formatBytes(summary.bytes)} {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}
@@ -252,7 +330,7 @@ style:flex-grow={item.bytes} > {/each} - {#if summary.trash_bytes > 0} + {#if chatId === null && summary.trash_bytes > 0} {/if} - + {#if chatId === null} + +
+ {#each LISTINGS as item (item.value)} + + {/each} +
+ {:else} + {#key scopeRevision} + { + hideUndo(); + reload(); + }} + target={{ chatId }} + /> + {/key} + {/if} {/if} - {#if files.length > 0} + {#if listing === "files" && files.length > 0}

Самые тяжёлые

{#if summary && group === null && summary.heavy_files > 0} @@ -314,7 +418,36 @@
{/if} -
+
+ {#each heavyChats as chat (chat.chat_id)} + storageUi.openChat(chat.chat_id)} + title={chatTitle(chat.chat_id)} + weight={heaviestChat > 0 ? chat.bytes / heaviestChat : 0} + > + {#snippet menu()} + storageUi.openChat(chat.chat_id)} + > + Файлы чата + + visit(chat)}> + Перейти в чат + + + policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))} + > + Политика захвата + + {/snippet} + + {/each} + {#each files as file, index (file.media_id)} 0 ? file.file_size / heaviest : 0} > {#snippet menu()} @@ -358,14 +493,8 @@

Не удалось загрузить хранилище.

- {:else if files.length === 0} -
-

- {group === null - ? "На диске нет скачанных файлов." - : "В этой группе нет файлов."} -

-
+ {:else if shown === 0} +

{emptiness}

{:else if !done} -
- {:else if files && files.files > 0} -
- {#if confirming} -
- - {filesLabel(files.files)}, {formatBytes(files.bytes)}, уйдут в - корзину и перестанут скачиваться сами. - - {#if files.gone > 0} - - {files.gone} - из них потом не скачать заново - - {/if} -
- - -
-
- {:else} -
- {formatBytes(files.bytes)} уже скачано - {filesLabel(files.files)} {where(files)} - {#if files.kept > 0} - - Ещё {filesLabel(files.kept)} есть и в других чатах, они - останутся - - {/if} -
- - {/if} -
+ {#if target} + {/if} {#if rule && (isChat || isFolder)} @@ -544,53 +412,6 @@ color: var(--color-error); } - .files { - display: flex; - gap: 0.5rem; - align-items: center; - - flex-wrap: wrap; - - 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; - } - } - - .files-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); - } - } - } - - .files-actions { - display: flex; - gap: 0.25rem; - justify-content: flex-end; - margin-top: 0.375rem; - } - .removal { display: flex; gap: 0.25rem; diff --git a/frontend/src/lib/stores/storage.svelte.ts b/frontend/src/lib/stores/storage.svelte.ts new file mode 100644 index 0000000..7d45680 --- /dev/null +++ b/frontend/src/lib/stores/storage.svelte.ts @@ -0,0 +1,26 @@ +import { ui } from "#lib/stores/ui.svelte.js"; + +export type StorageListing = "files" | "chats"; + +function createStorageUi() { + let listing = $state("files"); + let chatId = $state(null); + + return { + get chatId() { + return chatId; + }, + get listing() { + return listing; + }, + set listing(next: StorageListing) { + listing = next; + }, + openChat(id: number) { + chatId = id; + ui.openPanel("storage-chat"); + }, + }; +} + +export const storageUi = createStorageUi(); diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts index fbf2863..a8f5b0a 100644 --- a/frontend/src/lib/stores/ui.svelte.ts +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -16,6 +16,7 @@ export type RightPanel = | "shares" | "bots" | "storage" + | "storage-chat" | "storage-trash"; export type LeftView = "main" | "settings";