Compare commits

..
6 Commits
15 changed files with 899 additions and 247 deletions

No files matched your search

+19 -3
View File
@@ -13,6 +13,7 @@ from utils.read import storage as storage_read
from utils.read.models import ( from utils.read.models import (
DEFAULT_LIMIT, DEFAULT_LIMIT,
Page, Page,
StorageChatView,
StorageFileView, StorageFileView,
StorageScopeView, StorageScopeView,
StorageSummaryView, StorageSummaryView,
@@ -47,9 +48,11 @@ class PurgeRequest(BaseModel):
@router.get("/summary") @router.get("/summary")
async def summary( async def summary(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()] pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
chat_id: Annotated[int | None, Query()] = None,
) -> StorageSummaryView: ) -> StorageSummaryView:
return await storage_read.get_summary(pool, account_id) return await storage_read.get_summary(pool, account_id, chat_id)
@router.get("/files") @router.get("/files")
@@ -57,11 +60,24 @@ async def files(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()], account_id: Annotated[int, Query()],
group: Annotated[str | None, Query()] = None, group: Annotated[str | None, Query()] = None,
chat_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT, limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0, offset: Annotated[int, Query()] = 0,
) -> list[StorageFileView]: ) -> list[StorageFileView]:
page = Page(limit=limit, offset=offset) page = Page(limit=limit, offset=offset)
return await storage_read.list_files(pool, account_id, group, page) return await storage_read.list_files(pool, account_id, group, page, chat_id)
@router.get("/chats")
async def chats(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
group: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StorageChatView]:
page = Page(limit=limit, offset=offset)
return await storage_read.list_chats(pool, account_id, group, page)
async def _chat_ids( async def _chat_ids(
@@ -1,6 +1,6 @@
from pyrogram import Client from pyrogram import Client
from pyrogram.errors import FileIdInvalid, FileReferenceExpired, FileReferenceInvalid from pyrogram.errors import FileIdInvalid, FileReferenceExpired, FileReferenceInvalid
from pyrogram.types import Photo from pyrogram.types import ChatPhoto
from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded
from userbot.modules.download import download_bytes from userbot.modules.download import download_bytes
@@ -15,12 +15,9 @@ async def _fresh_file_id(client: Client, owner_id: int, unique_id: str) -> str |
photo = chat.photo if chat else None photo = chat.photo if chat else None
if photo and photo.big_photo_unique_id == unique_id: if photo and photo.big_photo_unique_id == unique_id:
return photo.big_file_id return photo.big_file_id
photos = client.get_chat_photos(owner_id) async for item in client.get_chat_photos(owner_id):
if photos is None: if isinstance(item, ChatPhoto) and item.big_photo_unique_id == unique_id:
return None return item.big_file_id
async for item in photos:
if isinstance(item, Photo) and item.file_unique_id == unique_id:
return item.file_id
return None return None
+3
View File
@@ -22,6 +22,7 @@ from utils.read.models import (
StickerView, StickerView,
WebPageView, WebPageView,
) )
from utils.read.rich_text import rich_text
_MEDIA_KEYS = ( _MEDIA_KEYS = (
"photo", "photo",
@@ -382,6 +383,8 @@ def build_message_view(
row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef] row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef]
) -> MessageView: ) -> MessageView:
base = _base_fields(row) base = _base_fields(row)
if base["text"] is None:
base["text"] = rich_text(raw.get("rich_message"))
via_bot = raw.get("via_bot") via_bot = raw.get("via_bot")
sticker = _sticker(raw) sticker = _sticker(raw)
try: try:
+7
View File
@@ -253,6 +253,13 @@ class StorageSummaryView(BaseModel):
groups: list[StorageGroupView] groups: list[StorageGroupView]
class StorageChatView(BaseModel):
chat_id: int
files: int
bytes: int
groups: list[StorageGroupView]
class StorageFileView(BaseModel): class StorageFileView(BaseModel):
media_id: int media_id: int
chat_id: int chat_id: int
+61
View File
@@ -0,0 +1,61 @@
from typing import Any
CELL_GAP = " | "
_TEXT_KEYS = ("summary", "text", "expression", "credit")
_INLINE_KEYS = ("text", "alternative_text", "expression")
def _button(button: dict[str, Any]) -> str:
label = _inline(button.get("text"))
url = button.get("url")
return f"{label} ({url})" if isinstance(url, str) else label
def _inline(value: object) -> str:
if isinstance(value, str):
return value
if isinstance(value, list):
return "".join(_inline(item) for item in value)
if not isinstance(value, dict):
return ""
button = value.get("button")
if isinstance(button, dict):
return _button(button)
return next((_inline(value[key]) for key in _INLINE_KEYS if key in value), "")
def _children(block: dict[str, Any], key: str) -> list[Any]:
value = block.get(key)
return value if isinstance(value, list) else []
def _row(row: object) -> str:
cells = row if isinstance(row, list) else []
return CELL_GAP.join(
_inline(cell.get("text")) for cell in cells if isinstance(cell, dict)
)
def _block(block: object) -> list[str]:
if not isinstance(block, dict):
return []
lines = [_inline(block[key]) for key in _TEXT_KEYS if key in block]
lines += [_row(row) for row in _children(block, "cells")]
lines += [
_button(button)
for button in _children(block, "buttons")
if isinstance(button, dict)
]
for key in ("items", "blocks"):
for child in _children(block, key):
lines += _block(child)
lines += _block(block.get("caption"))
lines = [line for line in lines if line]
label = block.get("label")
if isinstance(label, str) and lines:
lines[0] = f"{label} {lines[0]}"
return lines
def rich_text(rich: object) -> str | None:
return "\n".join(_block(rich)) or None
+80 -11
View File
@@ -1,5 +1,7 @@
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from itertools import groupby
from operator import itemgetter
import asyncpg import asyncpg
@@ -8,6 +10,7 @@ from utils.policy import repository as policy_repository
from utils.read import chats from utils.read import chats
from utils.read.models import ( from utils.read.models import (
Page, Page,
StorageChatView,
StorageFileView, StorageFileView,
StorageGroupView, StorageGroupView,
StorageScopeView, StorageScopeView,
@@ -34,6 +37,7 @@ WITH blobs AS (
SELECT storage_key, max(file_size) AS size, min(kind) AS kind SELECT storage_key, max(file_size) AS size, min(kind) AS kind
FROM media FROM media
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
AND ($3::bigint IS NULL OR chat_id = $3)
GROUP BY storage_key GROUP BY storage_key
) )
SELECT kind, count(*) AS files, coalesce(sum(size), 0)::bigint AS bytes, SELECT kind, count(*) AS files, coalesce(sum(size), 0)::bigint AS bytes,
@@ -49,24 +53,49 @@ FROM media_trash WHERE account_id = $1
_FILES = """ _FILES = """
WITH top AS MATERIALIZED ( WITH top AS MATERIALIZED (
SELECT storage_key, max(file_size) AS file_size, count(*) AS refs SELECT storage_key, max(file_size) AS file_size
FROM media FROM media
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3) AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
AND ($7::bigint IS NULL OR chat_id = $7)
GROUP BY storage_key GROUP BY storage_key
ORDER BY 2 DESC NULLS LAST, 1 ORDER BY 2 DESC NULLS LAST, 1
LIMIT $4 OFFSET $5 LIMIT $4 OFFSET $5
) )
SELECT * FROM ( SELECT * FROM (
SELECT t.storage_key, t.file_size, t.refs, m.id, m.chat_id, m.message_id, SELECT t.storage_key, t.file_size, m.id, m.chat_id, m.message_id,
m.kind, m.mime, m.ttl_seconds, m.kind, m.mime, m.ttl_seconds,
row_number() OVER (PARTITION BY t.storage_key ORDER BY m.id) AS n count(*) OVER (PARTITION BY t.storage_key) AS refs,
row_number() OVER (
PARTITION BY t.storage_key
ORDER BY (m.chat_id = $7) DESC NULLS LAST, m.id
) AS n
FROM top t FROM top t
JOIN media m ON m.storage_key = t.storage_key JOIN media m ON m.storage_key = t.storage_key
AND m.account_id = $1 AND m.downloaded AND m.account_id = $1 AND m.downloaded
) ranked ) ranked
WHERE n <= $6 WHERE n <= $6
ORDER BY file_size DESC NULLS LAST, storage_key, id ORDER BY file_size DESC NULLS LAST, storage_key, n
"""
_CHATS = """
WITH blobs AS (
SELECT chat_id, storage_key, max(file_size) AS size, min(kind) AS kind
FROM media
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
GROUP BY chat_id, storage_key
), top AS (
SELECT chat_id, coalesce(sum(size), 0)::bigint AS total
FROM blobs GROUP BY chat_id
ORDER BY 2 DESC, 1
LIMIT $4 OFFSET $5
)
SELECT t.chat_id, t.total, b.kind, count(*) AS files,
coalesce(sum(b.size), 0)::bigint AS bytes
FROM top t JOIN blobs b USING (chat_id)
GROUP BY t.chat_id, t.total, b.kind
ORDER BY t.total DESC, t.chat_id
""" """
_TRASH = """ _TRASH = """
@@ -143,9 +172,7 @@ def _group_filter(group: str | None) -> tuple[list[str] | None, bool]:
return list(GROUP_KINDS.get(group, ())), True return list(GROUP_KINDS.get(group, ())), True
async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView: def _groups(rows: list[asyncpg.Record]) -> list[StorageGroupView]:
rows = await pool.fetch(_SUMMARY, account_id, HEAVY_BYTES)
trash = await pool.fetchrow(_TRASH_TOTAL, account_id)
groups = { groups = {
name: StorageGroupView(group=name, files=0, bytes=0) name: StorageGroupView(group=name, files=0, bytes=0)
for name in (*GROUP_KINDS, OTHER_GROUP) for name in (*GROUP_KINDS, OTHER_GROUP)
@@ -154,6 +181,14 @@ async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView
group = groups[_KIND_GROUP.get(row["kind"], OTHER_GROUP)] group = groups[_KIND_GROUP.get(row["kind"], OTHER_GROUP)]
group.files += row["files"] group.files += row["files"]
group.bytes += row["bytes"] group.bytes += row["bytes"]
return list(groups.values())
async def get_summary(
pool: asyncpg.Pool, account_id: int, chat_id: int | None = None
) -> StorageSummaryView:
rows = await pool.fetch(_SUMMARY, account_id, HEAVY_BYTES, chat_id)
trash = await pool.fetchrow(_TRASH_TOTAL, account_id)
return StorageSummaryView( return StorageSummaryView(
files=sum(row["files"] for row in rows), files=sum(row["files"] for row in rows),
bytes=sum(row["bytes"] for row in rows), bytes=sum(row["bytes"] for row in rows),
@@ -162,10 +197,31 @@ async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView
heavy_threshold=HEAVY_BYTES, heavy_threshold=HEAVY_BYTES,
trash_files=trash["files"], trash_files=trash["files"],
trash_bytes=trash["bytes"], trash_bytes=trash["bytes"],
groups=list(groups.values()), groups=_groups(rows),
) )
async def list_chats(
pool: asyncpg.Pool, account_id: int, group: str | None, page: Page
) -> list[StorageChatView]:
kinds, include = _group_filter(group)
rows = await pool.fetch(
_CHATS, account_id, kinds, include, page.capped_limit, page.offset
)
return [
StorageChatView(
chat_id=chat_id,
files=sum(row["files"] for row in items),
bytes=items[0]["total"],
groups=_groups(items),
)
for chat_id, items in (
(chat_id, list(chunk))
for chat_id, chunk in groupby(rows, key=itemgetter("chat_id"))
)
]
async def _messages( async def _messages(
pool: asyncpg.Pool, account_id: int, refs: list[asyncpg.Record] pool: asyncpg.Pool, account_id: int, refs: list[asyncpg.Record]
) -> list[asyncpg.Record | None]: ) -> list[asyncpg.Record | None]:
@@ -189,18 +245,31 @@ def _alive(ref: asyncpg.Record, message: asyncpg.Record | None) -> bool:
async def list_files( async def list_files(
pool: asyncpg.Pool, account_id: int, group: str | None, page: Page pool: asyncpg.Pool,
account_id: int,
group: str | None,
page: Page,
chat_id: int | None = None,
) -> list[StorageFileView]: ) -> list[StorageFileView]:
kinds, include = _group_filter(group) kinds, include = _group_filter(group)
refs = await pool.fetch( refs = await pool.fetch(
_FILES, account_id, kinds, include, page.capped_limit, page.offset, REFS_CHECKED _FILES,
account_id,
kinds,
include,
page.capped_limit,
page.offset,
REFS_CHECKED,
chat_id,
) )
messages = await _messages(pool, account_id, refs) messages = await _messages(pool, account_id, refs)
files: dict[str, StorageFileView] = {} files: dict[str, StorageFileView] = {}
for ref, message in zip(refs, messages, strict=True): for ref, message in zip(refs, messages, strict=True):
alive = _alive(ref, message) alive = _alive(ref, message)
known = files.get(ref["storage_key"]) known = files.get(ref["storage_key"])
if known is not None and (known.refetchable or not alive): inside = chat_id is None or ref["chat_id"] == chat_id
if known is not None and not (alive and inside and not known.refetchable):
known.refetchable = known.refetchable or alive
continue continue
files[ref["storage_key"]] = StorageFileView( files[ref["storage_key"]] = StorageFileView(
media_id=ref["id"], media_id=ref["id"],
+20 -3
View File
@@ -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 },
}); });
+7
View File
@@ -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;
+25 -1
View File
@@ -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,251 @@
<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;
}
}
export function refresh() {
return load();
}
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="failed">{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)},
{plural(
files.files,
"уйдёт в корзину и перестанет скачиваться сам",
"уйдут в корзину и перестанут скачиваться сами",
"уйдут в корзину и перестанут скачиваться сами"
)}.
</span>
{#if files.gone > 0}
<small class="warn">
{files.gone}
из них потом не скачать заново
</small>
{/if}
{#if problem}
<small class="failed">{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>
{:else if label && files && files.kept > 0}
<div class="files">
<div class="text">
<span>{label}</span>
<small>
{filesLabel(files.kept)}
есть и в других чатах, убрать
{plural(files.kept, "его", "их", "их")}
можно из списка ниже
</small>
</div>
</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);
}
&.failed {
color: var(--color-error);
}
}
}
.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>
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount, tick } from "svelte";
import { SvelteSet } from "svelte/reactivity"; import { SvelteSet } from "svelte/reactivity";
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 scope = $state<ReturnType<typeof ScopeFiles>>();
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);
@@ -54,10 +75,26 @@
let viewerIndex = $state(0); let viewerIndex = $state(0);
const selected = new SvelteSet<number>(); const selected = new SvelteSet<number>();
let token = 0; let token = 0;
let scroller: HTMLElement | null = null;
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,19 +119,43 @@
})) }))
); );
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 loadMore() { 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) { if (loading || done) {
return; return;
} }
@@ -102,16 +163,10 @@
loading = true; loading = true;
failed = false; failed = false;
try { try {
const batch = await getStorageFiles(group, { const loaded = await loadPage(current, limit);
limit: PAGE, if (current === token) {
offset: files.length, done = loaded < limit;
});
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;
@@ -123,14 +178,34 @@
} }
} }
function reload() { function reload(limit = PAGE) {
token += 1; token += 1;
files = []; files = [];
heavyChats = [];
done = false; done = false;
loading = false; loading = false;
selected.clear(); selected.clear();
loadSummary(); return Promise.all([loadSummary(), loadMore(limit)]);
loadMore(); }
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) { function toggle(mediaId: number) {
@@ -171,6 +246,7 @@
} }
files = files.filter((file) => !moved.has(file.media_id)); files = files.filter((file) => !moved.has(file.media_id));
selected.clear(); selected.clear();
scope?.refresh();
showUndo(outcome, trashed); showUndo(outcome, trashed);
if (outcome.blocked.length > 0) { if (outcome.blocked.length > 0) {
problem = "Файл занят в сторис или аватаре и остался на диске"; problem = "Файл занят в сторис или аватаре и остался на диске";
@@ -194,6 +270,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
); );
scope?.refresh();
await loadSummary(); await loadSummary();
} catch { } catch {
problem = "Не удалось вернуть файлы"; problem = "Не удалось вернуть файлы";
@@ -227,24 +304,45 @@
reload(); reload();
} }
onMount(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)); onDestroy(() => clearTimeout(undoTimer));
</script> </script>
<div class="storage"> <div class="storage" {@attach hold}>
{#if summary} {#if summary}
{#if summary.files > 0} {#if summary.files > 0 || chatId !== null}
<section class="summary"> <section class="summary">
<div class="hero"> <div class="hero">
<span class="total">{formatBytes(summary.bytes)}</span> <span class="total">{formatBytes(summary.bytes) || "0 B"}</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" class:bare={groups.length === 0}>
{#each groups as item (item.group)} {#each groups as item (item.group)}
<span <span
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}" title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
@@ -252,7 +350,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,6 +379,7 @@
</section> </section>
{/if} {/if}
{#if chatId === null}
<button <button
class="trash-link" class="trash-link"
onclick={() => ui.openPanel("storage-trash")} onclick={() => ui.openPanel("storage-trash")}
@@ -299,9 +398,33 @@
</span> </span>
<Icon name="next" size="1.125rem" /> <Icon name="next" size="1.125rem" />
</button> </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}
<ScopeFiles
label="Все файлы чата"
onchange={() => {
hideUndo();
reload();
}}
target={{ chatId }}
bind:this={scope}
/>
{/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 +437,33 @@
</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={() => 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)}>
Файлы чата
</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 +476,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()}
@@ -356,18 +507,14 @@
{:else if failed} {:else if failed}
<div class="state"> <div class="state">
<p>Не удалось загрузить хранилище.</p> <p>Не удалось загрузить хранилище.</p>
<Button onclick={reload} smaller variant="text">Повторить</Button> <Button onclick={() => reload()} smaller variant="text">
</div> Повторить
{:else if files.length === 0} </Button>
<div class="state">
<p>
{group === null
? "На диске нет скачанных файлов."
: "В этой группе нет файлов."}
</p>
</div> </div>
{:else if shown === 0}
<div class="state"><p>{emptiness}</p></div>
{:else if !done} {:else if !done}
<button class="more" onclick={loadMore} type="button"> <button class="more" onclick={() => loadMore()} type="button">
Показать ещё Показать ещё
</button> </button>
{/if} {/if}
@@ -470,6 +617,11 @@
height: 0.625rem; height: 0.625rem;
margin-top: 0.875rem; margin-top: 0.875rem;
&.bare {
border-radius: 0.3125rem;
background-color: rgba(var(--color-text-secondary-rgb), 0.16);
}
span { span {
min-width: 0.1875rem; min-width: 0.1875rem;
border-radius: 2px; border-radius: 2px;
@@ -615,6 +767,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 +832,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;
+38
View File
@@ -0,0 +1,38 @@
import { ui } from "#lib/stores/ui.svelte.js";
export type StorageListing = "files" | "chats";
interface Resume {
count: number;
top: number;
}
function createStorageUi() {
let listing = $state<StorageListing>("files");
let chatId = $state<number | null>(null);
let resume: Resume | null = null;
return {
get chatId() {
return chatId;
},
get listing() {
return listing;
},
set listing(next: StorageListing) {
listing = next;
},
openChat(id: number, from: Resume) {
chatId = id;
resume = from;
ui.openPanel("storage-chat");
},
takeResume(): Resume | null {
const taken = resume;
resume = null;
return taken;
},
};
}
export const storageUi = createStorageUi();
+1
View File
@@ -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";