507 lines
13 KiB
Svelte
507 lines
13 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 { t } from "#lib/i18n/locale.svelte.js";
|
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
|
import { createFlash } from "#lib/stores/flash.svelte.js";
|
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
|
|
|
interface Props {
|
|
label?: string | null;
|
|
onchange?: (share: FileShare | null) => void;
|
|
open: boolean;
|
|
subject: ShareSubject | null;
|
|
}
|
|
|
|
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: 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: Choice = { label: () => t("shares.keep"), value: KEEP };
|
|
|
|
const limitChoices: Choice[] = [
|
|
{ label: () => t("shares.noLimit"), value: null },
|
|
...LIMITS.map((value) => ({ label: () => String(value), value })),
|
|
];
|
|
|
|
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 saved = createFlash<number>();
|
|
const revoked = createFlash<ShareSubject>();
|
|
|
|
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: MessageKey) {
|
|
toasts.error(error instanceof ApiError ? error.detail : t(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, "shares.checkFailed");
|
|
open = false;
|
|
}
|
|
} finally {
|
|
if (current === token) {
|
|
loading = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function publish() {
|
|
if (!subject || busy) {
|
|
return;
|
|
}
|
|
busy = true;
|
|
try {
|
|
adopt(await createShare(subject, settings));
|
|
} catch (error) {
|
|
fail(error, "shares.openFailed");
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function applyLimits() {
|
|
if (!share || busy) {
|
|
return;
|
|
}
|
|
busy = true;
|
|
try {
|
|
const next = await updateShare(share.id, settings);
|
|
adopt(next);
|
|
saved.show(next.id);
|
|
} catch (error) {
|
|
fail(error, "shares.updateFailed");
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function revoke() {
|
|
if (!(share && subject) || busy) {
|
|
return;
|
|
}
|
|
const target = subject;
|
|
busy = true;
|
|
try {
|
|
await revokeShare(share.id);
|
|
adopt(null);
|
|
revoked.show(target);
|
|
} catch (error) {
|
|
fail(error, "shares.revokeFailed");
|
|
} 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">{t("ui.shareLink")}</Dialog.Title>
|
|
<Dialog.Close aria-label={t("common.close")} 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 ? t("shares.qr.hide") : t("shares.qr.show")}
|
|
</button>
|
|
</div>
|
|
{#if showQr}
|
|
<div class="qr-slot">
|
|
<QrCode label={t("shares.qr.label")} value={shareUrl(share)} />
|
|
</div>
|
|
{/if}
|
|
|
|
<p class="hint raw">
|
|
{t("shares.raw.before")}<code>curl -O {shareUrl(share)}</code>
|
|
{t("shares.raw.after")}
|
|
</p>
|
|
|
|
<fieldset class="choices">
|
|
<legend>{t("shares.limit")}</legend>
|
|
<div class="segments">
|
|
{#each limitChoices as choice (choice.value)}
|
|
<button
|
|
class="segment"
|
|
onclick={() => {
|
|
maxDownloads = choice.value;
|
|
}}
|
|
type="button"
|
|
class:selected={maxDownloads === choice.value}
|
|
>
|
|
{choice.label()}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="choices">
|
|
<legend>{t("shares.lifetime")}</legend>
|
|
<div class="segments">
|
|
{#each liveExpiryChoices as choice (choice.value)}
|
|
<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 ?? t("shares.file")}</strong>
|
|
<span>{t("shares.private")}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<fieldset class="choices">
|
|
<legend>{t("shares.lifetime")}</legend>
|
|
<div class="segments">
|
|
{#each expiryChoices as choice (choice.value)}
|
|
<button
|
|
class="segment"
|
|
onclick={() => {
|
|
expiresIn = choice.value;
|
|
}}
|
|
type="button"
|
|
class:selected={expiresIn === choice.value}
|
|
>
|
|
{choice.label()}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="choices">
|
|
<legend>{t("shares.limit")}</legend>
|
|
<div class="segments">
|
|
{#each limitChoices as choice (choice.value)}
|
|
<button
|
|
class="segment"
|
|
onclick={() => {
|
|
maxDownloads = choice.value;
|
|
}}
|
|
type="button"
|
|
class:selected={maxDownloads === choice.value}
|
|
>
|
|
{choice.label()}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<p class="hint">{t("shares.publicHint")}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !loading}
|
|
<div class="dialog-actions">
|
|
{#if subject && revoked.current === subject}
|
|
<p class="done">
|
|
<Icon name="check" size="1.25rem" />
|
|
{t("shares.accessRevoked")}
|
|
</p>
|
|
{:else if share}
|
|
<Button loading={busy} onclick={revoke} pill variant="danger">
|
|
{t("shares.revoke")}
|
|
</Button>
|
|
<Button loading={busy} onclick={applyLimits} pill>
|
|
{#if saved.current === share.id}
|
|
<Icon name="check" />
|
|
{t("shares.saved")}
|
|
{:else}
|
|
{t("common.save")}
|
|
{/if}
|
|
</Button>
|
|
{:else}
|
|
<Button fluid loading={busy} onclick={publish} pill>
|
|
{t("shares.publish")}
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</Dialog.Content>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|
|
|
|
<style lang="scss">
|
|
.done {
|
|
display: flex;
|
|
flex: 1;
|
|
gap: 0.375rem;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
height: 3rem;
|
|
margin: 0;
|
|
|
|
font-weight: var(--font-weight-medium);
|
|
color: var(--color-text-green);
|
|
}
|
|
|
|
.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>
|