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

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

No files matched your search

@@ -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>