mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 02:01:10 +02:00
feat(forms): M2 builder + CRUD — drag-reorder + 11 field-types
Lieferung M2 (docs/plans/forms-module.md M2): - BuilderView mit Title/Description-Inputs (autosave-on-blur), Status-Pills (draft/published/closed), Delete-Button mit confirm, Drag-reorder über svelte-dnd-action + flip-Animation. - FieldEditor pro Feld: Label-Input, Pflichtfeld-Toggle, Typ-Switcher über alle 11 Field-Types, kollabierbare "Erweitert"-Section mit helpText + type-spezifischer Konfig (Optionen für choice-Felder mit Add/Remove, ratingScale-Toggle 5/10, min/max für number, maxLength für text). Type-switch räumt stale Konfig auf. - FieldPalette mit 11 Buttons (Glyph + Label) — Klick erzeugt frisches Feld via makeDefaultField + dispatch an Builder. - SettingsPanel: submitButtonLabel, successMessage, requireEmail, allowMultipleSubmissions, anonymous. - field-defaults.ts: makeDefaultField(type) generiert je Typ sinnvolle Defaults — choice mit 2 Optionen, rating mit 5er-Skala, consent required + Standard-Text. - Route /forms/[id] mit RoutePage-Wrapper. - 38 neue i18n-Keys × 5 Locales (forms.builder.*). Optimistic-UI: items-State wird lokal gepatcht vor store.update um Type-Switches sofort zu rendern; field-array re-syncet bei upstream-id-Änderung (multi-tab). Re-applied from notes-space-context branch (cherry 7767e6761, forms-only subset, ohne Parallel-Session context-removal). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
75d9207ff2
commit
f104a2bc35
12 changed files with 1414 additions and 0 deletions
|
|
@ -23,5 +23,53 @@
|
|||
"draft": "Entwurf",
|
||||
"published": "Veröffentlicht",
|
||||
"closed": "Geschlossen"
|
||||
},
|
||||
"builder": {
|
||||
"routeTitle": "Formular bearbeiten",
|
||||
"loading": "Lade ...",
|
||||
"notFound": "Formular nicht gefunden.",
|
||||
"backLink": "← Zurück zur Liste",
|
||||
"back": "← Zurück",
|
||||
"delete": "Löschen",
|
||||
"deleteConfirm": "Formular \"{title}\" wirklich löschen?",
|
||||
"statusGroupAria": "Status",
|
||||
"titlePlaceholder": "Formular-Titel",
|
||||
"titleAria": "Formular-Titel",
|
||||
"descriptionPlaceholder": "Beschreibung (optional)",
|
||||
"fields": {
|
||||
"title": "Felder",
|
||||
"count": "{n} Felder",
|
||||
"empty": "Noch keine Felder. Wähle unten einen Typ."
|
||||
},
|
||||
"palette": {
|
||||
"title": "Feld hinzufügen"
|
||||
},
|
||||
"field": {
|
||||
"labelPlaceholder": "Feldfrage ...",
|
||||
"labelAria": "Feldlabel",
|
||||
"removeAria": "Feld löschen",
|
||||
"required": "Pflichtfeld",
|
||||
"lessOptions": "− weniger",
|
||||
"moreOptions": "+ mehr",
|
||||
"helpPlaceholder": "Hilfetext (optional)",
|
||||
"options": "Optionen",
|
||||
"optionPlaceholder": "Option ...",
|
||||
"optionRemoveAria": "Option löschen",
|
||||
"addOption": "+ Option hinzufügen",
|
||||
"ratingScale": "Skala",
|
||||
"scale5": "1–5",
|
||||
"scale10": "1–10",
|
||||
"min": "Minimum",
|
||||
"max": "Maximum",
|
||||
"maxLength": "Max. Länge"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"submitLabel": "Beschriftung Absenden-Button",
|
||||
"successMessage": "Bestätigungstext nach Absenden",
|
||||
"requireEmail": "E-Mail-Adresse vom Absender abfragen",
|
||||
"allowMultiple": "Mehrere Antworten pro Person erlauben",
|
||||
"anonymous": "Anonym — Submitter-Daten nicht speichern"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,5 +23,53 @@
|
|||
"draft": "Draft",
|
||||
"published": "Published",
|
||||
"closed": "Closed"
|
||||
},
|
||||
"builder": {
|
||||
"routeTitle": "Edit form",
|
||||
"loading": "Loading ...",
|
||||
"notFound": "Form not found.",
|
||||
"backLink": "← Back to list",
|
||||
"back": "← Back",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Really delete form \"{title}\"?",
|
||||
"statusGroupAria": "Status",
|
||||
"titlePlaceholder": "Form title",
|
||||
"titleAria": "Form title",
|
||||
"descriptionPlaceholder": "Description (optional)",
|
||||
"fields": {
|
||||
"title": "Fields",
|
||||
"count": "{n} fields",
|
||||
"empty": "No fields yet. Pick a type below."
|
||||
},
|
||||
"palette": {
|
||||
"title": "Add field"
|
||||
},
|
||||
"field": {
|
||||
"labelPlaceholder": "Field question ...",
|
||||
"labelAria": "Field label",
|
||||
"removeAria": "Delete field",
|
||||
"required": "Required",
|
||||
"lessOptions": "− less",
|
||||
"moreOptions": "+ more",
|
||||
"helpPlaceholder": "Help text (optional)",
|
||||
"options": "Options",
|
||||
"optionPlaceholder": "Option ...",
|
||||
"optionRemoveAria": "Delete option",
|
||||
"addOption": "+ Add option",
|
||||
"ratingScale": "Scale",
|
||||
"scale5": "1–5",
|
||||
"scale10": "1–10",
|
||||
"min": "Minimum",
|
||||
"max": "Maximum",
|
||||
"maxLength": "Max length"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"submitLabel": "Submit button label",
|
||||
"successMessage": "Confirmation text after submit",
|
||||
"requireEmail": "Ask submitter for email address",
|
||||
"allowMultiple": "Allow multiple submissions per person",
|
||||
"anonymous": "Anonymous — don't store submitter data"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,5 +23,53 @@
|
|||
"draft": "Borrador",
|
||||
"published": "Publicado",
|
||||
"closed": "Cerrado"
|
||||
},
|
||||
"builder": {
|
||||
"routeTitle": "Editar formulario",
|
||||
"loading": "Cargando ...",
|
||||
"notFound": "Formulario no encontrado.",
|
||||
"backLink": "← Volver a la lista",
|
||||
"back": "← Atrás",
|
||||
"delete": "Eliminar",
|
||||
"deleteConfirm": "¿Eliminar el formulario \"{title}\"?",
|
||||
"statusGroupAria": "Estado",
|
||||
"titlePlaceholder": "Título del formulario",
|
||||
"titleAria": "Título del formulario",
|
||||
"descriptionPlaceholder": "Descripción (opcional)",
|
||||
"fields": {
|
||||
"title": "Campos",
|
||||
"count": "{n} campos",
|
||||
"empty": "Aún sin campos. Elige un tipo abajo."
|
||||
},
|
||||
"palette": {
|
||||
"title": "Añadir campo"
|
||||
},
|
||||
"field": {
|
||||
"labelPlaceholder": "Pregunta del campo ...",
|
||||
"labelAria": "Etiqueta del campo",
|
||||
"removeAria": "Eliminar campo",
|
||||
"required": "Obligatorio",
|
||||
"lessOptions": "− menos",
|
||||
"moreOptions": "+ más",
|
||||
"helpPlaceholder": "Texto de ayuda (opcional)",
|
||||
"options": "Opciones",
|
||||
"optionPlaceholder": "Opción ...",
|
||||
"optionRemoveAria": "Eliminar opción",
|
||||
"addOption": "+ Añadir opción",
|
||||
"ratingScale": "Escala",
|
||||
"scale5": "1–5",
|
||||
"scale10": "1–10",
|
||||
"min": "Mínimo",
|
||||
"max": "Máximo",
|
||||
"maxLength": "Longitud máx."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes",
|
||||
"submitLabel": "Texto del botón de envío",
|
||||
"successMessage": "Mensaje de confirmación tras el envío",
|
||||
"requireEmail": "Pedir correo electrónico al remitente",
|
||||
"allowMultiple": "Permitir varias respuestas por persona",
|
||||
"anonymous": "Anónimo — no guardar datos del remitente"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,5 +23,53 @@
|
|||
"draft": "Brouillon",
|
||||
"published": "Publié",
|
||||
"closed": "Clôturé"
|
||||
},
|
||||
"builder": {
|
||||
"routeTitle": "Modifier le formulaire",
|
||||
"loading": "Chargement ...",
|
||||
"notFound": "Formulaire introuvable.",
|
||||
"backLink": "← Retour à la liste",
|
||||
"back": "← Retour",
|
||||
"delete": "Supprimer",
|
||||
"deleteConfirm": "Vraiment supprimer le formulaire \"{title}\" ?",
|
||||
"statusGroupAria": "Statut",
|
||||
"titlePlaceholder": "Titre du formulaire",
|
||||
"titleAria": "Titre du formulaire",
|
||||
"descriptionPlaceholder": "Description (facultatif)",
|
||||
"fields": {
|
||||
"title": "Champs",
|
||||
"count": "{n} champs",
|
||||
"empty": "Pas encore de champs. Choisis un type ci-dessous."
|
||||
},
|
||||
"palette": {
|
||||
"title": "Ajouter un champ"
|
||||
},
|
||||
"field": {
|
||||
"labelPlaceholder": "Question du champ ...",
|
||||
"labelAria": "Libellé du champ",
|
||||
"removeAria": "Supprimer le champ",
|
||||
"required": "Obligatoire",
|
||||
"lessOptions": "− moins",
|
||||
"moreOptions": "+ plus",
|
||||
"helpPlaceholder": "Texte d'aide (facultatif)",
|
||||
"options": "Options",
|
||||
"optionPlaceholder": "Option ...",
|
||||
"optionRemoveAria": "Supprimer l'option",
|
||||
"addOption": "+ Ajouter une option",
|
||||
"ratingScale": "Échelle",
|
||||
"scale5": "1–5",
|
||||
"scale10": "1–10",
|
||||
"min": "Minimum",
|
||||
"max": "Maximum",
|
||||
"maxLength": "Longueur max."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"submitLabel": "Libellé du bouton d'envoi",
|
||||
"successMessage": "Message de confirmation après envoi",
|
||||
"requireEmail": "Demander l'adresse e-mail au destinataire",
|
||||
"allowMultiple": "Autoriser plusieurs réponses par personne",
|
||||
"anonymous": "Anonyme — ne pas conserver les données du destinataire"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,5 +23,53 @@
|
|||
"draft": "Bozza",
|
||||
"published": "Pubblicato",
|
||||
"closed": "Chiuso"
|
||||
},
|
||||
"builder": {
|
||||
"routeTitle": "Modifica modulo",
|
||||
"loading": "Caricamento ...",
|
||||
"notFound": "Modulo non trovato.",
|
||||
"backLink": "← Torna alla lista",
|
||||
"back": "← Indietro",
|
||||
"delete": "Elimina",
|
||||
"deleteConfirm": "Eliminare davvero il modulo \"{title}\"?",
|
||||
"statusGroupAria": "Stato",
|
||||
"titlePlaceholder": "Titolo del modulo",
|
||||
"titleAria": "Titolo del modulo",
|
||||
"descriptionPlaceholder": "Descrizione (facoltativa)",
|
||||
"fields": {
|
||||
"title": "Campi",
|
||||
"count": "{n} campi",
|
||||
"empty": "Ancora nessun campo. Scegli un tipo sotto."
|
||||
},
|
||||
"palette": {
|
||||
"title": "Aggiungi campo"
|
||||
},
|
||||
"field": {
|
||||
"labelPlaceholder": "Domanda del campo ...",
|
||||
"labelAria": "Etichetta del campo",
|
||||
"removeAria": "Elimina campo",
|
||||
"required": "Obbligatorio",
|
||||
"lessOptions": "− meno",
|
||||
"moreOptions": "+ altro",
|
||||
"helpPlaceholder": "Testo di aiuto (facoltativo)",
|
||||
"options": "Opzioni",
|
||||
"optionPlaceholder": "Opzione ...",
|
||||
"optionRemoveAria": "Elimina opzione",
|
||||
"addOption": "+ Aggiungi opzione",
|
||||
"ratingScale": "Scala",
|
||||
"scale5": "1–5",
|
||||
"scale10": "1–10",
|
||||
"min": "Minimo",
|
||||
"max": "Massimo",
|
||||
"maxLength": "Lunghezza max."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"submitLabel": "Testo del pulsante di invio",
|
||||
"successMessage": "Messaggio di conferma dopo l'invio",
|
||||
"requireEmail": "Chiedi l'indirizzo e-mail al mittente",
|
||||
"allowMultiple": "Permetti più risposte per persona",
|
||||
"anonymous": "Anonimo — non memorizzare i dati del mittente"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,411 @@
|
|||
<!--
|
||||
FieldEditor — inline editor for a single FormField inside the
|
||||
BuilderView. Type-specific config (options for choice fields,
|
||||
min/max for number, ratingScale for rating) lives in a collapsible
|
||||
"Erweitert" section to keep the default surface terse.
|
||||
|
||||
Save semantics: every edit calls `onchange(patch)` with a partial
|
||||
patch the parent then merges into store state. The parent is
|
||||
responsible for the persistence path (autosave-on-blur).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { FIELD_TYPE_LABELS } from '../types';
|
||||
import type { FieldOption, FieldType, FormField } from '../types';
|
||||
|
||||
let {
|
||||
field,
|
||||
index,
|
||||
onchange,
|
||||
onremove,
|
||||
}: {
|
||||
field: FormField;
|
||||
index: number;
|
||||
onchange: (patch: Partial<FormField>) => void;
|
||||
onremove: () => void;
|
||||
} = $props();
|
||||
|
||||
let advancedOpen = $state(false);
|
||||
|
||||
function patchOptions(next: FieldOption[]) {
|
||||
onchange({ options: next });
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
const next: FieldOption[] = [...(field.options ?? []), { id: crypto.randomUUID(), label: '' }];
|
||||
patchOptions(next);
|
||||
}
|
||||
|
||||
function updateOption(optionId: string, label: string) {
|
||||
const next = (field.options ?? []).map((o) => (o.id === optionId ? { ...o, label } : o));
|
||||
patchOptions(next);
|
||||
}
|
||||
|
||||
function removeOption(optionId: string) {
|
||||
const next = (field.options ?? []).filter((o) => o.id !== optionId);
|
||||
patchOptions(next);
|
||||
}
|
||||
|
||||
function patchConfig<K extends keyof NonNullable<FormField['config']>>(
|
||||
key: K,
|
||||
value: NonNullable<FormField['config']>[K] | undefined
|
||||
) {
|
||||
const cfg = { ...(field.config ?? {}) };
|
||||
if (value === undefined || value === null) {
|
||||
delete cfg[key];
|
||||
} else {
|
||||
cfg[key] = value;
|
||||
}
|
||||
onchange({ config: cfg });
|
||||
}
|
||||
|
||||
function setType(type: FieldType) {
|
||||
// Switching type — drop type-specific config so we don't keep
|
||||
// stale options/ratingScale dangling.
|
||||
const patch: Partial<FormField> = { type };
|
||||
if (type === 'single_choice' || type === 'multi_choice') {
|
||||
if (!field.options || field.options.length === 0) {
|
||||
patch.options = [
|
||||
{ id: crypto.randomUUID(), label: 'Option 1' },
|
||||
{ id: crypto.randomUUID(), label: 'Option 2' },
|
||||
];
|
||||
}
|
||||
} else {
|
||||
patch.options = undefined;
|
||||
}
|
||||
if (type !== 'rating' && type !== 'short_text' && type !== 'long_text' && type !== 'number') {
|
||||
patch.config = undefined;
|
||||
}
|
||||
if (type === 'rating' && !field.config?.ratingScale) {
|
||||
patch.config = { ratingScale: 5 };
|
||||
}
|
||||
onchange(patch);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="field-editor" data-type={field.type}>
|
||||
<div class="header">
|
||||
<span class="index">{index + 1}.</span>
|
||||
<input
|
||||
type="text"
|
||||
class="label-input"
|
||||
value={field.label}
|
||||
oninput={(e) => onchange({ label: (e.currentTarget as HTMLInputElement).value })}
|
||||
placeholder={$_('forms.builder.field.labelPlaceholder', { default: 'Feldfrage ...' })}
|
||||
aria-label={$_('forms.builder.field.labelAria', { default: 'Feldlabel' })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
onclick={onremove}
|
||||
aria-label={$_('forms.builder.field.removeAria', { default: 'Feld löschen' })}>×</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<select
|
||||
class="type-select"
|
||||
value={field.type}
|
||||
onchange={(e) => setType((e.currentTarget as HTMLSelectElement).value as FieldType)}
|
||||
>
|
||||
{#each Object.entries(FIELD_TYPE_LABELS) as [type, labels]}
|
||||
<option value={type}>{labels.de}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<label class="required-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onchange={(e) => onchange({ required: (e.currentTarget as HTMLInputElement).checked })}
|
||||
/>
|
||||
<span>{$_('forms.builder.field.required', { default: 'Pflichtfeld' })}</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="advanced-toggle"
|
||||
onclick={() => (advancedOpen = !advancedOpen)}
|
||||
aria-expanded={advancedOpen}
|
||||
>
|
||||
{advancedOpen
|
||||
? $_('forms.builder.field.lessOptions', { default: '− weniger' })
|
||||
: $_('forms.builder.field.moreOptions', { default: '+ mehr' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if advancedOpen}
|
||||
<div class="advanced">
|
||||
<input
|
||||
type="text"
|
||||
class="help-input"
|
||||
value={field.helpText ?? ''}
|
||||
oninput={(e) => {
|
||||
const val = (e.currentTarget as HTMLInputElement).value;
|
||||
onchange({ helpText: val.length > 0 ? val : undefined });
|
||||
}}
|
||||
placeholder={$_('forms.builder.field.helpPlaceholder', {
|
||||
default: 'Hilfetext (optional)',
|
||||
})}
|
||||
/>
|
||||
|
||||
{#if field.type === 'single_choice' || field.type === 'multi_choice'}
|
||||
<div class="options-block">
|
||||
<p class="block-label">
|
||||
{$_('forms.builder.field.options', { default: 'Optionen' })}
|
||||
</p>
|
||||
{#each field.options ?? [] as option (option.id)}
|
||||
<div class="option-row">
|
||||
<input
|
||||
type="text"
|
||||
value={option.label}
|
||||
oninput={(e) =>
|
||||
updateOption(option.id, (e.currentTarget as HTMLInputElement).value)}
|
||||
placeholder={$_('forms.builder.field.optionPlaceholder', {
|
||||
default: 'Option ...',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="option-remove"
|
||||
onclick={() => removeOption(option.id)}
|
||||
aria-label={$_('forms.builder.field.optionRemoveAria', {
|
||||
default: 'Option löschen',
|
||||
})}>×</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="option-add" onclick={addOption}>
|
||||
{$_('forms.builder.field.addOption', { default: '+ Option hinzufügen' })}
|
||||
</button>
|
||||
</div>
|
||||
{:else if field.type === 'rating'}
|
||||
<label class="config-row">
|
||||
<span>{$_('forms.builder.field.ratingScale', { default: 'Skala' })}</span>
|
||||
<select
|
||||
value={String(field.config?.ratingScale ?? 5)}
|
||||
onchange={(e) =>
|
||||
patchConfig(
|
||||
'ratingScale',
|
||||
Number((e.currentTarget as HTMLSelectElement).value) as 5 | 10
|
||||
)}
|
||||
>
|
||||
<option value="5">{$_('forms.builder.field.scale5', { default: '1–5' })}</option>
|
||||
<option value="10">{$_('forms.builder.field.scale10', { default: '1–10' })}</option>
|
||||
</select>
|
||||
</label>
|
||||
{:else if field.type === 'number'}
|
||||
<div class="config-grid">
|
||||
<label class="config-row">
|
||||
<span>{$_('forms.builder.field.min', { default: 'Minimum' })}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={field.config?.min ?? ''}
|
||||
oninput={(e) => {
|
||||
const raw = (e.currentTarget as HTMLInputElement).value;
|
||||
patchConfig('min', raw === '' ? undefined : Number(raw));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="config-row">
|
||||
<span>{$_('forms.builder.field.max', { default: 'Maximum' })}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={field.config?.max ?? ''}
|
||||
oninput={(e) => {
|
||||
const raw = (e.currentTarget as HTMLInputElement).value;
|
||||
patchConfig('max', raw === '' ? undefined : Number(raw));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{:else if field.type === 'short_text' || field.type === 'long_text'}
|
||||
<div class="config-grid">
|
||||
<label class="config-row">
|
||||
<span>{$_('forms.builder.field.maxLength', { default: 'Max. Länge' })}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={field.config?.maxLength ?? ''}
|
||||
min="1"
|
||||
oninput={(e) => {
|
||||
const raw = (e.currentTarget as HTMLInputElement).value;
|
||||
patchConfig('maxLength', raw === '' ? undefined : Number(raw));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.index {
|
||||
min-width: 1.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgb(255 255 255 / 0.45);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.label-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.label-input:focus {
|
||||
outline: none;
|
||||
border-color: rgb(20 184 166 / 0.5);
|
||||
}
|
||||
|
||||
.remove,
|
||||
.option-remove {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
color: rgb(255 255 255 / 0.4);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove:hover,
|
||||
.option-remove:hover {
|
||||
color: rgb(248 113 113);
|
||||
border-color: rgb(248 113 113 / 0.4);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.type-select,
|
||||
.advanced .help-input,
|
||||
.option-row input,
|
||||
.config-row input,
|
||||
.config-row select {
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.25rem;
|
||||
color: inherit;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.required-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.advanced-toggle {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.25rem;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.advanced {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.help-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.options-block .block-label {
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgb(255 255 255 / 0.4);
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.option-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.option-add {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: rgb(20 184 166 / 0.12);
|
||||
border: 1px solid rgb(20 184 166 / 0.25);
|
||||
border-radius: 0.25rem;
|
||||
color: rgb(94 234 212);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.option-add:hover {
|
||||
background: rgb(20 184 166 / 0.2);
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
.config-row input,
|
||||
.config-row select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<!--
|
||||
FieldPalette — picker that emits one of the 11 field-types when the
|
||||
user clicks an entry. Used inside the BuilderView between the field
|
||||
list and any preview area.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { FIELD_TYPE_LABELS } from '../types';
|
||||
import type { FieldType } from '../types';
|
||||
|
||||
let { onpick }: { onpick: (type: FieldType) => void } = $props();
|
||||
|
||||
const TYPES: FieldType[] = [
|
||||
'short_text',
|
||||
'long_text',
|
||||
'single_choice',
|
||||
'multi_choice',
|
||||
'number',
|
||||
'date',
|
||||
'email',
|
||||
'yes_no',
|
||||
'rating',
|
||||
'consent',
|
||||
'section',
|
||||
];
|
||||
|
||||
const ICONS: Record<FieldType, string> = {
|
||||
short_text: 'Aa',
|
||||
long_text: '¶',
|
||||
single_choice: '○',
|
||||
multi_choice: '☑',
|
||||
number: '#',
|
||||
date: '📅',
|
||||
email: '@',
|
||||
yes_no: '?',
|
||||
rating: '★',
|
||||
consent: '✓',
|
||||
section: '—',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="palette">
|
||||
<p class="palette-title">
|
||||
{$_('forms.builder.palette.title', { default: 'Feld hinzufügen' })}
|
||||
</p>
|
||||
<div class="palette-grid">
|
||||
{#each TYPES as type}
|
||||
<button type="button" class="palette-btn" onclick={() => onpick(type)}>
|
||||
<span class="icon">{ICONS[type]}</span>
|
||||
<span class="label">{FIELD_TYPE_LABELS[type].de}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.palette {
|
||||
padding: 0.875rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
border: 1px solid rgb(255 255 255 / 0.06);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.palette-title {
|
||||
margin: 0 0 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.palette-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.palette-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.palette-btn:hover {
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
border-color: rgb(255 255 255 / 0.16);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: rgb(20 184 166 / 0.85);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
<!--
|
||||
SettingsPanel — form-level settings (submit button, success message,
|
||||
email-required, multiple-submissions). The visibility/share controls
|
||||
land in M4 alongside the @mana/shared-privacy wiring.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { FormSettings } from '../types';
|
||||
|
||||
let {
|
||||
settings,
|
||||
onchange,
|
||||
}: {
|
||||
settings: FormSettings;
|
||||
onchange: (patch: Partial<FormSettings>) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="settings-panel">
|
||||
<p class="panel-title">
|
||||
{$_('forms.builder.settings.title', { default: 'Einstellungen' })}
|
||||
</p>
|
||||
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">
|
||||
{$_('forms.builder.settings.submitLabel', { default: 'Beschriftung Absenden-Button' })}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.submitButtonLabel}
|
||||
onblur={(e) =>
|
||||
onchange({
|
||||
submitButtonLabel: (e.currentTarget as HTMLInputElement).value.trim() || 'Senden',
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="setting-row">
|
||||
<span class="setting-label">
|
||||
{$_('forms.builder.settings.successMessage', {
|
||||
default: 'Bestätigungstext nach Absenden',
|
||||
})}
|
||||
</span>
|
||||
<textarea
|
||||
rows="2"
|
||||
value={settings.successMessage}
|
||||
onblur={(e) =>
|
||||
onchange({
|
||||
successMessage: (e.currentTarget as HTMLTextAreaElement).value.trim() || 'Danke!',
|
||||
})}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="setting-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.requireEmail}
|
||||
onchange={(e) => onchange({ requireEmail: (e.currentTarget as HTMLInputElement).checked })}
|
||||
/>
|
||||
<span>
|
||||
{$_('forms.builder.settings.requireEmail', {
|
||||
default: 'E-Mail-Adresse vom Absender abfragen',
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="setting-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allowMultipleSubmissions}
|
||||
onchange={(e) =>
|
||||
onchange({
|
||||
allowMultipleSubmissions: (e.currentTarget as HTMLInputElement).checked,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
{$_('forms.builder.settings.allowMultiple', {
|
||||
default: 'Mehrere Antworten pro Person erlauben',
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="setting-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.anonymous}
|
||||
onchange={(e) => onchange({ anonymous: (e.currentTarget as HTMLInputElement).checked })}
|
||||
/>
|
||||
<span>
|
||||
{$_('forms.builder.settings.anonymous', {
|
||||
default: 'Anonym — Submitter-Daten nicht speichern',
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
border: 1px solid rgb(255 255 255 / 0.06);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 0.75rem;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
.setting-row input,
|
||||
.setting-row textarea {
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.setting-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(255 255 255 / 0.8);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -16,6 +16,9 @@ export {
|
|||
// ─── Collections ─────────────────────────────────────────
|
||||
export { formTable, formResponseTable } from './collections';
|
||||
|
||||
// ─── Lib ─────────────────────────────────────────────────
|
||||
export { makeDefaultField } from './lib/field-defaults';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────
|
||||
export {
|
||||
FIELD_TYPE_LABELS,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import type { FieldType, FormField } from '../types';
|
||||
|
||||
/**
|
||||
* Build a fresh `FormField` for the given type with sensible defaults.
|
||||
* Generates a fresh UUID and the type-specific config so the builder
|
||||
* can append-and-go without touching the underlying schema engine.
|
||||
*/
|
||||
export function makeDefaultField(type: FieldType): FormField {
|
||||
const id = crypto.randomUUID();
|
||||
const base: FormField = {
|
||||
id,
|
||||
type,
|
||||
label: defaultLabel(type),
|
||||
required: false,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'single_choice':
|
||||
case 'multi_choice':
|
||||
return {
|
||||
...base,
|
||||
options: [
|
||||
{ id: crypto.randomUUID(), label: 'Option 1' },
|
||||
{ id: crypto.randomUUID(), label: 'Option 2' },
|
||||
],
|
||||
};
|
||||
case 'rating':
|
||||
return { ...base, config: { ratingScale: 5 } };
|
||||
case 'short_text':
|
||||
return { ...base, config: { maxLength: 120 } };
|
||||
case 'long_text':
|
||||
return { ...base, config: { maxLength: 2000 } };
|
||||
case 'number':
|
||||
return { ...base, config: {} };
|
||||
case 'consent':
|
||||
return {
|
||||
...base,
|
||||
required: true,
|
||||
label: 'Ich stimme der Datenverarbeitung zu.',
|
||||
};
|
||||
case 'section':
|
||||
return { ...base, label: 'Abschnitt' };
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLabel(type: FieldType): string {
|
||||
switch (type) {
|
||||
case 'short_text':
|
||||
return 'Kurze Frage';
|
||||
case 'long_text':
|
||||
return 'Längere Frage';
|
||||
case 'single_choice':
|
||||
return 'Wähle eine Option';
|
||||
case 'multi_choice':
|
||||
return 'Wähle eine oder mehrere Optionen';
|
||||
case 'number':
|
||||
return 'Zahl';
|
||||
case 'date':
|
||||
return 'Datum';
|
||||
case 'email':
|
||||
return 'E-Mail-Adresse';
|
||||
case 'yes_no':
|
||||
return 'Ja oder Nein?';
|
||||
case 'rating':
|
||||
return 'Wie würdest du das bewerten?';
|
||||
case 'section':
|
||||
return 'Abschnitt';
|
||||
case 'consent':
|
||||
return 'Einwilligung';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
<!--
|
||||
Forms — BuilderView (M2)
|
||||
|
||||
Edits the title, description, fields, and settings of a single form.
|
||||
Saves on blur (text fields) or change (settings, field-edits, drag-
|
||||
reorder). The store is the source of truth — local state mirrors
|
||||
`entry` and re-syncs whenever a different form id loads in.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { dndzone, SOURCES } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { formsStore } from '../stores/forms.svelte';
|
||||
import { FORM_STATUS_LABELS } from '../types';
|
||||
import type { Form, FormField, FormSettings, FormStatus } from '../types';
|
||||
import { makeDefaultField } from '../lib/field-defaults';
|
||||
import FieldEditor from '../components/FieldEditor.svelte';
|
||||
import FieldPalette from '../components/FieldPalette.svelte';
|
||||
import SettingsPanel from '../components/SettingsPanel.svelte';
|
||||
|
||||
let { entry }: { entry: Form } = $props();
|
||||
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let title = $state(entry.title);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let description = $state(entry.description ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let items = $state<FormField[]>(entry.fields.slice());
|
||||
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let lastSeenId = $state(entry.id);
|
||||
$effect(() => {
|
||||
if (entry.id !== lastSeenId) {
|
||||
lastSeenId = entry.id;
|
||||
title = entry.title;
|
||||
description = entry.description ?? '';
|
||||
items = entry.fields.slice();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-sync the field array when an upstream change rewrites it
|
||||
// (server pull, autosave from another tab) — guard against
|
||||
// clobbering an in-progress drag by comparing ids only.
|
||||
$effect(() => {
|
||||
const upstreamIds = entry.fields.map((f) => f.id).join(',');
|
||||
const localIds = items.map((f) => f.id).join(',');
|
||||
if (upstreamIds !== localIds) {
|
||||
items = entry.fields.slice();
|
||||
}
|
||||
});
|
||||
|
||||
async function saveTitle() {
|
||||
const next = title.trim();
|
||||
if (next && next !== entry.title) {
|
||||
await formsStore.updateForm(entry.id, { title: next });
|
||||
} else if (!next) {
|
||||
title = entry.title;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDescription() {
|
||||
const next = description.trim();
|
||||
const current = entry.description ?? '';
|
||||
if (next !== current) {
|
||||
await formsStore.updateForm(entry.id, { description: next.length > 0 ? next : null });
|
||||
}
|
||||
}
|
||||
|
||||
async function patchField(fieldId: string, patch: Partial<FormField>) {
|
||||
// Update local state optimistically so type-changes etc. render
|
||||
// without round-tripping through the store. The store update
|
||||
// then lands in the next live-query tick.
|
||||
items = items.map((f) => (f.id === fieldId ? { ...f, ...patch, id: f.id } : f));
|
||||
await formsStore.updateField(entry.id, fieldId, patch);
|
||||
}
|
||||
|
||||
async function removeField(fieldId: string) {
|
||||
items = items.filter((f) => f.id !== fieldId);
|
||||
await formsStore.removeField(entry.id, fieldId);
|
||||
}
|
||||
|
||||
async function pickField(type: FormField['type']) {
|
||||
const field = makeDefaultField(type);
|
||||
items = [...items, field];
|
||||
await formsStore.addField(entry.id, field);
|
||||
}
|
||||
|
||||
function handleDndConsider(e: CustomEvent<{ items: FormField[] }>) {
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
async function handleDndFinalize(
|
||||
e: CustomEvent<{ items: FormField[]; info: { source: string } }>
|
||||
) {
|
||||
items = e.detail.items;
|
||||
if (e.detail.info.source === SOURCES.POINTER) {
|
||||
await formsStore.reorderFields(
|
||||
entry.id,
|
||||
items.map((f) => f.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function patchSettings(patch: Partial<FormSettings>) {
|
||||
await formsStore.updateForm(entry.id, {
|
||||
settings: { ...entry.settings, ...patch },
|
||||
});
|
||||
}
|
||||
|
||||
async function setStatus(status: FormStatus) {
|
||||
await formsStore.setStatus(entry.id, status);
|
||||
}
|
||||
|
||||
async function deleteForm() {
|
||||
const ok = confirm(
|
||||
$_('forms.builder.deleteConfirm', {
|
||||
default: 'Formular "{title}" wirklich löschen?',
|
||||
values: { title: entry.title },
|
||||
})
|
||||
);
|
||||
if (!ok) return;
|
||||
await formsStore.deleteForm(entry.id);
|
||||
goto('/forms');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="builder">
|
||||
<div class="top-bar">
|
||||
<button type="button" class="back" onclick={() => goto('/forms')}>
|
||||
{$_('forms.builder.back', { default: '← Zurück' })}
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="status-pills"
|
||||
role="group"
|
||||
aria-label={$_('forms.builder.statusGroupAria', { default: 'Status' })}
|
||||
>
|
||||
{#each Object.keys(FORM_STATUS_LABELS) as st}
|
||||
<button
|
||||
type="button"
|
||||
class="status-pill"
|
||||
class:active={entry.status === st}
|
||||
data-status={st}
|
||||
onclick={() => setStatus(st as FormStatus)}
|
||||
>
|
||||
{FORM_STATUS_LABELS[st as FormStatus].de}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button type="button" class="delete" onclick={deleteForm}>
|
||||
{$_('forms.builder.delete', { default: 'Löschen' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<input
|
||||
type="text"
|
||||
class="title-input"
|
||||
bind:value={title}
|
||||
onblur={saveTitle}
|
||||
placeholder={$_('forms.builder.titlePlaceholder', { default: 'Formular-Titel' })}
|
||||
aria-label={$_('forms.builder.titleAria', { default: 'Formular-Titel' })}
|
||||
/>
|
||||
<textarea
|
||||
class="description-input"
|
||||
rows="2"
|
||||
bind:value={description}
|
||||
onblur={saveDescription}
|
||||
placeholder={$_('forms.builder.descriptionPlaceholder', {
|
||||
default: 'Beschreibung (optional)',
|
||||
})}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<section class="fields-section">
|
||||
<header class="section-header">
|
||||
<h2>{$_('forms.builder.fields.title', { default: 'Felder' })}</h2>
|
||||
<span class="count">
|
||||
{$_('forms.builder.fields.count', {
|
||||
default: '{n} Felder',
|
||||
values: { n: items.length },
|
||||
})}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="empty-fields">
|
||||
{$_('forms.builder.fields.empty', {
|
||||
default: 'Noch keine Felder. Wähle unten einen Typ.',
|
||||
})}
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
use:dndzone={{ items, flipDurationMs: 180, dropTargetStyle: {} }}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="fields-list"
|
||||
>
|
||||
{#each items as field, i (field.id)}
|
||||
<div animate:flip={{ duration: 180 }}>
|
||||
<FieldEditor
|
||||
{field}
|
||||
index={i}
|
||||
onchange={(patch) => patchField(field.id, patch)}
|
||||
onremove={() => removeField(field.id)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<FieldPalette onpick={pickField} />
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<SettingsPanel settings={entry.settings} onchange={patchSettings} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.back {
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
background: rgb(255 255 255 / 0.07);
|
||||
}
|
||||
|
||||
.status-pills {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-pill:hover {
|
||||
background: rgb(255 255 255 / 0.07);
|
||||
}
|
||||
|
||||
.status-pill.active[data-status='draft'] {
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: rgb(255 255 255 / 0.85);
|
||||
border-color: rgb(255 255 255 / 0.18);
|
||||
}
|
||||
|
||||
.status-pill.active[data-status='published'] {
|
||||
background: rgb(20 184 166 / 0.18);
|
||||
color: rgb(94 234 212);
|
||||
border-color: rgb(20 184 166 / 0.4);
|
||||
}
|
||||
|
||||
.status-pill.active[data-status='closed'] {
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
color: rgb(255 255 255 / 0.45);
|
||||
border-color: rgb(255 255 255 / 0.12);
|
||||
}
|
||||
|
||||
.delete {
|
||||
margin-left: auto;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.375rem;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delete:hover {
|
||||
color: rgb(248 113 113);
|
||||
border-color: rgb(248 113 113 / 0.4);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.5rem;
|
||||
color: inherit;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.title-input:focus {
|
||||
outline: none;
|
||||
border-color: rgb(20 184 166 / 0.5);
|
||||
}
|
||||
|
||||
.description-input {
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 0.5rem;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.description-input:focus {
|
||||
outline: none;
|
||||
border-color: rgb(255 255 255 / 0.18);
|
||||
}
|
||||
|
||||
.fields-section,
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 0.75rem;
|
||||
color: rgb(255 255 255 / 0.45);
|
||||
}
|
||||
|
||||
.empty-fields {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: rgb(255 255 255 / 0.45);
|
||||
background: rgb(255 255 255 / 0.02);
|
||||
border: 1px dashed rgb(255 255 255 / 0.1);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.fields-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
48
apps/mana/apps/web/src/routes/(app)/forms/[id]/+page.svelte
Normal file
48
apps/mana/apps/web/src/routes/(app)/forms/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import BuilderView from '$lib/modules/forms/views/BuilderView.svelte';
|
||||
import { useAllForms } from '$lib/modules/forms/queries';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
|
||||
const forms$ = useAllForms();
|
||||
const entry = $derived(forms$.value.find((f) => f.id === page.params.id));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title
|
||||
>{entry?.title ?? $_('forms.builder.routeTitle', { default: 'Formular bearbeiten' })} - Mana</title
|
||||
>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage
|
||||
appId="forms"
|
||||
backHref="/forms"
|
||||
title={$_('forms.builder.routeTitle', { default: 'Formular bearbeiten' })}
|
||||
>
|
||||
{#if forms$.loading}
|
||||
<p class="state">{$_('forms.builder.loading', { default: 'Lade ...' })}</p>
|
||||
{:else if !entry}
|
||||
<div class="state">
|
||||
<p>{$_('forms.builder.notFound', { default: 'Formular nicht gefunden.' })}</p>
|
||||
<a href="/forms">{$_('forms.builder.backLink', { default: '← Zurück zur Liste' })}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<BuilderView {entry} />
|
||||
{/if}
|
||||
</RoutePage>
|
||||
|
||||
<style>
|
||||
.state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.state a {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
color: rgb(94 234 212);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue