473 lines
12 KiB
Svelte
473 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { Dialog } from "bits-ui";
|
|
import { untrack } from "svelte";
|
|
import { ApiError } from "#lib/api/client.js";
|
|
import {
|
|
createShare,
|
|
lookupShare,
|
|
revokeShare,
|
|
type ShareSettings,
|
|
shareUrl,
|
|
updateShare,
|
|
} from "#lib/api/shares.js";
|
|
import type { FileShare, ShareSubject } from "#lib/api/types.js";
|
|
import ShareLinkField from "#lib/components/shares/ShareLinkField.svelte";
|
|
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 Spinner from "#lib/components/ui/Spinner.svelte";
|
|
import { formatShareLimits } from "#lib/format/shares.js";
|
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
|
|
|
interface Props {
|
|
label?: string;
|
|
onchange?: (share: FileShare | null) => void;
|
|
open: boolean;
|
|
subject: ShareSubject | null;
|
|
}
|
|
|
|
let {
|
|
open = $bindable(),
|
|
subject,
|
|
label = "файл",
|
|
onchange,
|
|
}: Props = $props();
|
|
|
|
const HOUR = 3600;
|
|
const DAY = 24 * HOUR;
|
|
|
|
const KEEP = -1;
|
|
|
|
const expiryChoices = [
|
|
{ label: "Бессрочно", value: null },
|
|
{ label: "1 час", value: HOUR },
|
|
{ label: "24 часа", value: DAY },
|
|
{ label: "7 дней", value: 7 * DAY },
|
|
{ label: "30 дней", value: 30 * DAY },
|
|
];
|
|
|
|
const keepChoice = { label: "Как есть", value: KEEP };
|
|
|
|
const limitChoices = [
|
|
{ label: "Без лимита", value: null },
|
|
{ label: "1", value: 1 },
|
|
{ label: "5", value: 5 },
|
|
{ label: "25", value: 25 },
|
|
{ label: "100", value: 100 },
|
|
];
|
|
|
|
let share = $state<FileShare | null>(null);
|
|
let loading = $state(false);
|
|
let busy = $state(false);
|
|
let expiresIn = $state<number | null>(null);
|
|
let maxDownloads = $state<number | null>(null);
|
|
let showQr = $state(false);
|
|
let token = 0;
|
|
|
|
const settings = $derived<ShareSettings>({
|
|
expiresInSeconds: expiresIn === KEEP ? null : expiresIn,
|
|
keepExpiry: expiresIn === KEEP,
|
|
maxDownloads,
|
|
});
|
|
const liveExpiryChoices = $derived(
|
|
share?.expires_at ? [keepChoice, ...expiryChoices] : expiryChoices
|
|
);
|
|
|
|
function fail(error: unknown, fallback: string) {
|
|
toasts.error(error instanceof ApiError ? error.detail : fallback);
|
|
}
|
|
|
|
function adopt(next: FileShare | null) {
|
|
share = next;
|
|
onchange?.(next);
|
|
}
|
|
|
|
async function load(target: ShareSubject) {
|
|
loading = true;
|
|
share = null;
|
|
showQr = false;
|
|
expiresIn = null;
|
|
maxDownloads = null;
|
|
token += 1;
|
|
const current = token;
|
|
try {
|
|
const found = await lookupShare(target);
|
|
if (current === token) {
|
|
share = found;
|
|
maxDownloads = found?.max_downloads ?? null;
|
|
expiresIn = found?.expires_at ? KEEP : null;
|
|
}
|
|
} catch (error) {
|
|
if (current === token) {
|
|
fail(error, "Не удалось проверить ссылку");
|
|
open = false;
|
|
}
|
|
} finally {
|
|
if (current === token) {
|
|
loading = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function publish() {
|
|
if (!subject || busy) {
|
|
return;
|
|
}
|
|
busy = true;
|
|
try {
|
|
adopt(await createShare(subject, settings));
|
|
toasts.success("Доступ по ссылке открыт");
|
|
} catch (error) {
|
|
fail(error, "Не удалось открыть доступ");
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function applyLimits() {
|
|
if (!share || busy) {
|
|
return;
|
|
}
|
|
busy = true;
|
|
try {
|
|
adopt(await updateShare(share.id, settings));
|
|
toasts.success("Настройки ссылки обновлены");
|
|
} catch (error) {
|
|
fail(error, "Не удалось обновить ссылку");
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function revoke() {
|
|
if (!share || busy) {
|
|
return;
|
|
}
|
|
busy = true;
|
|
try {
|
|
await revokeShare(share.id);
|
|
adopt(null);
|
|
toasts.success("Доступ отозван");
|
|
open = false;
|
|
} catch (error) {
|
|
fail(error, "Не удалось отозвать доступ");
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
const target = subject;
|
|
const isOpen = open;
|
|
untrack(() => {
|
|
if (isOpen && target) {
|
|
load(target);
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<Dialog.Root bind:open>
|
|
<Dialog.Portal>
|
|
<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">
|
|
<Icon name="close" size="1.25rem" />
|
|
</Dialog.Close>
|
|
</header>
|
|
|
|
<div class="dialog-body">
|
|
{#if loading}
|
|
<div class="center"><Spinner /></div>
|
|
{:else if share}
|
|
<div class="subject live">
|
|
<span class="badge"
|
|
><Icon name="allow-share" size="1.125rem" /></span
|
|
>
|
|
<div class="subject-text">
|
|
<strong>{share.file_name}</strong>
|
|
<span>{formatShareLimits(share)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<ShareLinkField url={shareUrl(share)} />
|
|
|
|
<div class="qr-row">
|
|
<button
|
|
class="qr-toggle"
|
|
onclick={() => {
|
|
showQr = !showQr;
|
|
}}
|
|
type="button"
|
|
>
|
|
<Icon name={showQr ? "collapse" : "webapp"} size="1rem" />
|
|
{showQr ? "Скрыть QR-код" : "Показать QR-код"}
|
|
</button>
|
|
</div>
|
|
{#if showQr}
|
|
<div class="qr-slot">
|
|
<QrCode label="QR-код ссылки на файл" value={shareUrl(share)} />
|
|
</div>
|
|
{/if}
|
|
|
|
<p class="hint raw">
|
|
Ссылка отдаёт файл как есть: картинки и видео откроются прямо в
|
|
браузере, <code>curl -O {shareUrl(share)}</code> скачает под
|
|
настоящим именем. Превью-краулеры Telegram скачивания не списывают.
|
|
</p>
|
|
|
|
<fieldset class="choices">
|
|
<legend>Лимит скачиваний</legend>
|
|
<div class="segments">
|
|
{#each limitChoices as choice (choice.label)}
|
|
<button
|
|
class="segment"
|
|
onclick={() => {
|
|
maxDownloads = choice.value;
|
|
}}
|
|
type="button"
|
|
class:selected={maxDownloads === choice.value}
|
|
>
|
|
{choice.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="choices">
|
|
<legend>Срок жизни ссылки</legend>
|
|
<div class="segments">
|
|
{#each liveExpiryChoices as choice (choice.label)}
|
|
<button
|
|
class="segment"
|
|
onclick={() => {
|
|
expiresIn = choice.value;
|
|
}}
|
|
type="button"
|
|
class:selected={expiresIn === choice.value}
|
|
>
|
|
{choice.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
{:else}
|
|
<div class="subject">
|
|
<span class="badge muted"
|
|
><Icon name="lock" size="1.125rem" /></span
|
|
>
|
|
<div class="subject-text">
|
|
<strong>{label}</strong>
|
|
<span>Сейчас доступен только вам</span>
|
|
</div>
|
|
</div>
|
|
|
|
<fieldset class="choices">
|
|
<legend>Срок жизни ссылки</legend>
|
|
<div class="segments">
|
|
{#each expiryChoices as choice (choice.label)}
|
|
<button
|
|
class="segment"
|
|
onclick={() => {
|
|
expiresIn = choice.value;
|
|
}}
|
|
type="button"
|
|
class:selected={expiresIn === choice.value}
|
|
>
|
|
{choice.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="choices">
|
|
<legend>Лимит скачиваний</legend>
|
|
<div class="segments">
|
|
{#each limitChoices as choice (choice.label)}
|
|
<button
|
|
class="segment"
|
|
onclick={() => {
|
|
maxDownloads = choice.value;
|
|
}}
|
|
type="button"
|
|
class:selected={maxDownloads === choice.value}
|
|
>
|
|
{choice.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<p class="hint">
|
|
Файл откроется всем, у кого есть ссылка. Отозвать можно в любой
|
|
момент.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !loading}
|
|
<div class="dialog-actions">
|
|
{#if share}
|
|
<Button loading={busy} onclick={revoke} pill variant="danger">
|
|
Отозвать
|
|
</Button>
|
|
<Button loading={busy} onclick={applyLimits} pill>Сохранить</Button>
|
|
{:else}
|
|
<Button fluid loading={busy} onclick={publish} pill>
|
|
Открыть доступ
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</Dialog.Content>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|
|
|
|
<style lang="scss">
|
|
.center {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 2rem 0;
|
|
}
|
|
|
|
.subject {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.badge {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
border-radius: 50%;
|
|
|
|
color: var(--color-white);
|
|
background-color: var(--color-green);
|
|
|
|
&.muted {
|
|
color: var(--color-text-secondary);
|
|
background-color: var(--color-background-secondary);
|
|
}
|
|
}
|
|
|
|
.subject-text {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
|
|
strong {
|
|
overflow: hidden;
|
|
font-size: 0.9375rem;
|
|
font-weight: var(--font-weight-medium);
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
span {
|
|
font-size: 0.8125rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
}
|
|
|
|
.qr-row {
|
|
display: flex;
|
|
justify-content: center;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.qr-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
|
|
padding: 0.25rem 0.5rem;
|
|
border: 0;
|
|
|
|
font-family: inherit;
|
|
font-size: 0.8125rem;
|
|
color: var(--color-primary);
|
|
|
|
cursor: pointer;
|
|
background: transparent;
|
|
}
|
|
|
|
.qr-slot {
|
|
--qr-bg: transparent;
|
|
--qr-fg: var(--color-text);
|
|
|
|
width: min(11rem, 60%);
|
|
margin: 0.25rem auto 0;
|
|
}
|
|
|
|
.hint {
|
|
margin: 1rem 0 0;
|
|
font-size: 0.8125rem;
|
|
line-height: 1.45;
|
|
color: var(--color-text-secondary);
|
|
|
|
&.raw {
|
|
margin-top: 0.875rem;
|
|
}
|
|
|
|
code {
|
|
overflow-wrap: anywhere;
|
|
|
|
padding: 0.0625rem 0.25rem;
|
|
border-radius: 0.25rem;
|
|
|
|
font-size: 0.75rem;
|
|
|
|
background-color: var(--color-background-secondary);
|
|
}
|
|
}
|
|
|
|
.choices {
|
|
margin: 1.25rem 0 0;
|
|
padding: 0;
|
|
border: 0;
|
|
}
|
|
|
|
legend {
|
|
padding: 0 0 0.5rem;
|
|
font-size: 0.8125rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.segments {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.375rem;
|
|
}
|
|
|
|
.segment {
|
|
padding: 0.375rem 0.75rem;
|
|
border: 1px solid var(--color-borders);
|
|
border-radius: 1rem;
|
|
|
|
font-family: inherit;
|
|
font-size: 0.8125rem;
|
|
color: var(--color-text);
|
|
|
|
cursor: pointer;
|
|
background-color: transparent;
|
|
transition:
|
|
background-color 0.15s,
|
|
border-color 0.15s,
|
|
color 0.15s;
|
|
|
|
&.selected {
|
|
border-color: transparent;
|
|
color: var(--color-white);
|
|
background-color: var(--color-primary);
|
|
}
|
|
}
|
|
</style>
|