Compare commits

..
Author SHA1 Message Date
renovate Bot ec5979437d chore(frontend): update template to v0.8.0 2026-10-20 05:00:56 +00:00
renovate Bot 59f171a62d chore(backend): update template to v0.7.1 2026-10-20 05:00:50 +00:00
renovate Bot e3559da2a7 chore(deps): update code dependencies (dev) (#21)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-18 04:02:20 +00:00
hh dcd6786ca5 fix(backend): avatar job ends without a crash when telegram does not know the owner 2026-10-14 05:25:48 +02:00
hh 32d58193f3 fix(backend): raw update reaches every handler that declares it, contact list follows user updates 2026-10-14 05:25:45 +02:00
hh 59d0e2c8ad fix(backend): shared contact card keeps its phone number in the stored message 2026-10-14 05:25:42 +02:00
hh 850d39ad6f fix(frontend): cleanup block shows its failure in the error color 2026-10-14 05:17:58 +02:00
hh b8a38092eb fix(frontend): emptied chat keeps its storage head, shared-only chat explains why nothing can be bulk moved, back from a chat returns to the same place in the list, cleanup wording agrees in number 2026-10-14 05:15:27 +02:00
hh 2836dcb3a9 fix(backend): stale avatar is refetched again under kurigram 2.2.26, profile photos come as chat photos 2026-10-14 05:09:38 +02:00
hh 01cf01b629 fix(backend): rich messages of the new telegram layer read as plain text instead of no text 2026-10-14 05:06:35 +02:00
hh 7f124059be feat(frontend): storage shows the heaviest chats, a chat opens its own files with selective cleanup and a whole-chat move to trash 2026-10-14 05:02:06 +02:00
hh 5eacabd101 feat(backend): storage lists the heaviest chats, summary and file list take a chat filter 2026-10-14 05:02:03 +02:00
20 changed files with 941 additions and 262 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
_commit: v0.6.0
_commit: v0.7.1
_src_path: https://git.kotikot.com/templates/backend-python.git
author_email: h@kotikot.com
author_name: h
+1 -1
View File
@@ -1,4 +1,4 @@
_commit: v0.7.0
_commit: v0.8.0
_src_path: https://git.kotikot.com/templates/frontend-svelte.git
install_deps: false
layout: part
+19 -3
View File
@@ -13,6 +13,7 @@ from utils.read import storage as storage_read
from utils.read.models import (
DEFAULT_LIMIT,
Page,
StorageChatView,
StorageFileView,
StorageScopeView,
StorageSummaryView,
@@ -47,9 +48,11 @@ class PurgeRequest(BaseModel):
@router.get("/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:
return await storage_read.get_summary(pool, account_id)
return await storage_read.get_summary(pool, account_id, chat_id)
@router.get("/files")
@@ -57,11 +60,24 @@ async def files(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
group: Annotated[str | None, Query()] = None,
chat_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StorageFileView]:
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(
@@ -24,8 +24,9 @@ _MODULES = (
read_receipts,
transcribed,
)
_REGISTRY: dict[type, RawHandler] = {
update_type: module.handle for module in _MODULES for update_type in module.HANDLES
_REGISTRY: dict[type, list[RawHandler]] = {
update_type: [module.handle for module in _MODULES if update_type in module.HANDLES]
for update_type in {kind for module in _MODULES for kind in module.HANDLES}
}
@@ -33,8 +34,7 @@ _REGISTRY: dict[type, RawHandler] = {
async def dispatch(
client: PyroClient, update: raw.base.Update, users: dict, chats: dict
) -> None:
handler = _REGISTRY.get(type(update))
if handler is not None:
for handler in _REGISTRY.get(type(update), ()):
await handler(client, update, users, chats)
@@ -1,6 +1,13 @@
from pyrogram import Client
from pyrogram.errors import FileIdInvalid, FileReferenceExpired, FileReferenceInvalid
from pyrogram.types import Photo
from pyrogram.errors import (
ChannelInvalid,
ChannelPrivate,
FileIdInvalid,
FileReferenceExpired,
FileReferenceInvalid,
PeerIdInvalid,
)
from pyrogram.types import ChatPhoto
from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded
from userbot.modules.download import download_bytes
@@ -8,19 +15,20 @@ from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
STALE_FILE_ID = (FileIdInvalid, FileReferenceExpired, FileReferenceInvalid)
UNKNOWN_OWNER = (PeerIdInvalid, ChannelInvalid, ChannelPrivate)
async def _fresh_file_id(client: Client, owner_id: int, unique_id: str) -> str | None:
try:
chat = await client.get_chat(owner_id)
except UNKNOWN_OWNER:
return None
photo = chat.photo if chat else None
if photo and photo.big_photo_unique_id == unique_id:
return photo.big_file_id
photos = client.get_chat_photos(owner_id)
if photos is None:
return None
async for item in photos:
if isinstance(item, Photo) and item.file_unique_id == unique_id:
return item.file_id
async for item in client.get_chat_photos(owner_id):
if isinstance(item, ChatPhoto) and item.big_photo_unique_id == unique_id:
return item.big_file_id
return None
+17 -2
View File
@@ -1,8 +1,23 @@
import json
from typing import Any
from pyrogram.raw.core import TLObject
from pyrogram.types import Object
from pyrogram.types import Contact, Object
EMPTY_JSON = "{}"
PHONE_KEY = "phone_number"
def _default(obj: Any) -> Any: # noqa: ANN401
if isinstance(obj, TLObject):
return TLObject.default(obj)
data = Object.default(obj)
if isinstance(obj, Contact) and isinstance(data, dict) and PHONE_KEY in data:
data[PHONE_KEY] = obj.phone_number
return data
def raw_json(obj: Object | TLObject | None) -> str:
return EMPTY_JSON if obj is None else str(obj)
if obj is None:
return EMPTY_JSON
return json.dumps(obj, indent=4, default=_default, ensure_ascii=False)
+3
View File
@@ -22,6 +22,7 @@ from utils.read.models import (
StickerView,
WebPageView,
)
from utils.read.rich_text import rich_text
_MEDIA_KEYS = (
"photo",
@@ -382,6 +383,8 @@ def build_message_view(
row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef]
) -> MessageView:
base = _base_fields(row)
if base["text"] is None:
base["text"] = rich_text(raw.get("rich_message"))
via_bot = raw.get("via_bot")
sticker = _sticker(raw)
try:
+7
View File
@@ -253,6 +253,13 @@ class StorageSummaryView(BaseModel):
groups: list[StorageGroupView]
class StorageChatView(BaseModel):
chat_id: int
files: int
bytes: int
groups: list[StorageGroupView]
class StorageFileView(BaseModel):
media_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
from collections import defaultdict
from itertools import groupby
from operator import itemgetter
import asyncpg
@@ -8,6 +10,7 @@ from utils.policy import repository as policy_repository
from utils.read import chats
from utils.read.models import (
Page,
StorageChatView,
StorageFileView,
StorageGroupView,
StorageScopeView,
@@ -34,6 +37,7 @@ WITH blobs AS (
SELECT 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 ($3::bigint IS NULL OR chat_id = $3)
GROUP BY storage_key
)
SELECT kind, count(*) AS files, coalesce(sum(size), 0)::bigint AS bytes,
@@ -49,24 +53,49 @@ FROM media_trash WHERE account_id = $1
_FILES = """
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
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
AND ($7::bigint IS NULL OR chat_id = $7)
GROUP BY storage_key
ORDER BY 2 DESC NULLS LAST, 1
LIMIT $4 OFFSET $5
)
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,
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
JOIN media m ON m.storage_key = t.storage_key
AND m.account_id = $1 AND m.downloaded
) ranked
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 = """
@@ -143,9 +172,7 @@ def _group_filter(group: str | None) -> tuple[list[str] | None, bool]:
return list(GROUP_KINDS.get(group, ())), True
async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView:
rows = await pool.fetch(_SUMMARY, account_id, HEAVY_BYTES)
trash = await pool.fetchrow(_TRASH_TOTAL, account_id)
def _groups(rows: list[asyncpg.Record]) -> list[StorageGroupView]:
groups = {
name: StorageGroupView(group=name, files=0, bytes=0)
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.files += row["files"]
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(
files=sum(row["files"] 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,
trash_files=trash["files"],
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(
pool: asyncpg.Pool, account_id: int, refs: list[asyncpg.Record]
) -> list[asyncpg.Record | None]:
@@ -189,18 +245,31 @@ def _alive(ref: asyncpg.Record, message: asyncpg.Record | None) -> bool:
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]:
kinds, include = _group_filter(group)
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)
files: dict[str, StorageFileView] = {}
for ref, message in zip(refs, messages, strict=True):
alive = _alive(ref, message)
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
files[ref["storage_key"]] = StorageFileView(
media_id=ref["id"],
+7 -6
View File
@@ -411,7 +411,7 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.143.0"
version = "0.143.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -421,9 +421,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" }
sdist = { url = "https://files.pythonhosted.org/packages/38/d5/b0aeb0b303650ac87b0411de4aaf3f901b06042dcc3fb00f30b97b7ff585/fastapi-0.143.3.tar.gz", hash = "sha256:b9ab567ee57afb4454004ce04c889f8f0160ac47946b99795887cd6e88484848", size = 469834, upload-time = "2026-10-16T14:14:13.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1a/03d246befcf307219bb2161c97d8c5d314f84125bf7dd245730a28589828/fastapi-0.143.3-py3-none-any.whl", hash = "sha256:75e8bd68e2f52f77d29b2879749c257d64de2d85282d25270caf2bfccce5efe5", size = 144849, upload-time = "2026-10-16T14:14:11.763Z" },
]
[[package]]
@@ -1080,16 +1080,17 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.15.0"
version = "2.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/3b/a5d2294799b53b448319978cfb5bd139d5a9d45e862af91661614f14c922/pydantic_settings-2.16.0.tar.gz", hash = "sha256:5b6c578049ede4db0e2ef3b4eaa4ad4069cfa9211f83fb38df899dfade50a614", size = 301031, upload-time = "2026-10-14T12:44:09.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
{ url = "https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl", hash = "sha256:7e73acf7f61936a15e5a3b6eedaea29f133357faf7272f2607ba479b049dd7f2", size = 80364, upload-time = "2026-10-14T12:44:08.233Z" },
]
[[package]]
+20 -3
View File
@@ -34,6 +34,7 @@ import type {
ResponseStats,
ScopeTarget,
SearchHit,
StorageChat,
StorageFile,
StorageGroupName,
StorageScope,
@@ -608,15 +609,31 @@ export function removeWatchedBot(botId: number): Promise<void> {
return request<void>(`/bots/${botId}`, { method: "DELETE" });
}
export function getStorageSummary(): Promise<StorageSummary> {
return request<StorageSummary>("/storage/summary", { account: true });
export function getStorageSummary(
chatId: number | null = null
): Promise<StorageSummary> {
return request<StorageSummary>("/storage/summary", {
account: true,
query: { chat_id: chatId },
});
}
export function getStorageFiles(
group: StorageGroupName | null,
page: Page = {}
page: Page = {},
chatId: number | null = null
): Promise<StorageFile[]> {
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,
query: { group, ...page },
});
+7
View File
@@ -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;
+25 -1
View File
@@ -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<RightPanel, string> = {
@@ -36,6 +38,7 @@
search: "Поиск",
shares: "Файлы по ссылке",
storage: "Хранилище",
"storage-chat": "Файлы чата",
"storage-trash": "Корзина",
stories: "Сторис",
"stories-all": "Все сторис",
@@ -44,10 +47,21 @@
};
const parents: Partial<Record<RightPanel, RightPanel>> = {
"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];
});
</script>
<header class="right-header">
@@ -72,7 +86,7 @@
<Icon name="close" />
</Button>
{/if}
<h2>{ui.rightPanel ? titles[ui.rightPanel] : ""}</h2>
<h2>{title}</h2>
</header>
<div class="right-body custom-scroll">
@@ -112,6 +126,10 @@
{#key accounts.selectedId}
<StoragePanel />
{/key}
{:else if ui.rightPanel === "storage-chat"}
{#key `${accounts.selectedId}:${storageUi.chatId}`}
<StoragePanel chatId={storageUi.chatId} />
{/key}
{:else if ui.rightPanel === "storage-trash"}
{#key accounts.selectedId}
<TrashPanel />
@@ -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;
}
}
@@ -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">
import { onDestroy, onMount } from "svelte";
import { onDestroy, onMount, tick } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { mediaUrl } from "#lib/api/client.js";
import { downloadMedia } from "#lib/api/download.js";
import {
getStorageChats,
getStorageFiles,
getStorageSummary,
moveToTrash,
@@ -11,12 +12,15 @@
} from "#lib/api/endpoints.js";
import { forgetMedia, type ViewerItem } from "#lib/api/media.js";
import type {
StorageChat,
StorageFile,
StorageGroupName,
StorageSummary,
TrashOutcome,
} from "#lib/api/types.js";
import ScopeFiles from "#lib/components/disk/ScopeFiles.svelte";
import SelectionBar from "#lib/components/disk/SelectionBar.svelte";
import StorageChatRow from "#lib/components/disk/StorageChatRow.svelte";
import StorageRow from "#lib/components/disk/StorageRow.svelte";
import MediaViewer from "#lib/components/MediaViewer.svelte";
import Button from "#lib/components/ui/Button.svelte";
@@ -31,16 +35,33 @@
messagesLabel,
} from "#lib/format/storage.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import {
type StorageListing,
storageUi,
} from "#lib/stores/storage.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
interface Props {
chatId?: number | null;
}
let { chatId = null }: Props = $props();
const PAGE = 30;
const UNDO_MS = 12_000;
const UNDO_GUARD_MS = 600;
const LISTINGS: { label: string; value: StorageListing }[] = [
{ label: "Файлы", value: "files" },
{ label: "Чаты", value: "chats" },
];
let summary = $state<StorageSummary | null>(null);
let files = $state<StorageFile[]>([]);
let heavyChats = $state<StorageChat[]>([]);
let scope = $state<ReturnType<typeof ScopeFiles>>();
let group = $state<StorageGroupName | null>(null);
let loading = $state(false);
let failed = $state(false);
@@ -54,10 +75,26 @@
let viewerIndex = $state(0);
const selected = new SvelteSet<number>();
let token = 0;
let scroller: HTMLElement | null = null;
let undoTimer: ReturnType<typeof setTimeout> | undefined;
let undoShownAt = 0;
const listing = $derived<StorageListing>(
chatId === null ? storageUi.listing : "files"
);
const shown = $derived(
listing === "chats" ? heavyChats.length : files.length
);
const heaviest = $derived(files[0]?.file_size ?? 0);
const heaviestChat = $derived(heavyChats[0]?.bytes ?? 0);
const emptiness = $derived.by(() => {
if (group !== null) {
return "В этой группе нет файлов.";
}
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,19 +119,43 @@
}))
);
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 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) {
return;
}
@@ -102,16 +163,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, limit);
if (current === token) {
done = loaded < limit;
}
chats.ensureMany(batch.map((file) => file.chat_id));
files = [...files, ...batch];
done = batch.length < PAGE;
} catch {
if (current === token) {
failed = true;
@@ -123,14 +178,34 @@
}
}
function reload() {
function reload(limit = PAGE) {
token += 1;
files = [];
heavyChats = [];
done = false;
loading = false;
selected.clear();
loadSummary();
loadMore();
return Promise.all([loadSummary(), loadMore(limit)]);
}
async function resume() {
const from = chatId === null ? storageUi.takeResume() : null;
await reload(Math.ceil(Math.max(1, from?.count ?? 0) / PAGE) * PAGE);
if (from && scroller) {
await tick();
scroller.scrollTop = from.top;
}
}
function openChat(id: number) {
storageUi.openChat(id, {
count: heavyChats.length,
top: scroller?.scrollTop ?? 0,
});
}
function hold(node: HTMLElement) {
scroller = node.parentElement;
}
function toggle(mediaId: number) {
@@ -171,6 +246,7 @@
}
files = files.filter((file) => !moved.has(file.media_id));
selected.clear();
scope?.refresh();
showUndo(outcome, trashed);
if (outcome.blocked.length > 0) {
problem = "Файл занят в сторис или аватаре и остался на диске";
@@ -194,6 +270,7 @@
files = [...files, ...restored.files].sort(
(a, b) => b.file_size - a.file_size
);
scope?.refresh();
await loadSummary();
} catch {
problem = "Не удалось вернуть файлы";
@@ -227,24 +304,45 @@
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));
</script>
<div class="storage">
<div class="storage" {@attach hold}>
{#if summary}
{#if summary.files > 0}
{#if summary.files > 0 || chatId !== null}
<section class="summary">
<div class="hero">
<span class="total">{formatBytes(summary.bytes)}</span>
<span class="total">{formatBytes(summary.bytes) || "0 B"}</span>
<span class="count">
{filesLabel(summary.files)}
{summary.trash_bytes > 0
? `· ещё ${formatBytes(summary.trash_bytes)} в корзине`
: "на диске"}
{#if chatId !== null}
в этом чате
{:else if summary.trash_bytes > 0}
· ещё {formatBytes(summary.trash_bytes)} в корзине
{:else}
на диске
{/if}
</span>
</div>
<div class="stack">
<div class="stack" class:bare={groups.length === 0}>
{#each groups as item (item.group)}
<span
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
@@ -252,7 +350,7 @@
style:flex-grow={item.bytes}
></span>
{/each}
{#if summary.trash_bytes > 0}
{#if chatId === null && summary.trash_bytes > 0}
<span
class="trashed"
title="Корзина: {formatBytes(summary.trash_bytes)}"
@@ -281,6 +379,7 @@
</section>
{/if}
{#if chatId === null}
<button
class="trash-link"
onclick={() => ui.openPanel("storage-trash")}
@@ -299,9 +398,33 @@
</span>
<Icon name="next" size="1.125rem" />
</button>
<div class="tabs">
{#each LISTINGS as item (item.value)}
<button
aria-pressed={listing === item.value}
class="tab"
onclick={() => show(item.value)}
type="button"
class:on={listing === item.value}
>
{item.label}
</button>
{/each}
</div>
{:else}
<ScopeFiles
label="Все файлы чата"
onchange={() => {
hideUndo();
reload();
}}
target={{ chatId }}
bind:this={scope}
/>
{/if}
{/if}
{#if files.length > 0}
{#if listing === "files" && files.length > 0}
<div class="section-title">
<h3>Самые тяжёлые</h3>
{#if summary && group === null && summary.heavy_files > 0}
@@ -314,7 +437,33 @@
</div>
{/if}
<div class="list">
<div class="list" class:chats={listing === "chats"}>
{#each heavyChats as chat (chat.chat_id)}
<StorageChatRow
{chat}
hasAvatar={chats.byId(chat.chat_id)?.has_avatar ?? false}
onopen={() => 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)}
<StorageRow
fileName={file.file_name}
@@ -327,8 +476,10 @@
selected={selected.has(file.media_id)}
size={file.file_size}
src={mediaUrl(file.media_id)}
sub={chatTitle(file.chat_id)}
tail={formatListDate(file.date)}
sub={chatId === null
? chatTitle(file.chat_id)
: formatListDate(file.date)}
tail={chatId === null ? formatListDate(file.date) : undefined}
weight={heaviest > 0 ? file.file_size / heaviest : 0}
>
{#snippet menu()}
@@ -356,18 +507,14 @@
{:else if failed}
<div class="state">
<p>Не удалось загрузить хранилище.</p>
<Button onclick={reload} smaller variant="text">Повторить</Button>
</div>
{:else if files.length === 0}
<div class="state">
<p>
{group === null
? "На диске нет скачанных файлов."
: "В этой группе нет файлов."}
</p>
<Button onclick={() => reload()} smaller variant="text">
Повторить
</Button>
</div>
{:else if shown === 0}
<div class="state"><p>{emptiness}</p></div>
{:else if !done}
<button class="more" onclick={loadMore} type="button">
<button class="more" onclick={() => loadMore()} type="button">
Показать ещё
</button>
{/if}
@@ -470,6 +617,11 @@
height: 0.625rem;
margin-top: 0.875rem;
&.bare {
border-radius: 0.3125rem;
background-color: rgba(var(--color-text-secondary-rgb), 0.16);
}
span {
min-width: 0.1875rem;
border-radius: 2px;
@@ -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 {
display: flex;
gap: 0.75rem;
@@ -639,6 +832,10 @@
.list {
flex: 1;
padding-bottom: 0.5rem;
&.chats {
padding-top: 0.375rem;
}
}
.state {
@@ -4,9 +4,6 @@
createPolicy,
deletePolicy,
getPolicyScope,
getStorageScope,
restoreFromTrash,
trashScope,
updatePolicy,
} from "#lib/api/endpoints.js";
import type {
@@ -15,13 +12,11 @@
PolicyRecord,
PolicyScopeType,
ScopeTarget,
StorageScope,
TrashOutcome,
} from "#lib/api/types.js";
import ScopeFiles from "#lib/components/disk/ScopeFiles.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes } from "#lib/format/media.js";
import {
parentLabel,
pickToggles,
@@ -32,8 +27,6 @@
TOGGLE_LABELS,
type ToggleKey,
} from "#lib/format/policy.js";
import { plural } from "#lib/format/shares.js";
import { filesLabel } from "#lib/format/storage.js";
interface Props {
onchange?: () => void;
@@ -62,10 +55,6 @@
let loading = $state(false);
let saving = $state(false);
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);
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 () => {
rule = initialRule ?? null;
parent = initialParent ?? null;
@@ -220,7 +158,6 @@
loading = false;
}
}
await loadFiles();
});
</script>
@@ -272,77 +209,8 @@
<p class="problem">{problem}</p>
{/if}
{#if moved && moved.files > 0}
<div class="files">
<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 target}
<ScopeFiles {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;
+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"
| "bots"
| "storage"
| "storage-chat"
| "storage-trash";
export type LeftView = "main" | "settings";