Compare commits
14
Commits
0362777a51
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21e99afae | ||
|
|
597c6e82dd | ||
|
|
9191ef9ad4 | ||
|
|
7697ba3e3c | ||
|
|
e3559da2a7 | ||
|
|
dcd6786ca5 | ||
|
|
32d58193f3 | ||
|
|
59d0e2c8ad | ||
|
|
850d39ad6f | ||
|
|
b8a38092eb | ||
|
|
2836dcb3a9 | ||
|
|
01cf01b629 | ||
|
|
7f124059be | ||
|
|
5eacabd101 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
Generated
+29
-18
@@ -21,16 +21,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.20.0"
|
||||
version = "1.20.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/93/1c/00909e0fbebad2121d916fdcab044cae988146680e9ee4387ba25fef8747/alembic-1.20.1.tar.gz", hash = "sha256:83490def1f723413db5096591f7609ac52544805be93849170088ed04f5a2e0b", size = 2103576, upload-time = "2026-10-22T03:24:35.93Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/3c/8740f5b4e1bd57333e4f84265b9b6363da4f7cf70f5622dd1e2fde7a0bd5/alembic-1.20.1-py3-none-any.whl", hash = "sha256:9970712cfb81ea26c8658d378aaa5b8f6ff9146e5bb111d70497c164016cf598", size = 270355, upload-time = "2026-10-22T03:24:37.667Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -226,6 +226,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.10.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
@@ -411,7 +420,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.143.0"
|
||||
version = "0.159.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -421,26 +430,26 @@ 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/9a/40/a212bffb9809afb9a94b250f11e0c29fd8b2589ec92e4bedd1cc7adf9938/fastapi-0.159.1.tar.gz", hash = "sha256:b45b1ec57f3aa4d2f80f09bfbcf068b46764a2ee892e56753f35bb3d9d845ce2", size = 522004, upload-time = "2026-10-21T20:59:29.054Z" }
|
||||
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/8d/35/b03b5df04a1a8ee400c9df4b1795af84370b2bb6f1cc4be57c0b79688d00/fastapi-0.159.1-py3-none-any.whl", hash = "sha256:49b94c29afec2fca1390e9c39be202c59755d5cda8e4f1148a133761e4313e7e", size = 149604, upload-time = "2026-10-21T20:59:27.58Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "4.1.0"
|
||||
version = "4.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastmcp-slim", extra = ["client", "server"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/c4/fe4e24af2cb03f4701f45634873d87557c2b5d283d287412345345ad0ede/fastmcp-4.1.0.tar.gz", hash = "sha256:7a8bf4e58cc6c2f3a8552b5a7a17ba1e4682b6fd4cafb7f461920a66dbd2499f", size = 42686511, upload-time = "2026-10-08T22:57:05.652Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/f4/79169f9a2419e1f541b3b39b1bda1c57569b4c7c1ac4b606750c81471ad1/fastmcp-4.1.3.tar.gz", hash = "sha256:a1f79e9db929a1603c75331df603f74530fc236aa711e0ee2e185821c6d9888d", size = 42755717, upload-time = "2026-10-20T22:32:28.005Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/81/77610de5d79187455ec4a4eafdc17942365f1bc4e5850633e50159e9afdc/fastmcp-4.1.0-py3-none-any.whl", hash = "sha256:cf19fd603315b48ef1829c8579431e77793feafdcbed51f0dc5607af10a24e9b", size = 8154, upload-time = "2026-10-08T22:57:02.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/7f/27c501d98b5681ca4f6c21a7d741b3e732e962c5e4c3b4db6dd1ffdf4fd8/fastmcp-4.1.3-py3-none-any.whl", hash = "sha256:b983d8acc44ab1143e0c86fd6da843f25043ee0aeb64ab983b36c527bd1d33e9", size = 8153, upload-time = "2026-10-20T22:32:22.881Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp-slim"
|
||||
version = "4.1.0"
|
||||
version = "4.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mcp-types" },
|
||||
@@ -451,9 +460,9 @@ dependencies = [
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/88/85aafec43ec9940a360d75f2d8e9517eda01ac38b8b1610b0e9e125d61d3/fastmcp_slim-4.1.0.tar.gz", hash = "sha256:390b2b430195e0cdd0327aff80fe5dceb862b2421baf71de6d4eec92f80f99f8", size = 734647, upload-time = "2026-10-08T22:56:40.642Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/86/01705c74106297c021e5f8a22ed051adb43061cc779cf543dc05a4d47aba/fastmcp_slim-4.1.3.tar.gz", hash = "sha256:d95277db0979e4cbfa84528cf9016ae079494b41ed8e88cc909fee8233db6035", size = 749467, upload-time = "2026-10-20T22:30:58.047Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/6a/d61e1541f4c65872146a576c1379f6f32013fd8c636bcaf0f8e70a04e533/fastmcp_slim-4.1.0-py3-none-any.whl", hash = "sha256:6edb48a78e508ef5ac691bf2a68f3e5000403773655d859eceea1068b894df4a", size = 911585, upload-time = "2026-10-08T22:56:39.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ff/a7feaeb130a2003700789ce32e933b4c2ae00fc7c264642e57ab12e4684f/fastmcp_slim-4.1.3-py3-none-any.whl", hash = "sha256:81af8699331c33d438caf85b6947f36df6e2f04118e6f318ca00374fd9afea9a", size = 931046, upload-time = "2026-10-20T22:30:56.182Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -470,6 +479,7 @@ client = [
|
||||
server = [
|
||||
{ name = "authlib" },
|
||||
{ name = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||
{ name = "certifi" },
|
||||
{ name = "cyclopts" },
|
||||
{ name = "exceptiongroup" },
|
||||
{ name = "griffelib" },
|
||||
@@ -912,14 +922,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openapi-pydantic"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/32/0c9bd3e4e847cd6117b64dbef4cf810faa9cdbd6689323b811569cc8a1b8/openapi_pydantic-0.6.0.tar.gz", hash = "sha256:11f3ac6ad41521fc156381ec587246b0c0ecea523bd9565ed74d3e7fac9d91cd", size = 1667049, upload-time = "2026-10-02T12:20:00.508Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/57/9d3eb823a03c33e9d4c810e6a3276e3a6dc820a7fdc0961db2c04e613817/openapi_pydantic-0.6.0-py3-none-any.whl", hash = "sha256:8bd6d548a250da37db96f461bcd52b3468e8834ad7d6db14b00a6b2ebbd163a3", size = 96931, upload-time = "2026-10-02T12:19:58.917Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1080,16 +1090,17 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.15.0"
|
||||
version = "2.16.1"
|
||||
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/05/ca/7817b4bf0c4b15c6a6ab8cdf4cb96c268335a8ca2f525de93b6ebe4c7d28/pydantic_settings-2.16.1.tar.gz", hash = "sha256:aff602a1886338c2df3d1e958e2193a8dc33cec75b91f202e83fd98484b4b0da", size = 303436, upload-time = "2026-10-20T11:37:19.024Z" }
|
||||
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/3c/74/fdf926afe145d8629c4b2ff57f4ea1d64ed7abc4d84d97463b2517a2547e/pydantic_settings-2.16.1-py3-none-any.whl", hash = "sha256:30f5313340da4e8f1b440d1910b92a5bc3ccbb6b4ba70ec2b8395bc921f418f5", size = 80771, upload-time = "2026-10-20T11:37:17.298Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -16,6 +16,7 @@ export type RightPanel =
|
||||
| "shares"
|
||||
| "bots"
|
||||
| "storage"
|
||||
| "storage-chat"
|
||||
| "storage-trash";
|
||||
|
||||
export type LeftView = "main" | "settings";
|
||||
|
||||
Reference in new issue
Block a user