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"> <script lang="ts">
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { auth } from "#lib/stores/auth.svelte.js"; import { auth } from "#lib/stores/auth.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -20,7 +21,7 @@
await accounts.load(); await accounts.load();
await goto("/app"); await goto("/app");
} catch { } catch {
toasts.error("Invalid token"); toasts.error(t("login.invalid"));
} finally { } finally {
busy = false; busy = false;
} }
@@ -33,16 +34,17 @@
<Icon name="lock" size="2.75rem" /> <Icon name="lock" size="2.75rem" />
</div> </div>
<h1>Beavergram</h1> <h1>Beavergram</h1>
<p class="subtitle">Enter your access token to continue.</p> <p class="subtitle">{t("login.subtitle")}</p>
<input <input
aria-label={t("login.token")}
autocomplete="current-password" autocomplete="current-password"
class="form-control" class="form-control"
placeholder="Access token" placeholder={t("login.token")}
type="password" type="password"
bind:value bind:value
> >
<Button disabled={busy || !value.trim()} loading={busy} type="submit"> <Button disabled={busy || !value.trim()} loading={busy} type="submit">
Log in {t("login.submit")}
</Button> </Button>
</form> </form>
</div> </div>
+21 -18
View File
@@ -15,6 +15,7 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js"; import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -61,7 +62,9 @@
const canSave = $derived( const canSave = $derived(
currentMediaId !== null && (fileOnly || result?.state === "ready") 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); const caption = $derived(items[index]?.caption);
function revoke() { function revoke() {
@@ -124,7 +127,7 @@
} }
} catch { } catch {
if (current === token) { if (current === token) {
toasts.error("Failed to load media"); toasts.error(t("media.loadFailed"));
} }
} finally { } finally {
if (current === token) { if (current === token) {
@@ -139,9 +142,9 @@
} }
try { try {
await fetchMedia(chatId, messageId); await fetchMedia(chatId, messageId);
toasts.success("Скачивание поставлено в очередь"); toasts.success(t("media.queued"));
} catch { } catch {
toasts.error("Не удалось поставить в очередь"); toasts.error(t("media.queueFailed"));
} }
} }
@@ -153,7 +156,7 @@
try { try {
await downloadMedia(currentMediaId); await downloadMedia(currentMediaId);
} catch { } catch {
toasts.error("Не удалось скачать файл"); toasts.error(t("ui.downloadFailed"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -225,7 +228,7 @@
{#if canSave} {#if canSave}
{#if ontrash} {#if ontrash}
<button <button
aria-label="В корзину" aria-label={t("storage.toTrash")}
class="media-action" class="media-action"
onclick={trash} onclick={trash}
type="button" type="button"
@@ -239,7 +242,7 @@
})} })}
<button <button
{...props} {...props}
aria-label="Скачать файл" aria-label={t("ui.downloadFile")}
class="media-action" class="media-action"
onclick={save} onclick={save}
type="button" type="button"
@@ -249,15 +252,15 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="download" onselect={save}> <ContextMenuItem icon="download" onselect={save}>
Скачать файл {t("ui.downloadFile")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}> <ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке {t("ui.shareLink")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
<button <button
aria-label="Доступ по ссылке" aria-label={t("ui.shareLink")}
class="media-action" class="media-action"
onclick={share} onclick={share}
type="button" type="button"
@@ -265,7 +268,7 @@
<Icon name="allow-share" size="1.375rem" /> <Icon name="allow-share" size="1.375rem" />
</button> </button>
{/if} {/if}
<Dialog.Close aria-label="Закрыть" class="media-close"> <Dialog.Close aria-label={t("common.close")} class="media-close">
<Icon name="close" size="1.5rem" /> <Icon name="close" size="1.5rem" />
</Dialog.Close> </Dialog.Close>
</div> </div>
@@ -276,7 +279,7 @@
{#if loading} {#if loading}
<Spinner color="white" /> <Spinner color="white" />
{:else if result?.state === "ready" && isImage} {: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} {:else if result?.state === "ready" && isVideo}
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
@@ -301,24 +304,24 @@
{#if currentMediaId !== null} {#if currentMediaId !== null}
<button class="media-download" onclick={save} type="button"> <button class="media-download" onclick={save} type="button">
<Icon name="download" /> <Icon name="download" />
Скачать файл {t("ui.downloadFile")}
</button> </button>
{/if} {/if}
</div> </div>
{:else if result?.state === "not-downloaded"} {:else if result?.state === "not-downloaded"}
<div class="media-message"> <div class="media-message">
<p>Файл ещё не скачан в архив.</p> <p>{t("media.notDownloaded")}</p>
<Button fluid onclick={queueFetch} variant="primary"> <Button fluid onclick={queueFetch} variant="primary">
Скачать в архив {t("media.downloadToArchive")}
</Button> </Button>
</div> </div>
{:else if result?.state === "missing"} {:else if result?.state === "missing"}
<p class="media-message">Файл не найден.</p> <p class="media-message">{t("media.missing")}</p>
{/if} {/if}
</div> </div>
{#if hasNav} {#if hasNav}
<button <button
aria-label="Previous" aria-label={t("ui.previous")}
class="media-nav prev" class="media-nav prev"
disabled={index === 0} disabled={index === 0}
onclick={() => step(-1)} onclick={() => step(-1)}
@@ -327,7 +330,7 @@
<Icon name="previous" size="1.75rem" /> <Icon name="previous" size="1.75rem" />
</button> </button>
<button <button
aria-label="Next" aria-label={t("ui.next")}
class="media-nav next" class="media-nav next"
disabled={index === items.length - 1} disabled={index === items.length - 1}
onclick={() => step(1)} onclick={() => step(1)}
+10 -15
View File
@@ -2,6 +2,7 @@
import { enqueueBackfill, trackChat } from "#lib/api/endpoints.js"; import { enqueueBackfill, trackChat } from "#lib/api/endpoints.js";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js";
@@ -17,7 +18,7 @@
const item = $derived(discover.get(chatId)); const item = $derived(discover.get(chatId));
const tracked = $derived(item?.tracked ?? false); const tracked = $derived(item?.tracked ?? false);
const title = $derived(item?.title ?? "Этот чат"); const title = $derived(item?.title ?? t("track.thisChat"));
$effect(() => { $effect(() => {
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
@@ -33,10 +34,10 @@
busy = true; busy = true;
try { try {
discover.set(await trackChat(chatId, true)); discover.set(await trackChat(chatId, true));
toasts.success("Отслеживание включено, история загружается"); toasts.success(t("track.started"));
chats.refresh(); chats.refresh();
} catch { } catch {
toasts.error("Не удалось включить отслеживание"); toasts.error(t("track.failed"));
} finally { } finally {
busy = false; busy = false;
} }
@@ -49,9 +50,9 @@
busy = true; busy = true;
try { try {
await enqueueBackfill(chatId, true, true); await enqueueBackfill(chatId, true, true);
toasts.success("Полный бэкфилл запущен"); toasts.success(t("jobs.backfillStarted"));
} catch { } catch {
toasts.error("Не удалось запустить бэкфилл"); toasts.error(t("jobs.backfillFailed"));
} finally { } finally {
busy = false; busy = false;
} }
@@ -59,26 +60,20 @@
</script> </script>
<div class="track"> <div class="track">
<div class="title">Здесь пока нет сообщений</div> <div class="title">{t("track.empty")}</div>
<p class="description"> <p class="description">
{#if tracked} {tracked ? t("track.tracked", { title }) : t("track.hint")}
{title}
отслеживается — новые сообщения и статистика собираются автоматически.
{:else}
Включите отслеживание, чтобы собирать сообщения, медиа и статистику по
этому чату.
{/if}
</p> </p>
<div class="actions"> <div class="actions">
{#if tracked} {#if tracked}
<Button loading={busy} onclick={backfill} pill variant="secondary"> <Button loading={busy} onclick={backfill} pill variant="secondary">
<Icon name="cloud-download" /> <Icon name="cloud-download" />
<span>Загрузить историю</span> <span>{t("track.loadHistory")}</span>
</Button> </Button>
{:else} {:else}
<Button loading={busy} onclick={track} pill variant="primary"> <Button loading={busy} onclick={track} pill variant="primary">
<Icon name="stats" /> <Icon name="stats" />
<span>Начать отслеживать</span> <span>{t("track.start")}</span>
</Button> </Button>
{/if} {/if}
</div> </div>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import Button from "../ui/Button.svelte"; import Button from "../ui/Button.svelte";
interface Props { interface Props {
@@ -13,7 +14,7 @@
const { const {
initial = "", initial = "",
saving = false, saving = false,
submitLabel = "Добавить заметку", submitLabel,
oncancel, oncancel,
onsubmit, onsubmit,
}: Props = $props(); }: Props = $props();
@@ -41,15 +42,19 @@
}} }}
> >
<textarea <textarea
placeholder="Заметка к сообщению…" placeholder={t("notes.placeholder")}
rows={oncancel ? 2 : 3} rows={oncancel ? 2 : 3}
bind:value={text} bind:value={text}
></textarea> ></textarea>
<div class="actions"> <div class="actions">
{#if oncancel} {#if oncancel}
<Button onclick={oncancel} variant="translucent">Отмена</Button> <Button onclick={oncancel} variant="translucent">
{t("common.cancel")}
</Button>
{/if} {/if}
<Button disabled={saving || !valid} type="submit">{submitLabel}</Button> <Button disabled={saving || !valid} type="submit">
{submitLabel ?? t("notes.add")}
</Button>
</div> </div>
</form> </form>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Annotation } from "#lib/api/types.js"; import type { Annotation } from "#lib/api/types.js";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import Icon from "../ui/Icon.svelte"; import Icon from "../ui/Icon.svelte";
import AnnotationEditor from "./AnnotationEditor.svelte"; import AnnotationEditor from "./AnnotationEditor.svelte";
@@ -28,14 +29,14 @@
onupdate(annotation, text); onupdate(annotation, text);
editingId = null; editingId = null;
}} }}
submitLabel="Сохранить" submitLabel={t("common.save")}
/> />
{:else} {:else}
<p class="text">{annotation.text}</p> <p class="text">{annotation.text}</p>
<div class="meta"> <div class="meta">
<span class="time">{formatFull(annotation.updated_at)}</span> <span class="time">{formatFull(annotation.updated_at)}</span>
<button <button
aria-label="Изменить" aria-label={t("notes.edit")}
class="action" class="action"
onclick={() => { onclick={() => {
editingId = annotation.id; editingId = annotation.id;
@@ -45,7 +46,7 @@
<Icon name="edit" size="1rem" /> <Icon name="edit" size="1rem" />
</button> </button>
<button <button
aria-label="Удалить" aria-label={t("common.delete")}
class="action" class="action"
onclick={() => ondelete(annotation)} onclick={() => ondelete(annotation)}
type="button" type="button"
@@ -9,6 +9,7 @@
import type { Annotation } from "#lib/api/types.js"; import type { Annotation } from "#lib/api/types.js";
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Spinner from "#lib/components/ui/Spinner.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 { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -75,7 +76,7 @@
</script> </script>
{#if chatId === null || messageId === null} {#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Заметки" /> <EmptyState description={t("ui.noMessage")} title={t("notes.title")} />
{:else} {:else}
<div class="panel-content"> <div class="panel-content">
<AnnotationEditor onsubmit={add} {saving} /> <AnnotationEditor onsubmit={add} {saving} />
@@ -8,6 +8,8 @@
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
@@ -17,14 +19,19 @@
let token = $state(""); let token = $state("");
let adding = $state(false); let adding = $state(false);
const statusLabels: Record<string, string> = { const STATUS_KEYS: Record<string, MessageKey> = {
error: "ошибка", error: "bots.status.error",
ok: "слушает", ok: "bots.status.ok",
pending: "подключается", pending: "bots.status.pending",
removed: "отключён", removed: "bots.status.removed",
unauthorized: "токен отклонён", unauthorized: "bots.status.unauthorized",
}; };
function statusLabel(status: string): string {
const key = STATUS_KEYS[status];
return key ? t(key) : status;
}
async function load() { async function load() {
loading = true; loading = true;
try { try {
@@ -49,7 +56,7 @@
await load(); await load();
await accounts.load(); await accounts.load();
} catch { } catch {
toasts.error("Не похоже на токен бота"); toasts.error(t("bots.badToken"));
} finally { } finally {
adding = false; adding = false;
} }
@@ -81,7 +88,7 @@
{#if loading} {#if loading}
<Spinner /> <Spinner />
{:else if bots.length === 0} {:else if bots.length === 0}
<p class="empty">Ботов пока нет.</p> <p class="empty">{t("bots.empty")}</p>
{:else} {:else}
<ul class="bots"> <ul class="bots">
{#each bots as bot (bot.bot_id)} {#each bots as bot (bot.bot_id)}
@@ -94,9 +101,9 @@
> >
<span class="name">{botName(bot)}</span> <span class="name">{botName(bot)}</span>
<span class="meta"> <span class="meta">
{statusLabels[bot.status] ?? bot.status} {statusLabel(bot.status)}
{#if bot.account_id !== null} {#if bot.account_id !== null}
· аккаунт {bot.account_id} · {t("bots.account", { id: bot.account_id })}
{/if} {/if}
</span> </span>
{#if bot.status_detail} {#if bot.status_detail}
@@ -105,7 +112,7 @@
</button> </button>
{#if bot.enabled} {#if bot.enabled}
<Button <Button
aria-label="Отключить" aria-label={t("bots.disconnect")}
onclick={() => remove(bot)} onclick={() => remove(bot)}
round round
smaller smaller
@@ -121,25 +128,14 @@
<div class="add"> <div class="add">
<label class="field"> <label class="field">
<span>Токен бота от @BotFather</span> <span>{t("bots.token")}</span>
<input placeholder="123456:AA…" type="password" bind:value={token}> <input placeholder="123456:AA…" type="password" bind:value={token}>
</label> </label>
<Button fluid loading={adding} onclick={add}>Добавить</Button> <Button fluid loading={adding} onclick={add}>{t("bots.add")}</Button>
</div> </div>
<p class="note"> <p class="note">{t("bots.login")}</p>
Бот входит по MTProto отдельной авторизацией — это не отнимает getUpdates у <p class="note">{t("bots.history")}</p>
того, кто держит того же бота через Bot API, и не требует вебхука. Дальше он
становится обычным аккаунтом архива: чаты, сообщения, медиа и участники
групп видны там же, где переписка остальных аккаунтов.
</p>
<p class="note">
Прошлое подтягивается из очереди недоставленных апдейтов, которую Telegram
держит для бота; история диалогов боту запрещена, глубже этой очереди
заглянуть нельзя. В личке бот видит каждого, кто ему написал; в группах всё
— только при выключенном privacy mode. Отключение убирает токен и сессию,
архив остаётся.
</p>
</div> </div>
<style lang="scss"> <style lang="scss">
@@ -13,8 +13,9 @@
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js"; import { formatBytes } from "#lib/format/media.js";
import { plural } from "#lib/format/shares.js";
import { filesLabel } from "#lib/format/storage.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 { interface Props {
label?: string; label?: string;
@@ -28,12 +29,12 @@
let confirming = $state(false); let confirming = $state(false);
let moving = $state(false); let moving = $state(false);
let moved = $state<TrashOutcome | null>(null); let moved = $state<TrashOutcome | null>(null);
let problem = $state<string | null>(null); let problem = $state<MessageKey | null>(null);
function where(scope: StorageScope): string { function where(scope: StorageScope): string {
return "folderId" in target return "folderId" in target
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки` ? t("storage.inFolder", { count: scope.chats })
: "в этом чате"; : t("storage.inChat");
} }
async function load() { async function load() {
@@ -56,7 +57,7 @@
await load(); await load();
onchange?.(); onchange?.();
} catch { } catch {
problem = "Не удалось переместить файлы в корзину"; problem = "storage.scope.trashFailed";
} finally { } finally {
moving = false; moving = false;
} }
@@ -74,7 +75,7 @@
await load(); await load();
onchange?.(); onchange?.();
} catch { } catch {
problem = "Не удалось вернуть файлы"; problem = "storage.restoreFailed";
} finally { } finally {
moving = false; moving = false;
} }
@@ -90,15 +91,15 @@
{#if moved && moved.files > 0} {#if moved && moved.files > 0}
<div class="files"> <div class="files">
<div class="text"> <div class="text">
<span>{filesLabel(moved.files)} в корзине</span> <span>{t("storage.inTrash", { files: filesLabel(moved.files) })}</span>
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small> <small>{t("storage.freed", { size: formatBytes(moved.bytes) })}</small>
{#if problem} {#if problem}
<small class="failed">{problem}</small> <small class="failed">{t(problem)}</small>
{/if} {/if}
</div> </div>
<Button disabled={moving} onclick={undo} smaller variant="text"> <Button disabled={moving} onclick={undo} smaller variant="text">
<Icon name="undo" size="1.25rem" /> <Icon name="undo" size="1.25rem" />
<span>Вернуть</span> <span>{t("storage.restore")}</span>
</Button> </Button>
</div> </div>
{:else if files && files.files > 0} {:else if files && files.files > 0}
@@ -106,22 +107,19 @@
{#if confirming} {#if confirming}
<div class="text"> <div class="text">
<span> <span>
{filesLabel(files.files)}, {formatBytes(files.bytes)}, {t("storage.scope.confirm", {
{plural( count: files.files,
files.files, files: filesLabel(files.files),
"уйдёт в корзину и перестанет скачиваться сам", size: formatBytes(files.bytes),
"уйдут в корзину и перестанут скачиваться сами", })}
"уйдут в корзину и перестанут скачиваться сами"
)}.
</span> </span>
{#if files.gone > 0} {#if files.gone > 0}
<small class="warn"> <small class="warn">
{files.gone} {t("storage.scope.gone", { count: files.gone })}
из них потом не скачать заново
</small> </small>
{/if} {/if}
{#if problem} {#if problem}
<small class="failed">{problem}</small> <small class="failed">{t(problem)}</small>
{/if} {/if}
<div class="actions"> <div class="actions">
<Button <Button
@@ -131,7 +129,7 @@
smaller smaller
variant="text" variant="text"
> >
Отмена {t("common.cancel")}
</Button> </Button>
<Button <Button
disabled={moving} disabled={moving}
@@ -141,7 +139,7 @@
smaller smaller
> >
<Icon name="delete" size="1.25rem" /> <Icon name="delete" size="1.25rem" />
<span>В корзину</span> <span>{t("storage.toTrash")}</span>
</Button> </Button>
</div> </div>
</div> </div>
@@ -156,18 +154,17 @@
</small> </small>
{/if} {/if}
{:else} {:else}
<span>{formatBytes(files.bytes)} уже скачано</span> <span>
{t("storage.scope.downloaded", { size: formatBytes(files.bytes) })}
</span>
<small>{filesLabel(files.files)} {where(files)}</small> <small>{filesLabel(files.files)} {where(files)}</small>
{/if} {/if}
{#if files.kept > 0} {#if files.kept > 0}
<small> <small>
Ещё {filesLabel(files.kept)} есть и в других чатах, {t("storage.scope.kept", {
{plural( count: files.kept,
files.kept, files: filesLabel(files.kept),
"он останется", })}
"они останутся",
"они останутся"
)}
</small> </small>
{/if} {/if}
</div> </div>
@@ -180,7 +177,7 @@
variant="gray" variant="gray"
> >
<Icon name="delete" size="1.25rem" /> <Icon name="delete" size="1.25rem" />
<span>В корзину</span> <span>{t("storage.toTrash")}</span>
</Button> </Button>
{/if} {/if}
</div> </div>
@@ -189,10 +186,10 @@
<div class="text"> <div class="text">
<span>{label}</span> <span>{label}</span>
<small> <small>
{filesLabel(files.kept)} {t("storage.scope.keptOnly", {
есть и в других чатах, убрать count: files.kept,
{plural(files.kept, "его", "их", "их")} files: filesLabel(files.kept),
можно из списка ниже })}
</small> </small>
</div> </div>
</div> </div>
@@ -4,7 +4,7 @@
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import { formatBytes } from "#lib/format/media.js"; 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 { interface Props {
chat: StorageChat; chat: StorageChat;
@@ -24,7 +24,7 @@
const parts = $derived(chat.groups.filter((item) => item.bytes > 0)); const parts = $derived(chat.groups.filter((item) => item.bytes > 0));
const makeup = $derived( const makeup = $derived(
parts parts
.map((item) => `${GROUP_LABELS[item.group]}: ${formatBytes(item.bytes)}`) .map((item) => `${groupLabel(item.group)}: ${formatBytes(item.bytes)}`)
.join(", ") .join(", ")
); );
</script> </script>
@@ -31,9 +31,11 @@
import { formatBytes } from "#lib/format/media.js"; import { formatBytes } from "#lib/format/media.js";
import { import {
filesLabel, filesLabel,
GROUP_LABELS, groupLabel,
messagesLabel, messagesLabel,
} from "#lib/format/storage.js"; } 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 { chats } from "#lib/stores/chats.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { import {
@@ -53,9 +55,9 @@
const PAGE = 30; const PAGE = 30;
const UNDO_MS = 12_000; const UNDO_MS = 12_000;
const UNDO_GUARD_MS = 600; const UNDO_GUARD_MS = 600;
const LISTINGS: { label: string; value: StorageListing }[] = [ const LISTINGS: { label: MessageKey; value: StorageListing }[] = [
{ label: "Файлы", value: "files" }, { label: "storage.listing.files", value: "files" },
{ label: "Чаты", value: "chats" }, { label: "storage.listing.chats", value: "chats" },
]; ];
let summary = $state<StorageSummary | null>(null); let summary = $state<StorageSummary | null>(null);
@@ -70,7 +72,7 @@
let undo = $state<{ files: StorageFile[]; outcome: TrashOutcome } | null>( let undo = $state<{ files: StorageFile[]; outcome: TrashOutcome } | null>(
null null
); );
let problem = $state<string | null>(null); let problem = $state<MessageKey | null>(null);
let viewerOpen = $state(false); let viewerOpen = $state(false);
let viewerIndex = $state(0); let viewerIndex = $state(0);
const selected = new SvelteSet<number>(); const selected = new SvelteSet<number>();
@@ -89,12 +91,11 @@
const heaviestChat = $derived(heavyChats[0]?.bytes ?? 0); const heaviestChat = $derived(heavyChats[0]?.bytes ?? 0);
const emptiness = $derived.by(() => { const emptiness = $derived.by(() => {
if (group !== null) { 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 chosen = $derived(files.filter((file) => selected.has(file.media_id)));
const chosenBytes = $derived( const chosenBytes = $derived(
chosen.reduce((sum, file) => sum + file.file_size, 0) chosen.reduce((sum, file) => sum + file.file_size, 0)
@@ -120,7 +121,7 @@
); );
function chatTitle(id: number): string { function chatTitle(id: number): string {
return chats.byId(id)?.title ?? `Чат ${id}`; return chats.byId(id)?.title ?? t("ui.chat", { id });
} }
async function loadSummary() { async function loadSummary() {
@@ -249,11 +250,11 @@
scope?.refresh(); scope?.refresh();
showUndo(outcome, trashed); showUndo(outcome, trashed);
if (outcome.blocked.length > 0) { if (outcome.blocked.length > 0) {
problem = "Файл занят в сторис или аватаре и остался на диске"; problem = "storage.blocked";
} }
await loadSummary(); await loadSummary();
} catch { } catch {
problem = "Не удалось переместить в корзину"; problem = "storage.trashFailed";
} finally { } finally {
busy = false; busy = false;
} }
@@ -273,7 +274,7 @@
scope?.refresh(); scope?.refresh();
await loadSummary(); await loadSummary();
} catch { } catch {
problem = "Не удалось вернуть файлы"; problem = "storage.restoreFailed";
} }
} }
@@ -281,7 +282,7 @@
try { try {
await downloadMedia(file.media_id); await downloadMedia(file.media_id);
} catch { } catch {
problem = "Не удалось скачать файл"; problem = "ui.downloadFailed";
} }
} }
@@ -330,22 +331,26 @@
{#if summary.files > 0 || chatId !== null} {#if summary.files > 0 || chatId !== null}
<section class="summary"> <section class="summary">
<div class="hero"> <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"> <span class="count">
{filesLabel(summary.files)}
{#if chatId !== null} {#if chatId !== null}
в этом чате {t("storage.hero.chat", { files: filesLabel(summary.files) })}
{:else if summary.trash_bytes > 0} {:else if summary.trash_bytes > 0}
· ещё {formatBytes(summary.trash_bytes)} в корзине {t("storage.hero.trash", {
files: filesLabel(summary.files),
size: formatBytes(summary.trash_bytes),
})}
{:else} {:else}
на диске {t("storage.hero.disk", { files: filesLabel(summary.files) })}
{/if} {/if}
</span> </span>
</div> </div>
<div class="stack" class:bare={groups.length === 0}> <div class="stack" class:bare={groups.length === 0}>
{#each groups as item (item.group)} {#each groups as item (item.group)}
<span <span
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}" title="{groupLabel(item.group)}: {formatBytes(item.bytes)}"
style:background-color="var(--color-storage-{item.group})" style:background-color="var(--color-storage-{item.group})"
style:flex-grow={item.bytes} style:flex-grow={item.bytes}
></span> ></span>
@@ -353,7 +358,9 @@
{#if chatId === null && summary.trash_bytes > 0} {#if chatId === null && summary.trash_bytes > 0}
<span <span
class="trashed" class="trashed"
title="Корзина: {formatBytes(summary.trash_bytes)}" title={t("storage.trash.title", {
size: formatBytes(summary.trash_bytes),
})}
style:flex-grow={summary.trash_bytes} style:flex-grow={summary.trash_bytes}
></span> ></span>
{/if} {/if}
@@ -371,7 +378,7 @@
class="dot" class="dot"
style:background-color="var(--color-storage-{item.group})" style:background-color="var(--color-storage-{item.group})"
></span> ></span>
{GROUP_LABELS[item.group]} {groupLabel(item.group)}
<span class="value">{formatBytes(item.bytes)}</span> <span class="value">{formatBytes(item.bytes)}</span>
</button> </button>
{/each} {/each}
@@ -386,14 +393,14 @@
type="button" type="button"
> >
<Icon name="delete" size="1.375rem" /> <Icon name="delete" size="1.375rem" />
<span class="label">Корзина</span> <span class="label">{t("storage.trash")}</span>
<span class="value"> <span class="value">
{#if summary.trash_files > 0} {#if summary.trash_files > 0}
<span class="swatch"></span> <span class="swatch"></span>
{filesLabel(summary.trash_files)} {filesLabel(summary.trash_files)}
· {formatBytes(summary.trash_bytes)} · {formatBytes(summary.trash_bytes)}
{:else} {:else}
пусто {t("storage.trash.nothing")}
{/if} {/if}
</span> </span>
<Icon name="next" size="1.125rem" /> <Icon name="next" size="1.125rem" />
@@ -407,13 +414,13 @@
type="button" type="button"
class:on={listing === item.value} class:on={listing === item.value}
> >
{item.label} {t(item.label)}
</button> </button>
{/each} {/each}
</div> </div>
{:else} {:else}
<ScopeFiles <ScopeFiles
label="Все файлы чата" label={t("storage.chatFiles")}
onchange={() => { onchange={() => {
hideUndo(); hideUndo();
reload(); reload();
@@ -426,12 +433,14 @@
{#if listing === "files" && files.length > 0} {#if listing === "files" && files.length > 0}
<div class="section-title"> <div class="section-title">
<h3>Самые тяжёлые</h3> <h3>{t("storage.heaviest")}</h3>
{#if summary && group === null && summary.heavy_files > 0} {#if summary && group === null && summary.heavy_files > 0}
<span> <span>
{filesLabel(summary.heavy_files)} {t("storage.heavy", {
крупнее {formatBytes(summary.heavy_threshold)} files: filesLabel(summary.heavy_files),
= {formatBytes(summary.heavy_bytes)} size: formatBytes(summary.heavy_bytes),
threshold: formatBytes(summary.heavy_threshold),
})}
</span> </span>
{/if} {/if}
</div> </div>
@@ -448,17 +457,17 @@
> >
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="data" onselect={() => openChat(chat.chat_id)}> <ContextMenuItem icon="data" onselect={() => openChat(chat.chat_id)}>
Файлы чата {t("storage.menu.chatFiles")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => visit(chat)}> <ContextMenuItem icon="reply" onselect={() => visit(chat)}>
Перейти в чат {t("storage.menu.openChat")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
icon="settings" icon="settings"
onselect={() => onselect={() =>
policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))} policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))}
> >
Политика захвата {t("storage.menu.policy")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</StorageChatRow> </StorageChatRow>
@@ -484,19 +493,19 @@
> >
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}> <ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть {t("ui.open")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(file)}> <ContextMenuItem icon="reply" onselect={() => jump(file)}>
Перейти к сообщению {t("ui.jumpToMessage")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="download" onselect={() => save(file)}> <ContextMenuItem icon="download" onselect={() => save(file)}>
Скачать файл {t("ui.downloadFile")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
icon="delete" icon="delete"
onselect={() => trash([file.media_id])} onselect={() => trash([file.media_id])}
> >
В корзину {t("storage.toTrash")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</StorageRow> </StorageRow>
@@ -506,29 +515,31 @@
<div class="state"><Spinner /></div> <div class="state"><Spinner /></div>
{:else if failed} {:else if failed}
<div class="state"> <div class="state">
<p>Не удалось загрузить хранилище.</p> <p>{t("storage.loadFailed")}</p>
<Button onclick={() => reload()} smaller variant="text"> <Button onclick={() => reload()} smaller variant="text">
Повторить {t("ui.retry")}
</Button> </Button>
</div> </div>
{:else if shown === 0} {:else if shown === 0}
<div class="state"><p>{emptiness}</p></div> <div class="state"><p>{emptiness}</p></div>
{:else if !done} {:else if !done}
<button class="more" onclick={() => loadMore()} type="button"> <button class="more" onclick={() => loadMore()} type="button">
Показать ещё {t("ui.showMore")}
</button> </button>
{/if} {/if}
</div> </div>
{#if chosen.length > 0} {#if chosen.length > 0}
<SelectionBar <SelectionBar
failure={problem} {failure}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined} note={chosenGone > 0
? t("storage.cantRefetch", { count: chosenGone })
: undefined}
title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}" title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}"
warn warn
> >
<Button onclick={() => selected.clear()} smaller variant="text"> <Button onclick={() => selected.clear()} smaller variant="text">
Снять {t("storage.deselect")}
</Button> </Button>
<Button <Button
disabled={busy} disabled={busy}
@@ -538,22 +549,22 @@
smaller smaller
> >
<Icon name="delete" size="1.25rem" /> <Icon name="delete" size="1.25rem" />
<span>В корзину</span> <span>{t("storage.toTrash")}</span>
</Button> </Button>
</SelectionBar> </SelectionBar>
{:else if undo} {:else if undo}
<SelectionBar <SelectionBar
failure={problem} {failure}
note="{formatBytes(undo.outcome.bytes)} освободится через 7 дней" note={t("storage.freed", { size: formatBytes(undo.outcome.bytes) })}
title="{filesLabel(undo.outcome.files)} в корзине" title={t("storage.inTrash", { files: filesLabel(undo.outcome.files) })}
> >
<Button onclick={restoreLast} smaller variant="text"> <Button onclick={restoreLast} smaller variant="text">
<Icon name="undo" size="1.25rem" /> <Icon name="undo" size="1.25rem" />
<span>Вернуть</span> <span>{t("storage.restore")}</span>
</Button> </Button>
</SelectionBar> </SelectionBar>
{:else if problem} {:else if failure}
<SelectionBar failure={problem} title="Не получилось"> <SelectionBar {failure} title={t("storage.failed")}>
<Button <Button
onclick={() => { onclick={() => {
problem = null; problem = null;
@@ -561,7 +572,7 @@
smaller smaller
variant="text" variant="text"
> >
Понятно {t("ui.gotIt")}
</Button> </Button>
</SelectionBar> </SelectionBar>
{/if} {/if}
@@ -5,6 +5,7 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js"; import { formatBytes } from "#lib/format/media.js";
import { groupOf } from "#lib/format/storage.js"; import { groupOf } from "#lib/format/storage.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
fileName: string; fileName: string;
@@ -54,7 +55,7 @@
> >
<span class="check"> <span class="check">
<input <input
aria-label="Выбрать {fileName}" aria-label={t("storage.select", { name: fileName })}
checked={selected} checked={selected}
onchange={ontoggle} onchange={ontoggle}
type="checkbox" type="checkbox"
@@ -82,7 +83,7 @@
<span class="note">{note}</span> <span class="note">{note}</span>
{/if} {/if}
{#if !refetchable} {#if !refetchable}
<span class="gone">Не скачать заново</span> <span class="gone">{t("storage.gone")}</span>
{/if} {/if}
</span> </span>
<span class="size">{formatBytes(size)}</span> <span class="size">{formatBytes(size)}</span>
@@ -19,12 +19,13 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes } from "#lib/format/media.js"; import { formatBytes } from "#lib/format/media.js";
import { plural } from "#lib/format/shares.js";
import { import {
filesLabel, filesLabel,
messagesLabel, messagesLabel,
purgeLabel, purgeLabel,
} from "#lib/format/storage.js"; } 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 { chats } from "#lib/stores/chats.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js"; import { isMobile } from "#lib/viewport.js";
@@ -40,7 +41,7 @@
let failed = $state(false); let failed = $state(false);
let busy = $state(false); let busy = $state(false);
let doomed = $state<TrashItem[] | null>(null); let doomed = $state<TrashItem[] | null>(null);
let problem = $state<string | null>(null); let problem = $state<MessageKey | null>(null);
let viewerOpen = $state(false); let viewerOpen = $state(false);
let viewerIndex = $state(0); let viewerIndex = $state(0);
const selected = new SvelteSet<string>(); const selected = new SvelteSet<string>();
@@ -58,6 +59,7 @@
const doomedGone = $derived( const doomedGone = $derived(
doomed?.filter((item) => !item.refetchable).length ?? 0 doomed?.filter((item) => !item.refetchable).length ?? 0
); );
const failure = $derived(problem && t(problem));
const viewerItems = $derived<ViewerItem[]>( const viewerItems = $derived<ViewerItem[]>(
items.map((item) => ({ items.map((item) => ({
caption: `${formatBytes(item.file_size)} · ${chatTitle(item.chat_id)}`, caption: `${formatBytes(item.file_size)} · ${chatTitle(item.chat_id)}`,
@@ -77,7 +79,7 @@
} }
function chatTitle(chatId: number): string { function chatTitle(chatId: number): string {
return chats.byId(chatId)?.title ?? `Чат ${chatId}`; return chats.byId(chatId)?.title ?? t("ui.chat", { id: chatId });
} }
async function loadTotal() { async function loadTotal() {
@@ -132,7 +134,7 @@
await restoreFromTrash(keys); await restoreFromTrash(keys);
drop(keys); drop(keys);
} catch { } catch {
problem = "Не удалось вернуть файлы"; problem = "storage.restoreFailed";
} finally { } finally {
busy = false; busy = false;
} }
@@ -148,7 +150,7 @@
await restoreFromTrash(null); await restoreFromTrash(null);
await load(); await load();
} catch { } catch {
problem = "Не удалось вернуть файлы"; problem = "storage.restoreFailed";
} finally { } finally {
busy = false; busy = false;
} }
@@ -167,7 +169,7 @@
everything = false; everything = false;
await load(); await load();
} catch { } catch {
problem = "Не удалось удалить файлы"; problem = "storage.trash.deleteFailed";
} finally { } finally {
busy = false; busy = false;
} }
@@ -190,15 +192,12 @@
</script> </script>
<div class="trash"> <div class="trash">
<p class="note"> <p class="note">{t("storage.trash.note")}</p>
Файлы лежат здесь 7 дней, потом удаляются с диска. До этого их можно
вернуть.
</p>
{#if total.files > 0} {#if total.files > 0}
<button class="act" disabled={busy} onclick={restoreAll} type="button"> <button class="act" disabled={busy} onclick={restoreAll} type="button">
<Icon name="undo" size="1.375rem" /> <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> <span class="value">{filesLabel(total.files)}</span>
</button> </button>
<button <button
@@ -209,7 +208,7 @@
type="button" type="button"
> >
<Icon name="delete" size="1.375rem" /> <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> <span class="value">{formatBytes(total.bytes)}</span>
</button> </button>
{/if} {/if}
@@ -233,13 +232,13 @@
> >
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}> <ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть {t("ui.open")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="undo" onselect={() => restore([item])}> <ContextMenuItem icon="undo" onselect={() => restore([item])}>
Вернуть {t("storage.restore")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(item)}> <ContextMenuItem icon="reply" onselect={() => jump(item)}>
Перейти к сообщению {t("ui.jumpToMessage")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
icon="delete" icon="delete"
@@ -247,7 +246,7 @@
doomed = [item]; doomed = [item];
}} }}
> >
Удалить насовсем {t("storage.trash.deleteForever")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</StorageRow> </StorageRow>
@@ -257,22 +256,24 @@
<div class="state"><Spinner /></div> <div class="state"><Spinner /></div>
{:else if failed} {:else if failed}
<div class="state"> <div class="state">
<p>Не удалось загрузить корзину.</p> <p>{t("storage.trash.loadFailed")}</p>
<Button onclick={load} smaller variant="text">Повторить</Button> <Button onclick={load} smaller variant="text">{t("ui.retry")}</Button>
</div> </div>
{:else if items.length === 0} {:else if items.length === 0}
<div class="state"><p>Корзина пуста.</p></div> <div class="state"><p>{t("storage.trash.empty")}</p></div>
{:else if !done} {:else if !done}
<button class="more" onclick={loadMore} type="button"> <button class="more" onclick={loadMore} type="button">
Показать ещё {t("ui.showMore")}
</button> </button>
{/if} {/if}
</div> </div>
{#if chosen.length > 0} {#if chosen.length > 0}
<SelectionBar <SelectionBar
failure={dialogOpen ? null : problem} failure={dialogOpen ? null : failure}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined} note={chosenGone > 0
? t("storage.cantRefetch", { count: chosenGone })
: undefined}
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}" title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
warn warn
> >
@@ -284,7 +285,7 @@
smaller smaller
variant="danger" variant="danger"
> >
Удалить {t("common.delete")}
</Button> </Button>
<Button <Button
disabled={busy} disabled={busy}
@@ -294,11 +295,11 @@
smaller smaller
> >
<Icon name="undo" size="1.25rem" /> <Icon name="undo" size="1.25rem" />
<span>Вернуть</span> <span>{t("storage.restore")}</span>
</Button> </Button>
</SelectionBar> </SelectionBar>
{:else if problem && !dialogOpen} {:else if failure && !dialogOpen}
<SelectionBar failure={problem} title="Не получилось"> <SelectionBar {failure} title={t("storage.failed")}>
<Button <Button
onclick={() => { onclick={() => {
problem = null; problem = null;
@@ -306,7 +307,7 @@
smaller smaller
variant="text" variant="text"
> >
Понятно {t("ui.gotIt")}
</Button> </Button>
</SelectionBar> </SelectionBar>
{/if} {/if}
@@ -327,59 +328,53 @@
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title"> <Dialog.Title class="dialog-title">
{everything ? "Очистить корзину?" : "Удалить насовсем?"} {everything
? t("storage.trash.clearTitle")
: t("storage.trash.deleteTitle")}
</Dialog.Title> </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" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
<div class="dialog-body"> <div class="dialog-body">
{#if everything} {#if everything}
<p class="confirm"> <p class="confirm">
{filesLabel(total.files)}, {formatBytes(total.bytes)}, {t("storage.trash.willDelete", {
{plural( count: total.files,
total.files, files: filesLabel(total.files),
"будет удалён", size: formatBytes(total.bytes),
"будут удалены", })}
"будут удалены"
)}
с диска.
</p>
<p class="confirm rest">
То, что ещё есть в Telegram, потом можно скачать заново. Файлы из
удалённых там сообщений пропадут насовсем.
</p> </p>
<p class="confirm rest">{t("storage.trash.clearRest")}</p>
{:else if doomed} {:else if doomed}
<p class="confirm"> <p class="confirm">
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))}, {t("storage.trash.willDelete", {
{plural( count: doomed.length,
doomed.length, files: filesLabel(doomed.length),
"будет удалён", size: formatBytes(bytesOf(doomed)),
"будут удалены", })}
"будут удалены"
)}
с диска.
</p> </p>
{#if doomedGone > 0} {#if doomedGone > 0}
<p class="confirm warn"> <p class="confirm warn">
<Icon name="warning" size="1.25rem" /> <Icon name="warning" size="1.25rem" />
<span> <span>
{filesLabel(doomedGone)} {t("storage.trash.goneWarn", {
скачать заново нельзя: в Telegram count: doomedGone,
{doomedGone === 1 ? "его" : "их"} files: filesLabel(doomedGone),
уже нет. })}
</span> </span>
</p> </p>
{/if} {/if}
{#if doomedGone < doomed.length} {#if doomedGone < doomed.length}
<p class="confirm rest"> <p class="confirm rest">
{doomedGone === 0 ? "Потом их" : "Остальные"} {doomedGone === 0
можно скачать из Telegram заново. ? t("storage.trash.laterAll", { count: doomed.length })
: t("storage.trash.laterRest")}
</p> </p>
{/if} {/if}
{/if} {/if}
{#if problem} {#if failure}
<p class="confirm failed">{problem}.</p> <p class="confirm failed">{failure}.</p>
{/if} {/if}
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
@@ -387,7 +382,9 @@
{#snippet child({ {#snippet child({
props, props,
})} })}
<Button {...props} pill variant="text">Отмена</Button> <Button {...props} pill variant="text">
{t("common.cancel")}
</Button>
{/snippet} {/snippet}
</Dialog.Close> </Dialog.Close>
<Button <Button
@@ -397,7 +394,7 @@
pill pill
variant="danger" variant="danger"
> >
Удалить {t("common.delete")}
</Button> </Button>
</div> </div>
</Dialog.Content> </Dialog.Content>
+35 -29
View File
@@ -3,6 +3,8 @@
import type { JobStatus, JobView } from "#lib/api/types.js"; import type { JobStatus, JobView } from "#lib/api/types.js";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js"; 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 { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -14,25 +16,22 @@
const POLL_MS = 2000; const POLL_MS = 2000;
const KIND_LABELS: Record<string, string> = { const KIND_KEYS: Record<string, MessageKey> = {
backfill: "Бэкфилл", backfill: "jobs.kind.backfill",
backfill_stories: "Бэкфилл сторис", backfill_stories: "jobs.kind.backfill_stories",
enrich_chat: "Обогащение чата", enrich_chat: "jobs.kind.enrich_chat",
fetch_avatar: "Аватар", fetch_avatar: "jobs.kind.fetch_avatar",
fetch_custom_emoji: "Кастом-эмодзи", fetch_custom_emoji: "jobs.kind.fetch_custom_emoji",
fetch_media: "Докачка медиа", fetch_media: "jobs.kind.fetch_media",
sync_dialogs: "Синхронизация диалогов", fetch_read_info: "jobs.kind.fetch_read_info",
transcribe: "Расшифровка", sync_contacts: "jobs.kind.sync_contacts",
sync_dialogs: "jobs.kind.sync_dialogs",
transcribe: "jobs.kind.transcribe",
}; };
const STATUS_LABELS: Record<JobStatus, string> = { function statusLabel(status: JobStatus): string {
canceled: "Отменено", return t(`jobs.status.${status}`);
done: "Готово", }
failed: "Ошибка",
paused: "Пауза",
pending: "В очереди",
running: "Выполняется",
};
let jobs = $state<JobView[]>([]); let jobs = $state<JobView[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -56,7 +55,7 @@
await cancelJob(job.id); await cancelJob(job.id);
await load(); await load();
} catch { } catch {
toasts.error("Не удалось остановить задачу"); toasts.error(t("jobs.stopFailed"));
} finally { } finally {
canceling = null; canceling = null;
} }
@@ -81,11 +80,14 @@
} }
function kindLabel(job: JobView): string { 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") { 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 { function processed(job: JobView): number | null {
@@ -116,10 +118,12 @@
{#if loading} {#if loading}
<div class="state"><Spinner /></div> <div class="state"><Spinner /></div>
{:else if jobs.length === 0} {:else if jobs.length === 0}
<div class="state empty">Задач нет</div> <div class="state empty">{t("jobs.empty")}</div>
{:else} {:else}
<div class="jobs"> <div class="jobs">
{#each jobs as job (job.id)} {#each jobs as job (job.id)}
{@const chat = chatId(job)}
{@const count = processed(job)}
<div class="job"> <div class="job">
<div class="job-head"> <div class="job-head">
<span class="kind">{kindLabel(job)}</span> <span class="kind">{kindLabel(job)}</span>
@@ -130,20 +134,22 @@
onclick={() => cancel(job)} onclick={() => cancel(job)}
type="button" type="button"
> >
Стоп {t("jobs.stop")}
</button> </button>
{/if} {/if}
<span class="badge {job.status}">{STATUS_LABELS[job.status]}</span> <span class="badge {job.status}">{statusLabel(job.status)}</span>
</div> </div>
<div class="meta"> <div class="meta">
{#if chatId(job) !== null} {#if chat !== null}
<span>чат {chatId(job)}</span> <span>{t("jobs.chat", { id: chat })}</span>
{/if} {/if}
{#if processed(job) !== null} {#if count !== null}
<span>обработано {processed(job)}</span> <span>
{t("jobs.processed", { count: count.toLocaleString(locale.tag) })}
</span>
{/if} {/if}
{#if job.flood_waits > 0} {#if job.flood_waits > 0}
<span>flood-wait {job.flood_waits}</span> <span>{t("jobs.floodWaits", { count: job.flood_waits })}</span>
{/if} {/if}
<span class="time">{formatFull(job.created_at)}</span> <span class="time">{formatFull(job.created_at)}</span>
</div> </div>
@@ -8,6 +8,7 @@
import JobList from "#lib/components/jobs/JobList.svelte"; import JobList from "#lib/components/jobs/JobList.svelte";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js"; import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -39,9 +40,9 @@
function chatTitle(id: number | null): string { function chatTitle(id: number | null): string {
if (id === null) { 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) { function selectChat(id: number) {
@@ -57,10 +58,10 @@
starting = true; starting = true;
try { try {
await enqueueBackfill(selected, media, true); await enqueueBackfill(selected, media, true);
toasts.success("Полный бэкфилл запущен"); toasts.success(t("jobs.backfillStarted"));
version += 1; version += 1;
} catch { } catch {
toasts.error("Не удалось запустить бэкфилл"); toasts.error(t("jobs.backfillFailed"));
} finally { } finally {
starting = false; starting = false;
} }
@@ -73,10 +74,10 @@
syncing = true; syncing = true;
try { try {
await syncDialogs(); await syncDialogs();
toasts.success("Синхронизация диалогов запущена"); toasts.success(t("jobs.syncStarted"));
version += 1; version += 1;
} catch { } catch {
toasts.error("Не удалось синхронизировать диалоги"); toasts.error(t("jobs.syncFailed"));
} finally { } finally {
syncing = false; syncing = false;
} }
@@ -89,10 +90,10 @@
syncingContacts = true; syncingContacts = true;
try { try {
await syncContacts(); await syncContacts();
toasts.success("Синхронизация контактов запущена"); toasts.success(t("jobs.contactsStarted"));
version += 1; version += 1;
} catch { } catch {
toasts.error("Не удалось синхронизировать контакты"); toasts.error(t("jobs.contactsFailed"));
} finally { } finally {
syncingContacts = false; syncingContacts = false;
} }
@@ -105,21 +106,15 @@
<div class="panel"> <div class="panel">
<section> <section>
<div class="section-title">Диалоги</div> <div class="section-title">{t("jobs.dialogs")}</div>
<p class="hint"> <p class="hint">{t("jobs.dialogsHint")}</p>
Синхронизация подтягивает все диалоги аккаунта — старые чаты станут видны
и доступны для бэкфилла.
</p>
<div class="action"> <div class="action">
<Button fluid loading={syncing} onclick={sync} variant="secondary"> <Button fluid loading={syncing} onclick={sync} variant="secondary">
<Icon name="reload" /> <Icon name="reload" />
<span>Синхронизировать диалоги</span> <span>{t("jobs.syncDialogs")}</span>
</Button> </Button>
</div> </div>
<p class="hint"> <p class="hint">{t("jobs.contactsHint")}</p>
Контакты подтягиваются отдельно — тогда в поиске появятся люди, с которыми
ещё не было переписки.
</p>
<div class="action"> <div class="action">
<Button <Button
fluid fluid
@@ -128,17 +123,14 @@
variant="secondary" variant="secondary"
> >
<Icon name="user" /> <Icon name="user" />
<span>Синхронизировать контакты</span> <span>{t("jobs.syncContacts")}</span>
</Button> </Button>
</div> </div>
</section> </section>
<section> <section>
<div class="section-title">Бэкфилл</div> <div class="section-title">{t("jobs.backfill")}</div>
<p class="hint"> <p class="hint">{t("jobs.backfillHint")}</p>
Полный бэкфилл перечитывает всю историю чата с самого начала. Кнопка в
шапке чата догружает только сообщения новее последнего сохранённого.
</p>
<button <button
class="select" class="select"
@@ -155,8 +147,9 @@
{#if picking} {#if picking}
<div class="picker"> <div class="picker">
<input <input
aria-label={t("ui.searchChat")}
class="filter" class="filter"
placeholder="Поиск чата" placeholder={t("ui.searchChat")}
type="text" type="text"
bind:value={filter} bind:value={filter}
> >
@@ -166,7 +159,7 @@
onclick={() => selectChat(chat.chat_id)} onclick={() => selectChat(chat.chat_id)}
type="button" type="button"
> >
{chat.title ?? `Чат ${chat.chat_id}`} {chat.title ?? t("ui.chat", { id: chat.chat_id })}
</button> </button>
{/each} {/each}
</div> </div>
@@ -181,7 +174,7 @@
role="switch" role="switch"
type="button" type="button"
> >
<span class="label">Скачивать медиа</span> <span class="label">{t("jobs.downloadMedia")}</span>
<span class="switch" class:on={media}> <span class="switch" class:on={media}>
<span class="knob"></span> <span class="knob"></span>
</span> </span>
@@ -196,13 +189,13 @@
variant="primary" variant="primary"
> >
<Icon name="cloud-download" /> <Icon name="cloud-download" />
<span>Запустить полный бэкфилл</span> <span>{t("jobs.startBackfill")}</span>
</Button> </Button>
</div> </div>
</section> </section>
<section> <section>
<div class="section-title">Задачи</div> <div class="section-title">{t("jobs.title")}</div>
<JobList {version} /> <JobList {version} />
</section> </section>
</div> </div>
@@ -9,6 +9,7 @@
import type { MediaRef } from "#lib/api/types.js"; import type { MediaRef } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -39,9 +40,9 @@
queuing = true; queuing = true;
try { try {
await fetchMedia(chatId, media.message_id); await fetchMedia(chatId, media.message_id);
toasts.success("Download queued"); toasts.success(t("media.queued"));
} catch { } catch {
toasts.error("Failed to queue download"); toasts.error(t("media.queueFailed"));
} finally { } finally {
queuing = false; queuing = false;
} }
@@ -62,7 +63,7 @@
</button> </button>
{:else if ready} {:else if ready}
<button class="tile" onclick={onopen} type="button"> <button class="tile" onclick={onopen} type="button">
<img alt="attachment" src={ready.url}> <img alt={t("media.attachment")} src={ready.url}>
</button> </button>
{:else if inline?.state === "not-downloaded"} {:else if inline?.state === "not-downloaded"}
<button class="tile placeholder" onclick={queue} type="button"> <button class="tile placeholder" onclick={queue} type="button">
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatDuration } from "#lib/format/duration.js"; import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js"; import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
interface Props { interface Props {
@@ -41,7 +42,12 @@
</script> </script>
<div class="Audio" class:own> <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" /> <Icon name={paused ? "play" : "pause"} size="1.625rem" />
</button> </button>
<div class="content"> <div class="content">
@@ -3,6 +3,7 @@
import type { MediaRef } from "#lib/api/types.js"; import type { MediaRef } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js"; 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 { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -16,7 +17,7 @@
let saving = $state(false); let saving = $state(false);
const name = $derived( 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)); const size = $derived(formatBytes(media.file_size));
@@ -28,7 +29,7 @@
try { try {
await downloadMedia(media.id); await downloadMedia(media.id);
} catch { } catch {
toasts.error("Не удалось скачать файл"); toasts.error(t("ui.downloadFailed"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -52,7 +53,7 @@
</span> </span>
</button> </button>
<button <button
aria-label="Доступ по ссылке" aria-label={t("ui.shareLink")}
class="file-share" class="file-share"
onclick={share} onclick={share}
type="button" type="button"
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatDuration } from "#lib/format/duration.js"; import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js"; import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { POSTER_TIME, poster } from "#lib/media/poster.js"; import { POSTER_TIME, poster } from "#lib/media/poster.js";
@@ -38,7 +39,7 @@
<div class="RoundVideoWrap"> <div class="RoundVideoWrap">
<button <button
aria-label="Play video message" aria-label={paused ? t("media.playRound") : t("ui.pause")}
class="RoundVideo" class="RoundVideo"
onclick={toggle} onclick={toggle}
type="button" type="button"
@@ -87,7 +88,7 @@
class:active={showTranscript} class:active={showTranscript}
> >
<Icon name="transcribe" size="1rem" /> <Icon name="transcribe" size="1rem" />
Transcription {t("media.transcription")}
</button> </button>
{#if showTranscript} {#if showTranscript}
<div class="transcript">{transcript}</div> <div class="transcript">{transcript}</div>
@@ -3,6 +3,7 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDuration } from "#lib/format/duration.js"; import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js"; import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { computeWaveform, flatWaveform } from "#lib/media/waveform.js"; import { computeWaveform, flatWaveform } from "#lib/media/waveform.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -38,10 +39,10 @@
fetched = result; fetched = result;
showTranscript = true; showTranscript = true;
} else { } else {
toasts.error("Не удалось расшифровать"); toasts.error(t("media.transcribeFailed"));
} }
} catch { } catch {
toasts.error("Не удалось расшифровать"); toasts.error(t("media.transcribeFailed"));
} finally { } finally {
pending = false; pending = false;
} }
@@ -96,7 +97,7 @@
<div class="VoiceWrap" class:own> <div class="VoiceWrap" class:own>
<div class="Voice"> <div class="Voice">
<button <button
aria-label="Play voice" aria-label={paused ? t("media.playVoice") : t("ui.pause")}
class="toggle" class="toggle"
onclick={toggle} onclick={toggle}
type="button" type="button"
@@ -121,7 +122,9 @@
<div class="time">{formatDuration(elapsed)}</div> <div class="time">{formatDuration(elapsed)}</div>
</div> </div>
<button <button
aria-label="Show transcription" aria-label={showTranscript && text
? t("media.hideTranscription")
: t("media.showTranscription")}
class="transcribe" class="transcribe"
disabled={pending} disabled={pending}
onclick={onTranscribe} onclick={onTranscribe}
@@ -24,9 +24,11 @@
sameToggles, sameToggles,
summary, summary,
TOGGLE_KEYS, TOGGLE_KEYS,
TOGGLE_LABELS,
type ToggleKey, type ToggleKey,
toggleLabel,
} from "#lib/format/policy.js"; } from "#lib/format/policy.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
interface Props { interface Props {
onchange?: () => void; onchange?: () => void;
@@ -44,17 +46,17 @@
onchange, onchange,
}: Props = $props(); }: Props = $props();
const PRESETS: { label: string; toggles: CaptureToggles }[] = [ const PRESETS: { label: MessageKey; toggles: CaptureToggles }[] = [
{ label: "Всё", toggles: preset("all") }, { label: "policy.summary.all", toggles: preset("all") },
{ label: "Только сообщения", toggles: preset(["messages"]) }, { label: "policy.summary.messagesOnly", toggles: preset(["messages"]) },
{ label: "Ничего", toggles: preset([]) }, { label: "policy.summary.none", toggles: preset([]) },
]; ];
let rule = $state<PolicyRecord | null>(null); let rule = $state<PolicyRecord | null>(null);
let parent = $state<PolicyParent | null>(null); let parent = $state<PolicyParent | null>(null);
let loading = $state(false); let loading = $state(false);
let saving = $state(false); let saving = $state(false);
let problem = $state<string | null>(null); let problem = $state<MessageKey | null>(null);
let removing = $state(false); let removing = $state(false);
const isChat = $derived(scopeType === "chat"); const isChat = $derived(scopeType === "chat");
@@ -79,13 +81,14 @@
if (isChat && parent) { if (isChat && parent) {
const from = parentLabel(parent.scope_type, parent.title); const from = parentLabel(parent.scope_type, parent.title);
return rule return rule
? `Без правила было бы как ${from}: ${summary(parent.toggles).toLowerCase()}.` ? t("policy.hint.chatRule", {
: `Сейчас как ${from}. Измени что-нибудь, и у чата появится своё правило.`; from,
summary: summary(parent.toggles).toLocaleLowerCase(locale.tag),
})
: t("policy.hint.chatInherit", { from });
} }
if (isFolder) { if (isFolder) {
return rule return rule ? t("policy.hint.folderRule") : t("policy.hint.folderNone");
? "Действует на чаты папки, у которых нет своего правила."
: "У папки нет правила, чаты идут по умолчаниям. Выбери набор, чтобы его создать.";
} }
return ""; return "";
} }
@@ -112,7 +115,7 @@
}); });
onchange?.(); onchange?.();
} catch { } catch {
problem = "Не удалось сохранить правило"; problem = "policy.rule.saveFailed";
} finally { } finally {
saving = false; saving = false;
} }
@@ -136,7 +139,7 @@
removing = false; removing = false;
onchange?.(); onchange?.();
} catch { } catch {
problem = "Не удалось удалить правило"; problem = "policy.rule.deleteFailed";
} finally { } finally {
saving = false; saving = false;
} }
@@ -153,7 +156,7 @@
try { try {
({ rule, parent } = await getPolicyScope(target)); ({ rule, parent } = await getPolicyScope(target));
} catch { } catch {
problem = "Не удалось загрузить правило"; problem = "policy.rule.loadFailed";
} finally { } finally {
loading = false; loading = false;
} }
@@ -179,7 +182,7 @@
type="button" type="button"
class:on={shown !== null && sameToggles(shown, item.toggles)} class:on={shown !== null && sameToggles(shown, item.toggles)}
> >
{item.label} {t(item.label)}
</button> </button>
{/each} {/each}
</div> </div>
@@ -196,7 +199,7 @@
type="button" type="button"
class:changed={differs(key)} 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="switch" class:on={shown[key]}>
<span class="knob"></span> <span class="knob"></span>
</span> </span>
@@ -206,7 +209,7 @@
{/if} {/if}
{#if problem} {#if problem}
<p class="problem">{problem}</p> <p class="problem">{t(problem)}</p>
{/if} {/if}
{#if target} {#if target}
@@ -216,7 +219,7 @@
{#if rule && (isChat || isFolder)} {#if rule && (isChat || isFolder)}
{#if removing} {#if removing}
<div class="removal"> <div class="removal">
<span>Удалить правило?</span> <span>{t("policy.rule.removeConfirm")}</span>
<Button <Button
onclick={() => { onclick={() => {
removing = false; removing = false;
@@ -224,7 +227,7 @@
smaller smaller
variant="text" variant="text"
> >
Отмена {t("common.cancel")}
</Button> </Button>
<Button <Button
disabled={saving} disabled={saving}
@@ -233,7 +236,7 @@
smaller smaller
variant="danger" variant="danger"
> >
Удалить {t("common.delete")}
</Button> </Button>
</div> </div>
{:else} {:else}
@@ -244,7 +247,7 @@
}} }}
type="button" type="button"
> >
Удалить правило {t("policy.rule.remove")}
</button> </button>
{/if} {/if}
{/if} {/if}
@@ -2,6 +2,7 @@
import { Dialog } from "bits-ui"; import { Dialog } from "bits-ui";
import PolicyCard from "#lib/components/policy/PolicyCard.svelte"; import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
import Icon from "#lib/components/ui/Icon.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"; import { policyUi } from "#lib/stores/policy.svelte.js";
const target = $derived(policyUi.target); const target = $derived(policyUi.target);
@@ -22,10 +23,10 @@
<div class="heading"> <div class="heading">
<Dialog.Title class="dialog-title">{policyUi.title}</Dialog.Title> <Dialog.Title class="dialog-title">{policyUi.title}</Dialog.Title>
<span class="kind"> <span class="kind">
{isChat ? "Политика захвата чата" : "Политика захвата папки"} {isChat ? t("policy.kind.chat") : t("policy.kind.folder")}
</span> </span>
</div> </div>
<Dialog.Close aria-label="Закрыть" class="dialog-close"> <Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
@@ -14,11 +14,13 @@
import { import {
type Chip, type Chip,
contentChips, contentChips,
DEFAULT_LABELS, defaultLabel,
diffChips, diffChips,
sameToggles, sameToggles,
summary, summary,
} from "#lib/format/policy.js"; } 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 { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
@@ -43,7 +45,7 @@
let folders = $state<Folder[]>([]); let folders = $state<Folder[]>([]);
let loading = $state(true); let loading = $state(true);
let failed = $state(false); let failed = $state(false);
let problem = $state<string | null>(null); let problem = $state<MessageKey | null>(null);
let expanded = $state<string | null>(null); let expanded = $state<string | null>(null);
let draft = $state<Draft | null>(null); let draft = $state<Draft | null>(null);
let picking = $state<"folder" | "chat" | null>(null); let picking = $state<"folder" | "chat" | null>(null);
@@ -64,7 +66,7 @@
const chatRules = $derived( const chatRules = $derived(
rules rules
.filter((rule) => rule.scope_type === "chat") .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( const freeFolders = $derived(
folders.filter( folders.filter(
@@ -88,12 +90,12 @@
function title(rule: PolicyRule): string { function title(rule: PolicyRule): string {
if (rule.scope_type === "chat") { 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") { 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 { function redundant(rule: PolicyRule): boolean {
@@ -164,7 +166,7 @@
await deletePolicy(rule.id); await deletePolicy(rule.id);
await reload(); await reload();
} catch { } catch {
problem = "Не удалось убрать правило"; problem = "policy.rule.dropFailed";
} }
} }
@@ -227,7 +229,7 @@
{#if plain} {#if plain}
<span class="sub">{summary(rule)}</span> <span class="sub">{summary(rule)}</span>
{:else if extra} {:else if extra}
<span class="sub">Ничего не меняет</span> <span class="sub">{t("policy.rule.redundant")}</span>
{:else} {:else}
<span class="chips"> <span class="chips">
{#each chips(rule) as chip (chip.sign + chip.label)} {#each chips(rule) as chip (chip.sign + chip.label)}
@@ -242,7 +244,7 @@
</button> </button>
{#if extra} {#if extra}
<Button onclick={() => drop(rule)} smaller variant="text"> <Button onclick={() => drop(rule)} smaller variant="text">
Убрать {t("policy.rule.drop")}
</Button> </Button>
{/if} {/if}
<button <button
@@ -291,7 +293,7 @@
{/if} {/if}
<span class="text"> <span class="text">
<span class="name">{item.title}</span> <span class="name">{item.title}</span>
<span class="sub">Новое правило</span> <span class="sub">{t("policy.rule.new")}</span>
</span> </span>
</div> </div>
<Button <Button
@@ -301,7 +303,7 @@
smaller smaller
variant="text" variant="text"
> >
Отмена {t("common.cancel")}
</Button> </Button>
</div> </div>
<div class="editor" {@attach reveal}> <div class="editor" {@attach reveal}>
@@ -318,17 +320,17 @@
<div class="state"><Spinner /></div> <div class="state"><Spinner /></div>
{:else if failed} {:else if failed}
<div class="state"> <div class="state">
<p>Не удалось загрузить политику.</p> <p>{t("policy.loadFailed")}</p>
<Button onclick={reload} smaller variant="text">Повторить</Button> <Button onclick={reload} smaller variant="text">{t("ui.retry")}</Button>
</div> </div>
{:else} {:else}
<div class="policy"> <div class="policy">
{#if problem} {#if problem}
<p class="problem">{problem}</p> <p class="problem">{t(problem)}</p>
{/if} {/if}
<section> <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)} {#each defaults as rule (rule.id)}
{@render row(rule, true)} {@render row(rule, true)}
{/each} {/each}
@@ -336,8 +338,8 @@
<section> <section>
<div class="section-title"> <div class="section-title">
<h3>Папки</h3> <h3>{t("policy.section.folders")}</h3>
<span>Что сохраняют. Из подходящих папок побеждает первая.</span> <span>{t("policy.section.foldersHint")}</span>
</div> </div>
{#each folderRules as rule (rule.id)} {#each folderRules as rule (rule.id)}
{#if draft && draftBefore(rule)} {#if draft && draftBefore(rule)}
@@ -356,7 +358,7 @@
type="button" type="button"
> >
<Icon name="add" size="1.25rem" /> <Icon name="add" size="1.25rem" />
<span>Правило для папки</span> <span>{t("policy.add.folder")}</span>
</button> </button>
{#if picking === "folder"} {#if picking === "folder"}
<div class="picker"> <div class="picker">
@@ -376,8 +378,8 @@
<section> <section>
<div class="section-title"> <div class="section-title">
<h3>Чаты</h3> <h3>{t("policy.section.chats")}</h3>
<span>Что меняют против папки или умолчания. Важнее папок.</span> <span>{t("policy.section.chatsHint")}</span>
</div> </div>
{#if draft?.scopeType === "chat"} {#if draft?.scopeType === "chat"}
{@render pending(draft)} {@render pending(draft)}
@@ -392,33 +394,29 @@
type="button" type="button"
> >
<Icon name="add" size="1.25rem" /> <Icon name="add" size="1.25rem" />
<span>Правило для чата</span> <span>{t("policy.add.chat")}</span>
</button> </button>
{#if picking === "chat"} {#if picking === "chat"}
<div class="picker"> <div class="picker">
<input <input
aria-label="Поиск чата" aria-label={t("ui.searchChat")}
class="filter" class="filter"
oninput={() => picker.search(chatFilter)} oninput={() => picker.search(chatFilter)}
placeholder="Поиск чата" placeholder={t("ui.searchChat")}
type="text" type="text"
bind:value={chatFilter} bind:value={chatFilter}
> >
{#each freeChats as chat (chat.chat_id)} {#each freeChats as chat (chat.chat_id)}
{@const name = chat.title ?? t("ui.chat", { id: chat.chat_id })}
<button <button
class="pick" class="pick"
onclick={() => onclick={() => start("chat", chat.chat_id, name)}
start(
"chat",
chat.chat_id,
chat.title ?? `Чат ${chat.chat_id}`
)}
type="button" type="button"
> >
{chat.title ?? `Чат ${chat.chat_id}`} {name}
</button> </button>
{:else} {:else}
<p class="nothing">Ничего не найдено.</p> <p class="nothing">{t("ui.nothingFound")}</p>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -14,6 +14,8 @@
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import QrCode from "#lib/components/ui/QrCode.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 { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -30,12 +32,9 @@
let busy = $state(false); let busy = $state(false);
let attempt = 0; let attempt = 0;
const hints: Record<Step, string> = { function hint(current: Step): string {
code: "Код отправлен в Telegram на этот номер.", return t(`login.hint.${current}`);
password: "Аккаунт защищён двухэтапной аутентификацией.", }
phone: "Номер телефона в международном формате, например +79991234567.",
qr: "Telegram → Настройки → Устройства → Подключить устройство, и наведите камеру на код.",
};
const filled = $derived.by(() => { const filled = $derived.by(() => {
if (step === "phone") { if (step === "phone") {
@@ -58,12 +57,12 @@
if (state.account) { if (state.account) {
accounts.select(state.account.account_id); accounts.select(state.account.account_id);
} }
toasts.success("Аккаунт добавлен"); toasts.success(t("login.added"));
open = false; open = false;
} }
function fail(error: unknown, fallback: string) { function fail(error: unknown, fallback: MessageKey) {
toasts.error(error instanceof ApiError ? error.detail : fallback); toasts.error(error instanceof ApiError ? error.detail : t(fallback));
} }
function drop() { function drop() {
@@ -89,7 +88,7 @@
} }
} catch (error) { } catch (error) {
if (mine === attempt) { if (mine === attempt) {
fail(error, "Не удалось получить QR-код"); fail(error, "login.qrFailed");
} }
} }
} }
@@ -135,7 +134,7 @@
try { try {
await apply(await next()); await apply(await next());
} catch (error) { } catch (error) {
fail(error, "Не удалось войти"); fail(error, "login.failed");
} finally { } finally {
busy = false; busy = false;
} }
@@ -159,29 +158,29 @@
<Dialog.Overlay class="dialog-overlay" /> <Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title">Добавить аккаунт</Dialog.Title> <Dialog.Title class="dialog-title">{t("login.title")}</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" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
{#if step === "qr"} {#if step === "qr"}
<div class="dialog-body"> <div class="dialog-body">
<p class="hint">{hints.qr}</p> <p class="hint">{hint("qr")}</p>
<div class="qr-slot"> <div class="qr-slot">
{#if qrUrl} {#if qrUrl}
<QrCode label="QR-код для входа в Telegram" value={qrUrl} /> <QrCode label={t("login.qrLabel")} value={qrUrl} />
{/if} {/if}
</div> </div>
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
<Button onclick={usePhone} pill variant="text"> <Button onclick={usePhone} pill variant="text">
Войти по номеру телефона {t("login.byPhone")}
</Button> </Button>
</div> </div>
{:else} {:else}
<form onsubmit={submit}> <form onsubmit={submit}>
<div class="dialog-body"> <div class="dialog-body">
<p class="hint">{hints[step]}</p> <p class="hint">{hint(step)}</p>
{#if step === "phone"} {#if step === "phone"}
<div class="input-group"> <div class="input-group">
<input <input
@@ -192,7 +191,7 @@
type="tel" type="tel"
bind:value={phone} bind:value={phone}
> >
<label for="login-phone">Номер телефона</label> <label for="login-phone">{t("login.phone")}</label>
</div> </div>
{:else if step === "code"} {:else if step === "code"}
<div class="input-group"> <div class="input-group">
@@ -205,7 +204,7 @@
type="text" type="text"
bind:value={code} bind:value={code}
> >
<label for="login-code">Код подтверждения</label> <label for="login-code">{t("login.code")}</label>
</div> </div>
{:else} {:else}
<div class="input-group"> <div class="input-group">
@@ -213,17 +212,19 @@
autocomplete="current-password" autocomplete="current-password"
class="form-control" class="form-control"
id="login-password" id="login-password"
placeholder="Пароль" placeholder={t("login.passwordPlaceholder")}
type="password" type="password"
bind:value={password} bind:value={password}
> >
<label for="login-password">Облачный пароль</label> <label for="login-password">{t("login.password")}</label>
</div> </div>
{/if} {/if}
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
{#if step === "phone"} {#if step === "phone"}
<Button onclick={useQr} pill variant="text">QR-код</Button> <Button onclick={useQr} pill variant="text">
{t("login.qr")}
</Button>
{/if} {/if}
<Button <Button
disabled={busy || !filled} disabled={busy || !filled}
@@ -231,7 +232,7 @@
pill pill
type="submit" type="submit"
> >
{step === "phone" ? "Отправить код" : "Продолжить"} {step === "phone" ? t("login.sendCode") : t("login.continue")}
</Button> </Button>
</div> </div>
</form> </form>
@@ -17,43 +17,52 @@
import QrCode from "#lib/components/ui/QrCode.svelte"; import QrCode from "#lib/components/ui/QrCode.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatShareLimits } from "#lib/format/shares.js"; 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"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
label?: string; label?: string | null;
onchange?: (share: FileShare | null) => void; onchange?: (share: FileShare | null) => void;
open: boolean; open: boolean;
subject: ShareSubject | null; subject: ShareSubject | null;
} }
let { let { open = $bindable(), subject, label, onchange }: Props = $props();
open = $bindable(),
subject, interface Choice {
label = "файл", label: () => string;
onchange, value: number | null;
}: Props = $props(); }
const HOUR = 3600; const HOUR = 3600;
const DAY = 24 * HOUR; const DAY = 24 * HOUR;
const DAY_HOURS = 24;
const WEEK_DAYS = 7;
const MONTH_DAYS = 30;
const KEEP = -1; const KEEP = -1;
const LIMITS = [1, 5, 25, 100];
const expiryChoices = [ const expiryChoices: Choice[] = [
{ label: "Бессрочно", value: null }, { label: () => t("shares.noExpiry"), value: null },
{ label: "1 час", value: HOUR }, { label: () => t("shares.hours", { count: 1 }), value: HOUR },
{ label: "24 часа", value: DAY }, { label: () => t("shares.hours", { count: DAY_HOURS }), value: DAY },
{ label: "7 дней", value: 7 * DAY }, {
{ label: "30 дней", value: 30 * 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 = [ const limitChoices: Choice[] = [
{ label: "Без лимита", value: null }, { label: () => t("shares.noLimit"), value: null },
{ label: "1", value: 1 }, ...LIMITS.map((value) => ({ label: () => String(value), value })),
{ label: "5", value: 5 },
{ label: "25", value: 25 },
{ label: "100", value: 100 },
]; ];
let share = $state<FileShare | null>(null); let share = $state<FileShare | null>(null);
@@ -73,8 +82,8 @@
share?.expires_at ? [keepChoice, ...expiryChoices] : expiryChoices share?.expires_at ? [keepChoice, ...expiryChoices] : expiryChoices
); );
function fail(error: unknown, fallback: string) { function fail(error: unknown, fallback: MessageKey) {
toasts.error(error instanceof ApiError ? error.detail : fallback); toasts.error(error instanceof ApiError ? error.detail : t(fallback));
} }
function adopt(next: FileShare | null) { function adopt(next: FileShare | null) {
@@ -99,7 +108,7 @@
} }
} catch (error) { } catch (error) {
if (current === token) { if (current === token) {
fail(error, "Не удалось проверить ссылку"); fail(error, "shares.checkFailed");
open = false; open = false;
} }
} finally { } finally {
@@ -116,9 +125,9 @@
busy = true; busy = true;
try { try {
adopt(await createShare(subject, settings)); adopt(await createShare(subject, settings));
toasts.success("Доступ по ссылке открыт"); toasts.success(t("shares.opened"));
} catch (error) { } catch (error) {
fail(error, "Не удалось открыть доступ"); fail(error, "shares.openFailed");
} finally { } finally {
busy = false; busy = false;
} }
@@ -131,9 +140,9 @@
busy = true; busy = true;
try { try {
adopt(await updateShare(share.id, settings)); adopt(await updateShare(share.id, settings));
toasts.success("Настройки ссылки обновлены"); toasts.success(t("shares.updated"));
} catch (error) { } catch (error) {
fail(error, "Не удалось обновить ссылку"); fail(error, "shares.updateFailed");
} finally { } finally {
busy = false; busy = false;
} }
@@ -147,10 +156,10 @@
try { try {
await revokeShare(share.id); await revokeShare(share.id);
adopt(null); adopt(null);
toasts.success("Доступ отозван"); toasts.success(t("shares.revokedToast"));
open = false; open = false;
} catch (error) { } catch (error) {
fail(error, "Не удалось отозвать доступ"); fail(error, "shares.revokeFailed");
} finally { } finally {
busy = false; busy = false;
} }
@@ -172,8 +181,8 @@
<Dialog.Overlay class="dialog-overlay" /> <Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title">Доступ по ссылке</Dialog.Title> <Dialog.Title class="dialog-title">{t("ui.shareLink")}</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" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
@@ -203,25 +212,24 @@
type="button" type="button"
> >
<Icon name={showQr ? "collapse" : "webapp"} size="1rem" /> <Icon name={showQr ? "collapse" : "webapp"} size="1rem" />
{showQr ? "Скрыть QR-код" : "Показать QR-код"} {showQr ? t("shares.qr.hide") : t("shares.qr.show")}
</button> </button>
</div> </div>
{#if showQr} {#if showQr}
<div class="qr-slot"> <div class="qr-slot">
<QrCode label="QR-код ссылки на файл" value={shareUrl(share)} /> <QrCode label={t("shares.qr.label")} value={shareUrl(share)} />
</div> </div>
{/if} {/if}
<p class="hint raw"> <p class="hint raw">
Ссылка отдаёт файл как есть: картинки и видео откроются прямо в {t("shares.raw.before")}<code>curl -O {shareUrl(share)}</code>
браузере, <code>curl -O {shareUrl(share)}</code> скачает под {t("shares.raw.after")}
настоящим именем. Превью-краулеры Telegram скачивания не списывают.
</p> </p>
<fieldset class="choices"> <fieldset class="choices">
<legend>Лимит скачиваний</legend> <legend>{t("shares.limit")}</legend>
<div class="segments"> <div class="segments">
{#each limitChoices as choice (choice.label)} {#each limitChoices as choice (choice.value)}
<button <button
class="segment" class="segment"
onclick={() => { onclick={() => {
@@ -230,16 +238,16 @@
type="button" type="button"
class:selected={maxDownloads === choice.value} class:selected={maxDownloads === choice.value}
> >
{choice.label} {choice.label()}
</button> </button>
{/each} {/each}
</div> </div>
</fieldset> </fieldset>
<fieldset class="choices"> <fieldset class="choices">
<legend>Срок жизни ссылки</legend> <legend>{t("shares.lifetime")}</legend>
<div class="segments"> <div class="segments">
{#each liveExpiryChoices as choice (choice.label)} {#each liveExpiryChoices as choice (choice.value)}
<button <button
class="segment" class="segment"
onclick={() => { onclick={() => {
@@ -248,7 +256,7 @@
type="button" type="button"
class:selected={expiresIn === choice.value} class:selected={expiresIn === choice.value}
> >
{choice.label} {choice.label()}
</button> </button>
{/each} {/each}
</div> </div>
@@ -259,15 +267,15 @@
><Icon name="lock" size="1.125rem" /></span ><Icon name="lock" size="1.125rem" /></span
> >
<div class="subject-text"> <div class="subject-text">
<strong>{label}</strong> <strong>{label ?? t("shares.file")}</strong>
<span>Сейчас доступен только вам</span> <span>{t("shares.private")}</span>
</div> </div>
</div> </div>
<fieldset class="choices"> <fieldset class="choices">
<legend>Срок жизни ссылки</legend> <legend>{t("shares.lifetime")}</legend>
<div class="segments"> <div class="segments">
{#each expiryChoices as choice (choice.label)} {#each expiryChoices as choice (choice.value)}
<button <button
class="segment" class="segment"
onclick={() => { onclick={() => {
@@ -276,16 +284,16 @@
type="button" type="button"
class:selected={expiresIn === choice.value} class:selected={expiresIn === choice.value}
> >
{choice.label} {choice.label()}
</button> </button>
{/each} {/each}
</div> </div>
</fieldset> </fieldset>
<fieldset class="choices"> <fieldset class="choices">
<legend>Лимит скачиваний</legend> <legend>{t("shares.limit")}</legend>
<div class="segments"> <div class="segments">
{#each limitChoices as choice (choice.label)} {#each limitChoices as choice (choice.value)}
<button <button
class="segment" class="segment"
onclick={() => { onclick={() => {
@@ -294,16 +302,13 @@
type="button" type="button"
class:selected={maxDownloads === choice.value} class:selected={maxDownloads === choice.value}
> >
{choice.label} {choice.label()}
</button> </button>
{/each} {/each}
</div> </div>
</fieldset> </fieldset>
<p class="hint"> <p class="hint">{t("shares.publicHint")}</p>
Файл откроется всем, у кого есть ссылка. Отозвать можно в любой
момент.
</p>
{/if} {/if}
</div> </div>
@@ -311,12 +316,14 @@
<div class="dialog-actions"> <div class="dialog-actions">
{#if share} {#if share}
<Button loading={busy} onclick={revoke} pill variant="danger"> <Button loading={busy} onclick={revoke} pill variant="danger">
Отозвать {t("shares.revoke")}
</Button>
<Button loading={busy} onclick={applyLimits} pill>
{t("common.save")}
</Button> </Button>
<Button loading={busy} onclick={applyLimits} pill>Сохранить</Button>
{:else} {:else}
<Button fluid loading={busy} onclick={publish} pill> <Button fluid loading={busy} onclick={publish} pill>
Открыть доступ {t("shares.publish")}
</Button> </Button>
{/if} {/if}
</div> </div>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -17,7 +18,7 @@
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
} catch { } catch {
toasts.error("Не удалось скопировать ссылку"); toasts.error(t("shares.copyFailed"));
return; return;
} }
copied = true; copied = true;
@@ -16,7 +16,8 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { formatBytes } from "#lib/format/media.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 { accounts } from "#lib/stores/accounts.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -25,13 +26,6 @@
type Filter = "active" | "all"; type Filter = "active" | "all";
const stateLabels: Record<string, string> = {
active: "Открыт",
exhausted: "Лимит исчерпан",
expired: "Истёк",
revoked: "Отозван",
};
let items = $state<FileShare[]>([]); let items = $state<FileShare[]>([]);
let loading = $state(false); let loading = $state(false);
let filter = $state<Filter>("active"); let filter = $state<Filter>("active");
@@ -63,7 +57,7 @@
} }
} catch { } catch {
if (current === token) { if (current === token) {
toasts.error("Не удалось загрузить список ссылок"); toasts.error(t("shares.listFailed"));
} }
} finally { } finally {
if (current === token) { if (current === token) {
@@ -91,24 +85,24 @@
async function copy(share: FileShare) { async function copy(share: FileShare) {
await navigator.clipboard.writeText(shareUrl(share)); await navigator.clipboard.writeText(shareUrl(share));
toasts.success("Ссылка скопирована"); toasts.success(t("shares.copied"));
} }
async function revoke(share: FileShare) { async function revoke(share: FileShare) {
try { try {
replace(await revokeShare(share.id)); replace(await revokeShare(share.id));
toasts.success("Доступ отозван"); toasts.success(t("shares.revokedToast"));
} catch { } catch {
toasts.error("Не удалось отозвать доступ"); toasts.error(t("shares.revokeFailed"));
} }
} }
async function reissue(share: FileShare) { async function reissue(share: FileShare) {
try { try {
replace(await reissueShare(share.id)); replace(await reissueShare(share.id));
toasts.success("Выдана новая ссылка"); toasts.success(t("shares.reissued"));
} catch { } catch {
toasts.error("Не удалось перевыпустить ссылку"); toasts.error(t("shares.reissueFailed"));
} }
} }
@@ -117,7 +111,7 @@
await deleteShare(share.id); await deleteShare(share.id);
items = items.filter((item) => item.id !== share.id); items = items.filter((item) => item.id !== share.id);
} catch { } catch {
toasts.error("Не удалось удалить запись"); toasts.error(t("shares.forgetFailed"));
} }
} }
@@ -142,7 +136,7 @@
type="button" type="button"
class:selected={filter === "active"} class:selected={filter === "active"}
> >
Открытые{activeCount ? ` · ${activeCount}` : ""} {t("shares.tab.active")}{activeCount ? ` · ${activeCount}` : ""}
</button> </button>
<button <button
class="tab" class="tab"
@@ -152,11 +146,11 @@
type="button" type="button"
class:selected={filter === "all"} class:selected={filter === "all"}
> >
Все {t("shares.tab.all")}
</button> </button>
</div> </div>
<button <button
aria-label="Обновить" aria-label={t("ui.refresh")}
class="reload" class="reload"
onclick={() => load()} onclick={() => load()}
type="button" type="button"
@@ -169,8 +163,8 @@
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else if visible.length === 0} {:else if visible.length === 0}
<EmptyState <EmptyState
description="Откройте доступ к файлу из меню медиа" description={t("shares.empty.hint")}
title="Ничего не открыто" title={t("shares.empty.title")}
/> />
{:else} {:else}
<ul class="list"> <ul class="list">
@@ -198,14 +192,14 @@
<span class="text"> <span class="text">
<span class="name">{share.file_name}</span> <span class="name">{share.file_name}</span>
<span class="sub"> <span class="sub">
{stateLabels[state]} {t(`shares.state.${state}`)}
· {formatDownloads(share)} · {formatDownloads(share)}
{#if share.file_size} {#if share.file_size}
· {formatBytes(share.file_size)} · {formatBytes(share.file_size)}
{/if} {/if}
</span> </span>
<span class="sub"> <span class="sub">
{share.title ?? "Без чата"} {share.title ?? t("shares.noChat")}
· {formatExpiry(share)} · {formatExpiry(share)}
</span> </span>
</span> </span>
@@ -216,11 +210,11 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="copy" onselect={() => copy(share)}> <ContextMenuItem icon="copy" onselect={() => copy(share)}>
Копировать ссылку {t("shares.copy")}
</ContextMenuItem> </ContextMenuItem>
{#if share.message_id !== null || share.peer_id !== null} {#if share.message_id !== null || share.peer_id !== null}
<ContextMenuItem icon="reply" onselect={() => jump(share)}> <ContextMenuItem icon="reply" onselect={() => jump(share)}>
Перейти к сообщению {t("ui.jumpToMessage")}
</ContextMenuItem> </ContextMenuItem>
{/if} {/if}
{#if state === "active"} {#if state === "active"}
@@ -228,14 +222,14 @@
icon="link-broken" icon="link-broken"
onselect={() => revoke(share)} onselect={() => revoke(share)}
> >
Отозвать {t("shares.revoke")}
</ContextMenuItem> </ContextMenuItem>
{:else} {:else}
<ContextMenuItem icon="replace" onselect={() => reissue(share)}> <ContextMenuItem icon="replace" onselect={() => reissue(share)}>
Выдать новую ссылку {t("shares.reissue")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="delete" onselect={() => forget(share)}> <ContextMenuItem icon="delete" onselect={() => forget(share)}>
Убрать из списка {t("shares.forget")}
</ContextMenuItem> </ContextMenuItem>
{/if} {/if}
{/snippet} {/snippet}
@@ -246,13 +240,15 @@
<ShareLinkField url={shareUrl(share)} /> <ShareLinkField url={shareUrl(share)} />
{#if share.last_download_at} {#if share.last_download_at}
<p class="last"> <p class="last">
Последнее скачивание: {formatFull(share.last_download_at)} {t("shares.lastDownload", {
date: formatFull(share.last_download_at),
})}
</p> </p>
{/if} {/if}
<div class="actions"> <div class="actions">
{#if share.message_id !== null || share.peer_id !== null} {#if share.message_id !== null || share.peer_id !== null}
<button class="pill" onclick={() => jump(share)} type="button"> <button class="pill" onclick={() => jump(share)} type="button">
<Icon name="reply" size="1rem" />К сообщению <Icon name="reply" size="1rem" />{t("shares.toMessage")}
</button> </button>
{/if} {/if}
{#if state === "active"} {#if state === "active"}
@@ -261,7 +257,7 @@
onclick={() => revoke(share)} onclick={() => revoke(share)}
type="button" type="button"
> >
<Icon name="link-broken" size="1rem" />Отозвать <Icon name="link-broken" size="1rem" />{t("shares.revoke")}
</button> </button>
{:else} {:else}
<button <button
@@ -269,7 +265,7 @@
onclick={() => reissue(share)} onclick={() => reissue(share)}
type="button" type="button"
> >
<Icon name="replace" size="1rem" />Новая ссылка <Icon name="replace" size="1rem" />{t("shares.newLink")}
</button> </button>
{/if} {/if}
</div> </div>
@@ -279,9 +275,7 @@
{/each} {/each}
</ul> </ul>
<p class="footnote"> <p class="footnote">
{visible.length} {t("shares.footnote", { count: visible.length })}
{plural(visible.length, "ссылка", "ссылки", "ссылок")}
· превью-краулеры не списывают скачивания
</p> </p>
{/if} {/if}
@@ -5,6 +5,7 @@
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -90,11 +91,11 @@
</script> </script>
{#if chatId === null || messageId === null} {#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Callback-данные" /> <EmptyState description={t("ui.noMessage")} title={t("social.callbacks")} />
{:else if loading && items.length === 0} {:else if loading && items.length === 0}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else if items.length === 0} {:else if items.length === 0}
<EmptyState title="Нет callback-кнопок" /> <EmptyState title={t("social.callbacks.empty")} />
{:else} {:else}
<ul class="list"> <ul class="list">
{#each items as item (item.position)} {#each items as item (item.position)}
@@ -106,7 +107,7 @@
</div> </div>
{#if value} {#if value}
<button <button
aria-label="Скопировать" aria-label={t("ui.copy")}
class="copy" class="copy"
onclick={() => copy(item.position, value)} onclick={() => copy(item.position, value)}
type="button" type="button"
@@ -6,6 +6,7 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { safeHref } from "#lib/format/url.js"; import { safeHref } from "#lib/format/url.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -56,11 +57,11 @@
</script> </script>
{#if chatId === null || messageId === null} {#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Ссылки" /> <EmptyState description={t("ui.noMessage")} title={t("social.links")} />
{:else if loading && items.length === 0} {:else if loading && items.length === 0}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else if items.length === 0} {:else if items.length === 0}
<EmptyState title="Нет ссылок" /> <EmptyState title={t("social.links.empty")} />
{:else} {:else}
<ul class="list"> <ul class="list">
{#each items as item (item.position)} {#each items as item (item.position)}
@@ -8,6 +8,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { peerColorIndex, peerName } from "#lib/format/peer.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 { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
@@ -65,11 +66,11 @@
</script> </script>
{#if chatId === null || messageId === null} {#if chatId === null || messageId === null}
<EmptyState description="Сообщение не выбрано" title="Реакции" /> <EmptyState description={t("ui.noMessage")} title={t("social.reactions")} />
{:else if loading && items.length === 0} {:else if loading && items.length === 0}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else if items.length === 0} {:else if items.length === 0}
<EmptyState title="Нет реакций" /> <EmptyState title={t("social.reactions.empty")} />
{:else} {:else}
<div class="groups"> <div class="groups">
{#each groups as [reaction, reactors] (reaction)} {#each groups as [reaction, reactors] (reaction)}
@@ -104,7 +105,9 @@
> >
<span class="time"> <span class="time">
{#if reactor.removed_at} {#if reactor.removed_at}
снято · {formatFull(reactor.removed_at)} {t("social.reactions.removed", {
date: formatFull(reactor.removed_at),
})}
{:else} {:else}
{formatFull(reactor.added_at)} {formatFull(reactor.added_at)}
{/if} {/if}
@@ -16,6 +16,7 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { peerName } from "#lib/format/peer.js"; import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -89,7 +90,7 @@
return { return {
hasAvatar: Boolean(chat?.has_avatar), hasAvatar: Boolean(chat?.has_avatar),
kind: "chat" as const, kind: "chat" as const,
name: chat?.title ?? `Chat ${peerId}`, name: chat?.title ?? t("ui.chat", { id: peerId }),
peerId, peerId,
stories: peerStories, stories: peerStories,
}; };
@@ -150,9 +151,9 @@
backfilling = peerId; backfilling = peerId;
try { try {
await enqueueStoriesBackfill(peerId); await enqueueStoriesBackfill(peerId);
toasts.success("Загружаем старые сторис"); toasts.success(t("stories.backfillStarted"));
} catch { } catch {
toasts.error("Не удалось запустить загрузку сторис"); toasts.error(t("stories.backfillFailed"));
} finally { } finally {
backfilling = null; backfilling = null;
} }
@@ -174,7 +175,7 @@
{#if loading} {#if loading}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else} {:else}
<EmptyState title="Нет сторис" /> <EmptyState title={t("stories.empty")} />
{/if} {/if}
{:else} {:else}
{#each groups as group (group.peerId)} {#each groups as group (group.peerId)}
@@ -200,7 +201,7 @@
</span> </span>
</button> </button>
<Button <Button
aria-label="Загрузить старые сторис" aria-label={t("stories.loadOlderStories")}
loading={backfilling === group.peerId} loading={backfilling === group.peerId}
onclick={() => backfill(group.peerId)} onclick={() => backfill(group.peerId)}
round round
@@ -9,6 +9,7 @@
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -109,9 +110,9 @@
backfilling = true; backfilling = true;
try { try {
await enqueueStoriesBackfill(id); await enqueueStoriesBackfill(id);
toasts.success("Загружаем старые сторис"); toasts.success(t("stories.backfillStarted"));
} catch { } catch {
toasts.error("Не удалось запустить загрузку сторис"); toasts.error(t("stories.backfillFailed"));
} finally { } finally {
backfilling = false; backfilling = false;
} }
@@ -127,7 +128,7 @@
</script> </script>
{#if peerId === null} {#if peerId === null}
<EmptyState description="Откройте чат" title="Сторис" /> <EmptyState description={t("stories.openChat")} title={t("stories.title")} />
{:else} {:else}
<div class="toolbar"> <div class="toolbar">
<Button <Button
@@ -137,10 +138,10 @@
smaller smaller
variant="secondary" variant="secondary"
> >
<Icon name="cloud-download" />Загрузить старые <Icon name="cloud-download" />{t("stories.loadOlder")}
</Button> </Button>
<Button <Button
aria-label="Обновить" aria-label={t("ui.refresh")}
onclick={() => peerId !== null && reload(peerId).catch(() => undefined)} onclick={() => peerId !== null && reload(peerId).catch(() => undefined)}
round round
smaller smaller
@@ -155,8 +156,8 @@
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else} {:else}
<EmptyState <EmptyState
description="Нажмите «Загрузить старые», чтобы забрать архив" description={t("stories.emptyHint")}
title="Нет сторис" title={t("stories.empty")}
/> />
{/if} {/if}
{:else} {:else}
@@ -177,7 +178,7 @@
onclick={() => peerId !== null && loadMore(peerId)} onclick={() => peerId !== null && loadMore(peerId)}
type="button" type="button"
> >
{loading ? "Загрузка…" : "Показать ещё"} {loading ? t("ui.loading") : t("ui.showMore")}
</button> </button>
{/if} {/if}
@@ -5,6 +5,7 @@
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -20,14 +21,16 @@
const { story, preview, onopen }: Props = $props(); const { story, preview, onopen }: Props = $props();
const label = $derived( const label = $derived(
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}` story.date
? t("stories.from", { date: formatFull(story.date) })
: t("stories.undated")
); );
async function save() { async function save() {
try { try {
await downloadStory(story.peer_id, story.story_id); await downloadStory(story.peer_id, story.story_id);
} catch { } catch {
toasts.error("Не удалось скачать сторис"); toasts.error(t("stories.downloadFailed"));
} }
} }
@@ -78,25 +81,26 @@
{/if} {/if}
{#if story.views} {#if story.views}
<span class="badge 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> </span>
{/if} {/if}
</button> </button>
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}> <ContextMenuItem icon="open-in-new-tab" onselect={onopen}>
Открыть {t("ui.open")}
</ContextMenuItem> </ContextMenuItem>
{#if story.downloaded} {#if story.downloaded}
<ContextMenuItem icon="download" onselect={save}> <ContextMenuItem icon="download" onselect={save}>
Скачать {t("ui.download")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}> <ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке {t("ui.shareLink")}
</ContextMenuItem> </ContextMenuItem>
{/if} {/if}
<ContextMenuItem icon="info" onselect={openAuthor}> <ContextMenuItem icon="info" onselect={openAuthor}>
Профиль автора {t("stories.author")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
@@ -9,6 +9,7 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js"; 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 { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -121,7 +122,7 @@
try { try {
await downloadStory(story.peer_id, story.story_id); await downloadStory(story.peer_id, story.story_id);
} catch { } catch {
toasts.error("Не удалось скачать сторис"); toasts.error(t("stories.downloadFailed"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -133,7 +134,9 @@
} }
shareUi.share( shareUi.share(
{ kind: "story", peerId: story.peer_id, storyId: story.story_id }, { 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.Portal>
<Dialog.Overlay class="story-overlay" /> <Dialog.Overlay class="story-overlay" />
<Dialog.Content class="story-content {held ? "held" : ""}"> <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"> <div class="bars">
{#each items as item, i (item.story_id)} {#each items as item, i (item.story_id)}
<div class="bar"> <div class="bar">
@@ -214,7 +219,7 @@
<div class="head-actions"> <div class="head-actions">
{#if isVideo} {#if isVideo}
<button <button
aria-label={muted ? "Включить звук" : "Выключить звук"} aria-label={muted ? t("stories.unmute") : t("stories.mute")}
class="round" class="round"
onclick={() => { onclick={() => {
muted = !muted; muted = !muted;
@@ -231,7 +236,7 @@
})} })}
<button <button
{...props} {...props}
aria-label="Скачать сторис" aria-label={t("stories.download")}
class="round" class="round"
onclick={save} onclick={save}
type="button" type="button"
@@ -241,15 +246,15 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="download" onselect={save}> <ContextMenuItem icon="download" onselect={save}>
Скачать {t("ui.download")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}> <ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке {t("ui.shareLink")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
{/if} {/if}
<Dialog.Close aria-label="Закрыть" class="round"> <Dialog.Close aria-label={t("common.close")} class="round">
<Icon name="close" size="1.5rem" /> <Icon name="close" size="1.5rem" />
</Dialog.Close> </Dialog.Close>
</div> </div>
@@ -283,7 +288,7 @@
src={url} src={url}
> >
{:else} {:else}
<p class="missing">Медиа недоступно</p> <p class="missing">{t("stories.unavailable")}</p>
{/if} {/if}
</div> </div>
@@ -296,12 +301,13 @@
{#if story?.views} {#if story?.views}
<span class="view-count"> <span class="view-count">
<Icon name="eye" size="1rem" />{story.views} <Icon name="eye" size="1rem" />
{story.views.toLocaleString(locale.tag)}
</span> </span>
{/if} {/if}
<button <button
aria-label="Назад" aria-label={t("ui.previous")}
class="tap prev" class="tap prev"
onclick={() => onTap(-1)} onclick={() => onTap(-1)}
onpointercancel={releaseHold} onpointercancel={releaseHold}
@@ -309,7 +315,7 @@
type="button" type="button"
></button> ></button>
<button <button
aria-label="Вперёд" aria-label={t("ui.next")}
class="tap next" class="tap next"
onclick={() => onTap(1)} onclick={() => onTap(1)}
onpointercancel={releaseHold} onpointercancel={releaseHold}
@@ -318,7 +324,7 @@
></button> ></button>
{#if held} {#if held}
<span class="hold-hint">Пауза</span> <span class="hold-hint">{t("stories.paused")}</span>
{/if} {/if}
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </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"; import type { MessageKey } from "#lib/i18n/messages.js";
const MEDIA_KIND_KEYS: Record<string, MessageKey> = { 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"); 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 BYTE_STEP = 1024;
const PRECISE_BELOW = 10;
export function formatBytes(bytes: number | null): string { export function formatBytes(bytes: number | null): string {
if (bytes === null || bytes <= 0) { if (bytes === null || bytes <= 0) {
@@ -40,7 +41,10 @@ export function formatBytes(bytes: number | null): string {
value /= BYTE_STEP; value /= BYTE_STEP;
unit += 1; unit += 1;
} }
const rounded = const digits = value >= PRECISE_BELOW || unit === 0 ? 0 : 1;
value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1); const rounded = value.toLocaleString(locale.tag, {
return `${rounded} ${BYTE_UNITS[unit]}`; 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 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 type ToggleKey = keyof CaptureToggles;
export const TOGGLE_LABELS: Record<ToggleKey, string> = { type DefaultScope = "channel" | "dm" | "group";
backfill: "Бэкфилл",
media: "Медиа",
messages: "Сообщения",
presence: "Присутствие",
profile_history: "История профиля",
reactions: "Реакции",
self_destruct_media: "Исчезающие",
stories: "Сторис",
stt: "Расшифровка",
track_edits_deletes: "Правки, удаления",
};
export const TOGGLE_KEYS: ToggleKey[] = [ export const TOGGLE_KEYS: ToggleKey[] = [
"messages", "messages",
@@ -29,16 +18,10 @@ export const TOGGLE_KEYS: ToggleKey[] = [
"backfill", "backfill",
]; ];
export const DEFAULT_LABELS: Partial<Record<PolicyScopeType, string>> = { const DEFAULT_SCOPES: Partial<Record<PolicyScopeType, DefaultScope>> = {
default_channel: "Каналы", default_channel: "channel",
default_dm: "Личные чаты", default_dm: "dm",
default_group: "Группы", default_group: "group",
};
const PARENT_LABELS: Partial<Record<PolicyScopeType, string>> = {
default_channel: "у каналов",
default_dm: "у личных чатов",
default_group: "у групп",
}; };
const SHOWN_CHIPS = 3; const SHOWN_CHIPS = 3;
@@ -50,6 +33,19 @@ export interface Chip {
sign: "plus" | "minus"; 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 { export function pickToggles(source: CaptureToggles): CaptureToggles {
return { return {
backfill: source.backfill, backfill: source.backfill,
@@ -77,37 +73,31 @@ export function sameToggles(a: CaptureToggles, b: CaptureToggles): boolean {
return TOGGLE_KEYS.every((key) => a[key] === b[key]); return TOGGLE_KEYS.every((key) => a[key] === b[key]);
} }
const EXCEPT_LABELS: Record<ToggleKey, string> = { function exceptList(keys: ToggleKey[]): string {
backfill: "бэкфилла", const [first, second] = keys.map(exceptLabel);
media: "медиа", return second === undefined
messages: "сообщений", ? (first ?? "")
presence: "присутствия", : t("policy.summary.pair", { a: first ?? "", b: second });
profile_history: "истории профиля", }
reactions: "реакций",
self_destruct_media: "исчезающих",
stories: "сторис",
stt: "расшифровки",
track_edits_deletes: "правок и удалений",
};
export function summary(toggles: CaptureToggles): string { export function summary(toggles: CaptureToggles): string {
const on = TOGGLE_KEYS.filter((key) => toggles[key]); const on = TOGGLE_KEYS.filter((key) => toggles[key]);
const off = TOGGLE_KEYS.filter((key) => !toggles[key]); const off = TOGGLE_KEYS.filter((key) => !toggles[key]);
if (off.length === 0) { if (off.length === 0) {
return "Всё"; return t("policy.summary.all");
} }
if (on.length === 0) { if (on.length === 0) {
return "Ничего"; return t("policy.summary.none");
} }
if (on.length === 1 && on[0] === "messages") { if (on.length === 1 && on[0] === "messages") {
return "Только сообщения"; return t("policy.summary.messagesOnly");
} }
if (off.length <= SHORT_LIST) { 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 names = on.slice(0, SHORT_LIST).map(toggleLabel).join(", ");
const rest = on.length - names.length; const rest = on.length - SHORT_LIST;
return rest > 0 ? `${names.join(", ")} и ещё ${rest}` : names.join(", "); return rest > 0 ? t("policy.summary.more", { count: rest, names }) : names;
} }
export function parentLabel( export function parentLabel(
@@ -115,17 +105,20 @@ export function parentLabel(
title: string | null title: string | null
): string { ): string {
if (scopeType === "folder") { 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 { 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[] { 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[] { 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) { if (keys.length <= SHOWN_CHIPS) {
return named(keys, sign); return named(keys, sign);
} }
const word = plural(keys.length, "настройка", "настройки", "настроек"); return [
return [{ hint: labelList(keys), label: `${keys.length} ${word}`, sign }]; {
hint: labelList(keys),
label: t("policy.chip.settings", { count: keys.length }),
sign,
},
];
} }
export function contentChips(rule: CaptureToggles): Chip[] { export function contentChips(rule: CaptureToggles): Chip[] {
const on = TOGGLE_KEYS.filter((key) => rule[key]); const on = TOGGLE_KEYS.filter((key) => rule[key]);
const off = TOGGLE_KEYS.filter((key) => !rule[key]); const off = TOGGLE_KEYS.filter((key) => !rule[key]);
if (off.length === 0) { if (off.length === 0) {
return [{ label: "Всё включено", sign: "plus" }]; return [{ label: t("policy.chip.allOn"), sign: "plus" }];
} }
if (on.length === 0) { if (on.length === 0) {
return [{ label: "Всё выключено", sign: "minus" }]; return [{ label: t("policy.chip.allOff"), sign: "minus" }];
} }
if (off.length <= SHOWN_CHIPS) { 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")]; 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 plus = TOGGLE_KEYS.filter((key) => rule[key] && !base[key]);
const minus = 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])) { 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])) { 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")]; return [...counted(plus, "plus"), ...counted(minus, "minus")];
} }
+12 -13
View File
@@ -1,5 +1,6 @@
import type { FileShare } from "#lib/api/types.js"; import type { FileShare } from "#lib/api/types.js";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
const TEEN_START = 11; const TEEN_START = 11;
const TEEN_END = 14; const TEEN_END = 14;
@@ -26,28 +27,26 @@ export function plural(
} }
export function formatDownloads(share: FileShare): string { export function formatDownloads(share: FileShare): string {
const word = plural(
share.download_count,
"скачивание",
"скачивания",
"скачиваний"
);
if (share.max_downloads === null) { 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 { export function formatExpiry(share: FileShare): string {
if (share.revoked_at) { if (share.revoked_at) {
return "отозвана"; return t("shares.revoked");
} }
if (!share.expires_at) { if (!share.expires_at) {
return "бессрочно"; return t("shares.forever");
} }
const expires = Date.parse(share.expires_at); const date = formatFull(share.expires_at);
const prefix = expires <= Date.now() ? "истекла" : "до"; return Date.parse(share.expires_at) <= Date.now()
return `${prefix} ${formatFull(share.expires_at)}`; ? t("shares.expired", { date })
: t("shares.until", { date });
} }
export function formatShareLimits(share: FileShare): string { export function formatShareLimits(share: FileShare): string {
+10 -14
View File
@@ -1,17 +1,9 @@
import type { StorageGroupName } from "#lib/api/types.js"; 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 DAY_MS = 86_400_000;
const EXTENSION_LIMIT = 4; const EXTENSION_LIMIT = 4;
export const GROUP_LABELS: Record<StorageGroupName, string> = {
audio: "Аудио",
file: "Файлы",
other: "Фото и прочее",
video: "Видео",
voice: "Кружки и голосовые",
};
const KIND_GROUPS: Record<string, StorageGroupName> = { const KIND_GROUPS: Record<string, StorageGroupName> = {
audio: "audio", audio: "audio",
document: "file", document: "file",
@@ -20,16 +12,20 @@ const KIND_GROUPS: Record<string, StorageGroupName> = {
voice: "voice", voice: "voice",
}; };
export function groupLabel(group: StorageGroupName): string {
return t(`storage.group.${group}`);
}
export function groupOf(kind: string): StorageGroupName { export function groupOf(kind: string): StorageGroupName {
return KIND_GROUPS[kind] ?? "other"; return KIND_GROUPS[kind] ?? "other";
} }
export function filesLabel(count: number): string { 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 { export function messagesLabel(count: number): string {
return `в ${count} ${plural(count, "сообщении", "сообщениях", "сообщениях")}`; return t("storage.inMessages", { count });
} }
export function fileExtension(fileName: string): string { export function fileExtension(fileName: string): string {
@@ -42,10 +38,10 @@ export function fileExtension(fileName: string): string {
export function purgeLabel(iso: string): string { export function purgeLabel(iso: string): string {
const days = Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS); const days = Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS);
if (days <= 0) { if (days <= 0) {
return "сегодня"; return t("storage.purge.today");
} }
if (days === 1) { 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 { account } from "#lib/i18n/messages/account.js";
import { alerts } from "#lib/i18n/messages/alerts.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 { common } from "#lib/i18n/messages/common.js";
import { feed } from "#lib/i18n/messages/feed.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 { 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 { profile } from "#lib/i18n/messages/profile.js";
import { read } from "#lib/i18n/messages/read.js"; import { read } from "#lib/i18n/messages/read.js";
import { search } from "#lib/i18n/messages/search.js"; import { search } from "#lib/i18n/messages/search.js";
import { settings } from "#lib/i18n/messages/settings.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"; import { watches } from "#lib/i18n/messages/watches.js";
export const messages = { export const messages = {
...account, ...account,
...alerts, ...alerts,
...bots,
...common, ...common,
...feed, ...feed,
...jobs,
...login,
...media,
...menu, ...menu,
...notes,
...policy,
...profile, ...profile,
...read, ...read,
...search, ...search,
...settings, ...settings,
...shares,
...social,
...storage,
...stories,
...track,
...ui,
...watches, ...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() { function createShareUi() {
let open = $state(false); let open = $state(false);
let subject = $state<ShareSubject | null>(null); let subject = $state<ShareSubject | null>(null);
let label = $state("файл"); let label = $state<string | null>(null);
let revision = $state(0); let revision = $state(0);
return { return {
@@ -19,7 +19,7 @@ function createShareUi() {
get revision() { get revision() {
return revision; return revision;
}, },
share(next: ShareSubject, name = "файл") { share(next: ShareSubject, name: string | null = null) {
subject = next; subject = next;
label = name; label = name;
open = true; open = true;