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

@@ -1,6 +1,7 @@
<script lang="ts">
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { auth } from "#lib/stores/auth.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -20,7 +21,7 @@
await accounts.load();
await goto("/app");
} catch {
toasts.error("Invalid token");
toasts.error(t("login.invalid"));
} finally {
busy = false;
}
@@ -33,16 +34,17 @@
<Icon name="lock" size="2.75rem" />
</div>
<h1>Beavergram</h1>
<p class="subtitle">Enter your access token to continue.</p>
<p class="subtitle">{t("login.subtitle")}</p>
<input
aria-label={t("login.token")}
autocomplete="current-password"
class="form-control"
placeholder="Access token"
placeholder={t("login.token")}
type="password"
bind:value
>
<Button disabled={busy || !value.trim()} loading={busy} type="submit">
Log in
{t("login.submit")}
</Button>
</form>
</div>
+21 -18
View File
@@ -15,6 +15,7 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -61,7 +62,9 @@
const canSave = $derived(
currentMediaId !== null && (fileOnly || result?.state === "ready")
);
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
const title = $derived(
fileName ?? mediaKindLabel(kind) ?? t("media.kind.other")
);
const caption = $derived(items[index]?.caption);
function revoke() {
@@ -124,7 +127,7 @@
}
} catch {
if (current === token) {
toasts.error("Failed to load media");
toasts.error(t("media.loadFailed"));
}
} finally {
if (current === token) {
@@ -139,9 +142,9 @@
}
try {
await fetchMedia(chatId, messageId);
toasts.success("Скачивание поставлено в очередь");
toasts.success(t("media.queued"));
} catch {
toasts.error("Не удалось поставить в очередь");
toasts.error(t("media.queueFailed"));
}
}
@@ -153,7 +156,7 @@
try {
await downloadMedia(currentMediaId);
} catch {
toasts.error("Не удалось скачать файл");
toasts.error(t("ui.downloadFailed"));
} finally {
saving = false;
}
@@ -225,7 +228,7 @@
{#if canSave}
{#if ontrash}
<button
aria-label="В корзину"
aria-label={t("storage.toTrash")}
class="media-action"
onclick={trash}
type="button"
@@ -239,7 +242,7 @@
})}
<button
{...props}
aria-label="Скачать файл"
aria-label={t("ui.downloadFile")}
class="media-action"
onclick={save}
type="button"
@@ -249,15 +252,15 @@
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="download" onselect={save}>
Скачать файл
{t("ui.downloadFile")}
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
{t("ui.shareLink")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
<button
aria-label="Доступ по ссылке"
aria-label={t("ui.shareLink")}
class="media-action"
onclick={share}
type="button"
@@ -265,7 +268,7 @@
<Icon name="allow-share" size="1.375rem" />
</button>
{/if}
<Dialog.Close aria-label="Закрыть" class="media-close">
<Dialog.Close aria-label={t("common.close")} class="media-close">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
</div>
@@ -276,7 +279,7 @@
{#if loading}
<Spinner color="white" />
{:else if result?.state === "ready" && isImage}
<img alt={kind} class="media-image" src={result.url}>
<img alt={title} class="media-image" src={result.url}>
{:else if result?.state === "ready" && isVideo}
<!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
@@ -301,24 +304,24 @@
{#if currentMediaId !== null}
<button class="media-download" onclick={save} type="button">
<Icon name="download" />
Скачать файл
{t("ui.downloadFile")}
</button>
{/if}
</div>
{:else if result?.state === "not-downloaded"}
<div class="media-message">
<p>Файл ещё не скачан в архив.</p>
<p>{t("media.notDownloaded")}</p>
<Button fluid onclick={queueFetch} variant="primary">
Скачать в архив
{t("media.downloadToArchive")}
</Button>
</div>
{:else if result?.state === "missing"}
<p class="media-message">Файл не найден.</p>
<p class="media-message">{t("media.missing")}</p>
{/if}
</div>
{#if hasNav}
<button
aria-label="Previous"
aria-label={t("ui.previous")}
class="media-nav prev"
disabled={index === 0}
onclick={() => step(-1)}
@@ -327,7 +330,7 @@
<Icon name="previous" size="1.75rem" />
</button>
<button
aria-label="Next"
aria-label={t("ui.next")}
class="media-nav next"
disabled={index === items.length - 1}
onclick={() => step(1)}
+10 -15
View File
@@ -2,6 +2,7 @@
import { enqueueBackfill, trackChat } from "#lib/api/endpoints.js";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js";
@@ -17,7 +18,7 @@
const item = $derived(discover.get(chatId));
const tracked = $derived(item?.tracked ?? false);
const title = $derived(item?.title ?? "Этот чат");
const title = $derived(item?.title ?? t("track.thisChat"));
$effect(() => {
if (accounts.selectedId === null) {
@@ -33,10 +34,10 @@
busy = true;
try {
discover.set(await trackChat(chatId, true));
toasts.success("Отслеживание включено, история загружается");
toasts.success(t("track.started"));
chats.refresh();
} catch {
toasts.error("Не удалось включить отслеживание");
toasts.error(t("track.failed"));
} finally {
busy = false;
}
@@ -49,9 +50,9 @@
busy = true;
try {
await enqueueBackfill(chatId, true, true);
toasts.success("Полный бэкфилл запущен");
toasts.success(t("jobs.backfillStarted"));
} catch {
toasts.error("Не удалось запустить бэкфилл");
toasts.error(t("jobs.backfillFailed"));
} finally {
busy = false;
}
@@ -59,26 +60,20 @@
</script>
<div class="track">
<div class="title">Здесь пока нет сообщений</div>
<div class="title">{t("track.empty")}</div>
<p class="description">
{#if tracked}
{title}
отслеживается — новые сообщения и статистика собираются автоматически.
{:else}
Включите отслеживание, чтобы собирать сообщения, медиа и статистику по
этому чату.
{/if}
{tracked ? t("track.tracked", { title }) : t("track.hint")}
</p>
<div class="actions">
{#if tracked}
<Button loading={busy} onclick={backfill} pill variant="secondary">
<Icon name="cloud-download" />
<span>Загрузить историю</span>
<span>{t("track.loadHistory")}</span>
</Button>
{:else}
<Button loading={busy} onclick={track} pill variant="primary">
<Icon name="stats" />
<span>Начать отслеживать</span>
<span>{t("track.start")}</span>
</Button>
{/if}
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
import { untrack } from "svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import Button from "../ui/Button.svelte";
interface Props {
@@ -13,7 +14,7 @@
const {
initial = "",
saving = false,
submitLabel = "Добавить заметку",
submitLabel,
oncancel,
onsubmit,
}: Props = $props();
@@ -41,15 +42,19 @@
}}
>
<textarea
placeholder="Заметка к сообщению…"
placeholder={t("notes.placeholder")}
rows={oncancel ? 2 : 3}
bind:value={text}
></textarea>
<div class="actions">
{#if oncancel}
<Button onclick={oncancel} variant="translucent">Отмена</Button>
<Button onclick={oncancel} variant="translucent">
{t("common.cancel")}
</Button>
{/if}
<Button disabled={saving || !valid} type="submit">{submitLabel}</Button>
<Button disabled={saving || !valid} type="submit">
{submitLabel ?? t("notes.add")}
</Button>
</div>
</form>
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Annotation } from "#lib/api/types.js";
import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import Icon from "../ui/Icon.svelte";
import AnnotationEditor from "./AnnotationEditor.svelte";
@@ -28,14 +29,14 @@
onupdate(annotation, text);
editingId = null;
}}
submitLabel="Сохранить"
submitLabel={t("common.save")}
/>
{:else}
<p class="text">{annotation.text}</p>
<div class="meta">
<span class="time">{formatFull(annotation.updated_at)}</span>
<button
aria-label="Изменить"
aria-label={t("notes.edit")}
class="action"
onclick={() => {
editingId = annotation.id;
@@ -45,7 +46,7 @@
<Icon name="edit" size="1rem" />
</button>
<button
aria-label="Удалить"
aria-label={t("common.delete")}
class="action"
onclick={() => ondelete(annotation)}
type="button"
@@ -9,6 +9,7 @@
import type { Annotation } from "#lib/api/types.js";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state";
@@ -75,7 +76,7 @@
</script>
{#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Заметки" />
<EmptyState description={t("ui.noMessage")} title={t("notes.title")} />
{:else}
<div class="panel-content">
<AnnotationEditor onsubmit={add} {saving} />
@@ -8,6 +8,8 @@
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 { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
@@ -17,14 +19,19 @@
let token = $state("");
let adding = $state(false);
const statusLabels: Record<string, string> = {
error: "ошибка",
ok: "слушает",
pending: "подключается",
removed: "отключён",
unauthorized: "токен отклонён",
const STATUS_KEYS: Record<string, MessageKey> = {
error: "bots.status.error",
ok: "bots.status.ok",
pending: "bots.status.pending",
removed: "bots.status.removed",
unauthorized: "bots.status.unauthorized",
};
function statusLabel(status: string): string {
const key = STATUS_KEYS[status];
return key ? t(key) : status;
}
async function load() {
loading = true;
try {
@@ -49,7 +56,7 @@
await load();
await accounts.load();
} catch {
toasts.error("Не похоже на токен бота");
toasts.error(t("bots.badToken"));
} finally {
adding = false;
}
@@ -81,7 +88,7 @@
{#if loading}
<Spinner />
{:else if bots.length === 0}
<p class="empty">Ботов пока нет.</p>
<p class="empty">{t("bots.empty")}</p>
{:else}
<ul class="bots">
{#each bots as bot (bot.bot_id)}
@@ -94,9 +101,9 @@
>
<span class="name">{botName(bot)}</span>
<span class="meta">
{statusLabels[bot.status] ?? bot.status}
{statusLabel(bot.status)}
{#if bot.account_id !== null}
· аккаунт {bot.account_id}
· {t("bots.account", { id: bot.account_id })}
{/if}
</span>
{#if bot.status_detail}
@@ -105,7 +112,7 @@
</button>
{#if bot.enabled}
<Button
aria-label="Отключить"
aria-label={t("bots.disconnect")}
onclick={() => remove(bot)}
round
smaller
@@ -121,25 +128,14 @@
<div class="add">
<label class="field">
<span>Токен бота от @BotFather</span>
<span>{t("bots.token")}</span>
<input placeholder="123456:AA…" type="password" bind:value={token}>
</label>
<Button fluid loading={adding} onclick={add}>Добавить</Button>
<Button fluid loading={adding} onclick={add}>{t("bots.add")}</Button>
</div>
<p class="note">
Бот входит по MTProto отдельной авторизацией — это не отнимает getUpdates у
того, кто держит того же бота через Bot API, и не требует вебхука. Дальше он
становится обычным аккаунтом архива: чаты, сообщения, медиа и участники
групп видны там же, где переписка остальных аккаунтов.
</p>
<p class="note">
Прошлое подтягивается из очереди недоставленных апдейтов, которую Telegram
держит для бота; история диалогов боту запрещена, глубже этой очереди
заглянуть нельзя. В личке бот видит каждого, кто ему написал; в группах всё
— только при выключенном privacy mode. Отключение убирает токен и сессию,
архив остаётся.
</p>
<p class="note">{t("bots.login")}</p>
<p class="note">{t("bots.history")}</p>
</div>
<style lang="scss">
@@ -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>
+35 -29
View File
@@ -3,6 +3,8 @@
import type { JobStatus, JobView } from "#lib/api/types.js";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -14,25 +16,22 @@
const POLL_MS = 2000;
const KIND_LABELS: Record<string, string> = {
backfill: "Бэкфилл",
backfill_stories: "Бэкфилл сторис",
enrich_chat: "Обогащение чата",
fetch_avatar: "Аватар",
fetch_custom_emoji: "Кастом-эмодзи",
fetch_media: "Докачка медиа",
sync_dialogs: "Синхронизация диалогов",
transcribe: "Расшифровка",
const KIND_KEYS: Record<string, MessageKey> = {
backfill: "jobs.kind.backfill",
backfill_stories: "jobs.kind.backfill_stories",
enrich_chat: "jobs.kind.enrich_chat",
fetch_avatar: "jobs.kind.fetch_avatar",
fetch_custom_emoji: "jobs.kind.fetch_custom_emoji",
fetch_media: "jobs.kind.fetch_media",
fetch_read_info: "jobs.kind.fetch_read_info",
sync_contacts: "jobs.kind.sync_contacts",
sync_dialogs: "jobs.kind.sync_dialogs",
transcribe: "jobs.kind.transcribe",
};
const STATUS_LABELS: Record<JobStatus, string> = {
canceled: "Отменено",
done: "Готово",
failed: "Ошибка",
paused: "Пауза",
pending: "В очереди",
running: "Выполняется",
};
function statusLabel(status: JobStatus): string {
return t(`jobs.status.${status}`);
}
let jobs = $state<JobView[]>([]);
let loading = $state(true);
@@ -56,7 +55,7 @@
await cancelJob(job.id);
await load();
} catch {
toasts.error("Не удалось остановить задачу");
toasts.error(t("jobs.stopFailed"));
} finally {
canceling = null;
}
@@ -81,11 +80,14 @@
}
function kindLabel(job: JobView): string {
const label = KIND_LABELS[job.kind] ?? job.kind;
const key = KIND_KEYS[job.kind];
const kind = key ? t(key) : job.kind;
if (job.kind !== "backfill") {
return label;
return kind;
}
return job.params.full ? `${label} (полный)` : `${label} (новые)`;
return job.params.full
? t("jobs.kind.full", { kind })
: t("jobs.kind.newer", { kind });
}
function processed(job: JobView): number | null {
@@ -116,10 +118,12 @@
{#if loading}
<div class="state"><Spinner /></div>
{:else if jobs.length === 0}
<div class="state empty">Задач нет</div>
<div class="state empty">{t("jobs.empty")}</div>
{:else}
<div class="jobs">
{#each jobs as job (job.id)}
{@const chat = chatId(job)}
{@const count = processed(job)}
<div class="job">
<div class="job-head">
<span class="kind">{kindLabel(job)}</span>
@@ -130,20 +134,22 @@
onclick={() => cancel(job)}
type="button"
>
Стоп
{t("jobs.stop")}
</button>
{/if}
<span class="badge {job.status}">{STATUS_LABELS[job.status]}</span>
<span class="badge {job.status}">{statusLabel(job.status)}</span>
</div>
<div class="meta">
{#if chatId(job) !== null}
<span>чат {chatId(job)}</span>
{#if chat !== null}
<span>{t("jobs.chat", { id: chat })}</span>
{/if}
{#if processed(job) !== null}
<span>обработано {processed(job)}</span>
{#if count !== null}
<span>
{t("jobs.processed", { count: count.toLocaleString(locale.tag) })}
</span>
{/if}
{#if job.flood_waits > 0}
<span>flood-wait {job.flood_waits}</span>
<span>{t("jobs.floodWaits", { count: job.flood_waits })}</span>
{/if}
<span class="time">{formatFull(job.created_at)}</span>
</div>
@@ -8,6 +8,7 @@
import JobList from "#lib/components/jobs/JobList.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -39,9 +40,9 @@
function chatTitle(id: number | null): string {
if (id === null) {
return "Чат не выбран";
return t("jobs.noChat");
}
return chats.byId(id)?.title ?? `Чат ${id}`;
return chats.byId(id)?.title ?? t("ui.chat", { id });
}
function selectChat(id: number) {
@@ -57,10 +58,10 @@
starting = true;
try {
await enqueueBackfill(selected, media, true);
toasts.success("Полный бэкфилл запущен");
toasts.success(t("jobs.backfillStarted"));
version += 1;
} catch {
toasts.error("Не удалось запустить бэкфилл");
toasts.error(t("jobs.backfillFailed"));
} finally {
starting = false;
}
@@ -73,10 +74,10 @@
syncing = true;
try {
await syncDialogs();
toasts.success("Синхронизация диалогов запущена");
toasts.success(t("jobs.syncStarted"));
version += 1;
} catch {
toasts.error("Не удалось синхронизировать диалоги");
toasts.error(t("jobs.syncFailed"));
} finally {
syncing = false;
}
@@ -89,10 +90,10 @@
syncingContacts = true;
try {
await syncContacts();
toasts.success("Синхронизация контактов запущена");
toasts.success(t("jobs.contactsStarted"));
version += 1;
} catch {
toasts.error("Не удалось синхронизировать контакты");
toasts.error(t("jobs.contactsFailed"));
} finally {
syncingContacts = false;
}
@@ -105,21 +106,15 @@
<div class="panel">
<section>
<div class="section-title">Диалоги</div>
<p class="hint">
Синхронизация подтягивает все диалоги аккаунта — старые чаты станут видны
и доступны для бэкфилла.
</p>
<div class="section-title">{t("jobs.dialogs")}</div>
<p class="hint">{t("jobs.dialogsHint")}</p>
<div class="action">
<Button fluid loading={syncing} onclick={sync} variant="secondary">
<Icon name="reload" />
<span>Синхронизировать диалоги</span>
<span>{t("jobs.syncDialogs")}</span>
</Button>
</div>
<p class="hint">
Контакты подтягиваются отдельно — тогда в поиске появятся люди, с которыми
ещё не было переписки.
</p>
<p class="hint">{t("jobs.contactsHint")}</p>
<div class="action">
<Button
fluid
@@ -128,17 +123,14 @@
variant="secondary"
>
<Icon name="user" />
<span>Синхронизировать контакты</span>
<span>{t("jobs.syncContacts")}</span>
</Button>
</div>
</section>
<section>
<div class="section-title">Бэкфилл</div>
<p class="hint">
Полный бэкфилл перечитывает всю историю чата с самого начала. Кнопка в
шапке чата догружает только сообщения новее последнего сохранённого.
</p>
<div class="section-title">{t("jobs.backfill")}</div>
<p class="hint">{t("jobs.backfillHint")}</p>
<button
class="select"
@@ -155,8 +147,9 @@
{#if picking}
<div class="picker">
<input
aria-label={t("ui.searchChat")}
class="filter"
placeholder="Поиск чата"
placeholder={t("ui.searchChat")}
type="text"
bind:value={filter}
>
@@ -166,7 +159,7 @@
onclick={() => selectChat(chat.chat_id)}
type="button"
>
{chat.title ?? `Чат ${chat.chat_id}`}
{chat.title ?? t("ui.chat", { id: chat.chat_id })}
</button>
{/each}
</div>
@@ -181,7 +174,7 @@
role="switch"
type="button"
>
<span class="label">Скачивать медиа</span>
<span class="label">{t("jobs.downloadMedia")}</span>
<span class="switch" class:on={media}>
<span class="knob"></span>
</span>
@@ -196,13 +189,13 @@
variant="primary"
>
<Icon name="cloud-download" />
<span>Запустить полный бэкфилл</span>
<span>{t("jobs.startBackfill")}</span>
</Button>
</div>
</section>
<section>
<div class="section-title">Задачи</div>
<div class="section-title">{t("jobs.title")}</div>
<JobList {version} />
</section>
</div>
@@ -9,6 +9,7 @@
import type { MediaRef } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -39,9 +40,9 @@
queuing = true;
try {
await fetchMedia(chatId, media.message_id);
toasts.success("Download queued");
toasts.success(t("media.queued"));
} catch {
toasts.error("Failed to queue download");
toasts.error(t("media.queueFailed"));
} finally {
queuing = false;
}
@@ -62,7 +63,7 @@
</button>
{:else if ready}
<button class="tile" onclick={onopen} type="button">
<img alt="attachment" src={ready.url}>
<img alt={t("media.attachment")} src={ready.url}>
</button>
{:else if inline?.state === "not-downloaded"}
<button class="tile placeholder" onclick={queue} type="button">
@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte";
import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
interface Props {
@@ -41,7 +42,12 @@
</script>
<div class="Audio" class:own>
<button aria-label="Play audio" class="toggle" onclick={toggle} type="button">
<button
aria-label={paused ? t("ui.play") : t("ui.pause")}
class="toggle"
onclick={toggle}
type="button"
>
<Icon name={paused ? "play" : "pause"} size="1.625rem" />
</button>
<div class="content">
@@ -3,6 +3,7 @@
import type { MediaRef } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -16,7 +17,7 @@
let saving = $state(false);
const name = $derived(
media.file_name ?? mediaKindLabel(media.kind) ?? "Файл"
media.file_name ?? mediaKindLabel(media.kind) ?? t("media.kind.document")
);
const size = $derived(formatBytes(media.file_size));
@@ -28,7 +29,7 @@
try {
await downloadMedia(media.id);
} catch {
toasts.error("Не удалось скачать файл");
toasts.error(t("ui.downloadFailed"));
} finally {
saving = false;
}
@@ -52,7 +53,7 @@
</span>
</button>
<button
aria-label="Доступ по ссылке"
aria-label={t("ui.shareLink")}
class="file-share"
onclick={share}
type="button"
@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte";
import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { POSTER_TIME, poster } from "#lib/media/poster.js";
@@ -38,7 +39,7 @@
<div class="RoundVideoWrap">
<button
aria-label="Play video message"
aria-label={paused ? t("media.playRound") : t("ui.pause")}
class="RoundVideo"
onclick={toggle}
type="button"
@@ -87,7 +88,7 @@
class:active={showTranscript}
>
<Icon name="transcribe" size="1rem" />
Transcription
{t("media.transcription")}
</button>
{#if showTranscript}
<div class="transcript">{transcript}</div>
@@ -3,6 +3,7 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { computeWaveform, flatWaveform } from "#lib/media/waveform.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -38,10 +39,10 @@
fetched = result;
showTranscript = true;
} else {
toasts.error("Не удалось расшифровать");
toasts.error(t("media.transcribeFailed"));
}
} catch {
toasts.error("Не удалось расшифровать");
toasts.error(t("media.transcribeFailed"));
} finally {
pending = false;
}
@@ -96,7 +97,7 @@
<div class="VoiceWrap" class:own>
<div class="Voice">
<button
aria-label="Play voice"
aria-label={paused ? t("media.playVoice") : t("ui.pause")}
class="toggle"
onclick={toggle}
type="button"
@@ -121,7 +122,9 @@
<div class="time">{formatDuration(elapsed)}</div>
</div>
<button
aria-label="Show transcription"
aria-label={showTranscript && text
? t("media.hideTranscription")
: t("media.showTranscription")}
class="transcribe"
disabled={pending}
onclick={onTranscribe}
@@ -24,9 +24,11 @@
sameToggles,
summary,
TOGGLE_KEYS,
TOGGLE_LABELS,
type ToggleKey,
toggleLabel,
} from "#lib/format/policy.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
interface Props {
onchange?: () => void;
@@ -44,17 +46,17 @@
onchange,
}: Props = $props();
const PRESETS: { label: string; toggles: CaptureToggles }[] = [
{ label: "Всё", toggles: preset("all") },
{ label: "Только сообщения", toggles: preset(["messages"]) },
{ label: "Ничего", toggles: preset([]) },
const PRESETS: { label: MessageKey; toggles: CaptureToggles }[] = [
{ label: "policy.summary.all", toggles: preset("all") },
{ label: "policy.summary.messagesOnly", toggles: preset(["messages"]) },
{ label: "policy.summary.none", toggles: preset([]) },
];
let rule = $state<PolicyRecord | null>(null);
let parent = $state<PolicyParent | null>(null);
let loading = $state(false);
let saving = $state(false);
let problem = $state<string | null>(null);
let problem = $state<MessageKey | null>(null);
let removing = $state(false);
const isChat = $derived(scopeType === "chat");
@@ -79,13 +81,14 @@
if (isChat && parent) {
const from = parentLabel(parent.scope_type, parent.title);
return rule
? `Без правила было бы как ${from}: ${summary(parent.toggles).toLowerCase()}.`
: `Сейчас как ${from}. Измени что-нибудь, и у чата появится своё правило.`;
? t("policy.hint.chatRule", {
from,
summary: summary(parent.toggles).toLocaleLowerCase(locale.tag),
})
: t("policy.hint.chatInherit", { from });
}
if (isFolder) {
return rule
? "Действует на чаты папки, у которых нет своего правила."
: "У папки нет правила, чаты идут по умолчаниям. Выбери набор, чтобы его создать.";
return rule ? t("policy.hint.folderRule") : t("policy.hint.folderNone");
}
return "";
}
@@ -112,7 +115,7 @@
});
onchange?.();
} catch {
problem = "Не удалось сохранить правило";
problem = "policy.rule.saveFailed";
} finally {
saving = false;
}
@@ -136,7 +139,7 @@
removing = false;
onchange?.();
} catch {
problem = "Не удалось удалить правило";
problem = "policy.rule.deleteFailed";
} finally {
saving = false;
}
@@ -153,7 +156,7 @@
try {
({ rule, parent } = await getPolicyScope(target));
} catch {
problem = "Не удалось загрузить правило";
problem = "policy.rule.loadFailed";
} finally {
loading = false;
}
@@ -179,7 +182,7 @@
type="button"
class:on={shown !== null && sameToggles(shown, item.toggles)}
>
{item.label}
{t(item.label)}
</button>
{/each}
</div>
@@ -196,7 +199,7 @@
type="button"
class:changed={differs(key)}
>
<span class="label">{TOGGLE_LABELS[key]}</span>
<span class="label">{toggleLabel(key)}</span>
<span class="switch" class:on={shown[key]}>
<span class="knob"></span>
</span>
@@ -206,7 +209,7 @@
{/if}
{#if problem}
<p class="problem">{problem}</p>
<p class="problem">{t(problem)}</p>
{/if}
{#if target}
@@ -216,7 +219,7 @@
{#if rule && (isChat || isFolder)}
{#if removing}
<div class="removal">
<span>Удалить правило?</span>
<span>{t("policy.rule.removeConfirm")}</span>
<Button
onclick={() => {
removing = false;
@@ -224,7 +227,7 @@
smaller
variant="text"
>
Отмена
{t("common.cancel")}
</Button>
<Button
disabled={saving}
@@ -233,7 +236,7 @@
smaller
variant="danger"
>
Удалить
{t("common.delete")}
</Button>
</div>
{:else}
@@ -244,7 +247,7 @@
}}
type="button"
>
Удалить правило
{t("policy.rule.remove")}
</button>
{/if}
{/if}
@@ -2,6 +2,7 @@
import { Dialog } from "bits-ui";
import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
const target = $derived(policyUi.target);
@@ -22,10 +23,10 @@
<div class="heading">
<Dialog.Title class="dialog-title">{policyUi.title}</Dialog.Title>
<span class="kind">
{isChat ? "Политика захвата чата" : "Политика захвата папки"}
{isChat ? t("policy.kind.chat") : t("policy.kind.folder")}
</span>
</div>
<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>
@@ -14,11 +14,13 @@
import {
type Chip,
contentChips,
DEFAULT_LABELS,
defaultLabel,
diffChips,
sameToggles,
summary,
} from "#lib/format/policy.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
@@ -43,7 +45,7 @@
let folders = $state<Folder[]>([]);
let loading = $state(true);
let failed = $state(false);
let problem = $state<string | null>(null);
let problem = $state<MessageKey | null>(null);
let expanded = $state<string | null>(null);
let draft = $state<Draft | null>(null);
let picking = $state<"folder" | "chat" | null>(null);
@@ -64,7 +66,7 @@
const chatRules = $derived(
rules
.filter((rule) => rule.scope_type === "chat")
.toSorted((a, b) => title(a).localeCompare(title(b), "ru"))
.toSorted((a, b) => title(a).localeCompare(title(b), locale.tag))
);
const freeFolders = $derived(
folders.filter(
@@ -88,12 +90,12 @@
function title(rule: PolicyRule): string {
if (rule.scope_type === "chat") {
return rule.title ?? `Чат ${rule.scope_id}`;
return rule.title ?? t("ui.chat", { id: rule.scope_id ?? "" });
}
if (rule.scope_type === "folder") {
return rule.title ?? `Папка ${rule.scope_id}`;
return rule.title ?? t("policy.folder", { id: rule.scope_id ?? "" });
}
return DEFAULT_LABELS[rule.scope_type] ?? "";
return defaultLabel(rule.scope_type);
}
function redundant(rule: PolicyRule): boolean {
@@ -164,7 +166,7 @@
await deletePolicy(rule.id);
await reload();
} catch {
problem = "Не удалось убрать правило";
problem = "policy.rule.dropFailed";
}
}
@@ -227,7 +229,7 @@
{#if plain}
<span class="sub">{summary(rule)}</span>
{:else if extra}
<span class="sub">Ничего не меняет</span>
<span class="sub">{t("policy.rule.redundant")}</span>
{:else}
<span class="chips">
{#each chips(rule) as chip (chip.sign + chip.label)}
@@ -242,7 +244,7 @@
</button>
{#if extra}
<Button onclick={() => drop(rule)} smaller variant="text">
Убрать
{t("policy.rule.drop")}
</Button>
{/if}
<button
@@ -291,7 +293,7 @@
{/if}
<span class="text">
<span class="name">{item.title}</span>
<span class="sub">Новое правило</span>
<span class="sub">{t("policy.rule.new")}</span>
</span>
</div>
<Button
@@ -301,7 +303,7 @@
smaller
variant="text"
>
Отмена
{t("common.cancel")}
</Button>
</div>
<div class="editor" {@attach reveal}>
@@ -318,17 +320,17 @@
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить политику.</p>
<Button onclick={reload} smaller variant="text">Повторить</Button>
<p>{t("policy.loadFailed")}</p>
<Button onclick={reload} smaller variant="text">{t("ui.retry")}</Button>
</div>
{:else}
<div class="policy">
{#if problem}
<p class="problem">{problem}</p>
<p class="problem">{t(problem)}</p>
{/if}
<section>
<div class="section-title"><h3>По умолчанию</h3></div>
<div class="section-title"><h3>{t("policy.section.defaults")}</h3></div>
{#each defaults as rule (rule.id)}
{@render row(rule, true)}
{/each}
@@ -336,8 +338,8 @@
<section>
<div class="section-title">
<h3>Папки</h3>
<span>Что сохраняют. Из подходящих папок побеждает первая.</span>
<h3>{t("policy.section.folders")}</h3>
<span>{t("policy.section.foldersHint")}</span>
</div>
{#each folderRules as rule (rule.id)}
{#if draft && draftBefore(rule)}
@@ -356,7 +358,7 @@
type="button"
>
<Icon name="add" size="1.25rem" />
<span>Правило для папки</span>
<span>{t("policy.add.folder")}</span>
</button>
{#if picking === "folder"}
<div class="picker">
@@ -376,8 +378,8 @@
<section>
<div class="section-title">
<h3>Чаты</h3>
<span>Что меняют против папки или умолчания. Важнее папок.</span>
<h3>{t("policy.section.chats")}</h3>
<span>{t("policy.section.chatsHint")}</span>
</div>
{#if draft?.scopeType === "chat"}
{@render pending(draft)}
@@ -392,33 +394,29 @@
type="button"
>
<Icon name="add" size="1.25rem" />
<span>Правило для чата</span>
<span>{t("policy.add.chat")}</span>
</button>
{#if picking === "chat"}
<div class="picker">
<input
aria-label="Поиск чата"
aria-label={t("ui.searchChat")}
class="filter"
oninput={() => picker.search(chatFilter)}
placeholder="Поиск чата"
placeholder={t("ui.searchChat")}
type="text"
bind:value={chatFilter}
>
{#each freeChats as chat (chat.chat_id)}
{@const name = chat.title ?? t("ui.chat", { id: chat.chat_id })}
<button
class="pick"
onclick={() =>
start(
"chat",
chat.chat_id,
chat.title ?? `Чат ${chat.chat_id}`
)}
onclick={() => start("chat", chat.chat_id, name)}
type="button"
>
{chat.title ?? `Чат ${chat.chat_id}`}
{name}
</button>
{:else}
<p class="nothing">Ничего не найдено.</p>
<p class="nothing">{t("ui.nothingFound")}</p>
{/each}
</div>
{/if}
@@ -14,6 +14,8 @@
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import QrCode from "#lib/components/ui/QrCode.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -30,12 +32,9 @@
let busy = $state(false);
let attempt = 0;
const hints: Record<Step, string> = {
code: "Код отправлен в Telegram на этот номер.",
password: "Аккаунт защищён двухэтапной аутентификацией.",
phone: "Номер телефона в международном формате, например +79991234567.",
qr: "Telegram → Настройки → Устройства → Подключить устройство, и наведите камеру на код.",
};
function hint(current: Step): string {
return t(`login.hint.${current}`);
}
const filled = $derived.by(() => {
if (step === "phone") {
@@ -58,12 +57,12 @@
if (state.account) {
accounts.select(state.account.account_id);
}
toasts.success("Аккаунт добавлен");
toasts.success(t("login.added"));
open = false;
}
function fail(error: unknown, fallback: string) {
toasts.error(error instanceof ApiError ? error.detail : fallback);
function fail(error: unknown, fallback: MessageKey) {
toasts.error(error instanceof ApiError ? error.detail : t(fallback));
}
function drop() {
@@ -89,7 +88,7 @@
}
} catch (error) {
if (mine === attempt) {
fail(error, "Не удалось получить QR-код");
fail(error, "login.qrFailed");
}
}
}
@@ -135,7 +134,7 @@
try {
await apply(await next());
} catch (error) {
fail(error, "Не удалось войти");
fail(error, "login.failed");
} finally {
busy = false;
}
@@ -159,29 +158,29 @@
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Добавить аккаунт</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Dialog.Title class="dialog-title">{t("login.title")}</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
{#if step === "qr"}
<div class="dialog-body">
<p class="hint">{hints.qr}</p>
<p class="hint">{hint("qr")}</p>
<div class="qr-slot">
{#if qrUrl}
<QrCode label="QR-код для входа в Telegram" value={qrUrl} />
<QrCode label={t("login.qrLabel")} value={qrUrl} />
{/if}
</div>
</div>
<div class="dialog-actions">
<Button onclick={usePhone} pill variant="text">
Войти по номеру телефона
{t("login.byPhone")}
</Button>
</div>
{:else}
<form onsubmit={submit}>
<div class="dialog-body">
<p class="hint">{hints[step]}</p>
<p class="hint">{hint(step)}</p>
{#if step === "phone"}
<div class="input-group">
<input
@@ -192,7 +191,7 @@
type="tel"
bind:value={phone}
>
<label for="login-phone">Номер телефона</label>
<label for="login-phone">{t("login.phone")}</label>
</div>
{:else if step === "code"}
<div class="input-group">
@@ -205,7 +204,7 @@
type="text"
bind:value={code}
>
<label for="login-code">Код подтверждения</label>
<label for="login-code">{t("login.code")}</label>
</div>
{:else}
<div class="input-group">
@@ -213,17 +212,19 @@
autocomplete="current-password"
class="form-control"
id="login-password"
placeholder="Пароль"
placeholder={t("login.passwordPlaceholder")}
type="password"
bind:value={password}
>
<label for="login-password">Облачный пароль</label>
<label for="login-password">{t("login.password")}</label>
</div>
{/if}
</div>
<div class="dialog-actions">
{#if step === "phone"}
<Button onclick={useQr} pill variant="text">QR-код</Button>
<Button onclick={useQr} pill variant="text">
{t("login.qr")}
</Button>
{/if}
<Button
disabled={busy || !filled}
@@ -231,7 +232,7 @@
pill
type="submit"
>
{step === "phone" ? "Отправить код" : "Продолжить"}
{step === "phone" ? t("login.sendCode") : t("login.continue")}
</Button>
</div>
</form>
@@ -17,43 +17,52 @@
import QrCode from "#lib/components/ui/QrCode.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatShareLimits } from "#lib/format/shares.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props {
label?: string;
label?: string | null;
onchange?: (share: FileShare | null) => void;
open: boolean;
subject: ShareSubject | null;
}
let {
open = $bindable(),
subject,
label = "файл",
onchange,
}: Props = $props();
let { open = $bindable(), subject, label, onchange }: Props = $props();
interface Choice {
label: () => string;
value: number | null;
}
const HOUR = 3600;
const DAY = 24 * HOUR;
const DAY_HOURS = 24;
const WEEK_DAYS = 7;
const MONTH_DAYS = 30;
const KEEP = -1;
const LIMITS = [1, 5, 25, 100];
const expiryChoices = [
{ label: "Бессрочно", value: null },
{ label: "1 час", value: HOUR },
{ label: "24 часа", value: DAY },
{ label: "7 дней", value: 7 * DAY },
{ label: "30 дней", value: 30 * DAY },
const expiryChoices: Choice[] = [
{ label: () => t("shares.noExpiry"), value: null },
{ label: () => t("shares.hours", { count: 1 }), value: HOUR },
{ label: () => t("shares.hours", { count: DAY_HOURS }), value: DAY },
{
label: () => t("shares.days", { count: WEEK_DAYS }),
value: WEEK_DAYS * DAY,
},
{
label: () => t("shares.days", { count: MONTH_DAYS }),
value: MONTH_DAYS * DAY,
},
];
const keepChoice = { label: "Как есть", value: KEEP };
const keepChoice: Choice = { label: () => t("shares.keep"), value: KEEP };
const limitChoices = [
{ label: "Без лимита", value: null },
{ label: "1", value: 1 },
{ label: "5", value: 5 },
{ label: "25", value: 25 },
{ label: "100", value: 100 },
const limitChoices: Choice[] = [
{ label: () => t("shares.noLimit"), value: null },
...LIMITS.map((value) => ({ label: () => String(value), value })),
];
let share = $state<FileShare | null>(null);
@@ -73,8 +82,8 @@
share?.expires_at ? [keepChoice, ...expiryChoices] : expiryChoices
);
function fail(error: unknown, fallback: string) {
toasts.error(error instanceof ApiError ? error.detail : fallback);
function fail(error: unknown, fallback: MessageKey) {
toasts.error(error instanceof ApiError ? error.detail : t(fallback));
}
function adopt(next: FileShare | null) {
@@ -99,7 +108,7 @@
}
} catch (error) {
if (current === token) {
fail(error, "Не удалось проверить ссылку");
fail(error, "shares.checkFailed");
open = false;
}
} finally {
@@ -116,9 +125,9 @@
busy = true;
try {
adopt(await createShare(subject, settings));
toasts.success("Доступ по ссылке открыт");
toasts.success(t("shares.opened"));
} catch (error) {
fail(error, "Не удалось открыть доступ");
fail(error, "shares.openFailed");
} finally {
busy = false;
}
@@ -131,9 +140,9 @@
busy = true;
try {
adopt(await updateShare(share.id, settings));
toasts.success("Настройки ссылки обновлены");
toasts.success(t("shares.updated"));
} catch (error) {
fail(error, "Не удалось обновить ссылку");
fail(error, "shares.updateFailed");
} finally {
busy = false;
}
@@ -147,10 +156,10 @@
try {
await revokeShare(share.id);
adopt(null);
toasts.success("Доступ отозван");
toasts.success(t("shares.revokedToast"));
open = false;
} catch (error) {
fail(error, "Не удалось отозвать доступ");
fail(error, "shares.revokeFailed");
} finally {
busy = false;
}
@@ -172,8 +181,8 @@
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Доступ по ссылке</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Dialog.Title class="dialog-title">{t("ui.shareLink")}</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
@@ -203,25 +212,24 @@
type="button"
>
<Icon name={showQr ? "collapse" : "webapp"} size="1rem" />
{showQr ? "Скрыть QR-код" : "Показать QR-код"}
{showQr ? t("shares.qr.hide") : t("shares.qr.show")}
</button>
</div>
{#if showQr}
<div class="qr-slot">
<QrCode label="QR-код ссылки на файл" value={shareUrl(share)} />
<QrCode label={t("shares.qr.label")} value={shareUrl(share)} />
</div>
{/if}
<p class="hint raw">
Ссылка отдаёт файл как есть: картинки и видео откроются прямо в
браузере, <code>curl -O {shareUrl(share)}</code> скачает под
настоящим именем. Превью-краулеры Telegram скачивания не списывают.
{t("shares.raw.before")}<code>curl -O {shareUrl(share)}</code>
{t("shares.raw.after")}
</p>
<fieldset class="choices">
<legend>Лимит скачиваний</legend>
<legend>{t("shares.limit")}</legend>
<div class="segments">
{#each limitChoices as choice (choice.label)}
{#each limitChoices as choice (choice.value)}
<button
class="segment"
onclick={() => {
@@ -230,16 +238,16 @@
type="button"
class:selected={maxDownloads === choice.value}
>
{choice.label}
{choice.label()}
</button>
{/each}
</div>
</fieldset>
<fieldset class="choices">
<legend>Срок жизни ссылки</legend>
<legend>{t("shares.lifetime")}</legend>
<div class="segments">
{#each liveExpiryChoices as choice (choice.label)}
{#each liveExpiryChoices as choice (choice.value)}
<button
class="segment"
onclick={() => {
@@ -248,7 +256,7 @@
type="button"
class:selected={expiresIn === choice.value}
>
{choice.label}
{choice.label()}
</button>
{/each}
</div>
@@ -259,15 +267,15 @@
><Icon name="lock" size="1.125rem" /></span
>
<div class="subject-text">
<strong>{label}</strong>
<span>Сейчас доступен только вам</span>
<strong>{label ?? t("shares.file")}</strong>
<span>{t("shares.private")}</span>
</div>
</div>
<fieldset class="choices">
<legend>Срок жизни ссылки</legend>
<legend>{t("shares.lifetime")}</legend>
<div class="segments">
{#each expiryChoices as choice (choice.label)}
{#each expiryChoices as choice (choice.value)}
<button
class="segment"
onclick={() => {
@@ -276,16 +284,16 @@
type="button"
class:selected={expiresIn === choice.value}
>
{choice.label}
{choice.label()}
</button>
{/each}
</div>
</fieldset>
<fieldset class="choices">
<legend>Лимит скачиваний</legend>
<legend>{t("shares.limit")}</legend>
<div class="segments">
{#each limitChoices as choice (choice.label)}
{#each limitChoices as choice (choice.value)}
<button
class="segment"
onclick={() => {
@@ -294,16 +302,13 @@
type="button"
class:selected={maxDownloads === choice.value}
>
{choice.label}
{choice.label()}
</button>
{/each}
</div>
</fieldset>
<p class="hint">
Файл откроется всем, у кого есть ссылка. Отозвать можно в любой
момент.
</p>
<p class="hint">{t("shares.publicHint")}</p>
{/if}
</div>
@@ -311,12 +316,14 @@
<div class="dialog-actions">
{#if share}
<Button loading={busy} onclick={revoke} pill variant="danger">
Отозвать
{t("shares.revoke")}
</Button>
<Button loading={busy} onclick={applyLimits} pill>
{t("common.save")}
</Button>
<Button loading={busy} onclick={applyLimits} pill>Сохранить</Button>
{:else}
<Button fluid loading={busy} onclick={publish} pill>
Открыть доступ
{t("shares.publish")}
</Button>
{/if}
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props {
@@ -17,7 +18,7 @@
try {
await navigator.clipboard.writeText(url);
} catch {
toasts.error("Не удалось скопировать ссылку");
toasts.error(t("shares.copyFailed"));
return;
}
copied = true;
@@ -16,7 +16,8 @@
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { formatBytes } from "#lib/format/media.js";
import { formatDownloads, formatExpiry, plural } from "#lib/format/shares.js";
import { formatDownloads, formatExpiry } from "#lib/format/shares.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -25,13 +26,6 @@
type Filter = "active" | "all";
const stateLabels: Record<string, string> = {
active: "Открыт",
exhausted: "Лимит исчерпан",
expired: "Истёк",
revoked: "Отозван",
};
let items = $state<FileShare[]>([]);
let loading = $state(false);
let filter = $state<Filter>("active");
@@ -63,7 +57,7 @@
}
} catch {
if (current === token) {
toasts.error("Не удалось загрузить список ссылок");
toasts.error(t("shares.listFailed"));
}
} finally {
if (current === token) {
@@ -91,24 +85,24 @@
async function copy(share: FileShare) {
await navigator.clipboard.writeText(shareUrl(share));
toasts.success("Ссылка скопирована");
toasts.success(t("shares.copied"));
}
async function revoke(share: FileShare) {
try {
replace(await revokeShare(share.id));
toasts.success("Доступ отозван");
toasts.success(t("shares.revokedToast"));
} catch {
toasts.error("Не удалось отозвать доступ");
toasts.error(t("shares.revokeFailed"));
}
}
async function reissue(share: FileShare) {
try {
replace(await reissueShare(share.id));
toasts.success("Выдана новая ссылка");
toasts.success(t("shares.reissued"));
} catch {
toasts.error("Не удалось перевыпустить ссылку");
toasts.error(t("shares.reissueFailed"));
}
}
@@ -117,7 +111,7 @@
await deleteShare(share.id);
items = items.filter((item) => item.id !== share.id);
} catch {
toasts.error("Не удалось удалить запись");
toasts.error(t("shares.forgetFailed"));
}
}
@@ -142,7 +136,7 @@
type="button"
class:selected={filter === "active"}
>
Открытые{activeCount ? ` · ${activeCount}` : ""}
{t("shares.tab.active")}{activeCount ? ` · ${activeCount}` : ""}
</button>
<button
class="tab"
@@ -152,11 +146,11 @@
type="button"
class:selected={filter === "all"}
>
Все
{t("shares.tab.all")}
</button>
</div>
<button
aria-label="Обновить"
aria-label={t("ui.refresh")}
class="reload"
onclick={() => load()}
type="button"
@@ -169,8 +163,8 @@
<div class="center"><Spinner /></div>
{:else if visible.length === 0}
<EmptyState
description="Откройте доступ к файлу из меню медиа"
title="Ничего не открыто"
description={t("shares.empty.hint")}
title={t("shares.empty.title")}
/>
{:else}
<ul class="list">
@@ -198,14 +192,14 @@
<span class="text">
<span class="name">{share.file_name}</span>
<span class="sub">
{stateLabels[state]}
{t(`shares.state.${state}`)}
· {formatDownloads(share)}
{#if share.file_size}
· {formatBytes(share.file_size)}
{/if}
</span>
<span class="sub">
{share.title ?? "Без чата"}
{share.title ?? t("shares.noChat")}
· {formatExpiry(share)}
</span>
</span>
@@ -216,11 +210,11 @@
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="copy" onselect={() => copy(share)}>
Копировать ссылку
{t("shares.copy")}
</ContextMenuItem>
{#if share.message_id !== null || share.peer_id !== null}
<ContextMenuItem icon="reply" onselect={() => jump(share)}>
Перейти к сообщению
{t("ui.jumpToMessage")}
</ContextMenuItem>
{/if}
{#if state === "active"}
@@ -228,14 +222,14 @@
icon="link-broken"
onselect={() => revoke(share)}
>
Отозвать
{t("shares.revoke")}
</ContextMenuItem>
{:else}
<ContextMenuItem icon="replace" onselect={() => reissue(share)}>
Выдать новую ссылку
{t("shares.reissue")}
</ContextMenuItem>
<ContextMenuItem icon="delete" onselect={() => forget(share)}>
Убрать из списка
{t("shares.forget")}
</ContextMenuItem>
{/if}
{/snippet}
@@ -246,13 +240,15 @@
<ShareLinkField url={shareUrl(share)} />
{#if share.last_download_at}
<p class="last">
Последнее скачивание: {formatFull(share.last_download_at)}
{t("shares.lastDownload", {
date: formatFull(share.last_download_at),
})}
</p>
{/if}
<div class="actions">
{#if share.message_id !== null || share.peer_id !== null}
<button class="pill" onclick={() => jump(share)} type="button">
<Icon name="reply" size="1rem" />К сообщению
<Icon name="reply" size="1rem" />{t("shares.toMessage")}
</button>
{/if}
{#if state === "active"}
@@ -261,7 +257,7 @@
onclick={() => revoke(share)}
type="button"
>
<Icon name="link-broken" size="1rem" />Отозвать
<Icon name="link-broken" size="1rem" />{t("shares.revoke")}
</button>
{:else}
<button
@@ -269,7 +265,7 @@
onclick={() => reissue(share)}
type="button"
>
<Icon name="replace" size="1rem" />Новая ссылка
<Icon name="replace" size="1rem" />{t("shares.newLink")}
</button>
{/if}
</div>
@@ -279,9 +275,7 @@
{/each}
</ul>
<p class="footnote">
{visible.length}
{plural(visible.length, "ссылка", "ссылки", "ссылок")}
· превью-краулеры не списывают скачивания
{t("shares.footnote", { count: visible.length })}
</p>
{/if}
@@ -5,6 +5,7 @@
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state";
@@ -90,11 +91,11 @@
</script>
{#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Callback-данные" />
<EmptyState description={t("ui.noMessage")} title={t("social.callbacks")} />
{:else if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length === 0}
<EmptyState title="Нет callback-кнопок" />
<EmptyState title={t("social.callbacks.empty")} />
{:else}
<ul class="list">
{#each items as item (item.position)}
@@ -106,7 +107,7 @@
</div>
{#if value}
<button
aria-label="Скопировать"
aria-label={t("ui.copy")}
class="copy"
onclick={() => copy(item.position, value)}
type="button"
@@ -6,6 +6,7 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { safeHref } from "#lib/format/url.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state";
@@ -56,11 +57,11 @@
</script>
{#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Ссылки" />
<EmptyState description={t("ui.noMessage")} title={t("social.links")} />
{:else if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length === 0}
<EmptyState title="Нет ссылок" />
<EmptyState title={t("social.links.empty")} />
{:else}
<ul class="list">
{#each items as item (item.position)}
@@ -8,6 +8,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { peerColorIndex, peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
@@ -65,11 +66,11 @@
</script>
{#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Реакции" />
<EmptyState description={t("ui.noMessage")} title={t("social.reactions")} />
{:else if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length === 0}
<EmptyState title="Нет реакций" />
<EmptyState title={t("social.reactions.empty")} />
{:else}
<div class="groups">
{#each groups as [reaction, reactors] (reaction)}
@@ -104,7 +105,9 @@
>
<span class="time">
{#if reactor.removed_at}
снято · {formatFull(reactor.removed_at)}
{t("social.reactions.removed", {
date: formatFull(reactor.removed_at),
})}
{:else}
{formatFull(reactor.added_at)}
{/if}
@@ -16,6 +16,7 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -89,7 +90,7 @@
return {
hasAvatar: Boolean(chat?.has_avatar),
kind: "chat" as const,
name: chat?.title ?? `Chat ${peerId}`,
name: chat?.title ?? t("ui.chat", { id: peerId }),
peerId,
stories: peerStories,
};
@@ -150,9 +151,9 @@
backfilling = peerId;
try {
await enqueueStoriesBackfill(peerId);
toasts.success("Загружаем старые сторис");
toasts.success(t("stories.backfillStarted"));
} catch {
toasts.error("Не удалось запустить загрузку сторис");
toasts.error(t("stories.backfillFailed"));
} finally {
backfilling = null;
}
@@ -174,7 +175,7 @@
{#if loading}
<div class="center"><Spinner /></div>
{:else}
<EmptyState title="Нет сторис" />
<EmptyState title={t("stories.empty")} />
{/if}
{:else}
{#each groups as group (group.peerId)}
@@ -200,7 +201,7 @@
</span>
</button>
<Button
aria-label="Загрузить старые сторис"
aria-label={t("stories.loadOlderStories")}
loading={backfilling === group.peerId}
onclick={() => backfill(group.peerId)}
round
@@ -9,6 +9,7 @@
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -109,9 +110,9 @@
backfilling = true;
try {
await enqueueStoriesBackfill(id);
toasts.success("Загружаем старые сторис");
toasts.success(t("stories.backfillStarted"));
} catch {
toasts.error("Не удалось запустить загрузку сторис");
toasts.error(t("stories.backfillFailed"));
} finally {
backfilling = false;
}
@@ -127,7 +128,7 @@
</script>
{#if peerId === null}
<EmptyState description="Откройте чат" title="Сторис" />
<EmptyState description={t("stories.openChat")} title={t("stories.title")} />
{:else}
<div class="toolbar">
<Button
@@ -137,10 +138,10 @@
smaller
variant="secondary"
>
<Icon name="cloud-download" />Загрузить старые
<Icon name="cloud-download" />{t("stories.loadOlder")}
</Button>
<Button
aria-label="Обновить"
aria-label={t("ui.refresh")}
onclick={() => peerId !== null && reload(peerId).catch(() => undefined)}
round
smaller
@@ -155,8 +156,8 @@
<div class="center"><Spinner /></div>
{:else}
<EmptyState
description="Нажмите «Загрузить старые», чтобы забрать архив"
title="Нет сторис"
description={t("stories.emptyHint")}
title={t("stories.empty")}
/>
{/if}
{:else}
@@ -177,7 +178,7 @@
onclick={() => peerId !== null && loadMore(peerId)}
type="button"
>
{loading ? "Загрузка…" : "Показать ещё"}
{loading ? t("ui.loading") : t("ui.showMore")}
</button>
{/if}
@@ -5,6 +5,7 @@
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -20,14 +21,16 @@
const { story, preview, onopen }: Props = $props();
const label = $derived(
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}`
story.date
? t("stories.from", { date: formatFull(story.date) })
: t("stories.undated")
);
async function save() {
try {
await downloadStory(story.peer_id, story.story_id);
} catch {
toasts.error("Не удалось скачать сторис");
toasts.error(t("stories.downloadFailed"));
}
}
@@ -78,25 +81,26 @@
{/if}
{#if story.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{story.views}
<Icon name="eye" size="0.875rem" />
{story.views.toLocaleString(locale.tag)}
</span>
{/if}
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}>
Открыть
{t("ui.open")}
</ContextMenuItem>
{#if story.downloaded}
<ContextMenuItem icon="download" onselect={save}>
Скачать
{t("ui.download")}
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
{t("ui.shareLink")}
</ContextMenuItem>
{/if}
<ContextMenuItem icon="info" onselect={openAuthor}>
Профиль автора
{t("stories.author")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
@@ -9,6 +9,7 @@
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -121,7 +122,7 @@
try {
await downloadStory(story.peer_id, story.story_id);
} catch {
toasts.error("Не удалось скачать сторис");
toasts.error(t("stories.downloadFailed"));
} finally {
saving = false;
}
@@ -133,7 +134,9 @@
}
shareUi.share(
{ kind: "story", peerId: story.peer_id, storyId: story.story_id },
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}`
story.date
? t("stories.from", { date: formatFull(story.date) })
: t("stories.undated")
);
}
@@ -185,7 +188,9 @@
<Dialog.Portal>
<Dialog.Overlay class="story-overlay" />
<Dialog.Content class="story-content {held ? "held" : ""}">
<Dialog.Title class="story-a11y-title">Сторис</Dialog.Title>
<Dialog.Title class="story-a11y-title">
{t("stories.title")}
</Dialog.Title>
<div class="bars">
{#each items as item, i (item.story_id)}
<div class="bar">
@@ -214,7 +219,7 @@
<div class="head-actions">
{#if isVideo}
<button
aria-label={muted ? "Включить звук" : "Выключить звук"}
aria-label={muted ? t("stories.unmute") : t("stories.mute")}
class="round"
onclick={() => {
muted = !muted;
@@ -231,7 +236,7 @@
})}
<button
{...props}
aria-label="Скачать сторис"
aria-label={t("stories.download")}
class="round"
onclick={save}
type="button"
@@ -241,15 +246,15 @@
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="download" onselect={save}>
Скачать
{t("ui.download")}
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
{t("ui.shareLink")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
{/if}
<Dialog.Close aria-label="Закрыть" class="round">
<Dialog.Close aria-label={t("common.close")} class="round">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
</div>
@@ -283,7 +288,7 @@
src={url}
>
{:else}
<p class="missing">Медиа недоступно</p>
<p class="missing">{t("stories.unavailable")}</p>
{/if}
</div>
@@ -296,12 +301,13 @@
{#if story?.views}
<span class="view-count">
<Icon name="eye" size="1rem" />{story.views}
<Icon name="eye" size="1rem" />
{story.views.toLocaleString(locale.tag)}
</span>
{/if}
<button
aria-label="Назад"
aria-label={t("ui.previous")}
class="tap prev"
onclick={() => onTap(-1)}
onpointercancel={releaseHold}
@@ -309,7 +315,7 @@
type="button"
></button>
<button
aria-label="Вперёд"
aria-label={t("ui.next")}
class="tap next"
onclick={() => onTap(1)}
onpointercancel={releaseHold}
@@ -318,7 +324,7 @@
></button>
{#if held}
<span class="hold-hint">Пауза</span>
<span class="hold-hint">{t("stories.paused")}</span>
{/if}
</Dialog.Content>
</Dialog.Portal>
+9 -5
View File
@@ -1,4 +1,4 @@
import { t } from "#lib/i18n/locale.svelte.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
const MEDIA_KIND_KEYS: Record<string, MessageKey> = {
@@ -27,8 +27,9 @@ export function mediaKindLabel(kind: string | null): string | null {
return t(MEDIA_KIND_KEYS[kind] ?? "media.kind.other");
}
const BYTE_UNITS = ["B", "KB", "MB", "GB"];
const BYTE_UNITS = ["B", "KB", "MB", "GB"] as const;
const BYTE_STEP = 1024;
const PRECISE_BELOW = 10;
export function formatBytes(bytes: number | null): string {
if (bytes === null || bytes <= 0) {
@@ -40,7 +41,10 @@ export function formatBytes(bytes: number | null): string {
value /= BYTE_STEP;
unit += 1;
}
const rounded =
value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1);
return `${rounded} ${BYTE_UNITS[unit]}`;
const digits = value >= PRECISE_BELOW || unit === 0 ? 0 : 1;
const rounded = value.toLocaleString(locale.tag, {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
return `${rounded} ${t(`media.unit.${BYTE_UNITS[unit] ?? "B"}`)}`;
}
+54 -53
View File
@@ -1,20 +1,9 @@
import type { CaptureToggles, PolicyScopeType } from "#lib/api/types.js";
import { plural } from "#lib/format/shares.js";
import { t } from "#lib/i18n/locale.svelte.js";
export type ToggleKey = keyof CaptureToggles;
export const TOGGLE_LABELS: Record<ToggleKey, string> = {
backfill: "Бэкфилл",
media: "Медиа",
messages: "Сообщения",
presence: "Присутствие",
profile_history: "История профиля",
reactions: "Реакции",
self_destruct_media: "Исчезающие",
stories: "Сторис",
stt: "Расшифровка",
track_edits_deletes: "Правки, удаления",
};
type DefaultScope = "channel" | "dm" | "group";
export const TOGGLE_KEYS: ToggleKey[] = [
"messages",
@@ -29,16 +18,10 @@ export const TOGGLE_KEYS: ToggleKey[] = [
"backfill",
];
export const DEFAULT_LABELS: Partial<Record<PolicyScopeType, string>> = {
default_channel: "Каналы",
default_dm: "Личные чаты",
default_group: "Группы",
};
const PARENT_LABELS: Partial<Record<PolicyScopeType, string>> = {
default_channel: "у каналов",
default_dm: "у личных чатов",
default_group: "у групп",
const DEFAULT_SCOPES: Partial<Record<PolicyScopeType, DefaultScope>> = {
default_channel: "channel",
default_dm: "dm",
default_group: "group",
};
const SHOWN_CHIPS = 3;
@@ -50,6 +33,19 @@ export interface Chip {
sign: "plus" | "minus";
}
export function toggleLabel(key: ToggleKey): string {
return t(`policy.toggle.${key}`);
}
function exceptLabel(key: ToggleKey): string {
return t(`policy.except.${key}`);
}
export function defaultLabel(scopeType: PolicyScopeType): string {
const scope = DEFAULT_SCOPES[scopeType];
return scope ? t(`policy.default.${scope}`) : "";
}
export function pickToggles(source: CaptureToggles): CaptureToggles {
return {
backfill: source.backfill,
@@ -77,37 +73,31 @@ export function sameToggles(a: CaptureToggles, b: CaptureToggles): boolean {
return TOGGLE_KEYS.every((key) => a[key] === b[key]);
}
const EXCEPT_LABELS: Record<ToggleKey, string> = {
backfill: "бэкфилла",
media: "медиа",
messages: "сообщений",
presence: "присутствия",
profile_history: "истории профиля",
reactions: "реакций",
self_destruct_media: "исчезающих",
stories: "сторис",
stt: "расшифровки",
track_edits_deletes: "правок и удалений",
};
function exceptList(keys: ToggleKey[]): string {
const [first, second] = keys.map(exceptLabel);
return second === undefined
? (first ?? "")
: t("policy.summary.pair", { a: first ?? "", b: second });
}
export function summary(toggles: CaptureToggles): string {
const on = TOGGLE_KEYS.filter((key) => toggles[key]);
const off = TOGGLE_KEYS.filter((key) => !toggles[key]);
if (off.length === 0) {
return "Всё";
return t("policy.summary.all");
}
if (on.length === 0) {
return "Ничего";
return t("policy.summary.none");
}
if (on.length === 1 && on[0] === "messages") {
return "Только сообщения";
return t("policy.summary.messagesOnly");
}
if (off.length <= SHORT_LIST) {
return `Всё, кроме ${off.map((key) => EXCEPT_LABELS[key]).join(" и ")}`;
return t("policy.summary.except", { list: exceptList(off) });
}
const names = on.slice(0, SHORT_LIST).map((key) => TOGGLE_LABELS[key]);
const rest = on.length - names.length;
return rest > 0 ? `${names.join(", ")} и ещё ${rest}` : names.join(", ");
const names = on.slice(0, SHORT_LIST).map(toggleLabel).join(", ");
const rest = on.length - SHORT_LIST;
return rest > 0 ? t("policy.summary.more", { count: rest, names }) : names;
}
export function parentLabel(
@@ -115,17 +105,20 @@ export function parentLabel(
title: string | null
): string {
if (scopeType === "folder") {
return `у папки «${title ?? "без названия"}»`;
return t("policy.parent.folder", {
title: title ?? t("policy.parent.untitled"),
});
}
return PARENT_LABELS[scopeType] ?? "по умолчанию";
const scope = DEFAULT_SCOPES[scopeType];
return scope ? t(`policy.parent.${scope}`) : t("policy.parent.default");
}
function labelList(keys: ToggleKey[]): string {
return keys.map((key) => TOGGLE_LABELS[key]).join(", ");
return keys.map(toggleLabel).join(", ");
}
function named(keys: ToggleKey[], sign: Chip["sign"]): Chip[] {
return keys.map((key) => ({ label: TOGGLE_LABELS[key], sign }));
return keys.map((key) => ({ label: toggleLabel(key), sign }));
}
function counted(keys: ToggleKey[], sign: Chip["sign"]): Chip[] {
@@ -135,21 +128,29 @@ function counted(keys: ToggleKey[], sign: Chip["sign"]): Chip[] {
if (keys.length <= SHOWN_CHIPS) {
return named(keys, sign);
}
const word = plural(keys.length, "настройка", "настройки", "настроек");
return [{ hint: labelList(keys), label: `${keys.length} ${word}`, sign }];
return [
{
hint: labelList(keys),
label: t("policy.chip.settings", { count: keys.length }),
sign,
},
];
}
export function contentChips(rule: CaptureToggles): Chip[] {
const on = TOGGLE_KEYS.filter((key) => rule[key]);
const off = TOGGLE_KEYS.filter((key) => !rule[key]);
if (off.length === 0) {
return [{ label: "Всё включено", sign: "plus" }];
return [{ label: t("policy.chip.allOn"), sign: "plus" }];
}
if (on.length === 0) {
return [{ label: "Всё выключено", sign: "minus" }];
return [{ label: t("policy.chip.allOff"), sign: "minus" }];
}
if (off.length <= SHOWN_CHIPS) {
return [{ label: "Всё", sign: "plus" }, ...named(off, "minus")];
return [
{ label: t("policy.summary.all"), sign: "plus" },
...named(off, "minus"),
];
}
return [...counted(on, "plus"), ...counted(off, "minus")];
}
@@ -158,10 +159,10 @@ export function diffChips(rule: CaptureToggles, base: CaptureToggles): Chip[] {
const plus = TOGGLE_KEYS.filter((key) => rule[key] && !base[key]);
const minus = TOGGLE_KEYS.filter((key) => !rule[key] && base[key]);
if (plus.length > SHOWN_CHIPS && TOGGLE_KEYS.every((key) => rule[key])) {
return [{ label: "Всё включено", sign: "plus" }];
return [{ label: t("policy.chip.allOn"), sign: "plus" }];
}
if (minus.length > SHOWN_CHIPS && TOGGLE_KEYS.every((key) => !rule[key])) {
return [{ label: "Всё выключено", sign: "minus" }];
return [{ label: t("policy.chip.allOff"), sign: "minus" }];
}
return [...counted(plus, "plus"), ...counted(minus, "minus")];
}
+12 -13
View File
@@ -1,5 +1,6 @@
import type { FileShare } from "#lib/api/types.js";
import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
const TEEN_START = 11;
const TEEN_END = 14;
@@ -26,28 +27,26 @@ export function plural(
}
export function formatDownloads(share: FileShare): string {
const word = plural(
share.download_count,
"скачивание",
"скачивания",
"скачиваний"
);
if (share.max_downloads === null) {
return `${share.download_count} ${word}`;
return t("shares.downloads", { count: share.download_count });
}
return `${share.download_count} из ${share.max_downloads}`;
return t("shares.downloadsOf", {
count: share.download_count,
max: share.max_downloads,
});
}
export function formatExpiry(share: FileShare): string {
if (share.revoked_at) {
return "отозвана";
return t("shares.revoked");
}
if (!share.expires_at) {
return "бессрочно";
return t("shares.forever");
}
const expires = Date.parse(share.expires_at);
const prefix = expires <= Date.now() ? "истекла" : "до";
return `${prefix} ${formatFull(share.expires_at)}`;
const date = formatFull(share.expires_at);
return Date.parse(share.expires_at) <= Date.now()
? t("shares.expired", { date })
: t("shares.until", { date });
}
export function formatShareLimits(share: FileShare): string {
+10 -14
View File
@@ -1,17 +1,9 @@
import type { StorageGroupName } from "#lib/api/types.js";
import { plural } from "#lib/format/shares.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
const DAY_MS = 86_400_000;
const EXTENSION_LIMIT = 4;
export const GROUP_LABELS: Record<StorageGroupName, string> = {
audio: "Аудио",
file: "Файлы",
other: "Фото и прочее",
video: "Видео",
voice: "Кружки и голосовые",
};
const KIND_GROUPS: Record<string, StorageGroupName> = {
audio: "audio",
document: "file",
@@ -20,16 +12,20 @@ const KIND_GROUPS: Record<string, StorageGroupName> = {
voice: "voice",
};
export function groupLabel(group: StorageGroupName): string {
return t(`storage.group.${group}`);
}
export function groupOf(kind: string): StorageGroupName {
return KIND_GROUPS[kind] ?? "other";
}
export function filesLabel(count: number): string {
return `${count.toLocaleString("ru-RU")} ${plural(count, "файл", "файла", "файлов")}`;
return t("storage.files", { count, n: count.toLocaleString(locale.tag) });
}
export function messagesLabel(count: number): string {
return `в ${count} ${plural(count, "сообщении", "сообщениях", "сообщениях")}`;
return t("storage.inMessages", { count });
}
export function fileExtension(fileName: string): string {
@@ -42,10 +38,10 @@ export function fileExtension(fileName: string): string {
export function purgeLabel(iso: string): string {
const days = Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS);
if (days <= 0) {
return "сегодня";
return t("storage.purge.today");
}
if (days === 1) {
return "завтра";
return t("storage.purge.tomorrow");
}
return `через ${days} дн.`;
return t("storage.purge.days", { count: days });
}
+24
View File
@@ -1,24 +1,48 @@
import { account } from "#lib/i18n/messages/account.js";
import { alerts } from "#lib/i18n/messages/alerts.js";
import { bots } from "#lib/i18n/messages/bots.js";
import { common } from "#lib/i18n/messages/common.js";
import { feed } from "#lib/i18n/messages/feed.js";
import { jobs } from "#lib/i18n/messages/jobs.js";
import { login } from "#lib/i18n/messages/login.js";
import { media } from "#lib/i18n/messages/media.js";
import { menu } from "#lib/i18n/messages/menu.js";
import { notes } from "#lib/i18n/messages/notes.js";
import { policy } from "#lib/i18n/messages/policy.js";
import { profile } from "#lib/i18n/messages/profile.js";
import { read } from "#lib/i18n/messages/read.js";
import { search } from "#lib/i18n/messages/search.js";
import { settings } from "#lib/i18n/messages/settings.js";
import { shares } from "#lib/i18n/messages/shares.js";
import { social } from "#lib/i18n/messages/social.js";
import { storage } from "#lib/i18n/messages/storage.js";
import { stories } from "#lib/i18n/messages/stories.js";
import { track } from "#lib/i18n/messages/track.js";
import { ui } from "#lib/i18n/messages/ui.js";
import { watches } from "#lib/i18n/messages/watches.js";
export const messages = {
...account,
...alerts,
...bots,
...common,
...feed,
...jobs,
...login,
...media,
...menu,
...notes,
...policy,
...profile,
...read,
...search,
...settings,
...shares,
...social,
...storage,
...stories,
...track,
...ui,
...watches,
};
+29
View File
@@ -0,0 +1,29 @@
import type { Messages } from "#lib/i18n/types.js";
export const bots = {
"bots.account": { en: "account {id}", ru: "аккаунт {id}" },
"bots.add": { en: "Add", ru: "Добавить" },
"bots.badToken": {
en: "That doesn't look like a bot token",
ru: "Не похоже на токен бота",
},
"bots.disconnect": { en: "Disconnect", ru: "Отключить" },
"bots.empty": { en: "No bots yet.", ru: "Ботов пока нет." },
"bots.history": {
en: "The past comes from the queue of undelivered updates Telegram keeps for the bot. Bots can't read chat history, so nothing older than that queue can be reached. In private chats the bot sees everyone who wrote to it; in groups it sees everything only with privacy mode off. Disconnecting removes the token and the session, the archive stays.",
ru: "Прошлое подтягивается из очереди недоставленных апдейтов, которую Telegram держит для бота; история диалогов боту запрещена, глубже этой очереди заглянуть нельзя. В личке бот видит каждого, кто ему написал; в группах всё, но только при выключенном privacy mode. Отключение убирает токен и сессию, архив остаётся.",
},
"bots.login": {
en: "The bot signs in over MTProto with a separate authorization. It doesn't take getUpdates away from whoever runs the same bot through the Bot API, and needs no webhook. From then on it's a regular archive account: chats, messages, media and group members show up in the same place as the other accounts' chats.",
ru: "Бот входит по MTProto отдельной авторизацией. Это не отнимает getUpdates у того, кто держит того же бота через Bot API, и не требует вебхука. Дальше он становится обычным аккаунтом архива: чаты, сообщения, медиа и участники групп видны там же, где переписка остальных аккаунтов.",
},
"bots.status.error": { en: "error", ru: "ошибка" },
"bots.status.ok": { en: "listening", ru: "слушает" },
"bots.status.pending": { en: "connecting", ru: "подключается" },
"bots.status.removed": { en: "disconnected", ru: "отключён" },
"bots.status.unauthorized": { en: "token rejected", ru: "токен отклонён" },
"bots.token": {
en: "Bot token from @BotFather",
ru: "Токен бота от @BotFather",
},
} satisfies Messages;
+84
View File
@@ -0,0 +1,84 @@
import type { Messages } from "#lib/i18n/types.js";
export const jobs = {
"jobs.backfill": { en: "Backfill", ru: "Бэкфилл" },
"jobs.backfillFailed": {
en: "Could not start the backfill",
ru: "Не удалось запустить бэкфилл",
},
"jobs.backfillHint": {
en: "A full backfill rereads the whole chat history from the very beginning. The button in the chat header only loads messages newer than the last saved one.",
ru: "Полный бэкфилл перечитывает всю историю чата с самого начала. Кнопка в шапке чата догружает только сообщения новее последнего сохранённого.",
},
"jobs.backfillStarted": {
en: "Full backfill started",
ru: "Полный бэкфилл запущен",
},
"jobs.chat": { en: "chat {id}", ru: "чат {id}" },
"jobs.contactsFailed": {
en: "Could not sync contacts",
ru: "Не удалось синхронизировать контакты",
},
"jobs.contactsHint": {
en: "Contacts are synced separately, then search finds people you haven't chatted with yet.",
ru: "Контакты подтягиваются отдельно, тогда в поиске появятся люди, с которыми ещё не было переписки.",
},
"jobs.contactsStarted": {
en: "Contact sync started",
ru: "Синхронизация контактов запущена",
},
"jobs.dialogs": { en: "Chats", ru: "Диалоги" },
"jobs.dialogsHint": {
en: "Sync pulls in every chat of the account, so old chats show up and can be backfilled.",
ru: "Синхронизация подтягивает все диалоги аккаунта: старые чаты станут видны и доступны для бэкфилла.",
},
"jobs.downloadMedia": { en: "Download media", ru: "Скачивать медиа" },
"jobs.empty": { en: "No jobs", ru: "Задач нет" },
"jobs.floodWaits": { en: "flood wait {count}", ru: "flood-wait {count}" },
"jobs.kind.backfill": { en: "Backfill", ru: "Бэкфилл" },
"jobs.kind.backfill_stories": {
en: "Stories backfill",
ru: "Бэкфилл сторис",
},
"jobs.kind.enrich_chat": { en: "Chat enrichment", ru: "Обогащение чата" },
"jobs.kind.fetch_avatar": { en: "Avatar", ru: "Аватар" },
"jobs.kind.fetch_custom_emoji": { en: "Custom emoji", ru: "Кастом-эмодзи" },
"jobs.kind.fetch_media": { en: "Media download", ru: "Докачка медиа" },
"jobs.kind.fetch_read_info": { en: "Read time", ru: "Время прочтения" },
"jobs.kind.full": { en: "{kind} (full)", ru: "{kind} (полный)" },
"jobs.kind.newer": { en: "{kind} (new only)", ru: "{kind} (новые)" },
"jobs.kind.sync_contacts": {
en: "Contact sync",
ru: "Синхронизация контактов",
},
"jobs.kind.sync_dialogs": { en: "Chat sync", ru: "Синхронизация диалогов" },
"jobs.kind.transcribe": { en: "Transcription", ru: "Расшифровка" },
"jobs.noChat": { en: "No chat selected", ru: "Чат не выбран" },
"jobs.processed": { en: "processed {count}", ru: "обработано {count}" },
"jobs.startBackfill": {
en: "Start full backfill",
ru: "Запустить полный бэкфилл",
},
"jobs.status.canceled": { en: "Canceled", ru: "Отменено" },
"jobs.status.done": { en: "Done", ru: "Готово" },
"jobs.status.failed": { en: "Failed", ru: "Ошибка" },
"jobs.status.paused": { en: "Paused", ru: "Пауза" },
"jobs.status.pending": { en: "Queued", ru: "В очереди" },
"jobs.status.running": { en: "Running", ru: "Выполняется" },
"jobs.stop": { en: "Stop", ru: "Стоп" },
"jobs.stopFailed": {
en: "Could not stop the job",
ru: "Не удалось остановить задачу",
},
"jobs.syncContacts": { en: "Sync contacts", ru: "Синхронизировать контакты" },
"jobs.syncDialogs": { en: "Sync chats", ru: "Синхронизировать диалоги" },
"jobs.syncFailed": {
en: "Could not sync chats",
ru: "Не удалось синхронизировать диалоги",
},
"jobs.syncStarted": {
en: "Chat sync started",
ru: "Синхронизация диалогов запущена",
},
"jobs.title": { en: "Jobs", ru: "Задачи" },
} satisfies Messages;
+49
View File
@@ -0,0 +1,49 @@
import type { Messages } from "#lib/i18n/types.js";
export const login = {
"login.added": { en: "Account added", ru: "Аккаунт добавлен" },
"login.byPhone": {
en: "Log in by phone number",
ru: "Войти по номеру телефона",
},
"login.code": { en: "Code", ru: "Код подтверждения" },
"login.continue": { en: "Next", ru: "Продолжить" },
"login.failed": { en: "Could not log in", ru: "Не удалось войти" },
"login.hint.code": {
en: "The code was sent to Telegram on this number.",
ru: "Код отправлен в Telegram на этот номер.",
},
"login.hint.password": {
en: "The account is protected with two-step verification.",
ru: "Аккаунт защищён двухэтапной аутентификацией.",
},
"login.hint.phone": {
en: "Phone number in international format, for example +79991234567.",
ru: "Номер телефона в международном формате, например +79991234567.",
},
"login.hint.qr": {
en: "In Telegram open Settings → Devices → Link Desktop Device and point the camera at the code.",
ru: "Telegram → Настройки → Устройства → Подключить устройство, и наведите камеру на код.",
},
"login.invalid": { en: "Invalid token", ru: "Неверный токен" },
"login.password": { en: "Cloud password", ru: "Облачный пароль" },
"login.passwordPlaceholder": { en: "Password", ru: "Пароль" },
"login.phone": { en: "Phone number", ru: "Номер телефона" },
"login.qr": { en: "QR code", ru: "QR-код" },
"login.qrFailed": {
en: "Could not get the QR code",
ru: "Не удалось получить QR-код",
},
"login.qrLabel": {
en: "QR code to log in to Telegram",
ru: "QR-код для входа в Telegram",
},
"login.sendCode": { en: "Send code", ru: "Отправить код" },
"login.submit": { en: "Log in", ru: "Войти" },
"login.subtitle": {
en: "Enter your access token to continue.",
ru: "Введите токен доступа, чтобы продолжить.",
},
"login.title": { en: "Add account", ru: "Добавить аккаунт" },
"login.token": { en: "Access token", ru: "Токен доступа" },
} satisfies Messages;
+51
View File
@@ -0,0 +1,51 @@
import type { Messages } from "#lib/i18n/types.js";
export const media = {
"media.attachment": { en: "Attachment", ru: "Вложение" },
"media.downloadToArchive": {
en: "Download to archive",
ru: "Скачать в архив",
},
"media.hideTranscription": {
en: "Hide transcription",
ru: "Скрыть расшифровку",
},
"media.loadFailed": {
en: "Could not load the media",
ru: "Не удалось загрузить медиа",
},
"media.missing": { en: "File not found.", ru: "Файл не найден." },
"media.notDownloaded": {
en: "The file isn't in the archive yet.",
ru: "Файл ещё не скачан в архив.",
},
"media.playRound": {
en: "Play video message",
ru: "Воспроизвести кружок",
},
"media.playVoice": {
en: "Play voice message",
ru: "Воспроизвести голосовое",
},
"media.queued": {
en: "Download queued",
ru: "Скачивание поставлено в очередь",
},
"media.queueFailed": {
en: "Could not queue the download",
ru: "Не удалось поставить в очередь",
},
"media.showTranscription": {
en: "Show transcription",
ru: "Показать расшифровку",
},
"media.transcribeFailed": {
en: "Could not transcribe",
ru: "Не удалось расшифровать",
},
"media.transcription": { en: "Transcription", ru: "Расшифровка" },
"media.unit.B": { en: "B", ru: "Б" },
"media.unit.GB": { en: "GB", ru: "ГБ" },
"media.unit.KB": { en: "KB", ru: "КБ" },
"media.unit.MB": { en: "MB", ru: "МБ" },
} satisfies Messages;
+11
View File
@@ -0,0 +1,11 @@
import type { Messages } from "#lib/i18n/types.js";
export const notes = {
"notes.add": { en: "Add note", ru: "Добавить заметку" },
"notes.edit": { en: "Edit", ru: "Изменить" },
"notes.placeholder": {
en: "Note on the message…",
ru: "Заметка к сообщению…",
},
"notes.title": { en: "Notes", ru: "Заметки" },
} satisfies Messages;
+146
View File
@@ -0,0 +1,146 @@
import type { Messages } from "#lib/i18n/types.js";
export const policy = {
"policy.add.chat": { en: "Rule for a chat", ru: "Правило для чата" },
"policy.add.folder": { en: "Rule for a folder", ru: "Правило для папки" },
"policy.chip.allOff": { en: "All off", ru: "Всё выключено" },
"policy.chip.allOn": { en: "All on", ru: "Всё включено" },
"policy.chip.settings": {
en: { one: "{count} setting", other: "{count} settings" },
ru: {
few: "{count} настройки",
many: "{count} настроек",
one: "{count} настройка",
other: "{count} настройки",
},
},
"policy.default.channel": { en: "Channels", ru: "Каналы" },
"policy.default.dm": { en: "Private chats", ru: "Личные чаты" },
"policy.default.group": { en: "Groups", ru: "Группы" },
"policy.except.backfill": { en: "backfill", ru: "бэкфилла" },
"policy.except.media": { en: "media", ru: "медиа" },
"policy.except.messages": { en: "messages", ru: "сообщений" },
"policy.except.presence": { en: "online status", ru: "присутствия" },
"policy.except.profile_history": {
en: "profile history",
ru: "истории профиля",
},
"policy.except.reactions": { en: "reactions", ru: "реакций" },
"policy.except.self_destruct_media": {
en: "disappearing media",
ru: "исчезающих",
},
"policy.except.stories": { en: "stories", ru: "сторис" },
"policy.except.stt": { en: "transcription", ru: "расшифровки" },
"policy.except.track_edits_deletes": {
en: "edits and deletions",
ru: "правок и удалений",
},
"policy.folder": { en: "Folder {id}", ru: "Папка {id}" },
"policy.hint.chatInherit": {
en: "Now follows {from}. Change anything and the chat gets its own rule.",
ru: "Сейчас как {from}. Измени что-нибудь, и у чата появится своё правило.",
},
"policy.hint.chatRule": {
en: "Without a rule it would follow {from}: {summary}.",
ru: "Без правила было бы как {from}: {summary}.",
},
"policy.hint.folderNone": {
en: "The folder has no rule, its chats follow the defaults. Pick a set to create one.",
ru: "У папки нет правила, чаты идут по умолчаниям. Выбери набор, чтобы его создать.",
},
"policy.hint.folderRule": {
en: "Applies to the folder's chats that have no rule of their own.",
ru: "Действует на чаты папки, у которых нет своего правила.",
},
"policy.kind.chat": {
en: "Chat capture policy",
ru: "Политика захвата чата",
},
"policy.kind.folder": {
en: "Folder capture policy",
ru: "Политика захвата папки",
},
"policy.loadFailed": {
en: "Could not load the policy.",
ru: "Не удалось загрузить политику.",
},
"policy.parent.channel": { en: "the channel defaults", ru: "у каналов" },
"policy.parent.default": { en: "the defaults", ru: "по умолчанию" },
"policy.parent.dm": { en: "the private chat defaults", ru: "у личных чатов" },
"policy.parent.folder": {
en: "the “{title}” folder",
ru: "у папки «{title}»",
},
"policy.parent.group": { en: "the group defaults", ru: "у групп" },
"policy.parent.untitled": { en: "untitled", ru: "без названия" },
"policy.rule.deleteFailed": {
en: "Could not delete the rule",
ru: "Не удалось удалить правило",
},
"policy.rule.drop": { en: "Remove", ru: "Убрать" },
"policy.rule.dropFailed": {
en: "Could not remove the rule",
ru: "Не удалось убрать правило",
},
"policy.rule.loadFailed": {
en: "Could not load the rule",
ru: "Не удалось загрузить правило",
},
"policy.rule.new": { en: "New rule", ru: "Новое правило" },
"policy.rule.redundant": { en: "Changes nothing", ru: "Ничего не меняет" },
"policy.rule.remove": { en: "Delete rule", ru: "Удалить правило" },
"policy.rule.removeConfirm": {
en: "Delete the rule?",
ru: "Удалить правило?",
},
"policy.rule.saveFailed": {
en: "Could not save the rule",
ru: "Не удалось сохранить правило",
},
"policy.section.chats": { en: "Chats", ru: "Чаты" },
"policy.section.chatsHint": {
en: "What differs from the folder or the default. Chats beat folders.",
ru: "Что меняют против папки или умолчания. Важнее папок.",
},
"policy.section.defaults": { en: "Defaults", ru: "По умолчанию" },
"policy.section.folders": { en: "Folders", ru: "Папки" },
"policy.section.foldersHint": {
en: "What gets saved. If several folders match, the first one wins.",
ru: "Что сохраняют. Из подходящих папок побеждает первая.",
},
"policy.summary.all": { en: "Everything", ru: "Всё" },
"policy.summary.except": {
en: "Everything except {list}",
ru: "Всё, кроме {list}",
},
"policy.summary.messagesOnly": {
en: "Messages only",
ru: "Только сообщения",
},
"policy.summary.more": {
en: "{names} and {count} more",
ru: "{names} и ещё {count}",
},
"policy.summary.none": { en: "Nothing", ru: "Ничего" },
"policy.summary.pair": { en: "{a} and {b}", ru: "{a} и {b}" },
"policy.toggle.backfill": { en: "Backfill", ru: "Бэкфилл" },
"policy.toggle.media": { en: "Media", ru: "Медиа" },
"policy.toggle.messages": { en: "Messages", ru: "Сообщения" },
"policy.toggle.presence": { en: "Online status", ru: "Присутствие" },
"policy.toggle.profile_history": {
en: "Profile history",
ru: "История профиля",
},
"policy.toggle.reactions": { en: "Reactions", ru: "Реакции" },
"policy.toggle.self_destruct_media": {
en: "Disappearing media",
ru: "Исчезающие",
},
"policy.toggle.stories": { en: "Stories", ru: "Сторис" },
"policy.toggle.stt": { en: "Transcription", ru: "Расшифровка" },
"policy.toggle.track_edits_deletes": {
en: "Edits and deletions",
ru: "Правки, удаления",
},
} satisfies Messages;
+139
View File
@@ -0,0 +1,139 @@
import type { Messages } from "#lib/i18n/types.js";
export const shares = {
"shares.checkFailed": {
en: "Could not check the link",
ru: "Не удалось проверить ссылку",
},
"shares.copied": { en: "Link copied", ru: "Ссылка скопирована" },
"shares.copy": { en: "Copy link", ru: "Копировать ссылку" },
"shares.copyFailed": {
en: "Could not copy the link",
ru: "Не удалось скопировать ссылку",
},
"shares.days": {
en: { one: "{count} day", other: "{count} days" },
ru: {
few: "{count} дня",
many: "{count} дней",
one: "{count} день",
other: "{count} дня",
},
},
"shares.downloads": {
en: { one: "{count} download", other: "{count} downloads" },
ru: {
few: "{count} скачивания",
many: "{count} скачиваний",
one: "{count} скачивание",
other: "{count} скачивания",
},
},
"shares.downloadsOf": { en: "{count} of {max}", ru: "{count} из {max}" },
"shares.empty.hint": {
en: "Share a file from the media menu",
ru: "Откройте доступ к файлу из меню медиа",
},
"shares.empty.title": { en: "Nothing shared", ru: "Ничего не открыто" },
"shares.expired": { en: "expired {date}", ru: "истекла {date}" },
"shares.file": { en: "file", ru: "файл" },
"shares.footnote": {
en: {
one: "{count} link · preview crawlers don't count as downloads",
other: "{count} links · preview crawlers don't count as downloads",
},
ru: {
few: "{count} ссылки · превью-краулеры не списывают скачивания",
many: "{count} ссылок · превью-краулеры не списывают скачивания",
one: "{count} ссылка · превью-краулеры не списывают скачивания",
other: "{count} ссылки · превью-краулеры не списывают скачивания",
},
},
"shares.forever": { en: "no expiry", ru: "бессрочно" },
"shares.forget": { en: "Remove from list", ru: "Убрать из списка" },
"shares.forgetFailed": {
en: "Could not remove the entry",
ru: "Не удалось удалить запись",
},
"shares.hours": {
en: { one: "{count} hour", other: "{count} hours" },
ru: {
few: "{count} часа",
many: "{count} часов",
one: "{count} час",
other: "{count} часа",
},
},
"shares.keep": { en: "As is", ru: "Как есть" },
"shares.lastDownload": {
en: "Last download: {date}",
ru: "Последнее скачивание: {date}",
},
"shares.lifetime": { en: "Link lifetime", ru: "Срок жизни ссылки" },
"shares.limit": { en: "Download limit", ru: "Лимит скачиваний" },
"shares.listFailed": {
en: "Could not load the links",
ru: "Не удалось загрузить список ссылок",
},
"shares.newLink": { en: "New link", ru: "Новая ссылка" },
"shares.noChat": { en: "No chat", ru: "Без чата" },
"shares.noExpiry": { en: "No expiry", ru: "Бессрочно" },
"shares.noLimit": { en: "No limit", ru: "Без лимита" },
"shares.opened": { en: "Link sharing is on", ru: "Доступ по ссылке открыт" },
"shares.openFailed": {
en: "Could not share the file",
ru: "Не удалось открыть доступ",
},
"shares.private": {
en: "Only you can access it now",
ru: "Сейчас доступен только вам",
},
"shares.publicHint": {
en: "Anyone with the link can open the file. You can revoke it at any time.",
ru: "Файл откроется всем, у кого есть ссылка. Отозвать можно в любой момент.",
},
"shares.publish": { en: "Create link", ru: "Открыть доступ" },
"shares.qr.hide": { en: "Hide QR code", ru: "Скрыть QR-код" },
"shares.qr.label": {
en: "QR code of the file link",
ru: "QR-код ссылки на файл",
},
"shares.qr.show": { en: "Show QR code", ru: "Показать QR-код" },
"shares.raw.after": {
en: " downloads it under its real name. Telegram preview crawlers don't count as downloads.",
ru: " скачает под настоящим именем. Превью-краулеры Telegram скачивания не списывают.",
},
"shares.raw.before": {
en: "The link serves the file as is: images and videos open right in the browser, ",
ru: "Ссылка отдаёт файл как есть: картинки и видео откроются прямо в браузере, ",
},
"shares.reissue": { en: "Issue a new link", ru: "Выдать новую ссылку" },
"shares.reissued": { en: "New link issued", ru: "Выдана новая ссылка" },
"shares.reissueFailed": {
en: "Could not reissue the link",
ru: "Не удалось перевыпустить ссылку",
},
"shares.revoke": { en: "Revoke", ru: "Отозвать" },
"shares.revoked": { en: "revoked", ru: "отозвана" },
"shares.revokedToast": { en: "Access revoked", ru: "Доступ отозван" },
"shares.revokeFailed": {
en: "Could not revoke access",
ru: "Не удалось отозвать доступ",
},
"shares.state.active": { en: "Active", ru: "Открыт" },
"shares.state.exhausted": { en: "Limit reached", ru: "Лимит исчерпан" },
"shares.state.expired": { en: "Expired", ru: "Истёк" },
"shares.state.revoked": { en: "Revoked", ru: "Отозван" },
"shares.tab.active": { en: "Active", ru: "Открытые" },
"shares.tab.all": { en: "All", ru: "Все" },
"shares.toMessage": { en: "Go to message", ru: "К сообщению" },
"shares.until": { en: "until {date}", ru: "до {date}" },
"shares.updated": {
en: "Link settings updated",
ru: "Настройки ссылки обновлены",
},
"shares.updateFailed": {
en: "Could not update the link",
ru: "Не удалось обновить ссылку",
},
} satisfies Messages;
+17
View File
@@ -0,0 +1,17 @@
import type { Messages } from "#lib/i18n/types.js";
export const social = {
"social.callbacks": { en: "Callback data", ru: "Callback-данные" },
"social.callbacks.empty": {
en: "No callback buttons",
ru: "Нет callback-кнопок",
},
"social.links": { en: "Links", ru: "Ссылки" },
"social.links.empty": { en: "No links", ru: "Нет ссылок" },
"social.reactions": { en: "Reactions", ru: "Реакции" },
"social.reactions.empty": { en: "No reactions", ru: "Нет реакций" },
"social.reactions.removed": {
en: "removed · {date}",
ru: "снято · {date}",
},
} satisfies Messages;
+234
View File
@@ -0,0 +1,234 @@
import type { Messages } from "#lib/i18n/types.js";
export const storage = {
"storage.blocked": {
en: "A file is used in a story or avatar and stayed on disk",
ru: "Файл занят в сторис или аватаре и остался на диске",
},
"storage.cantRefetch": {
en: "{count} can't be downloaded again",
ru: "{count} не скачать заново",
},
"storage.chatFiles": { en: "All chat files", ru: "Все файлы чата" },
"storage.deselect": { en: "Deselect", ru: "Снять" },
"storage.empty.all": {
en: "No downloaded files on disk.",
ru: "На диске нет скачанных файлов.",
},
"storage.empty.chat": {
en: "No downloaded files in this chat.",
ru: "В этом чате нет скачанных файлов.",
},
"storage.empty.group": {
en: "No files in this group.",
ru: "В этой группе нет файлов.",
},
"storage.failed": { en: "Something went wrong", ru: "Не получилось" },
"storage.files": {
en: { one: "{n} file", other: "{n} files" },
ru: {
few: "{n} файла",
many: "{n} файлов",
one: "{n} файл",
other: "{n} файла",
},
},
"storage.freed": {
en: "{size} will be freed in 7 days",
ru: "{size} освободится через 7 дней",
},
"storage.gone": { en: "Can't be downloaded again", ru: "Не скачать заново" },
"storage.group.audio": { en: "Audio", ru: "Аудио" },
"storage.group.file": { en: "Files", ru: "Файлы" },
"storage.group.other": { en: "Photos and other", ru: "Фото и прочее" },
"storage.group.video": { en: "Videos", ru: "Видео" },
"storage.group.voice": {
en: "Voice and video messages",
ru: "Кружки и голосовые",
},
"storage.heaviest": { en: "Largest", ru: "Самые тяжёлые" },
"storage.heavy": {
en: "{files} over {threshold} = {size}",
ru: "{files} крупнее {threshold} = {size}",
},
"storage.hero.chat": {
en: "{files} in this chat",
ru: "{files} в этом чате",
},
"storage.hero.disk": { en: "{files} on disk", ru: "{files} на диске" },
"storage.hero.trash": {
en: "{files} · {size} more in the trash",
ru: "{files} · ещё {size} в корзине",
},
"storage.inChat": { en: "in this chat", ru: "в этом чате" },
"storage.inFolder": {
en: {
one: "in {count} chat of the folder",
other: "in {count} chats of the folder",
},
ru: {
few: "в {count} чатах папки",
many: "в {count} чатах папки",
one: "в {count} чате папки",
other: "в {count} чатах папки",
},
},
"storage.inMessages": {
en: { one: "in {count} message", other: "in {count} messages" },
ru: {
few: "в {count} сообщениях",
many: "в {count} сообщениях",
one: "в {count} сообщении",
other: "в {count} сообщениях",
},
},
"storage.inTrash": { en: "{files} in the trash", ru: "{files} в корзине" },
"storage.listing.chats": { en: "Chats", ru: "Чаты" },
"storage.listing.files": { en: "Files", ru: "Файлы" },
"storage.loadFailed": {
en: "Could not load the storage.",
ru: "Не удалось загрузить хранилище.",
},
"storage.menu.chatFiles": { en: "Chat files", ru: "Файлы чата" },
"storage.menu.openChat": { en: "Open chat", ru: "Перейти в чат" },
"storage.menu.policy": { en: "Capture policy", ru: "Политика захвата" },
"storage.purge.days": {
en: { one: "in {count} day", other: "in {count} days" },
ru: "через {count} дн.",
},
"storage.purge.today": { en: "today", ru: "сегодня" },
"storage.purge.tomorrow": { en: "tomorrow", ru: "завтра" },
"storage.restore": { en: "Restore", ru: "Вернуть" },
"storage.restoreFailed": {
en: "Could not restore the files",
ru: "Не удалось вернуть файлы",
},
"storage.scope.confirm": {
en: {
one: "{files}, {size}, will move to the trash and stop downloading on its own.",
other:
"{files}, {size}, will move to the trash and stop downloading on their own.",
},
ru: {
few: "{files}, {size}, уйдут в корзину и перестанут скачиваться сами.",
many: "{files}, {size}, уйдут в корзину и перестанут скачиваться сами.",
one: "{files}, {size}, уйдёт в корзину и перестанет скачиваться сам.",
other: "{files}, {size}, уйдут в корзину и перестанут скачиваться сами.",
},
},
"storage.scope.downloaded": {
en: "{size} already downloaded",
ru: "{size} уже скачано",
},
"storage.scope.gone": {
en: "{count} of them can't be downloaded again",
ru: "{count} из них потом не скачать заново",
},
"storage.scope.kept": {
en: {
one: "{files} more is in other chats too, it will stay",
other: "{files} more are in other chats too, they will stay",
},
ru: {
few: "Ещё {files} есть и в других чатах, они останутся",
many: "Ещё {files} есть и в других чатах, они останутся",
one: "Ещё {files} есть и в других чатах, он останется",
other: "Ещё {files} есть и в других чатах, они останутся",
},
},
"storage.scope.keptOnly": {
en: {
one: "{files} is in other chats too, remove it from the list below",
other: "{files} are in other chats too, remove them from the list below",
},
ru: {
few: "{files} есть и в других чатах, убрать их можно из списка ниже",
many: "{files} есть и в других чатах, убрать их можно из списка ниже",
one: "{files} есть и в других чатах, убрать его можно из списка ниже",
other: "{files} есть и в других чатах, убрать их можно из списка ниже",
},
},
"storage.scope.trashFailed": {
en: "Could not move the files to the trash",
ru: "Не удалось переместить файлы в корзину",
},
"storage.select": { en: "Select {name}", ru: "Выбрать {name}" },
"storage.toTrash": { en: "Move to trash", ru: "В корзину" },
"storage.trash": { en: "Trash", ru: "Корзина" },
"storage.trash.clear": { en: "Empty trash", ru: "Очистить корзину" },
"storage.trash.clearRest": {
en: "Whatever is still in Telegram can be downloaded again later. Files from messages deleted there will be gone for good.",
ru: "То, что ещё есть в Telegram, потом можно скачать заново. Файлы из удалённых там сообщений пропадут насовсем.",
},
"storage.trash.clearTitle": { en: "Empty trash?", ru: "Очистить корзину?" },
"storage.trash.deleteFailed": {
en: "Could not delete the files",
ru: "Не удалось удалить файлы",
},
"storage.trash.deleteForever": {
en: "Delete permanently",
ru: "Удалить насовсем",
},
"storage.trash.deleteTitle": {
en: "Delete permanently?",
ru: "Удалить насовсем?",
},
"storage.trash.empty": { en: "The trash is empty.", ru: "Корзина пуста." },
"storage.trash.goneWarn": {
en: {
one: "{files} can't be downloaded again: it's no longer in Telegram.",
other:
"{files} can't be downloaded again: they're no longer in Telegram.",
},
ru: {
few: "{files} скачать заново нельзя: в Telegram их уже нет.",
many: "{files} скачать заново нельзя: в Telegram их уже нет.",
one: "{files} скачать заново нельзя: в Telegram его уже нет.",
other: "{files} скачать заново нельзя: в Telegram их уже нет.",
},
},
"storage.trash.laterAll": {
en: {
one: "You can download it from Telegram again later.",
other: "You can download them from Telegram again later.",
},
ru: {
few: "Потом их можно скачать из Telegram заново.",
many: "Потом их можно скачать из Telegram заново.",
one: "Потом его можно скачать из Telegram заново.",
other: "Потом их можно скачать из Telegram заново.",
},
},
"storage.trash.laterRest": {
en: "The rest can be downloaded from Telegram again.",
ru: "Остальные можно скачать из Telegram заново.",
},
"storage.trash.loadFailed": {
en: "Could not load the trash.",
ru: "Не удалось загрузить корзину.",
},
"storage.trash.note": {
en: "Files stay here for 7 days, then they're deleted from disk. Until then you can restore them.",
ru: "Файлы лежат здесь 7 дней, потом удаляются с диска. До этого их можно вернуть.",
},
"storage.trash.nothing": { en: "empty", ru: "пусто" },
"storage.trash.restoreAll": { en: "Restore all", ru: "Вернуть всё" },
"storage.trash.title": { en: "Trash: {size}", ru: "Корзина: {size}" },
"storage.trash.willDelete": {
en: {
one: "{files}, {size}, will be deleted from disk.",
other: "{files}, {size}, will be deleted from disk.",
},
ru: {
few: "{files}, {size}, будут удалены с диска.",
many: "{files}, {size}, будут удалены с диска.",
one: "{files}, {size}, будет удалён с диска.",
other: "{files}, {size}, будут удалены с диска.",
},
},
"storage.trashFailed": {
en: "Could not move to the trash",
ru: "Не удалось переместить в корзину",
},
"storage.zero": { en: "0 B", ru: "0 Б" },
} satisfies Messages;
+39
View File
@@ -0,0 +1,39 @@
import type { Messages } from "#lib/i18n/types.js";
export const stories = {
"stories.author": { en: "Author's profile", ru: "Профиль автора" },
"stories.backfillFailed": {
en: "Could not start loading stories",
ru: "Не удалось запустить загрузку сторис",
},
"stories.backfillStarted": {
en: "Loading older stories",
ru: "Загружаем старые сторис",
},
"stories.download": { en: "Download story", ru: "Скачать сторис" },
"stories.downloadFailed": {
en: "Could not download the story",
ru: "Не удалось скачать сторис",
},
"stories.empty": { en: "No stories", ru: "Нет сторис" },
"stories.emptyHint": {
en: "Press “Load older” to fetch the archive",
ru: "Нажмите «Загрузить старые», чтобы забрать архив",
},
"stories.from": { en: "Story from {date}", ru: "Сторис от {date}" },
"stories.loadOlder": { en: "Load older", ru: "Загрузить старые" },
"stories.loadOlderStories": {
en: "Load older stories",
ru: "Загрузить старые сторис",
},
"stories.mute": { en: "Mute", ru: "Выключить звук" },
"stories.openChat": { en: "Open a chat", ru: "Откройте чат" },
"stories.paused": { en: "Paused", ru: "Пауза" },
"stories.title": { en: "Stories", ru: "Сторис" },
"stories.unavailable": { en: "Media unavailable", ru: "Медиа недоступно" },
"stories.undated": {
en: "Story with no date",
ru: "Сторис от неизвестной даты",
},
"stories.unmute": { en: "Unmute", ru: "Включить звук" },
} satisfies Messages;
+24
View File
@@ -0,0 +1,24 @@
import type { Messages } from "#lib/i18n/types.js";
export const track = {
"track.empty": { en: "No messages here yet", ru: "Здесь пока нет сообщений" },
"track.failed": {
en: "Could not turn on tracking",
ru: "Не удалось включить отслеживание",
},
"track.hint": {
en: "Turn on tracking to collect messages, media and stats for this chat.",
ru: "Включите отслеживание, чтобы собирать сообщения, медиа и статистику по этому чату.",
},
"track.loadHistory": { en: "Load history", ru: "Загрузить историю" },
"track.start": { en: "Start tracking", ru: "Начать отслеживать" },
"track.started": {
en: "Tracking is on, loading the history",
ru: "Отслеживание включено, история загружается",
},
"track.thisChat": { en: "This chat", ru: "Этот чат" },
"track.tracked": {
en: "{title} is tracked, new messages and stats are collected automatically.",
ru: "{title} отслеживается, новые сообщения и статистика собираются автоматически.",
},
} satisfies Messages;
+27
View File
@@ -0,0 +1,27 @@
import type { Messages } from "#lib/i18n/types.js";
export const ui = {
"ui.chat": { en: "Chat {id}", ru: "Чат {id}" },
"ui.copy": { en: "Copy", ru: "Скопировать" },
"ui.download": { en: "Download", ru: "Скачать" },
"ui.downloadFailed": {
en: "Could not download the file",
ru: "Не удалось скачать файл",
},
"ui.downloadFile": { en: "Download file", ru: "Скачать файл" },
"ui.gotIt": { en: "Got it", ru: "Понятно" },
"ui.jumpToMessage": { en: "Go to message", ru: "Перейти к сообщению" },
"ui.loading": { en: "Loading…", ru: "Загрузка…" },
"ui.next": { en: "Next", ru: "Вперёд" },
"ui.noMessage": { en: "No message selected", ru: "Сообщение не выбрано" },
"ui.nothingFound": { en: "No results.", ru: "Ничего не найдено." },
"ui.open": { en: "Open", ru: "Открыть" },
"ui.pause": { en: "Pause", ru: "Пауза" },
"ui.play": { en: "Play", ru: "Воспроизвести" },
"ui.previous": { en: "Previous", ru: "Назад" },
"ui.refresh": { en: "Refresh", ru: "Обновить" },
"ui.retry": { en: "Retry", ru: "Повторить" },
"ui.searchChat": { en: "Search chats", ru: "Поиск чата" },
"ui.shareLink": { en: "Share by link", ru: "Доступ по ссылке" },
"ui.showMore": { en: "Show more", ru: "Показать ещё" },
} satisfies Messages;
+2 -2
View File
@@ -3,7 +3,7 @@ import type { ShareSubject } from "#lib/api/types.js";
function createShareUi() {
let open = $state(false);
let subject = $state<ShareSubject | null>(null);
let label = $state("файл");
let label = $state<string | null>(null);
let revision = $state(0);
return {
@@ -19,7 +19,7 @@ function createShareUi() {
get revision() {
return revision;
},
share(next: ShareSubject, name = "файл") {
share(next: ShareSubject, name: string | null = null) {
subject = next;
label = name;
open = true;