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
@@ -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}
|
||||
|
||||
|
||||
Reference in new issue
Block a user