feat(forms): M4b visibility + unlisted-sharing + public render

Form-Sharing-Pipeline (docs/plans/forms-module.md M4 — Teil 2):

- formsStore.setVisibility(id, level): private/space/unlisted-Wechsel.
  Bei `unlisted` wird publishUnlistedSnapshot mit dem Form-Schema-Blob
  aufgerufen, Token + Expiry landen am LocalForm. Setze Status auf
  'published' Voraussetzung — sonst klare Fehlermeldung mit Hinweis.
- regenerateUnlistedToken: Token-Rotation für leak-Verdacht
  (revoke + neu publish, expiry beibehalten).
- setUnlistedExpiry: TTL-Update mit re-publish.
- buildFormBlob in data/unlisted/resolvers.ts mit Whitelist
  title/description/fields/branching + nur submitButtonLabel +
  successMessage aus settings. Hard-blocks: nicht-published Forms +
  deletedAt → RecordNotFoundError. Server-side Settings (requireEmail,
  anonymous, zkMode, autoSync, responseLimit, closedAt, responsesPublic)
  bleiben strukturell aussen vor — Public-Endpoint validiert
  authoritativ ohne Discovery-Surface.
- VisibilityPicker + SharedLinkControls in BuilderView, eigene Section
  mit Status-Hint wenn Form noch nicht published ist.
- SharedFormView (498 Zeilen): public-render mit allen 11 Field-Types
  (short/long_text, single/multi_choice, number, date, email, yes_no,
  rating, section, consent), Live-Branching via resolveVisibleFields
  bei jedem Keystroke, Required-Field-Validierung blockt Submit-Button.
  Submit zeigt successMessage + DEV-Hinweis (Public-Submit-Endpoint
  landet in M3.b). Mana-Branding-Footer.
- Share-Dispatcher /share/[token] kennt `forms` collection.
- 10 neue i18n-Keys × 5 Locales (forms.builder.visibility.*).

Public-Submit-Pipeline (mana-api POST → mana-sync → owner client) ist
M3.b. Bis dahin zeigt SharedFormView.handleSubmit nur die success-
Message ohne Server-Roundtrip.

svelte-check: 0 errors in forms/. Pre-existing context-removal-Drift
in cross-app-queries + widget-registry (4 errors) ist Parallel-Session-
WIP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 23:39:41 +02:00
parent afeb32f922
commit 18f13e19b2
10 changed files with 800 additions and 1 deletions

View file

@ -23,6 +23,7 @@ import type { LocalPlace } from '$lib/modules/places/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type { LocalAugurEntry } from '$lib/modules/augur/types';
import type { LocalLast } from '$lib/modules/lasts/types';
import type { LocalForm } from '$lib/modules/forms/types';
export class UnsupportedCollectionError extends Error {
constructor(collection: string) {
@ -57,6 +58,8 @@ export async function buildUnlistedBlob(
return buildAugurEntryBlob(recordId);
case 'lasts':
return buildLastBlob(recordId);
case 'forms':
return buildFormBlob(recordId);
default:
throw new UnsupportedCollectionError(collection);
}
@ -268,3 +271,59 @@ async function buildLastBlob(recordId: string): Promise<Record<string, unknown>>
wouldReclaim: decrypted.wouldReclaim ?? null,
};
}
/**
* Form public-submit snapshot blob.
*
* Whitelist: title, description, fields, branching,
* settings.submitButtonLabel, settings.successMessage. The blob is
* what an unauthenticated submitter sees when they hit
* `/forms/<token>` it's the form schema plus the two pieces of
* settings copy that surface in the public UI.
*
* Hard-blocked from the snapshot:
* - `responseCount` internal counter, leaks scale
* - `settings.requireEmail`/`allowMultipleSubmissions`/`anonymous`/
* `zkMode`/`autoSync`/`responseLimit`/`closedAt` these are
* authoritative server-side checks in the public-submit endpoint
* (M3.b) and should not be discoverable via the public blob;
* leaking them invites enumeration probes.
* - `responsesPublic` explicitly omitted; the public view shows
* the form, not the answers. M-future will need a separate share
* token + endpoint for response-aggregate publication.
*
* Refuses to serialise closed forms (status === 'closed') so revoked
* tokens can't be brought back via a snapshot replay. Draft forms are
* also refused only `published` forms have a public surface.
*/
async function buildFormBlob(recordId: string): Promise<Record<string, unknown>> {
const raw = await db.table<LocalForm>('forms').get(recordId);
if (!raw || raw.deletedAt) {
throw new RecordNotFoundError('forms', recordId);
}
if (raw.status !== 'published') {
throw new RecordNotFoundError('forms', recordId);
}
const decrypted = (await decryptRecord('forms', { ...raw })) as LocalForm;
const settings = decrypted.settings ?? {
submitButtonLabel: 'Senden',
successMessage: 'Danke! Deine Antwort wurde übermittelt.',
allowMultipleSubmissions: false,
requireEmail: false,
anonymous: false,
zkMode: false,
};
return {
title: decrypted.title,
description: decrypted.description ?? null,
fields: decrypted.fields ?? [],
branching: decrypted.branching ?? [],
settings: {
submitButtonLabel: settings.submitButtonLabel,
successMessage: settings.successMessage,
},
};
}

View file

@ -71,6 +71,10 @@
"allowMultiple": "Mehrere Antworten pro Person erlauben",
"anonymous": "Anonym — Submitter-Daten nicht speichern"
},
"visibility": {
"title": "Sichtbarkeit & Teilen",
"publishHint": "Setze den Status auf \"Veröffentlicht\", um zu teilen."
},
"viewResponses": "Antworten ({n})"
},
"responses": {

View file

@ -71,6 +71,10 @@
"allowMultiple": "Allow multiple submissions per person",
"anonymous": "Anonymous — don't store submitter data"
},
"visibility": {
"title": "Visibility & Sharing",
"publishHint": "Set the status to \"Published\" to share."
},
"viewResponses": "Responses ({n})"
},
"responses": {

View file

@ -71,6 +71,10 @@
"allowMultiple": "Permitir varias respuestas por persona",
"anonymous": "Anónimo — no guardar datos del remitente"
},
"visibility": {
"title": "Visibilidad y compartir",
"publishHint": "Pon el estado en \"Publicado\" para compartir."
},
"viewResponses": "Respuestas ({n})"
},
"responses": {

View file

@ -71,6 +71,10 @@
"allowMultiple": "Autoriser plusieurs réponses par personne",
"anonymous": "Anonyme — ne pas conserver les données du destinataire"
},
"visibility": {
"title": "Visibilité et partage",
"publishHint": "Mets le statut sur \"Publié\" pour partager."
},
"viewResponses": "Réponses ({n})"
},
"responses": {

View file

@ -71,6 +71,10 @@
"allowMultiple": "Permetti più risposte per persona",
"anonymous": "Anonimo — non memorizzare i dati del mittente"
},
"visibility": {
"title": "Visibilità e condivisione",
"publishHint": "Imposta lo stato su \"Pubblicato\" per condividere."
},
"viewResponses": "Risposte ({n})"
},
"responses": {

View file

@ -0,0 +1,477 @@
<!--
SharedFormView — public-render of an unlisted form. Rendered by the
/share/[token] dispatcher when the resolver returned a forms blob.
Runs without auth — the schema is the only client-side state.
M4b ships this as the rendering surface; the actual Public-Submit
POST to mana-api `/api/v1/forms/public/<token>/submit` lands in M3.b
alongside server-side validation + sync-pickup. Until then, the
submit button is disabled with an explanatory note.
-->
<script lang="ts">
import { resolveVisibleFields } from './lib/branching';
import type { AnswerValue, BranchingRule, FieldOption, FormField, FormSettings } from './types';
interface FormBlob {
title: string;
description: string | null;
fields: FormField[];
branching: BranchingRule[];
settings: Pick<FormSettings, 'submitButtonLabel' | 'successMessage'>;
}
let {
blob,
token: _token,
expiresAt,
}: {
blob: Record<string, unknown>;
token: string;
expiresAt: string | null;
} = $props();
const form = $derived(blob as unknown as FormBlob);
let answers = $state<Record<string, AnswerValue>>({});
let submitted = $state(false);
const visibleFields = $derived(resolveVisibleFields(form.fields, form.branching ?? [], answers));
function setAnswer(fieldId: string, value: AnswerValue) {
answers = { ...answers, [fieldId]: value };
}
function toggleMulti(fieldId: string, optionId: string) {
const current = (answers[fieldId] as string[] | undefined) ?? [];
const next = current.includes(optionId)
? current.filter((v) => v !== optionId)
: [...current, optionId];
setAnswer(fieldId, next);
}
function ratingScale(field: FormField): number[] {
const max = field.config?.ratingScale ?? 5;
return Array.from({ length: max }, (_, i) => i + 1);
}
function isFieldRequired(field: FormField): boolean {
return field.required && field.type !== 'section';
}
function isFieldFilled(field: FormField): boolean {
const v = answers[field.id];
if (v === null || v === undefined) return false;
if (typeof v === 'string') return v.trim().length > 0;
if (Array.isArray(v)) return v.length > 0;
if (typeof v === 'boolean') return v === true;
return true;
}
const allRequiredFilled = $derived(visibleFields.filter(isFieldRequired).every(isFieldFilled));
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
// Public-Submit endpoint not yet wired (M3.b). Once it lands, this
// posts answers to /api/v1/forms/public/<token>/submit and shows
// the success message.
submitted = true;
}
function fmtExpiry(iso: string | null): string {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString();
} catch {
return iso;
}
}
</script>
<article class="public-form">
<header class="hero">
<h1>{form.title}</h1>
{#if form.description}
<p class="description">{form.description}</p>
{/if}
</header>
{#if submitted}
<div class="thanks">
<p class="thanks-message">{form.settings.successMessage}</p>
<p class="dev-note">
<em
>Hinweis: Public-Submit ist im aktuellen Mana-Build noch nicht serverseitig verdrahtet —
deine Antwort wurde nicht übermittelt.</em
>
</p>
</div>
{:else}
<form class="form-body" onsubmit={handleSubmit}>
{#each visibleFields as field (field.id)}
<div class="field" data-type={field.type}>
{#if field.type === 'section'}
<hr class="section-divider" />
<h2 class="section-title">{field.label}</h2>
{:else if field.type === 'consent'}
<label class="consent-row">
<input
type="checkbox"
checked={answers[field.id] === true}
onchange={(e) => setAnswer(field.id, (e.currentTarget as HTMLInputElement).checked)}
/>
<span
>{field.label}
{#if field.required}<em class="req">*</em>{/if}</span
>
</label>
{:else}
<label class="field-label">
{field.label}
{#if field.required}<em class="req">*</em>{/if}
</label>
{#if field.helpText}
<p class="help">{field.helpText}</p>
{/if}
{#if field.type === 'short_text'}
<input
type="text"
maxlength={field.config?.maxLength ?? 200}
value={(answers[field.id] as string | undefined) ?? ''}
oninput={(e) => setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)}
/>
{:else if field.type === 'long_text'}
<textarea
rows="4"
maxlength={field.config?.maxLength ?? 4000}
value={(answers[field.id] as string | undefined) ?? ''}
oninput={(e) => setAnswer(field.id, (e.currentTarget as HTMLTextAreaElement).value)}
></textarea>
{:else if field.type === 'email'}
<input
type="email"
value={(answers[field.id] as string | undefined) ?? ''}
oninput={(e) => setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)}
/>
{:else if field.type === 'number'}
<input
type="number"
min={field.config?.min}
max={field.config?.max}
value={(answers[field.id] as number | string | undefined) ?? ''}
oninput={(e) => {
const v = (e.currentTarget as HTMLInputElement).value;
setAnswer(field.id, v === '' ? null : Number(v));
}}
/>
{:else if field.type === 'date'}
<input
type="date"
value={(answers[field.id] as string | undefined) ?? ''}
oninput={(e) => setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)}
/>
{:else if field.type === 'yes_no'}
<div class="yes-no">
<button
type="button"
class="yn-btn"
class:active={answers[field.id] === true}
onclick={() => setAnswer(field.id, true)}>Ja</button
>
<button
type="button"
class="yn-btn"
class:active={answers[field.id] === false}
onclick={() => setAnswer(field.id, false)}>Nein</button
>
</div>
{:else if field.type === 'rating'}
<div class="rating">
{#each ratingScale(field) as n}
<button
type="button"
class="rate-btn"
class:active={(answers[field.id] as number | undefined) === n}
onclick={() => setAnswer(field.id, n)}>{n}</button
>
{/each}
</div>
{:else if field.type === 'single_choice'}
<div class="options">
{#each field.options ?? [] as opt (opt.id)}
<label class="option-row">
<input
type="radio"
name={field.id}
value={opt.id}
checked={answers[field.id] === opt.id}
onchange={() => setAnswer(field.id, opt.id)}
/>
<span>{opt.label}</span>
</label>
{/each}
</div>
{:else if field.type === 'multi_choice'}
<div class="options">
{#each field.options ?? [] as opt (opt.id)}
{@const checked = ((answers[field.id] as string[] | undefined) ?? []).includes(
opt.id
)}
<label class="option-row">
<input
type="checkbox"
value={opt.id}
{checked}
onchange={() => toggleMulti(field.id, opt.id)}
/>
<span>{opt.label}</span>
</label>
{/each}
</div>
{/if}
{/if}
</div>
{/each}
<footer class="form-footer">
<button type="submit" class="submit" disabled={!allRequiredFilled}>
{form.settings.submitButtonLabel}
</button>
<p class="dev-note">
<em
>Public-Submit landet im nächsten Mana-Schritt — vorerst zeigt der Klick nur die
Bestätigungsnachricht.</em
>
</p>
{#if expiresAt}
<p class="expiry">Dieser Link läuft am {fmtExpiry(expiresAt)} ab.</p>
{/if}
</footer>
</form>
{/if}
<footer class="brand">
<a href="https://mana.how/forms" class="brand-link">via Mana Forms</a>
</footer>
</article>
<style>
.public-form {
max-width: 640px;
margin: 2rem auto;
padding: 1.5rem;
background: white;
border-radius: 0.75rem;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
color: #1a1a1a;
font-family:
system-ui,
-apple-system,
sans-serif;
}
.hero {
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.875rem;
margin-bottom: 1.25rem;
}
.hero h1 {
margin: 0 0 0.375rem;
font-size: 1.5rem;
font-weight: 600;
}
.description {
margin: 0;
color: #4b5563;
font-size: 0.9375rem;
white-space: pre-wrap;
}
.form-body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-label,
.consent-row {
font-size: 0.9375rem;
font-weight: 500;
}
.req {
color: #dc2626;
font-style: normal;
margin-left: 0.125rem;
}
.help {
margin: 0;
font-size: 0.8125rem;
color: #6b7280;
}
.field input[type='text'],
.field input[type='email'],
.field input[type='number'],
.field input[type='date'],
.field textarea {
padding: 0.5rem 0.75rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
color: inherit;
font-size: 0.9375rem;
font-family: inherit;
}
.field input:focus,
.field textarea:focus {
outline: none;
border-color: #14b8a6;
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.18);
}
.section-divider {
border: none;
border-top: 1px solid #e5e7eb;
margin: 0.5rem 0 0;
}
.section-title {
margin: 0.25rem 0 0;
font-size: 1rem;
font-weight: 600;
color: #374151;
}
.consent-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-weight: normal;
font-size: 0.875rem;
color: #374151;
}
.options {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.option-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: normal;
}
.yes-no,
.rating {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.yn-btn,
.rate-btn {
min-width: 2.5rem;
padding: 0.375rem 0.75rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
color: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.yn-btn:hover,
.rate-btn:hover {
background: #f9fafb;
}
.yn-btn.active,
.rate-btn.active {
background: #14b8a6;
border-color: #14b8a6;
color: white;
}
.form-footer {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.submit {
padding: 0.625rem 1.25rem;
background: #14b8a6;
border: none;
border-radius: 0.375rem;
color: white;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
}
.submit:hover:not(:disabled) {
background: #0f766e;
}
.submit:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.dev-note {
margin: 0;
font-size: 0.75rem;
color: #9ca3af;
}
.expiry {
margin: 0;
font-size: 0.75rem;
color: #6b7280;
}
.thanks {
text-align: center;
padding: 2rem 1rem;
}
.thanks-message {
margin: 0 0 0.5rem;
font-size: 1rem;
color: #1a1a1a;
white-space: pre-wrap;
}
.brand {
margin-top: 1.5rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
text-align: center;
}
.brand-link {
font-size: 0.75rem;
color: #6b7280;
text-decoration: none;
}
.brand-link:hover {
color: #14b8a6;
}
</style>

View file

@ -3,6 +3,16 @@ import { toForm } from '../queries';
import { encryptRecord } from '$lib/data/crypto';
import { DEFAULT_FORM_SETTINGS } from '../types';
import type { BranchingRule, Form, FormField, FormSettings, FormStatus, LocalForm } from '../types';
import {
publishUnlistedSnapshot,
revokeUnlistedSnapshot,
type VisibilityLevel,
} from '@mana/shared-privacy';
import { buildUnlistedBlob } from '$lib/data/unlisted/resolvers';
import { authStore } from '$lib/stores/auth.svelte';
import { getManaApiUrl } from '$lib/api/config';
import { getActiveSpace } from '$lib/data/scope';
import { getEffectiveUserId } from '$lib/data/current-user';
function nowIso(): string {
return new Date().toISOString();
@ -96,4 +106,134 @@ export const formsStore = {
await encryptRecord('forms', diff);
await formTable.update(id, diff);
},
// ── Visibility / Unlisted-Sharing (M4b) ───────────────────
/**
* Change a form's visibility level. Transitions to `unlisted` publish
* the public snapshot blob and assign a share token; transitions away
* revoke it. Only `published` forms can go public the resolver
* defends this too, so a draft unlisted attempt fails fast here
* with a clear message.
*/
async setVisibility(id: string, next: VisibilityLevel) {
const existing = await formTable.get(id);
if (!existing) throw new Error(`Form ${id} not found`);
const before: VisibilityLevel = existing.visibility ?? 'private';
if (before === next) return;
if (next === 'unlisted' && existing.status !== 'published') {
throw new Error(
'Nur veröffentlichte Formulare können geteilt werden. Setze erst den Status auf "Veröffentlicht".'
);
}
const now = nowIso();
const patch: Partial<LocalForm> = {
visibility: next,
visibilityChangedAt: now,
visibilityChangedBy: getEffectiveUserId() ?? undefined,
};
if (next === 'unlisted') {
const jwt = await authStore.getValidToken();
if (!jwt) throw new Error('Nicht eingeloggt — Share-Link kann nicht erzeugt werden.');
const blob = await buildUnlistedBlob('forms', id);
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
const { token } = await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'forms',
recordId: id,
spaceId,
blob,
});
patch.unlistedToken = token;
patch.unlistedExpiresAt = null;
} else if (before === 'unlisted') {
const jwt = await authStore.getValidToken();
if (jwt) {
try {
await revokeUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'forms',
recordId: id,
});
} catch {
// Server may already have GC'd the row — local flip still correct.
}
}
patch.unlistedToken = '';
patch.unlistedExpiresAt = null;
}
await formTable.update(id, patch);
},
/**
* Rotate the share token. Old URL stops working immediately, new one
* carries the same expiry (if any). Useful when the link leaked.
*/
async regenerateUnlistedToken(id: string): Promise<string | null> {
const existing = await formTable.get(id);
if (!existing || existing.visibility !== 'unlisted') return null;
const jwt = await authStore.getValidToken();
if (!jwt) return null;
try {
await revokeUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'forms',
recordId: id,
});
} catch {
// Defensive — proceed even if server GC'd the snapshot.
}
const blob = await buildUnlistedBlob('forms', id);
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
const { token } = await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'forms',
recordId: id,
spaceId,
blob,
expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined,
});
await formTable.update(id, { unlistedToken: token });
return token;
},
/**
* Update the auto-revoke deadline. `null` = never expires. Server
* re-publishes the same blob with the new TTL.
*/
async setUnlistedExpiry(id: string, expiresAt: Date | null) {
const existing = await formTable.get(id);
if (!existing || existing.visibility !== 'unlisted') return;
const jwt = await authStore.getValidToken();
if (!jwt) return;
const blob = await buildUnlistedBlob('forms', id);
const spaceId =
(existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? '';
const { token } = await publishUnlistedSnapshot({
apiUrl: getManaApiUrl(),
jwt,
collection: 'forms',
recordId: id,
spaceId,
blob,
expiresAt: expiresAt ?? undefined,
});
await formTable.update(id, {
unlistedToken: token,
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : null,
});
},
};

View file

@ -19,6 +19,12 @@
import FieldPalette from '../components/FieldPalette.svelte';
import SettingsPanel from '../components/SettingsPanel.svelte';
import BranchingEditor from '../components/BranchingEditor.svelte';
import {
VisibilityPicker,
SharedLinkControls,
buildShareUrl,
type VisibilityLevel,
} from '@mana/shared-privacy';
let { entry }: { entry: Form } = $props();
@ -113,6 +119,36 @@
await formsStore.updateBranching(entry.id, next);
}
// ── Visibility / Share-Link ────────────────────────────
let visibilityError = $state<string | null>(null);
async function onVisibilityChange(next: VisibilityLevel) {
visibilityError = null;
try {
await formsStore.setVisibility(entry.id, next);
} catch (err) {
visibilityError = err instanceof Error ? err.message : String(err);
}
}
async function handleRegenerate() {
await formsStore.regenerateUnlistedToken(entry.id);
}
async function handleRevoke() {
await formsStore.setVisibility(entry.id, 'private');
}
async function handleExpiryChange(expiresAt: Date | null) {
await formsStore.setUnlistedExpiry(entry.id, expiresAt);
}
const shareUrl = $derived.by(() => {
if (!entry.unlistedToken) return '';
const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin;
return buildShareUrl(origin, entry.unlistedToken);
});
async function setStatus(status: FormStatus) {
await formsStore.setStatus(entry.id, status);
}
@ -226,6 +262,35 @@
<FieldPalette onpick={pickField} />
</section>
<section class="visibility-section">
<header class="vis-header">
<p class="panel-title">
{$_('forms.builder.visibility.title', { default: 'Sichtbarkeit & Teilen' })}
</p>
{#if entry.status !== 'published' && entry.visibility !== 'unlisted'}
<span class="vis-hint">
{$_('forms.builder.visibility.publishHint', {
default: 'Setze den Status auf "Veröffentlicht", um zu teilen.',
})}
</span>
{/if}
</header>
<VisibilityPicker level={entry.visibility} onChange={onVisibilityChange} />
{#if visibilityError}
<p class="vis-error">{visibilityError}</p>
{/if}
{#if entry.visibility === 'unlisted' && entry.unlistedToken && shareUrl}
<SharedLinkControls
token={entry.unlistedToken}
url={shareUrl}
expiresAt={entry.unlistedExpiresAt}
onRegenerate={handleRegenerate}
onRevoke={handleRevoke}
onExpiryChange={handleExpiryChange}
/>
{/if}
</section>
<section class="branching-section">
<BranchingEditor fields={items} branching={entry.branching} onchange={patchBranching} />
</section>
@ -372,12 +437,47 @@
.fields-section,
.settings-section,
.branching-section {
.branching-section,
.visibility-section {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.visibility-section {
padding: 0.875rem;
background: rgb(255 255 255 / 0.03);
border: 1px solid rgb(255 255 255 / 0.06);
border-radius: 0.5rem;
}
.vis-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.panel-title {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgb(255 255 255 / 0.5);
}
.vis-hint {
font-size: 0.75rem;
color: rgb(255 255 255 / 0.45);
}
.vis-error {
margin: 0;
font-size: 0.8125rem;
color: rgb(252 165 165);
}
.section-header {
display: flex;
align-items: baseline;

View file

@ -9,6 +9,7 @@
import SharedPlaceView from '$lib/modules/places/SharedPlaceView.svelte';
import SharedAugurEntryView from '$lib/modules/augur/SharedAugurEntryView.svelte';
import SharedLastView from '$lib/modules/lasts/SharedLastView.svelte';
import SharedFormView from '$lib/modules/forms/SharedFormView.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@ -24,6 +25,8 @@
<SharedAugurEntryView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'lasts'}
<SharedLastView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'forms'}
<SharedFormView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else}
<div class="unknown">
<h1>Unbekannter Link-Typ</h1>