feat(frontend): storage, trash, policy, stories, shares, jobs, bots, media, login and notes speak russian and english

This commit is contained in:
hh committed 2026-10-19 03:53:08 +00:00
1 parent e5a5edcaae
commit 4a5c92f0e9
51 files changed
+1504 -588

No files matched your search

@@ -13,8 +13,9 @@
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";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
interface Props {
label?: string;
@@ -28,12 +29,12 @@
let confirming = $state(false);
let moving = $state(false);
let moved = $state<TrashOutcome | null>(null);
let problem = $state<string | null>(null);
let problem = $state<MessageKey | null>(null);
function where(scope: StorageScope): string {
return "folderId" in target
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки`
: "в этом чате";
? t("storage.inFolder", { count: scope.chats })
: t("storage.inChat");
}
async function load() {
@@ -56,7 +57,7 @@
await load();
onchange?.();
} catch {
problem = "Не удалось переместить файлы в корзину";
problem = "storage.scope.trashFailed";
} finally {
moving = false;
}
@@ -74,7 +75,7 @@
await load();
onchange?.();
} catch {
problem = "Не удалось вернуть файлы";
problem = "storage.restoreFailed";
} finally {
moving = false;
}
@@ -90,15 +91,15 @@
{#if moved && moved.files > 0}
<div class="files">
<div class="text">
<span>{filesLabel(moved.files)} в корзине</span>
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
<span>{t("storage.inTrash", { files: filesLabel(moved.files) })}</span>
<small>{t("storage.freed", { size: formatBytes(moved.bytes) })}</small>
{#if problem}
<small class="failed">{problem}</small>
<small class="failed">{t(problem)}</small>
{/if}
</div>
<Button disabled={moving} onclick={undo} smaller variant="text">
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
<span>{t("storage.restore")}</span>
</Button>
</div>
{:else if files && files.files > 0}
@@ -106,22 +107,19 @@
{#if confirming}
<div class="text">
<span>
{filesLabel(files.files)}, {formatBytes(files.bytes)},
{plural(
files.files,
"уйдёт в корзину и перестанет скачиваться сам",
"уйдут в корзину и перестанут скачиваться сами",
"уйдут в корзину и перестанут скачиваться сами"
)}.
{t("storage.scope.confirm", {
count: files.files,
files: filesLabel(files.files),
size: formatBytes(files.bytes),
})}
</span>
{#if files.gone > 0}
<small class="warn">
{files.gone}
из них потом не скачать заново
{t("storage.scope.gone", { count: files.gone })}
</small>
{/if}
{#if problem}
<small class="failed">{problem}</small>
<small class="failed">{t(problem)}</small>
{/if}
<div class="actions">
<Button
@@ -131,7 +129,7 @@
smaller
variant="text"
>
Отмена
{t("common.cancel")}
</Button>
<Button
disabled={moving}
@@ -141,7 +139,7 @@
smaller
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
<span>{t("storage.toTrash")}</span>
</Button>
</div>
</div>
@@ -156,18 +154,17 @@
</small>
{/if}
{:else}
<span>{formatBytes(files.bytes)} уже скачано</span>
<span>
{t("storage.scope.downloaded", { size: formatBytes(files.bytes) })}
</span>
<small>{filesLabel(files.files)} {where(files)}</small>
{/if}
{#if files.kept > 0}
<small>
Ещё {filesLabel(files.kept)} есть и в других чатах,
{plural(
files.kept,
"он останется",
"они останутся",
"они останутся"
)}
{t("storage.scope.kept", {
count: files.kept,
files: filesLabel(files.kept),
})}
</small>
{/if}
</div>
@@ -180,7 +177,7 @@
variant="gray"
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
<span>{t("storage.toTrash")}</span>
</Button>
{/if}
</div>
@@ -189,10 +186,10 @@
<div class="text">
<span>{label}</span>
<small>
{filesLabel(files.kept)}
есть и в других чатах, убрать
{plural(files.kept, "его", "их", "их")}
можно из списка ниже
{t("storage.scope.keptOnly", {
count: files.kept,
files: filesLabel(files.kept),
})}
</small>
</div>
</div>
@@ -4,7 +4,7 @@
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";
import { filesLabel, groupLabel } from "#lib/format/storage.js";
interface Props {
chat: StorageChat;
@@ -24,7 +24,7 @@
const parts = $derived(chat.groups.filter((item) => item.bytes > 0));
const makeup = $derived(
parts
.map((item) => `${GROUP_LABELS[item.group]}: ${formatBytes(item.bytes)}`)
.map((item) => `${groupLabel(item.group)}: ${formatBytes(item.bytes)}`)
.join(", ")
);
</script>
@@ -31,9 +31,11 @@
import { formatBytes } from "#lib/format/media.js";
import {
filesLabel,
GROUP_LABELS,
groupLabel,
messagesLabel,
} from "#lib/format/storage.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import {
@@ -53,9 +55,9 @@
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" },
const LISTINGS: { label: MessageKey; value: StorageListing }[] = [
{ label: "storage.listing.files", value: "files" },
{ label: "storage.listing.chats", value: "chats" },
];
let summary = $state<StorageSummary | null>(null);
@@ -70,7 +72,7 @@
let undo = $state<{ files: StorageFile[]; outcome: TrashOutcome } | null>(
null
);
let problem = $state<string | null>(null);
let problem = $state<MessageKey | null>(null);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const selected = new SvelteSet<number>();
@@ -89,12 +91,11 @@
const heaviestChat = $derived(heavyChats[0]?.bytes ?? 0);
const emptiness = $derived.by(() => {
if (group !== null) {
return "В этой группе нет файлов.";
return t("storage.empty.group");
}
return chatId === null
? "На диске нет скачанных файлов."
: "В этом чате нет скачанных файлов.";
return chatId === null ? t("storage.empty.all") : t("storage.empty.chat");
});
const failure = $derived(problem && t(problem));
const chosen = $derived(files.filter((file) => selected.has(file.media_id)));
const chosenBytes = $derived(
chosen.reduce((sum, file) => sum + file.file_size, 0)
@@ -120,7 +121,7 @@
);
function chatTitle(id: number): string {
return chats.byId(id)?.title ?? `Чат ${id}`;
return chats.byId(id)?.title ?? t("ui.chat", { id });
}
async function loadSummary() {
@@ -249,11 +250,11 @@
scope?.refresh();
showUndo(outcome, trashed);
if (outcome.blocked.length > 0) {
problem = "Файл занят в сторис или аватаре и остался на диске";
problem = "storage.blocked";
}
await loadSummary();
} catch {
problem = "Не удалось переместить в корзину";
problem = "storage.trashFailed";
} finally {
busy = false;
}
@@ -273,7 +274,7 @@
scope?.refresh();
await loadSummary();
} catch {
problem = "Не удалось вернуть файлы";
problem = "storage.restoreFailed";
}
}
@@ -281,7 +282,7 @@
try {
await downloadMedia(file.media_id);
} catch {
problem = "Не удалось скачать файл";
problem = "ui.downloadFailed";
}
}
@@ -330,22 +331,26 @@
{#if summary.files > 0 || chatId !== null}
<section class="summary">
<div class="hero">
<span class="total">{formatBytes(summary.bytes) || "0 B"}</span>
<span class="total">
{formatBytes(summary.bytes) || t("storage.zero")}
</span>
<span class="count">
{filesLabel(summary.files)}
{#if chatId !== null}
в этом чате
{t("storage.hero.chat", { files: filesLabel(summary.files) })}
{:else if summary.trash_bytes > 0}
· ещё {formatBytes(summary.trash_bytes)} в корзине
{t("storage.hero.trash", {
files: filesLabel(summary.files),
size: formatBytes(summary.trash_bytes),
})}
{:else}
на диске
{t("storage.hero.disk", { files: filesLabel(summary.files) })}
{/if}
</span>
</div>
<div class="stack" class:bare={groups.length === 0}>
{#each groups as item (item.group)}
<span
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
title="{groupLabel(item.group)}: {formatBytes(item.bytes)}"
style:background-color="var(--color-storage-{item.group})"
style:flex-grow={item.bytes}
></span>
@@ -353,7 +358,9 @@
{#if chatId === null && summary.trash_bytes > 0}
<span
class="trashed"
title="Корзина: {formatBytes(summary.trash_bytes)}"
title={t("storage.trash.title", {
size: formatBytes(summary.trash_bytes),
})}
style:flex-grow={summary.trash_bytes}
></span>
{/if}
@@ -371,7 +378,7 @@
class="dot"
style:background-color="var(--color-storage-{item.group})"
></span>
{GROUP_LABELS[item.group]}
{groupLabel(item.group)}
<span class="value">{formatBytes(item.bytes)}</span>
</button>
{/each}
@@ -386,14 +393,14 @@
type="button"
>
<Icon name="delete" size="1.375rem" />
<span class="label">Корзина</span>
<span class="label">{t("storage.trash")}</span>
<span class="value">
{#if summary.trash_files > 0}
<span class="swatch"></span>
{filesLabel(summary.trash_files)}
· {formatBytes(summary.trash_bytes)}
{:else}
пусто
{t("storage.trash.nothing")}
{/if}
</span>
<Icon name="next" size="1.125rem" />
@@ -407,13 +414,13 @@
type="button"
class:on={listing === item.value}
>
{item.label}
{t(item.label)}
</button>
{/each}
</div>
{:else}
<ScopeFiles
label="Все файлы чата"
label={t("storage.chatFiles")}
onchange={() => {
hideUndo();
reload();
@@ -426,12 +433,14 @@
{#if listing === "files" && files.length > 0}
<div class="section-title">
<h3>Самые тяжёлые</h3>
<h3>{t("storage.heaviest")}</h3>
{#if summary && group === null && summary.heavy_files > 0}
<span>
{filesLabel(summary.heavy_files)}
крупнее {formatBytes(summary.heavy_threshold)}
= {formatBytes(summary.heavy_bytes)}
{t("storage.heavy", {
files: filesLabel(summary.heavy_files),
size: formatBytes(summary.heavy_bytes),
threshold: formatBytes(summary.heavy_threshold),
})}
</span>
{/if}
</div>
@@ -448,17 +457,17 @@
>
{#snippet menu()}
<ContextMenuItem icon="data" onselect={() => openChat(chat.chat_id)}>
Файлы чата
{t("storage.menu.chatFiles")}
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => visit(chat)}>
Перейти в чат
{t("storage.menu.openChat")}
</ContextMenuItem>
<ContextMenuItem
icon="settings"
onselect={() =>
policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))}
>
Политика захвата
{t("storage.menu.policy")}
</ContextMenuItem>
{/snippet}
</StorageChatRow>
@@ -484,19 +493,19 @@
>
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть
{t("ui.open")}
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(file)}>
Перейти к сообщению
{t("ui.jumpToMessage")}
</ContextMenuItem>
<ContextMenuItem icon="download" onselect={() => save(file)}>
Скачать файл
{t("ui.downloadFile")}
</ContextMenuItem>
<ContextMenuItem
icon="delete"
onselect={() => trash([file.media_id])}
>
В корзину
{t("storage.toTrash")}
</ContextMenuItem>
{/snippet}
</StorageRow>
@@ -506,29 +515,31 @@
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить хранилище.</p>
<p>{t("storage.loadFailed")}</p>
<Button onclick={() => reload()} smaller variant="text">
Повторить
{t("ui.retry")}
</Button>
</div>
{:else if shown === 0}
<div class="state"><p>{emptiness}</p></div>
{:else if !done}
<button class="more" onclick={() => loadMore()} type="button">
Показать ещё
{t("ui.showMore")}
</button>
{/if}
</div>
{#if chosen.length > 0}
<SelectionBar
failure={problem}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
{failure}
note={chosenGone > 0
? t("storage.cantRefetch", { count: chosenGone })
: undefined}
title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}"
warn
>
<Button onclick={() => selected.clear()} smaller variant="text">
Снять
{t("storage.deselect")}
</Button>
<Button
disabled={busy}
@@ -538,22 +549,22 @@
smaller
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
<span>{t("storage.toTrash")}</span>
</Button>
</SelectionBar>
{:else if undo}
<SelectionBar
failure={problem}
note="{formatBytes(undo.outcome.bytes)} освободится через 7 дней"
title="{filesLabel(undo.outcome.files)} в корзине"
{failure}
note={t("storage.freed", { size: formatBytes(undo.outcome.bytes) })}
title={t("storage.inTrash", { files: filesLabel(undo.outcome.files) })}
>
<Button onclick={restoreLast} smaller variant="text">
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
<span>{t("storage.restore")}</span>
</Button>
</SelectionBar>
{:else if problem}
<SelectionBar failure={problem} title="Не получилось">
{:else if failure}
<SelectionBar {failure} title={t("storage.failed")}>
<Button
onclick={() => {
problem = null;
@@ -561,7 +572,7 @@
smaller
variant="text"
>
Понятно
{t("ui.gotIt")}
</Button>
</SelectionBar>
{/if}
@@ -5,6 +5,7 @@
import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js";
import { groupOf } from "#lib/format/storage.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
fileName: string;
@@ -54,7 +55,7 @@
>
<span class="check">
<input
aria-label="Выбрать {fileName}"
aria-label={t("storage.select", { name: fileName })}
checked={selected}
onchange={ontoggle}
type="checkbox"
@@ -82,7 +83,7 @@
<span class="note">{note}</span>
{/if}
{#if !refetchable}
<span class="gone">Не скачать заново</span>
<span class="gone">{t("storage.gone")}</span>
{/if}
</span>
<span class="size">{formatBytes(size)}</span>
@@ -19,12 +19,13 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes } from "#lib/format/media.js";
import { plural } from "#lib/format/shares.js";
import {
filesLabel,
messagesLabel,
purgeLabel,
} from "#lib/format/storage.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
@@ -40,7 +41,7 @@
let failed = $state(false);
let busy = $state(false);
let doomed = $state<TrashItem[] | null>(null);
let problem = $state<string | null>(null);
let problem = $state<MessageKey | null>(null);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const selected = new SvelteSet<string>();
@@ -58,6 +59,7 @@
const doomedGone = $derived(
doomed?.filter((item) => !item.refetchable).length ?? 0
);
const failure = $derived(problem && t(problem));
const viewerItems = $derived<ViewerItem[]>(
items.map((item) => ({
caption: `${formatBytes(item.file_size)} · ${chatTitle(item.chat_id)}`,
@@ -77,7 +79,7 @@
}
function chatTitle(chatId: number): string {
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
return chats.byId(chatId)?.title ?? t("ui.chat", { id: chatId });
}
async function loadTotal() {
@@ -132,7 +134,7 @@
await restoreFromTrash(keys);
drop(keys);
} catch {
problem = "Не удалось вернуть файлы";
problem = "storage.restoreFailed";
} finally {
busy = false;
}
@@ -148,7 +150,7 @@
await restoreFromTrash(null);
await load();
} catch {
problem = "Не удалось вернуть файлы";
problem = "storage.restoreFailed";
} finally {
busy = false;
}
@@ -167,7 +169,7 @@
everything = false;
await load();
} catch {
problem = "Не удалось удалить файлы";
problem = "storage.trash.deleteFailed";
} finally {
busy = false;
}
@@ -190,15 +192,12 @@
</script>
<div class="trash">
<p class="note">
Файлы лежат здесь 7 дней, потом удаляются с диска. До этого их можно
вернуть.
</p>
<p class="note">{t("storage.trash.note")}</p>
{#if total.files > 0}
<button class="act" disabled={busy} onclick={restoreAll} type="button">
<Icon name="undo" size="1.375rem" />
<span class="label">Вернуть всё</span>
<span class="label">{t("storage.trash.restoreAll")}</span>
<span class="value">{filesLabel(total.files)}</span>
</button>
<button
@@ -209,7 +208,7 @@
type="button"
>
<Icon name="delete" size="1.375rem" />
<span class="label">Очистить корзину</span>
<span class="label">{t("storage.trash.clear")}</span>
<span class="value">{formatBytes(total.bytes)}</span>
</button>
{/if}
@@ -233,13 +232,13 @@
>
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть
{t("ui.open")}
</ContextMenuItem>
<ContextMenuItem icon="undo" onselect={() => restore([item])}>
Вернуть
{t("storage.restore")}
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(item)}>
Перейти к сообщению
{t("ui.jumpToMessage")}
</ContextMenuItem>
<ContextMenuItem
icon="delete"
@@ -247,7 +246,7 @@
doomed = [item];
}}
>
Удалить насовсем
{t("storage.trash.deleteForever")}
</ContextMenuItem>
{/snippet}
</StorageRow>
@@ -257,22 +256,24 @@
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить корзину.</p>
<Button onclick={load} smaller variant="text">Повторить</Button>
<p>{t("storage.trash.loadFailed")}</p>
<Button onclick={load} smaller variant="text">{t("ui.retry")}</Button>
</div>
{:else if items.length === 0}
<div class="state"><p>Корзина пуста.</p></div>
<div class="state"><p>{t("storage.trash.empty")}</p></div>
{:else if !done}
<button class="more" onclick={loadMore} type="button">
Показать ещё
{t("ui.showMore")}
</button>
{/if}
</div>
{#if chosen.length > 0}
<SelectionBar
failure={dialogOpen ? null : problem}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
failure={dialogOpen ? null : failure}
note={chosenGone > 0
? t("storage.cantRefetch", { count: chosenGone })
: undefined}
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
warn
>
@@ -284,7 +285,7 @@
smaller
variant="danger"
>
Удалить
{t("common.delete")}
</Button>
<Button
disabled={busy}
@@ -294,11 +295,11 @@
smaller
>
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
<span>{t("storage.restore")}</span>
</Button>
</SelectionBar>
{:else if problem && !dialogOpen}
<SelectionBar failure={problem} title="Не получилось">
{:else if failure && !dialogOpen}
<SelectionBar {failure} title={t("storage.failed")}>
<Button
onclick={() => {
problem = null;
@@ -306,7 +307,7 @@
smaller
variant="text"
>
Понятно
{t("ui.gotIt")}
</Button>
</SelectionBar>
{/if}
@@ -327,59 +328,53 @@
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">
{everything ? "Очистить корзину?" : "Удалить насовсем?"}
{everything
? t("storage.trash.clearTitle")
: t("storage.trash.deleteTitle")}
</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
{#if everything}
<p class="confirm">
{filesLabel(total.files)}, {formatBytes(total.bytes)},
{plural(
total.files,
"будет удалён",
"будут удалены",
"будут удалены"
)}
с диска.
</p>
<p class="confirm rest">
То, что ещё есть в Telegram, потом можно скачать заново. Файлы из
удалённых там сообщений пропадут насовсем.
{t("storage.trash.willDelete", {
count: total.files,
files: filesLabel(total.files),
size: formatBytes(total.bytes),
})}
</p>
<p class="confirm rest">{t("storage.trash.clearRest")}</p>
{:else if doomed}
<p class="confirm">
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))},
{plural(
doomed.length,
"будет удалён",
"будут удалены",
"будут удалены"
)}
с диска.
{t("storage.trash.willDelete", {
count: doomed.length,
files: filesLabel(doomed.length),
size: formatBytes(bytesOf(doomed)),
})}
</p>
{#if doomedGone > 0}
<p class="confirm warn">
<Icon name="warning" size="1.25rem" />
<span>
{filesLabel(doomedGone)}
скачать заново нельзя: в Telegram
{doomedGone === 1 ? "его" : "их"}
уже нет.
{t("storage.trash.goneWarn", {
count: doomedGone,
files: filesLabel(doomedGone),
})}
</span>
</p>
{/if}
{#if doomedGone < doomed.length}
<p class="confirm rest">
{doomedGone === 0 ? "Потом их" : "Остальные"}
можно скачать из Telegram заново.
{doomedGone === 0
? t("storage.trash.laterAll", { count: doomed.length })
: t("storage.trash.laterRest")}
</p>
{/if}
{/if}
{#if problem}
<p class="confirm failed">{problem}.</p>
{#if failure}
<p class="confirm failed">{failure}.</p>
{/if}
</div>
<div class="dialog-actions">
@@ -387,7 +382,9 @@
{#snippet child({
props,
})}
<Button {...props} pill variant="text">Отмена</Button>
<Button {...props} pill variant="text">
{t("common.cancel")}
</Button>
{/snippet}
</Dialog.Close>
<Button
@@ -397,7 +394,7 @@
pill
variant="danger"
>
Удалить
{t("common.delete")}
</Button>
</div>
</Dialog.Content>