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
@@ -34,6 +34,7 @@ import type {
|
|||||||
ResponseStats,
|
ResponseStats,
|
||||||
ScopeTarget,
|
ScopeTarget,
|
||||||
SearchHit,
|
SearchHit,
|
||||||
|
StorageChat,
|
||||||
StorageFile,
|
StorageFile,
|
||||||
StorageGroupName,
|
StorageGroupName,
|
||||||
StorageScope,
|
StorageScope,
|
||||||
@@ -608,15 +609,31 @@ export function removeWatchedBot(botId: number): Promise<void> {
|
|||||||
return request<void>(`/bots/${botId}`, { method: "DELETE" });
|
return request<void>(`/bots/${botId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStorageSummary(): Promise<StorageSummary> {
|
export function getStorageSummary(
|
||||||
return request<StorageSummary>("/storage/summary", { account: true });
|
chatId: number | null = null
|
||||||
|
): Promise<StorageSummary> {
|
||||||
|
return request<StorageSummary>("/storage/summary", {
|
||||||
|
account: true,
|
||||||
|
query: { chat_id: chatId },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStorageFiles(
|
export function getStorageFiles(
|
||||||
group: StorageGroupName | null,
|
group: StorageGroupName | null,
|
||||||
page: Page = {}
|
page: Page = {},
|
||||||
|
chatId: number | null = null
|
||||||
): Promise<StorageFile[]> {
|
): Promise<StorageFile[]> {
|
||||||
return request<StorageFile[]>("/storage/files", {
|
return request<StorageFile[]>("/storage/files", {
|
||||||
|
account: true,
|
||||||
|
query: { chat_id: chatId, group, ...page },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStorageChats(
|
||||||
|
group: StorageGroupName | null,
|
||||||
|
page: Page = {}
|
||||||
|
): Promise<StorageChat[]> {
|
||||||
|
return request<StorageChat[]>("/storage/chats", {
|
||||||
account: true,
|
account: true,
|
||||||
query: { group, ...page },
|
query: { group, ...page },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -598,6 +598,13 @@ export interface StorageSummary {
|
|||||||
trash_files: number;
|
trash_files: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StorageChat {
|
||||||
|
bytes: number;
|
||||||
|
chat_id: number;
|
||||||
|
files: number;
|
||||||
|
groups: StorageGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface StorageFile {
|
export interface StorageFile {
|
||||||
chat_id: number;
|
chat_id: number;
|
||||||
date: string | null;
|
date: string | null;
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
|
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
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 { 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";
|
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
|
||||||
const titles: Record<RightPanel, string> = {
|
const titles: Record<RightPanel, string> = {
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
search: "Поиск",
|
search: "Поиск",
|
||||||
shares: "Файлы по ссылке",
|
shares: "Файлы по ссылке",
|
||||||
storage: "Хранилище",
|
storage: "Хранилище",
|
||||||
|
"storage-chat": "Файлы чата",
|
||||||
"storage-trash": "Корзина",
|
"storage-trash": "Корзина",
|
||||||
stories: "Сторис",
|
stories: "Сторис",
|
||||||
"stories-all": "Все сторис",
|
"stories-all": "Все сторис",
|
||||||
@@ -44,10 +47,21 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
||||||
|
"storage-chat": "storage",
|
||||||
"storage-trash": "storage",
|
"storage-trash": "storage",
|
||||||
};
|
};
|
||||||
|
|
||||||
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
|
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];
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="right-header">
|
<header class="right-header">
|
||||||
@@ -72,7 +86,7 @@
|
|||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<h2>{ui.rightPanel ? titles[ui.rightPanel] : ""}</h2>
|
<h2>{title}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="right-body custom-scroll">
|
<div class="right-body custom-scroll">
|
||||||
@@ -112,6 +126,10 @@
|
|||||||
{#key accounts.selectedId}
|
{#key accounts.selectedId}
|
||||||
<StoragePanel />
|
<StoragePanel />
|
||||||
{/key}
|
{/key}
|
||||||
|
{:else if ui.rightPanel === "storage-chat"}
|
||||||
|
{#key `${accounts.selectedId}:${storageUi.chatId}`}
|
||||||
|
<StoragePanel chatId={storageUi.chatId} />
|
||||||
|
{/key}
|
||||||
{:else if ui.rightPanel === "storage-trash"}
|
{:else if ui.rightPanel === "storage-trash"}
|
||||||
{#key accounts.selectedId}
|
{#key accounts.selectedId}
|
||||||
<TrashPanel />
|
<TrashPanel />
|
||||||
@@ -131,9 +149,15 @@
|
|||||||
border-bottom: 1px solid var(--color-borders);
|
border-bottom: 1px solid var(--color-borders);
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import {
|
||||||
|
getStorageScope,
|
||||||
|
restoreFromTrash,
|
||||||
|
trashScope,
|
||||||
|
} from "#lib/api/endpoints.js";
|
||||||
|
import type {
|
||||||
|
ScopeTarget,
|
||||||
|
StorageScope,
|
||||||
|
TrashOutcome,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { formatBytes } from "#lib/format/media.js";
|
||||||
|
import { plural } from "#lib/format/shares.js";
|
||||||
|
import { filesLabel } from "#lib/format/storage.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label?: string;
|
||||||
|
onchange?: () => void;
|
||||||
|
target: ScopeTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { target, label, onchange }: Props = $props();
|
||||||
|
|
||||||
|
let files = $state<StorageScope | null>(null);
|
||||||
|
let confirming = $state(false);
|
||||||
|
let moving = $state(false);
|
||||||
|
let moved = $state<TrashOutcome | null>(null);
|
||||||
|
let problem = $state<string | null>(null);
|
||||||
|
|
||||||
|
function where(scope: StorageScope): string {
|
||||||
|
return "folderId" in target
|
||||||
|
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки`
|
||||||
|
: "в этом чате";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
files = await getStorageScope(target);
|
||||||
|
} catch {
|
||||||
|
files = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move() {
|
||||||
|
if (moving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moving = true;
|
||||||
|
problem = null;
|
||||||
|
try {
|
||||||
|
moved = await trashScope(target);
|
||||||
|
confirming = false;
|
||||||
|
await load();
|
||||||
|
onchange?.();
|
||||||
|
} catch {
|
||||||
|
problem = "Не удалось переместить файлы в корзину";
|
||||||
|
} finally {
|
||||||
|
moving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function undo() {
|
||||||
|
if (moved === null || moving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moving = true;
|
||||||
|
problem = null;
|
||||||
|
try {
|
||||||
|
await restoreFromTrash(moved.keys);
|
||||||
|
moved = null;
|
||||||
|
await load();
|
||||||
|
onchange?.();
|
||||||
|
} catch {
|
||||||
|
problem = "Не удалось вернуть файлы";
|
||||||
|
} finally {
|
||||||
|
moving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if moved && moved.files > 0}
|
||||||
|
<div class="files">
|
||||||
|
<div class="text">
|
||||||
|
<span>{filesLabel(moved.files)} в корзине</span>
|
||||||
|
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
|
||||||
|
{#if problem}
|
||||||
|
<small class="warn">{problem}</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Button disabled={moving} onclick={undo} smaller variant="text">
|
||||||
|
<Icon name="undo" size="1.25rem" />
|
||||||
|
<span>Вернуть</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else if files && files.files > 0}
|
||||||
|
<div class="files">
|
||||||
|
{#if confirming}
|
||||||
|
<div class="text">
|
||||||
|
<span>
|
||||||
|
{filesLabel(files.files)}, {formatBytes(files.bytes)}, уйдут в корзину
|
||||||
|
и перестанут скачиваться сами.
|
||||||
|
</span>
|
||||||
|
{#if files.gone > 0}
|
||||||
|
<small class="warn">
|
||||||
|
{files.gone}
|
||||||
|
из них потом не скачать заново
|
||||||
|
</small>
|
||||||
|
{/if}
|
||||||
|
{#if problem}
|
||||||
|
<small class="warn">{problem}</small>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<Button
|
||||||
|
onclick={() => {
|
||||||
|
confirming = false;
|
||||||
|
}}
|
||||||
|
smaller
|
||||||
|
variant="text"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={moving}
|
||||||
|
loading={moving}
|
||||||
|
onclick={move}
|
||||||
|
pill
|
||||||
|
smaller
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="1.25rem" />
|
||||||
|
<span>В корзину</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text">
|
||||||
|
{#if label}
|
||||||
|
<span>{label}</span>
|
||||||
|
{#if files.kept > 0}
|
||||||
|
<small>
|
||||||
|
{filesLabel(files.files)}
|
||||||
|
· {formatBytes(files.bytes)}
|
||||||
|
</small>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span>{formatBytes(files.bytes)} уже скачано</span>
|
||||||
|
<small>{filesLabel(files.files)} {where(files)}</small>
|
||||||
|
{/if}
|
||||||
|
{#if files.kept > 0}
|
||||||
|
<small>
|
||||||
|
Ещё {filesLabel(files.kept)} есть и в других чатах,
|
||||||
|
{plural(
|
||||||
|
files.kept,
|
||||||
|
"он останется",
|
||||||
|
"они останутся",
|
||||||
|
"они останутся"
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onclick={() => {
|
||||||
|
confirming = true;
|
||||||
|
}}
|
||||||
|
pill
|
||||||
|
smaller
|
||||||
|
variant="gray"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="1.25rem" />
|
||||||
|
<span>В корзину</span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.files {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { StorageChat } from "#lib/api/types.js";
|
||||||
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
|
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||||
|
import { formatBytes } from "#lib/format/media.js";
|
||||||
|
import { filesLabel, GROUP_LABELS } from "#lib/format/storage.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chat: StorageChat;
|
||||||
|
hasAvatar: boolean;
|
||||||
|
menu: Snippet;
|
||||||
|
onopen: () => void;
|
||||||
|
title: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { chat, title, hasAvatar, weight, onopen, menu }: Props = $props();
|
||||||
|
|
||||||
|
const MIN_WEIGHT = 0.02;
|
||||||
|
const AVATAR_REM = 3;
|
||||||
|
|
||||||
|
const scale = $derived(Math.max(MIN_WEIGHT, Math.min(1, weight)));
|
||||||
|
const parts = $derived(chat.groups.filter((item) => item.bytes > 0));
|
||||||
|
const makeup = $derived(
|
||||||
|
parts
|
||||||
|
.map((item) => `${GROUP_LABELS[item.group]}: ${formatBytes(item.bytes)}`)
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ContextMenu {menu}>
|
||||||
|
{#snippet children({
|
||||||
|
props,
|
||||||
|
})}
|
||||||
|
<button {...props} class="row" onclick={onopen} type="button">
|
||||||
|
<Avatar
|
||||||
|
avatar={{ id: chat.chat_id, kind: chat.chat_id > 0 ? "peer" : "chat" }}
|
||||||
|
colorKey={chat.chat_id}
|
||||||
|
{hasAvatar}
|
||||||
|
name={title}
|
||||||
|
size={AVATAR_REM}
|
||||||
|
/>
|
||||||
|
<span class="text">
|
||||||
|
<span class="name">{title}</span>
|
||||||
|
<span class="sub">{filesLabel(chat.files)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="size">{formatBytes(chat.bytes)}</span>
|
||||||
|
<span class="weight" title={makeup}>
|
||||||
|
<span class="fill" style:width="{scale * 100}%">
|
||||||
|
{#each parts as part (part.group)}
|
||||||
|
<span
|
||||||
|
style:background-color="var(--color-storage-{part.group})"
|
||||||
|
style:flex-grow={part.bytes}
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.row {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3rem minmax(0, 1fr) auto;
|
||||||
|
column-gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 1rem 0.625rem;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: start;
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.size {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weight {
|
||||||
|
grid-column: 2 / 4;
|
||||||
|
|
||||||
|
height: 0.1875rem;
|
||||||
|
margin-top: 0.4375rem;
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
|
||||||
|
background-color: rgba(var(--color-text-secondary-rgb), 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
|
||||||
|
span {
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { mediaUrl } from "#lib/api/client.js";
|
import { mediaUrl } from "#lib/api/client.js";
|
||||||
import { downloadMedia } from "#lib/api/download.js";
|
import { downloadMedia } from "#lib/api/download.js";
|
||||||
import {
|
import {
|
||||||
|
getStorageChats,
|
||||||
getStorageFiles,
|
getStorageFiles,
|
||||||
getStorageSummary,
|
getStorageSummary,
|
||||||
moveToTrash,
|
moveToTrash,
|
||||||
@@ -11,12 +12,15 @@
|
|||||||
} from "#lib/api/endpoints.js";
|
} from "#lib/api/endpoints.js";
|
||||||
import { forgetMedia, type ViewerItem } from "#lib/api/media.js";
|
import { forgetMedia, type ViewerItem } from "#lib/api/media.js";
|
||||||
import type {
|
import type {
|
||||||
|
StorageChat,
|
||||||
StorageFile,
|
StorageFile,
|
||||||
StorageGroupName,
|
StorageGroupName,
|
||||||
StorageSummary,
|
StorageSummary,
|
||||||
TrashOutcome,
|
TrashOutcome,
|
||||||
} from "#lib/api/types.js";
|
} from "#lib/api/types.js";
|
||||||
|
import ScopeFiles from "#lib/components/disk/ScopeFiles.svelte";
|
||||||
import SelectionBar from "#lib/components/disk/SelectionBar.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 StorageRow from "#lib/components/disk/StorageRow.svelte";
|
||||||
import MediaViewer from "#lib/components/MediaViewer.svelte";
|
import MediaViewer from "#lib/components/MediaViewer.svelte";
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
@@ -31,16 +35,33 @@
|
|||||||
messagesLabel,
|
messagesLabel,
|
||||||
} from "#lib/format/storage.js";
|
} from "#lib/format/storage.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.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 { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { isMobile } from "#lib/viewport.js";
|
import { isMobile } from "#lib/viewport.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chatId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { chatId = null }: Props = $props();
|
||||||
|
|
||||||
const PAGE = 30;
|
const PAGE = 30;
|
||||||
const UNDO_MS = 12_000;
|
const UNDO_MS = 12_000;
|
||||||
const UNDO_GUARD_MS = 600;
|
const UNDO_GUARD_MS = 600;
|
||||||
|
const LISTINGS: { label: string; value: StorageListing }[] = [
|
||||||
|
{ label: "Файлы", value: "files" },
|
||||||
|
{ label: "Чаты", value: "chats" },
|
||||||
|
];
|
||||||
|
|
||||||
let summary = $state<StorageSummary | null>(null);
|
let summary = $state<StorageSummary | null>(null);
|
||||||
let files = $state<StorageFile[]>([]);
|
let files = $state<StorageFile[]>([]);
|
||||||
|
let heavyChats = $state<StorageChat[]>([]);
|
||||||
|
let scopeRevision = $state(0);
|
||||||
let group = $state<StorageGroupName | null>(null);
|
let group = $state<StorageGroupName | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
@@ -57,7 +78,22 @@
|
|||||||
let undoTimer: ReturnType<typeof setTimeout> | undefined;
|
let undoTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let undoShownAt = 0;
|
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 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 chosen = $derived(files.filter((file) => selected.has(file.media_id)));
|
||||||
const chosenBytes = $derived(
|
const chosenBytes = $derived(
|
||||||
chosen.reduce((sum, file) => sum + file.file_size, 0)
|
chosen.reduce((sum, file) => sum + file.file_size, 0)
|
||||||
@@ -82,18 +118,42 @@
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
function chatTitle(chatId: number): string {
|
function chatTitle(id: number): string {
|
||||||
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
|
return chats.byId(id)?.title ?? `Чат ${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSummary() {
|
async function loadSummary() {
|
||||||
try {
|
try {
|
||||||
summary = await getStorageSummary();
|
summary = await getStorageSummary(chatId);
|
||||||
} catch {
|
} catch {
|
||||||
failed = true;
|
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() {
|
async function loadMore() {
|
||||||
if (loading || done) {
|
if (loading || done) {
|
||||||
return;
|
return;
|
||||||
@@ -102,16 +162,10 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
failed = false;
|
failed = false;
|
||||||
try {
|
try {
|
||||||
const batch = await getStorageFiles(group, {
|
const loaded = await loadPage(current);
|
||||||
limit: PAGE,
|
if (current === token) {
|
||||||
offset: files.length,
|
done = loaded < PAGE;
|
||||||
});
|
|
||||||
if (current !== token) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
chats.ensureMany(batch.map((file) => file.chat_id));
|
|
||||||
files = [...files, ...batch];
|
|
||||||
done = batch.length < PAGE;
|
|
||||||
} catch {
|
} catch {
|
||||||
if (current === token) {
|
if (current === token) {
|
||||||
failed = true;
|
failed = true;
|
||||||
@@ -126,6 +180,7 @@
|
|||||||
function reload() {
|
function reload() {
|
||||||
token += 1;
|
token += 1;
|
||||||
files = [];
|
files = [];
|
||||||
|
heavyChats = [];
|
||||||
done = false;
|
done = false;
|
||||||
loading = false;
|
loading = false;
|
||||||
selected.clear();
|
selected.clear();
|
||||||
@@ -171,6 +226,7 @@
|
|||||||
}
|
}
|
||||||
files = files.filter((file) => !moved.has(file.media_id));
|
files = files.filter((file) => !moved.has(file.media_id));
|
||||||
selected.clear();
|
selected.clear();
|
||||||
|
scopeRevision += 1;
|
||||||
showUndo(outcome, trashed);
|
showUndo(outcome, trashed);
|
||||||
if (outcome.blocked.length > 0) {
|
if (outcome.blocked.length > 0) {
|
||||||
problem = "Файл занят в сторис или аватаре и остался на диске";
|
problem = "Файл занят в сторис или аватаре и остался на диске";
|
||||||
@@ -194,6 +250,7 @@
|
|||||||
files = [...files, ...restored.files].sort(
|
files = [...files, ...restored.files].sort(
|
||||||
(a, b) => b.file_size - a.file_size
|
(a, b) => b.file_size - a.file_size
|
||||||
);
|
);
|
||||||
|
scopeRevision += 1;
|
||||||
await loadSummary();
|
await loadSummary();
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось вернуть файлы";
|
problem = "Не удалось вернуть файлы";
|
||||||
@@ -227,6 +284,23 @@
|
|||||||
reload();
|
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);
|
onMount(reload);
|
||||||
onDestroy(() => clearTimeout(undoTimer));
|
onDestroy(() => clearTimeout(undoTimer));
|
||||||
</script>
|
</script>
|
||||||
@@ -239,9 +313,13 @@
|
|||||||
<span class="total">{formatBytes(summary.bytes)}</span>
|
<span class="total">{formatBytes(summary.bytes)}</span>
|
||||||
<span class="count">
|
<span class="count">
|
||||||
{filesLabel(summary.files)}
|
{filesLabel(summary.files)}
|
||||||
{summary.trash_bytes > 0
|
{#if chatId !== null}
|
||||||
? `· ещё ${formatBytes(summary.trash_bytes)} в корзине`
|
в этом чате
|
||||||
: "на диске"}
|
{:else if summary.trash_bytes > 0}
|
||||||
|
· ещё {formatBytes(summary.trash_bytes)} в корзине
|
||||||
|
{:else}
|
||||||
|
на диске
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
@@ -252,7 +330,7 @@
|
|||||||
style:flex-grow={item.bytes}
|
style:flex-grow={item.bytes}
|
||||||
></span>
|
></span>
|
||||||
{/each}
|
{/each}
|
||||||
{#if summary.trash_bytes > 0}
|
{#if chatId === null && summary.trash_bytes > 0}
|
||||||
<span
|
<span
|
||||||
class="trashed"
|
class="trashed"
|
||||||
title="Корзина: {formatBytes(summary.trash_bytes)}"
|
title="Корзина: {formatBytes(summary.trash_bytes)}"
|
||||||
@@ -281,27 +359,53 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
{#if chatId === null}
|
||||||
class="trash-link"
|
<button
|
||||||
onclick={() => ui.openPanel("storage-trash")}
|
class="trash-link"
|
||||||
type="button"
|
onclick={() => ui.openPanel("storage-trash")}
|
||||||
>
|
type="button"
|
||||||
<Icon name="delete" size="1.375rem" />
|
>
|
||||||
<span class="label">Корзина</span>
|
<Icon name="delete" size="1.375rem" />
|
||||||
<span class="value">
|
<span class="label">Корзина</span>
|
||||||
{#if summary.trash_files > 0}
|
<span class="value">
|
||||||
<span class="swatch"></span>
|
{#if summary.trash_files > 0}
|
||||||
{filesLabel(summary.trash_files)}
|
<span class="swatch"></span>
|
||||||
· {formatBytes(summary.trash_bytes)}
|
{filesLabel(summary.trash_files)}
|
||||||
{:else}
|
· {formatBytes(summary.trash_bytes)}
|
||||||
пусто
|
{:else}
|
||||||
{/if}
|
пусто
|
||||||
</span>
|
{/if}
|
||||||
<Icon name="next" size="1.125rem" />
|
</span>
|
||||||
</button>
|
<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}
|
||||||
|
|
||||||
{#if files.length > 0}
|
{#if listing === "files" && files.length > 0}
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h3>Самые тяжёлые</h3>
|
<h3>Самые тяжёлые</h3>
|
||||||
{#if summary && group === null && summary.heavy_files > 0}
|
{#if summary && group === null && summary.heavy_files > 0}
|
||||||
@@ -314,7 +418,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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)}
|
{#each files as file, index (file.media_id)}
|
||||||
<StorageRow
|
<StorageRow
|
||||||
fileName={file.file_name}
|
fileName={file.file_name}
|
||||||
@@ -327,8 +460,10 @@
|
|||||||
selected={selected.has(file.media_id)}
|
selected={selected.has(file.media_id)}
|
||||||
size={file.file_size}
|
size={file.file_size}
|
||||||
src={mediaUrl(file.media_id)}
|
src={mediaUrl(file.media_id)}
|
||||||
sub={chatTitle(file.chat_id)}
|
sub={chatId === null
|
||||||
tail={formatListDate(file.date)}
|
? chatTitle(file.chat_id)
|
||||||
|
: formatListDate(file.date)}
|
||||||
|
tail={chatId === null ? formatListDate(file.date) : undefined}
|
||||||
weight={heaviest > 0 ? file.file_size / heaviest : 0}
|
weight={heaviest > 0 ? file.file_size / heaviest : 0}
|
||||||
>
|
>
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
@@ -358,14 +493,8 @@
|
|||||||
<p>Не удалось загрузить хранилище.</p>
|
<p>Не удалось загрузить хранилище.</p>
|
||||||
<Button onclick={reload} smaller variant="text">Повторить</Button>
|
<Button onclick={reload} smaller variant="text">Повторить</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if files.length === 0}
|
{:else if shown === 0}
|
||||||
<div class="state">
|
<div class="state"><p>{emptiness}</p></div>
|
||||||
<p>
|
|
||||||
{group === null
|
|
||||||
? "На диске нет скачанных файлов."
|
|
||||||
: "В этой группе нет файлов."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else if !done}
|
{:else if !done}
|
||||||
<button class="more" onclick={loadMore} type="button">
|
<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 {
|
.section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -639,6 +809,10 @@
|
|||||||
.list {
|
.list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
|
|
||||||
|
&.chats {
|
||||||
|
padding-top: 0.375rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
createPolicy,
|
createPolicy,
|
||||||
deletePolicy,
|
deletePolicy,
|
||||||
getPolicyScope,
|
getPolicyScope,
|
||||||
getStorageScope,
|
|
||||||
restoreFromTrash,
|
|
||||||
trashScope,
|
|
||||||
updatePolicy,
|
updatePolicy,
|
||||||
} from "#lib/api/endpoints.js";
|
} from "#lib/api/endpoints.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -15,13 +12,11 @@
|
|||||||
PolicyRecord,
|
PolicyRecord,
|
||||||
PolicyScopeType,
|
PolicyScopeType,
|
||||||
ScopeTarget,
|
ScopeTarget,
|
||||||
StorageScope,
|
|
||||||
TrashOutcome,
|
|
||||||
} from "#lib/api/types.js";
|
} from "#lib/api/types.js";
|
||||||
|
import ScopeFiles from "#lib/components/disk/ScopeFiles.svelte";
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatBytes } from "#lib/format/media.js";
|
|
||||||
import {
|
import {
|
||||||
parentLabel,
|
parentLabel,
|
||||||
pickToggles,
|
pickToggles,
|
||||||
@@ -32,8 +27,6 @@
|
|||||||
TOGGLE_LABELS,
|
TOGGLE_LABELS,
|
||||||
type ToggleKey,
|
type ToggleKey,
|
||||||
} from "#lib/format/policy.js";
|
} from "#lib/format/policy.js";
|
||||||
import { plural } from "#lib/format/shares.js";
|
|
||||||
import { filesLabel } from "#lib/format/storage.js";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onchange?: () => void;
|
onchange?: () => void;
|
||||||
@@ -62,10 +55,6 @@
|
|||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let problem = $state<string | null>(null);
|
let problem = $state<string | null>(null);
|
||||||
let files = $state<StorageScope | null>(null);
|
|
||||||
let confirming = $state(false);
|
|
||||||
let moving = $state(false);
|
|
||||||
let moved = $state<TrashOutcome | null>(null);
|
|
||||||
let removing = $state(false);
|
let removing = $state(false);
|
||||||
|
|
||||||
const isChat = $derived(scopeType === "chat");
|
const isChat = $derived(scopeType === "chat");
|
||||||
@@ -153,57 +142,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFiles() {
|
|
||||||
if (target === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
files = await getStorageScope(target);
|
|
||||||
} catch {
|
|
||||||
files = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function moveFiles() {
|
|
||||||
if (target === null || moving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
moving = true;
|
|
||||||
problem = null;
|
|
||||||
try {
|
|
||||||
moved = await trashScope(target);
|
|
||||||
confirming = false;
|
|
||||||
await loadFiles();
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось переместить файлы в корзину";
|
|
||||||
} finally {
|
|
||||||
moving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function undoMove() {
|
|
||||||
if (moved === null || moving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
moving = true;
|
|
||||||
problem = null;
|
|
||||||
try {
|
|
||||||
await restoreFromTrash(moved.keys);
|
|
||||||
moved = null;
|
|
||||||
await loadFiles();
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось вернуть файлы";
|
|
||||||
} finally {
|
|
||||||
moving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function where(scope: StorageScope): string {
|
|
||||||
return isFolder
|
|
||||||
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки`
|
|
||||||
: "в этом чате";
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
rule = initialRule ?? null;
|
rule = initialRule ?? null;
|
||||||
parent = initialParent ?? null;
|
parent = initialParent ?? null;
|
||||||
@@ -220,7 +158,6 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await loadFiles();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -272,77 +209,8 @@
|
|||||||
<p class="problem">{problem}</p>
|
<p class="problem">{problem}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if moved && moved.files > 0}
|
{#if target}
|
||||||
<div class="files">
|
<ScopeFiles {target} />
|
||||||
<div class="files-text">
|
|
||||||
<span>{filesLabel(moved.files)} в корзине</span>
|
|
||||||
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
|
|
||||||
</div>
|
|
||||||
<Button disabled={moving} onclick={undoMove} smaller variant="text">
|
|
||||||
<Icon name="undo" size="1.25rem" />
|
|
||||||
<span>Вернуть</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{:else if files && files.files > 0}
|
|
||||||
<div class="files">
|
|
||||||
{#if confirming}
|
|
||||||
<div class="files-text">
|
|
||||||
<span>
|
|
||||||
{filesLabel(files.files)}, {formatBytes(files.bytes)}, уйдут в
|
|
||||||
корзину и перестанут скачиваться сами.
|
|
||||||
</span>
|
|
||||||
{#if files.gone > 0}
|
|
||||||
<small class="warn">
|
|
||||||
{files.gone}
|
|
||||||
из них потом не скачать заново
|
|
||||||
</small>
|
|
||||||
{/if}
|
|
||||||
<div class="files-actions">
|
|
||||||
<Button
|
|
||||||
onclick={() => {
|
|
||||||
confirming = false;
|
|
||||||
}}
|
|
||||||
smaller
|
|
||||||
variant="text"
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={moving}
|
|
||||||
loading={moving}
|
|
||||||
onclick={moveFiles}
|
|
||||||
pill
|
|
||||||
smaller
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="1.25rem" />
|
|
||||||
<span>В корзину</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="files-text">
|
|
||||||
<span>{formatBytes(files.bytes)} уже скачано</span>
|
|
||||||
<small>{filesLabel(files.files)} {where(files)}</small>
|
|
||||||
{#if files.kept > 0}
|
|
||||||
<small>
|
|
||||||
Ещё {filesLabel(files.kept)} есть и в других чатах, они
|
|
||||||
останутся
|
|
||||||
</small>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onclick={() => {
|
|
||||||
confirming = true;
|
|
||||||
}}
|
|
||||||
pill
|
|
||||||
smaller
|
|
||||||
variant="gray"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="1.25rem" />
|
|
||||||
<span>В корзину</span>
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if rule && (isChat || isFolder)}
|
{#if rule && (isChat || isFolder)}
|
||||||
@@ -544,53 +412,6 @@
|
|||||||
color: var(--color-error);
|
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 {
|
.removal {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
|
||||||
|
export type StorageListing = "files" | "chats";
|
||||||
|
|
||||||
|
function createStorageUi() {
|
||||||
|
let listing = $state<StorageListing>("files");
|
||||||
|
let chatId = $state<number | null>(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();
|
||||||
@@ -16,6 +16,7 @@ export type RightPanel =
|
|||||||
| "shares"
|
| "shares"
|
||||||
| "bots"
|
| "bots"
|
||||||
| "storage"
|
| "storage"
|
||||||
|
| "storage-chat"
|
||||||
| "storage-trash";
|
| "storage-trash";
|
||||||
|
|
||||||
export type LeftView = "main" | "settings";
|
export type LeftView = "main" | "settings";
|
||||||
|
|||||||
Reference in new issue
Block a user