feat(userbot,api,frontend): file links, media downloads, story hold-pause, drop scheduled dupes
This commit is contained in:
1 parent
683b9a31a3
commit
3e08698b62
38 files changed
+2889
-134
No files matched your search
@@ -0,0 +1,472 @@
|
||||
<script lang="ts">
|
||||
import { Dialog } from "bits-ui";
|
||||
import { untrack } from "svelte";
|
||||
import { ApiError } from "$lib/api/client";
|
||||
import {
|
||||
createShare,
|
||||
lookupShare,
|
||||
revokeShare,
|
||||
type ShareSettings,
|
||||
shareUrl,
|
||||
updateShare,
|
||||
} from "$lib/api/shares";
|
||||
import type { FileShare, ShareSubject } from "$lib/api/types";
|
||||
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";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
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;
|
||||
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 class="dialog-close" aria-label="Закрыть">
|
||||
<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
|
||||
type="button"
|
||||
class="qr-toggle"
|
||||
onclick={() => {
|
||||
showQr = !showQr;
|
||||
}}
|
||||
>
|
||||
<Icon name={showQr ? "collapse" : "webapp"} size="1rem" />
|
||||
{showQr ? "Скрыть QR-код" : "Показать QR-код"}
|
||||
</button>
|
||||
</div>
|
||||
{#if showQr}
|
||||
<div class="qr-slot">
|
||||
<QrCode value={shareUrl(share)} label="QR-код ссылки на файл" />
|
||||
</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
|
||||
type="button"
|
||||
class="segment"
|
||||
class:selected={maxDownloads === choice.value}
|
||||
onclick={() => {
|
||||
maxDownloads = choice.value;
|
||||
}}
|
||||
>
|
||||
{choice.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="choices">
|
||||
<legend>Срок жизни ссылки</legend>
|
||||
<div class="segments">
|
||||
{#each liveExpiryChoices as choice (choice.label)}
|
||||
<button
|
||||
type="button"
|
||||
class="segment"
|
||||
class:selected={expiresIn === choice.value}
|
||||
onclick={() => {
|
||||
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
|
||||
type="button"
|
||||
class="segment"
|
||||
class:selected={expiresIn === choice.value}
|
||||
onclick={() => {
|
||||
expiresIn = choice.value;
|
||||
}}
|
||||
>
|
||||
{choice.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="choices">
|
||||
<legend>Лимит скачиваний</legend>
|
||||
<div class="segments">
|
||||
{#each limitChoices as choice (choice.label)}
|
||||
<button
|
||||
type="button"
|
||||
class="segment"
|
||||
class:selected={maxDownloads === choice.value}
|
||||
onclick={() => {
|
||||
maxDownloads = choice.value;
|
||||
}}
|
||||
>
|
||||
{choice.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p class="hint">
|
||||
Файл откроется всем, у кого есть ссылка. Отозвать можно в любой
|
||||
момент.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !loading}
|
||||
<div class="dialog-actions">
|
||||
{#if share}
|
||||
<Button variant="danger" pill loading={busy} onclick={revoke}>
|
||||
Отозвать
|
||||
</Button>
|
||||
<Button pill loading={busy} onclick={applyLimits}>Сохранить</Button>
|
||||
{:else}
|
||||
<Button pill fluid loading={busy} onclick={publish}>
|
||||
Открыть доступ
|
||||
</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>
|
||||
Reference in new issue
Block a user