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

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

No files matched your search

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