feat(frontend): storage, trash, policy, stories, shares, jobs, bots, media, login and notes speak russian and english
This commit is contained in:
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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user