mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 04:01:09 +02:00
SharedLinkControls now renders a lazy QR code (qrcode npm) and a
datetime-local "Läuft ab" picker. Both stay in sync with the active
URL — regenerating the link rebuilds the QR; clearing the expiry
re-publishes with no `expiresAt`.
Wired across all three unlisted collections:
- Calendar: LocalEvent.unlistedExpiresAt + setUnlistedExpiry +
preserve-on-refresh + clear-on-flip; both Workbench DetailView and
EventDetailModal pass expiresAt+onExpiryChange to SharedLinkControls.
- Library: same pattern in libraryEntriesStore + DetailView.
- Places: same pattern in placesStore + DetailView.
setVisibility clears any prior expiry so a flip-away-flip-back gets
a fresh "never expires" link. refreshUnlistedSnapshot and
regenerateUnlistedToken preserve the existing expiry so a content
edit or token rotation never silently extends a link's lifetime.
The qrcode dep ships as a regular `dependencies` entry on
@mana/shared-privacy so any consuming app picks it up via the
workspace.
Note: an unrelated svelte-check error in writing/components/DraftCard
("draft" not assignable to DragType) exists from a parallel session
and is not introduced by this commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
410 lines
9.6 KiB
Svelte
410 lines
9.6 KiB
Svelte
<!--
|
|
SharedLinkControls — UI for managing a record's unlisted share-link.
|
|
|
|
Shown below the VisibilityPicker in a module's DetailView when
|
|
`visibility === 'unlisted'` and a token exists. Dumb — owner (the
|
|
module DetailView) passes the token + handlers; this component only
|
|
renders the URL, manages copy-to-clipboard, QR-code display, and
|
|
dispatches regenerate/revoke/expiry actions.
|
|
|
|
See docs/plans/unlisted-sharing.md §7.
|
|
-->
|
|
<script lang="ts">
|
|
import QRCode from 'qrcode';
|
|
|
|
let {
|
|
/** The 32-char token — used only for display fallback. */
|
|
token,
|
|
/** Full share URL (e.g. https://mana.how/share/AbC…). */
|
|
url,
|
|
/** Current expiry (ISO string) or null if the link never expires. */
|
|
expiresAt = null,
|
|
/** Called when the user clicks "Neu erzeugen". Caller should
|
|
* revoke-then-republish and update the URL in response. */
|
|
onRegenerate,
|
|
/** Called when the user clicks "Widerrufen". Caller flips the
|
|
* record's visibility away from 'unlisted' (which triggers the
|
|
* revoke on the server). */
|
|
onRevoke,
|
|
/** Called when the user picks a new expiry or clears it. Caller
|
|
* re-publishes with the new `expiresAt`. */
|
|
onExpiryChange,
|
|
/** When the caller is in the middle of a publish/revoke, we
|
|
* disable buttons to avoid racing. */
|
|
disabled = false,
|
|
}: {
|
|
token: string;
|
|
url: string;
|
|
expiresAt?: string | null;
|
|
onRegenerate: () => Promise<void> | void;
|
|
onRevoke: () => Promise<void> | void;
|
|
onExpiryChange?: (expiresAt: Date | null) => Promise<void> | void;
|
|
disabled?: boolean;
|
|
} = $props();
|
|
|
|
let copied = $state(false);
|
|
let showConfirmRegenerate = $state(false);
|
|
let editingExpiry = $state(false);
|
|
let showQr = $state(false);
|
|
let qrSvg = $state<string | null>(null);
|
|
let qrError = $state<string | null>(null);
|
|
|
|
// Lazy-render QR. Re-runs whenever the URL changes (e.g. after
|
|
// regenerate) so the QR always tracks the live token.
|
|
$effect(() => {
|
|
if (!showQr) {
|
|
qrSvg = null;
|
|
qrError = null;
|
|
return;
|
|
}
|
|
QRCode.toString(url, {
|
|
type: 'svg',
|
|
errorCorrectionLevel: 'M',
|
|
margin: 1,
|
|
width: 220,
|
|
})
|
|
.then((svg) => {
|
|
qrSvg = svg;
|
|
qrError = null;
|
|
})
|
|
.catch((e: unknown) => {
|
|
qrError = e instanceof Error ? e.message : 'QR-Code-Generierung fehlgeschlagen';
|
|
qrSvg = null;
|
|
});
|
|
});
|
|
|
|
// datetime-local wants YYYY-MM-DDTHH:MM (no seconds, no zone).
|
|
const expiryInputValue = $derived.by(() => {
|
|
if (!expiresAt) return '';
|
|
const d = new Date(expiresAt);
|
|
if (Number.isNaN(d.getTime())) return '';
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
|
d.getHours()
|
|
)}:${pad(d.getMinutes())}`;
|
|
});
|
|
|
|
async function copyToClipboard() {
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
copied = true;
|
|
setTimeout(() => (copied = false), 1500);
|
|
} catch {
|
|
// Clipboard API blocked (Safari private mode, unsecure origin).
|
|
// Fall back to a selection prompt so the user can copy manually.
|
|
// eslint-disable-next-line no-alert
|
|
prompt('Link kopieren:', url);
|
|
}
|
|
}
|
|
|
|
async function confirmRegenerate() {
|
|
showConfirmRegenerate = false;
|
|
await onRegenerate();
|
|
}
|
|
|
|
async function handleExpiryChange(e: Event) {
|
|
if (!onExpiryChange) return;
|
|
const value = (e.target as HTMLInputElement).value;
|
|
if (!value) {
|
|
await onExpiryChange(null);
|
|
return;
|
|
}
|
|
await onExpiryChange(new Date(value));
|
|
}
|
|
|
|
async function clearExpiry() {
|
|
if (!onExpiryChange) return;
|
|
await onExpiryChange(null);
|
|
}
|
|
</script>
|
|
|
|
<div class="slc">
|
|
<div class="slc__label">Geteilter Link</div>
|
|
|
|
<div class="slc__row">
|
|
<input
|
|
class="slc__url"
|
|
type="text"
|
|
readonly
|
|
value={url}
|
|
aria-label="Share-URL"
|
|
onfocus={(e) => (e.currentTarget as HTMLInputElement).select()}
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="slc__btn slc__btn--primary"
|
|
onclick={copyToClipboard}
|
|
{disabled}
|
|
title="Link kopieren"
|
|
>
|
|
{copied ? '✓ Kopiert' : '📋 Kopieren'}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="slc__actions">
|
|
<button
|
|
type="button"
|
|
class="slc__btn"
|
|
onclick={() => (showQr = !showQr)}
|
|
{disabled}
|
|
aria-pressed={showQr}
|
|
title="QR-Code anzeigen"
|
|
>
|
|
🔗 QR
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="slc__btn"
|
|
onclick={() => (showConfirmRegenerate = true)}
|
|
{disabled}
|
|
title="Neu erzeugen (alter Link wird sofort ungültig)"
|
|
>
|
|
🔄 Neu erzeugen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="slc__btn slc__btn--danger"
|
|
onclick={onRevoke}
|
|
{disabled}
|
|
title="Link widerrufen"
|
|
>
|
|
🗑 Widerrufen
|
|
</button>
|
|
</div>
|
|
|
|
{#if showQr}
|
|
<div class="slc__qr">
|
|
{#if qrSvg}
|
|
<!-- QR is plain SVG returned by `qrcode/toString`, safe to inline. -->
|
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
{@html qrSvg}
|
|
{:else if qrError}
|
|
<p class="slc__qr-error">{qrError}</p>
|
|
{:else}
|
|
<p class="slc__qr-loading">QR-Code wird erstellt …</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if onExpiryChange}
|
|
<div class="slc__expiry">
|
|
{#if editingExpiry || expiresAt}
|
|
<label class="slc__expiry-label">
|
|
<span>Läuft ab:</span>
|
|
<input
|
|
type="datetime-local"
|
|
class="slc__expiry-input"
|
|
value={expiryInputValue}
|
|
onchange={handleExpiryChange}
|
|
{disabled}
|
|
/>
|
|
</label>
|
|
{#if expiresAt}
|
|
<button
|
|
type="button"
|
|
class="slc__btn slc__btn--ghost"
|
|
onclick={clearExpiry}
|
|
{disabled}
|
|
title="Ablauf entfernen — Link gilt dann dauerhaft"
|
|
>
|
|
✕
|
|
</button>
|
|
{/if}
|
|
{:else}
|
|
<button
|
|
type="button"
|
|
class="slc__btn slc__btn--ghost"
|
|
onclick={() => (editingExpiry = true)}
|
|
{disabled}
|
|
>
|
|
+ Ablauf setzen
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Fallback token display if url rendering fails (rare). -->
|
|
<p class="slc__token" aria-label="Token zum Debuggen">Token: {token.slice(0, 8)}…</p>
|
|
</div>
|
|
|
|
{#if showConfirmRegenerate}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="slc__backdrop"
|
|
onclick={() => (showConfirmRegenerate = false)}
|
|
role="presentation"
|
|
></div>
|
|
<div class="slc__dialog" role="dialog" aria-modal="true">
|
|
<p>Alter Link wird <strong>sofort ungültig</strong>. Weiter?</p>
|
|
<div class="slc__dialog-actions">
|
|
<button type="button" class="slc__btn slc__btn--primary" onclick={confirmRegenerate}>
|
|
Ja, neuen Link erzeugen
|
|
</button>
|
|
<button type="button" class="slc__btn" onclick={() => (showConfirmRegenerate = false)}>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.slc {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
padding: 0.75rem;
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
border-radius: 0.5rem;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
font-size: 0.8125rem;
|
|
}
|
|
.slc__label {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
opacity: 0.6;
|
|
}
|
|
.slc__row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
}
|
|
.slc__url {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
padding: 0.35rem 0.55rem;
|
|
font-family: ui-monospace, monospace;
|
|
font-size: 0.75rem;
|
|
background: rgba(0, 0, 0, 0.18);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
border-radius: 0.375rem;
|
|
color: inherit;
|
|
}
|
|
.slc__actions {
|
|
display: flex;
|
|
gap: 0.35rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.slc__btn {
|
|
padding: 0.35rem 0.7rem;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
border-radius: 0.375rem;
|
|
background: transparent;
|
|
color: inherit;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
.slc__btn:hover:not(:disabled) {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border-color: rgba(255, 255, 255, 0.22);
|
|
}
|
|
.slc__btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
.slc__btn--primary {
|
|
background: rgba(99, 102, 241, 0.15);
|
|
border-color: rgba(99, 102, 241, 0.45);
|
|
}
|
|
.slc__btn--primary:hover:not(:disabled) {
|
|
background: rgba(99, 102, 241, 0.25);
|
|
}
|
|
.slc__btn--danger:hover:not(:disabled) {
|
|
background: rgba(248, 113, 113, 0.15);
|
|
border-color: rgba(248, 113, 113, 0.5);
|
|
color: rgb(248, 113, 113);
|
|
}
|
|
.slc__btn--ghost {
|
|
border-color: transparent;
|
|
opacity: 0.7;
|
|
}
|
|
.slc__btn--ghost:hover:not(:disabled) {
|
|
opacity: 1;
|
|
}
|
|
.slc__expiry {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
}
|
|
.slc__expiry-label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.75rem;
|
|
opacity: 0.8;
|
|
}
|
|
.slc__expiry-input {
|
|
padding: 0.25rem 0.45rem;
|
|
background: rgba(0, 0, 0, 0.18);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
border-radius: 0.375rem;
|
|
color: inherit;
|
|
font-size: 0.75rem;
|
|
}
|
|
.slc__qr {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 0.5rem;
|
|
background: #ffffff;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
}
|
|
.slc__qr :global(svg) {
|
|
display: block;
|
|
max-width: 220px;
|
|
width: 100%;
|
|
height: auto;
|
|
}
|
|
.slc__qr-loading,
|
|
.slc__qr-error {
|
|
margin: 0;
|
|
padding: 1rem;
|
|
font-size: 0.8125rem;
|
|
color: #6b7280;
|
|
text-align: center;
|
|
}
|
|
.slc__qr-error {
|
|
color: rgb(248, 113, 113);
|
|
}
|
|
|
|
.slc__token {
|
|
margin: 0;
|
|
font-family: ui-monospace, monospace;
|
|
font-size: 0.65rem;
|
|
opacity: 0.35;
|
|
}
|
|
|
|
.slc__backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 100;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
}
|
|
.slc__dialog {
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
z-index: 101;
|
|
min-width: 18rem;
|
|
max-width: 22rem;
|
|
padding: 1rem;
|
|
border-radius: 0.5rem;
|
|
background: rgb(20, 24, 32);
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
|
}
|
|
.slc__dialog p {
|
|
margin: 0 0 0.75rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
.slc__dialog-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
}
|
|
</style>
|