feat(unlisted-sharing): QR code + per-link expiry picker (M8.5)

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>
This commit is contained in:
Till JS 2026-04-25 12:29:53 +02:00
parent 85fca7ccdc
commit b7a54ccd10
16 changed files with 379 additions and 215 deletions

View file

@ -26,9 +26,11 @@
},
"dependencies": {
"@mana/shared-icons": "workspace:*",
"qrcode": "^1.5.4",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/qrcode": "^1.5.5",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.7.3",

View file

@ -4,16 +4,14 @@
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, and dispatches
regenerate/revoke/expiry actions.
QR-code rendering is a later polish (M8.5) — currently stubbed as
a button that disables itself. Expiry-picker is present but
minimal — datetime-local input, no fancy picker.
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,
@ -47,6 +45,33 @@
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(() => {
@ -117,6 +142,16 @@
</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"
@ -137,6 +172,20 @@
</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}
@ -296,6 +345,32 @@
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;