mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat(forms): M1 skeleton — module + Dexie v57 + welcome-seed
Erste Lieferung des Forms-Moduls (docs/plans/forms-module.md M1):
- Modul-Struktur unter src/lib/modules/forms/: types (LocalForm,
LocalFormResponse, 11 Field-Types, BranchingRule, FormSettings),
module.config, collections, queries (live + type-converters),
stores (forms-CRUD inkl. add/update/remove/reorderFields,
responses-submit mit denormalized responseCount-bump),
ListView mit Quick-Create + Search + 3-fach Empty-State.
- Dexie v57: forms (id, status, _updatedAtIndex) + formResponses
(id, formId, status, submittedAt, _updatedAtIndex, [formId+status]).
- Encryption-Registry typed entries: title/description/fields/branching/
settings auf forms; answers/submitterEmail/submitterName/submitterMeta
auf formResponses. Status, formId, submittedAt, responseCount,
visibility, unlistedToken bleiben plaintext (Routing- + Sort-Felder).
- Per-Space-Welcome-Seed mit Beispiel-Formular (3 Felder), wired in
data/seeds/index.ts.
- Route /forms via RoutePage (appId='forms').
- i18n-Namespace forms/ × 5 Locales (de/en/es/fr/it).
App-Registry-Eintrag (APP_ICONS.forms + MANA_APPS) ist bereits in
6d193a9fa gelandet (paralleler app-registry-polish-Commit).
Validatoren grün: validate:turbo, validate:pg-schema,
validate:i18n-parity (77 namespaces × 5), validate:theme-{parity,
utilities,variables}, audit:encrypted-tools (23 tools, M1 hat keine),
svelte-check 0/0/0 über 7680 Files. check:crypto: 213 Tables (+2
sind meine), 3 Violations sind pre-existing dead entries.
LOCAL TIER PATCH: requiredTier='guest' mit revert-Marker vor Release.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6d193a9fa7
commit
75d9207ff2
19 changed files with 1063 additions and 0 deletions
|
|
@ -99,6 +99,7 @@ import type {
|
|||
} from '../../modules/writing/types';
|
||||
import type { LocalComicStory, LocalComicCharacter } from '../../modules/comic/types';
|
||||
import type { LocalAugurEntry } from '../../modules/augur/types';
|
||||
import type { LocalForm, LocalFormResponse } from '../../modules/forms/types';
|
||||
|
||||
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||
// ─── Chat ────────────────────────────────────────────────
|
||||
|
|
@ -959,6 +960,30 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
'footer',
|
||||
'defaultTerms',
|
||||
]),
|
||||
|
||||
// ─── Forms ──────────────────────────────────────────────
|
||||
// User-defined questionnaires + the answers people submit. Plan: see
|
||||
// docs/plans/forms-module.md. The schema (title, description, fields,
|
||||
// branching, settings) carries the full text of every prompt the user
|
||||
// wrote — encrypted. Plaintext (intentional): status drives the
|
||||
// draft/published/closed filter; responseCount is a denormalized UI
|
||||
// counter; visibility/visibilityChanged*/unlistedToken/unlistedExpiresAt
|
||||
// are the share-routing surface the server-side public-submit
|
||||
// endpoint must read without the master key.
|
||||
forms: entry<LocalForm>(['title', 'description', 'fields', 'branching', 'settings']),
|
||||
|
||||
// Answers travel encrypted as one blob — `answers` is a free-form
|
||||
// Record<fieldId, value> that may carry PII (names, emails, free text).
|
||||
// `submitterEmail` / `submitterName` / `submitterMeta` are encrypted
|
||||
// separately so the audit log can selectively decrypt only what the
|
||||
// owner asked for. Plaintext: formId (FK), submittedAt (sort), status
|
||||
// (review filter), syncedTargets (no PII, just internal IDs).
|
||||
formResponses: entry<LocalFormResponse>([
|
||||
'answers',
|
||||
'submitterEmail',
|
||||
'submitterName',
|
||||
'submitterMeta',
|
||||
]),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1430,6 +1430,21 @@ db.version(56).stores({
|
|||
articleExtractPickup: 'id, itemId, _updatedAtIndex',
|
||||
});
|
||||
|
||||
// v57 — Forms module M1 skeleton (docs/plans/forms-module.md).
|
||||
// Two tables: `forms` carries the schema definition (fields, branching,
|
||||
// settings) plus the visibility/unlisted-token surface so the public
|
||||
// share endpoint can resolve a token to a form without decrypting; the
|
||||
// indexed `status` powers the draft/published/closed filter and
|
||||
// `_updatedAtIndex` keeps the workbench sort cheap. `formResponses`
|
||||
// holds one row per submission — `[formId+status]` is the hot index for
|
||||
// the responses tab (per-form, filtered by review state); `formId`
|
||||
// alone is needed for the cross-status response feed; `submittedAt`
|
||||
// drives the chronological default sort.
|
||||
db.version(57).stores({
|
||||
forms: 'id, status, _updatedAtIndex',
|
||||
formResponses: 'id, formId, status, submittedAt, _updatedAtIndex, [formId+status]',
|
||||
});
|
||||
|
||||
// ─── Sync Routing ──────────────────────────────────────────
|
||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||
// toSyncName() and fromSyncName() are now derived from per-module
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';
|
|||
import { writingModuleConfig } from '$lib/modules/writing/module.config';
|
||||
import { comicModuleConfig } from '$lib/modules/comic/module.config';
|
||||
import { augurModuleConfig } from '$lib/modules/augur/module.config';
|
||||
import { formsModuleConfig } from '$lib/modules/forms/module.config';
|
||||
import { aiModuleConfig } from '$lib/data/ai/module.config';
|
||||
|
||||
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
||||
|
|
@ -174,6 +175,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
writingModuleConfig,
|
||||
comicModuleConfig,
|
||||
augurModuleConfig,
|
||||
formsModuleConfig,
|
||||
aiModuleConfig,
|
||||
];
|
||||
|
||||
|
|
|
|||
67
apps/mana/apps/web/src/lib/data/seeds/forms.ts
Normal file
67
apps/mana/apps/web/src/lib/data/seeds/forms.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Per-Space "Welcome" seed for the Forms module.
|
||||
*
|
||||
* Drops a single draft form into each Space the first time it is
|
||||
* activated, so the empty state is replaced by a concrete example
|
||||
* users can edit, publish, or delete. Idempotent via deterministic id —
|
||||
* see docs/plans/workbench-seeding-cleanup.md.
|
||||
*
|
||||
* Plan: docs/plans/forms-module.md M1.
|
||||
*/
|
||||
|
||||
import { db } from '../database';
|
||||
import { encryptRecord } from '../crypto';
|
||||
import { registerSpaceSeed } from '../scope/per-space-seeds';
|
||||
import { DEFAULT_FORM_SETTINGS } from '$lib/modules/forms/types';
|
||||
import type { FormField, LocalForm } from '$lib/modules/forms/types';
|
||||
|
||||
const TABLE = 'forms';
|
||||
|
||||
export function formsWelcomeSeedId(spaceId: string): string {
|
||||
return `seed-welcome-${spaceId}`;
|
||||
}
|
||||
|
||||
const exampleFields: FormField[] = [
|
||||
{
|
||||
id: 'seed-name',
|
||||
type: 'short_text',
|
||||
label: 'Wie heißt du?',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'seed-mood',
|
||||
type: 'rating',
|
||||
label: 'Wie geht es dir heute?',
|
||||
required: false,
|
||||
config: { ratingScale: 5 },
|
||||
},
|
||||
{
|
||||
id: 'seed-note',
|
||||
type: 'long_text',
|
||||
label: 'Magst du etwas teilen?',
|
||||
helpText: 'Ein Satz reicht.',
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
registerSpaceSeed('forms-welcome', async (spaceId) => {
|
||||
const id = formsWelcomeSeedId(spaceId);
|
||||
const existing = await db.table(TABLE).get(id);
|
||||
if (existing) return;
|
||||
|
||||
const row: LocalForm = {
|
||||
id,
|
||||
spaceId,
|
||||
title: 'Beispiel-Formular',
|
||||
description: 'Ein Mini-Pulse-Check als Startpunkt. Bearbeite oder lösche es jederzeit.',
|
||||
fields: exampleFields,
|
||||
branching: [],
|
||||
status: 'draft',
|
||||
settings: { ...DEFAULT_FORM_SETTINGS },
|
||||
responseCount: 0,
|
||||
visibility: 'private',
|
||||
} as LocalForm;
|
||||
|
||||
await encryptRecord(TABLE, row);
|
||||
await db.table(TABLE).add(row);
|
||||
});
|
||||
|
|
@ -19,3 +19,6 @@ import './workbench-home';
|
|||
|
||||
// Side-effect: registers `lasts-welcome` per-space-seed.
|
||||
import './lasts';
|
||||
|
||||
// Side-effect: registers `forms-welcome` per-space-seed.
|
||||
import './forms';
|
||||
|
|
|
|||
27
apps/mana/apps/web/src/lib/i18n/locales/forms/de.json
Normal file
27
apps/mana/apps/web/src/lib/i18n/locales/forms/de.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"app": {
|
||||
"title": "Formulare",
|
||||
"tagline": "Eigene Formulare bauen und Antworten sammeln."
|
||||
},
|
||||
"list": {
|
||||
"emptyAll": "Noch keine Formulare angelegt.",
|
||||
"emptyHint": "Tipp: Tippe oben einen Titel und drücke Enter.",
|
||||
"emptySearch": "Keine Treffer für deine Suche.",
|
||||
"searchPlaceholder": "Formulare durchsuchen ...",
|
||||
"fieldCount": "{n} Felder",
|
||||
"responseCount": "{n} Antworten",
|
||||
"justNow": "gerade eben",
|
||||
"minutesAgo": "vor {n} min",
|
||||
"hoursAgo": "vor {n} h",
|
||||
"daysAgo": "vor {n} Tagen"
|
||||
},
|
||||
"quickAdd": {
|
||||
"placeholder": "Neues Formular ... (Enter)",
|
||||
"ariaLabel": "Formular-Titel"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Entwurf",
|
||||
"published": "Veröffentlicht",
|
||||
"closed": "Geschlossen"
|
||||
}
|
||||
}
|
||||
27
apps/mana/apps/web/src/lib/i18n/locales/forms/en.json
Normal file
27
apps/mana/apps/web/src/lib/i18n/locales/forms/en.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"app": {
|
||||
"title": "Forms",
|
||||
"tagline": "Build your own forms and collect responses."
|
||||
},
|
||||
"list": {
|
||||
"emptyAll": "No forms yet.",
|
||||
"emptyHint": "Tip: type a title above and hit Enter.",
|
||||
"emptySearch": "No matches for your search.",
|
||||
"searchPlaceholder": "Search forms ...",
|
||||
"fieldCount": "{n} fields",
|
||||
"responseCount": "{n} responses",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{n} min ago",
|
||||
"hoursAgo": "{n} h ago",
|
||||
"daysAgo": "{n} days ago"
|
||||
},
|
||||
"quickAdd": {
|
||||
"placeholder": "New form ... (Enter)",
|
||||
"ariaLabel": "Form title"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Draft",
|
||||
"published": "Published",
|
||||
"closed": "Closed"
|
||||
}
|
||||
}
|
||||
27
apps/mana/apps/web/src/lib/i18n/locales/forms/es.json
Normal file
27
apps/mana/apps/web/src/lib/i18n/locales/forms/es.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"app": {
|
||||
"title": "Formularios",
|
||||
"tagline": "Crea formularios y recoge respuestas."
|
||||
},
|
||||
"list": {
|
||||
"emptyAll": "Todavía no hay formularios.",
|
||||
"emptyHint": "Sugerencia: escribe un título arriba y pulsa Enter.",
|
||||
"emptySearch": "Sin resultados para tu búsqueda.",
|
||||
"searchPlaceholder": "Buscar formularios ...",
|
||||
"fieldCount": "{n} campos",
|
||||
"responseCount": "{n} respuestas",
|
||||
"justNow": "ahora mismo",
|
||||
"minutesAgo": "hace {n} min",
|
||||
"hoursAgo": "hace {n} h",
|
||||
"daysAgo": "hace {n} días"
|
||||
},
|
||||
"quickAdd": {
|
||||
"placeholder": "Nuevo formulario ... (Enter)",
|
||||
"ariaLabel": "Título del formulario"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Borrador",
|
||||
"published": "Publicado",
|
||||
"closed": "Cerrado"
|
||||
}
|
||||
}
|
||||
27
apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json
Normal file
27
apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"app": {
|
||||
"title": "Formulaires",
|
||||
"tagline": "Crée tes propres formulaires et collecte des réponses."
|
||||
},
|
||||
"list": {
|
||||
"emptyAll": "Aucun formulaire pour le moment.",
|
||||
"emptyHint": "Astuce : tape un titre ci-dessus et appuie sur Entrée.",
|
||||
"emptySearch": "Aucun résultat pour ta recherche.",
|
||||
"searchPlaceholder": "Rechercher des formulaires ...",
|
||||
"fieldCount": "{n} champs",
|
||||
"responseCount": "{n} réponses",
|
||||
"justNow": "à l'instant",
|
||||
"minutesAgo": "il y a {n} min",
|
||||
"hoursAgo": "il y a {n} h",
|
||||
"daysAgo": "il y a {n} jours"
|
||||
},
|
||||
"quickAdd": {
|
||||
"placeholder": "Nouveau formulaire ... (Entrée)",
|
||||
"ariaLabel": "Titre du formulaire"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Brouillon",
|
||||
"published": "Publié",
|
||||
"closed": "Clôturé"
|
||||
}
|
||||
}
|
||||
27
apps/mana/apps/web/src/lib/i18n/locales/forms/it.json
Normal file
27
apps/mana/apps/web/src/lib/i18n/locales/forms/it.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"app": {
|
||||
"title": "Moduli",
|
||||
"tagline": "Crea i tuoi moduli e raccogli risposte."
|
||||
},
|
||||
"list": {
|
||||
"emptyAll": "Ancora nessun modulo.",
|
||||
"emptyHint": "Suggerimento: digita un titolo sopra e premi Invio.",
|
||||
"emptySearch": "Nessun risultato per la tua ricerca.",
|
||||
"searchPlaceholder": "Cerca moduli ...",
|
||||
"fieldCount": "{n} campi",
|
||||
"responseCount": "{n} risposte",
|
||||
"justNow": "proprio adesso",
|
||||
"minutesAgo": "{n} min fa",
|
||||
"hoursAgo": "{n} h fa",
|
||||
"daysAgo": "{n} giorni fa"
|
||||
},
|
||||
"quickAdd": {
|
||||
"placeholder": "Nuovo modulo ... (Invio)",
|
||||
"ariaLabel": "Titolo del modulo"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Bozza",
|
||||
"published": "Pubblicato",
|
||||
"closed": "Chiuso"
|
||||
}
|
||||
}
|
||||
283
apps/mana/apps/web/src/lib/modules/forms/ListView.svelte
Normal file
283
apps/mana/apps/web/src/lib/modules/forms/ListView.svelte
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
<!--
|
||||
Forms — Workbench ListView (M1 skeleton)
|
||||
|
||||
Renders the active Space's forms with an empty-state for new Spaces
|
||||
and a Quick-Create input. Builder + responses views land in M2/M3.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte';
|
||||
import { hasActiveSceneScope } from '$lib/stores/scene-scope.svelte';
|
||||
import { useAllForms, searchForms } from './queries';
|
||||
import { formsStore } from './stores/forms.svelte';
|
||||
import { FORM_STATUS_LABELS } from './types';
|
||||
import type { Form } from './types';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
|
||||
let { navigate: _navigate, goBack: _goBack, params: _params }: ViewProps = $props();
|
||||
|
||||
const forms$ = useAllForms();
|
||||
const forms = $derived(forms$.value);
|
||||
|
||||
let searchQuery = $state('');
|
||||
const filtered = $derived(searchForms(forms, searchQuery));
|
||||
|
||||
let newTitle = $state('');
|
||||
|
||||
async function handleQuickCreate(e: KeyboardEvent) {
|
||||
if (e.key !== 'Enter' || !newTitle.trim()) return;
|
||||
e.preventDefault();
|
||||
const title = newTitle.trim();
|
||||
newTitle = '';
|
||||
const created = await formsStore.createForm({ title });
|
||||
goto(`/forms/${created.id}`);
|
||||
}
|
||||
|
||||
function openForm(form: Form) {
|
||||
goto(`/forms/${form.id}`);
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.round(diffMs / 60000);
|
||||
if (minutes < 1) return $_('forms.list.justNow', { default: 'gerade eben' });
|
||||
if (minutes < 60)
|
||||
return $_('forms.list.minutesAgo', {
|
||||
default: 'vor {n} min',
|
||||
values: { n: minutes },
|
||||
});
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24)
|
||||
return $_('forms.list.hoursAgo', {
|
||||
default: 'vor {n} h',
|
||||
values: { n: hours },
|
||||
});
|
||||
const days = Math.round(hours / 24);
|
||||
return $_('forms.list.daysAgo', {
|
||||
default: 'vor {n} Tagen',
|
||||
values: { n: days },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="forms-shell">
|
||||
<header class="forms-header">
|
||||
<h1>{$_('forms.app.title', { default: 'Formulare' })}</h1>
|
||||
<p class="tagline">
|
||||
{$_('forms.app.tagline', {
|
||||
default: 'Eigene Formulare bauen und Antworten sammeln.',
|
||||
})}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="quick-create">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newTitle}
|
||||
onkeydown={handleQuickCreate}
|
||||
placeholder={$_('forms.quickAdd.placeholder', {
|
||||
default: 'Neues Formular ... (Enter)',
|
||||
})}
|
||||
aria-label={$_('forms.quickAdd.ariaLabel', { default: 'Formular-Titel' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="search-row">
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder={$_('forms.list.searchPlaceholder', { default: 'Formulare durchsuchen ...' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
{#if hasActiveSceneScope()}
|
||||
<ScopeEmptyState label={$_('forms.app.title', { default: 'Formulare' })} />
|
||||
{:else if forms.length === 0}
|
||||
<div class="empty-state">
|
||||
<p class="empty-title">
|
||||
{$_('forms.list.emptyAll', { default: 'Noch keine Formulare angelegt.' })}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{$_('forms.list.emptyHint', {
|
||||
default: 'Tipp: Tippe oben einen Titel und drücke Enter.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<p class="empty-title">
|
||||
{$_('forms.list.emptySearch', { default: 'Keine Treffer für deine Suche.' })}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<ul class="form-list">
|
||||
{#each filtered as form (form.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="form-card"
|
||||
onclick={() => openForm(form)}
|
||||
aria-label={form.title}
|
||||
>
|
||||
<span class="status-pill status-{form.status}">
|
||||
{FORM_STATUS_LABELS[form.status].de}
|
||||
</span>
|
||||
<span class="title">{form.title}</span>
|
||||
{#if form.description}
|
||||
<span class="description">{form.description}</span>
|
||||
{/if}
|
||||
<span class="meta">
|
||||
<span class="field-count">
|
||||
{$_('forms.list.fieldCount', {
|
||||
default: '{n} Felder',
|
||||
values: { n: form.fields.length },
|
||||
})}
|
||||
</span>
|
||||
<span class="response-count">
|
||||
{$_('forms.list.responseCount', {
|
||||
default: '{n} Antworten',
|
||||
values: { n: form.responseCount },
|
||||
})}
|
||||
</span>
|
||||
<span class="updated">{relativeTime(form.updatedAt)}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.forms-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.forms-header h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: rgb(255 255 255 / 0.6);
|
||||
font-size: 0.875rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.quick-create input,
|
||||
.search {
|
||||
width: 100%;
|
||||
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: 0.9375rem;
|
||||
}
|
||||
|
||||
.quick-create input:focus,
|
||||
.search:focus {
|
||||
outline: none;
|
||||
border-color: rgb(255 255 255 / 0.2);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 0.9375rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: rgb(255 255 255 / 0.4);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
grid-template-areas: 'pill title' '. description' '. meta';
|
||||
gap: 0.25rem 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
border: 1px solid rgb(255 255 255 / 0.06);
|
||||
border-radius: 0.5rem;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-card:hover {
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
border-color: rgb(255 255 255 / 0.12);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
grid-area: pill;
|
||||
align-self: start;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
}
|
||||
|
||||
.status-published {
|
||||
background: rgb(20 184 166 / 0.18);
|
||||
color: rgb(94 234 212);
|
||||
}
|
||||
|
||||
.status-closed {
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
color: rgb(255 255 255 / 0.45);
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-area: description;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
grid-area: meta;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
color: rgb(255 255 255 / 0.45);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
5
apps/mana/apps/web/src/lib/modules/forms/collections.ts
Normal file
5
apps/mana/apps/web/src/lib/modules/forms/collections.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { db } from '$lib/data/database';
|
||||
import type { LocalForm, LocalFormResponse } from './types';
|
||||
|
||||
export const formTable = db.table<LocalForm>('forms');
|
||||
export const formResponseTable = db.table<LocalFormResponse>('formResponses');
|
||||
46
apps/mana/apps/web/src/lib/modules/forms/index.ts
Normal file
46
apps/mana/apps/web/src/lib/modules/forms/index.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// ─── Stores ──────────────────────────────────────────────
|
||||
export { formsStore } from './stores/forms.svelte';
|
||||
export { responsesStore } from './stores/responses.svelte';
|
||||
|
||||
// ─── Queries ─────────────────────────────────────────────
|
||||
export {
|
||||
useAllForms,
|
||||
useFormsByStatus,
|
||||
useFormResponses,
|
||||
useResponsesByStatus,
|
||||
toForm,
|
||||
toFormResponse,
|
||||
searchForms,
|
||||
} from './queries';
|
||||
|
||||
// ─── Collections ─────────────────────────────────────────
|
||||
export { formTable, formResponseTable } from './collections';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────
|
||||
export {
|
||||
FIELD_TYPE_LABELS,
|
||||
FORM_STATUS_LABELS,
|
||||
RESPONSE_STATUS_LABELS,
|
||||
DEFAULT_FORM_SETTINGS,
|
||||
} from './types';
|
||||
export type {
|
||||
LocalForm,
|
||||
Form,
|
||||
FormStatus,
|
||||
FormField,
|
||||
FieldType,
|
||||
FieldOption,
|
||||
FieldConfig,
|
||||
FormSettings,
|
||||
BranchingRule,
|
||||
BranchOperator,
|
||||
BranchAction,
|
||||
AutoSyncConfig,
|
||||
AutoSyncTarget,
|
||||
LocalFormResponse,
|
||||
FormResponse,
|
||||
ResponseStatus,
|
||||
AnswerValue,
|
||||
SubmitterMeta,
|
||||
SyncedTarget,
|
||||
} from './types';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const formsModuleConfig: ModuleConfig = {
|
||||
appId: 'forms',
|
||||
tables: [{ name: 'forms' }, { name: 'formResponses' }],
|
||||
};
|
||||
109
apps/mana/apps/web/src/lib/modules/forms/queries.ts
Normal file
109
apps/mana/apps/web/src/lib/modules/forms/queries.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { DEFAULT_FORM_SETTINGS } from './types';
|
||||
import type {
|
||||
Form,
|
||||
FormResponse,
|
||||
FormStatus,
|
||||
LocalForm,
|
||||
LocalFormResponse,
|
||||
ResponseStatus,
|
||||
} from './types';
|
||||
|
||||
// ─── Type Converters ───────────────────────────────────────
|
||||
|
||||
export function toForm(local: LocalForm): Form {
|
||||
return {
|
||||
id: local.id,
|
||||
title: local.title,
|
||||
description: local.description,
|
||||
fields: local.fields ?? [],
|
||||
branching: local.branching ?? [],
|
||||
status: local.status,
|
||||
settings: { ...DEFAULT_FORM_SETTINGS, ...(local.settings ?? {}) },
|
||||
responseCount: local.responseCount ?? 0,
|
||||
visibility: local.visibility ?? 'private',
|
||||
unlistedToken: local.unlistedToken ?? '',
|
||||
unlistedExpiresAt: local.unlistedExpiresAt ?? null,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
export function toFormResponse(local: LocalFormResponse): FormResponse {
|
||||
return {
|
||||
id: local.id,
|
||||
formId: local.formId,
|
||||
submittedAt: local.submittedAt,
|
||||
answers: local.answers ?? {},
|
||||
submitterEmail: local.submitterEmail ?? null,
|
||||
submitterName: local.submitterName ?? null,
|
||||
submitterMeta: local.submitterMeta ?? null,
|
||||
status: local.status,
|
||||
syncedTargets: local.syncedTargets ?? [],
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Live Queries ──────────────────────────────────────────
|
||||
|
||||
export function useAllForms() {
|
||||
return useScopedLiveQuery(async () => {
|
||||
const visible = (await scopedForModule<LocalForm, string>('forms', 'forms').toArray()).filter(
|
||||
(f) => !f.deletedAt
|
||||
);
|
||||
const decrypted = await decryptRecords('forms', visible);
|
||||
return decrypted.map(toForm).sort(compareForms);
|
||||
}, [] as Form[]);
|
||||
}
|
||||
|
||||
export function useFormsByStatus(status: FormStatus) {
|
||||
return useScopedLiveQuery(async () => {
|
||||
const visible = (await scopedForModule<LocalForm, string>('forms', 'forms').toArray()).filter(
|
||||
(f) => !f.deletedAt && f.status === status
|
||||
);
|
||||
const decrypted = await decryptRecords('forms', visible);
|
||||
return decrypted.map(toForm).sort(compareForms);
|
||||
}, [] as Form[]);
|
||||
}
|
||||
|
||||
export function useFormResponses(formId: string) {
|
||||
return useScopedLiveQuery(async () => {
|
||||
const visible = (
|
||||
await scopedForModule<LocalFormResponse, string>('forms', 'formResponses').toArray()
|
||||
).filter((r) => !r.deletedAt && r.formId === formId);
|
||||
const decrypted = await decryptRecords('formResponses', visible);
|
||||
return decrypted.map(toFormResponse).sort((a, b) => b.submittedAt.localeCompare(a.submittedAt));
|
||||
}, [] as FormResponse[]);
|
||||
}
|
||||
|
||||
export function useResponsesByStatus(formId: string, status: ResponseStatus) {
|
||||
return useScopedLiveQuery(async () => {
|
||||
const visible = (
|
||||
await scopedForModule<LocalFormResponse, string>('forms', 'formResponses').toArray()
|
||||
).filter((r) => !r.deletedAt && r.formId === formId && r.status === status);
|
||||
const decrypted = await decryptRecords('formResponses', visible);
|
||||
return decrypted.map(toFormResponse).sort((a, b) => b.submittedAt.localeCompare(a.submittedAt));
|
||||
}, [] as FormResponse[]);
|
||||
}
|
||||
|
||||
// ─── Pure Helpers ──────────────────────────────────────────
|
||||
|
||||
function compareForms(a: Form, b: Form): number {
|
||||
// Drafts at the top while editing, then published, then closed.
|
||||
const order: Record<FormStatus, number> = { draft: 0, published: 1, closed: 2 };
|
||||
if (order[a.status] !== order[b.status]) return order[a.status] - order[b.status];
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
}
|
||||
|
||||
export function searchForms(forms: Form[], query: string): Form[] {
|
||||
if (!query.trim()) return forms;
|
||||
const q = query.toLowerCase();
|
||||
return forms.filter((f) => {
|
||||
const haystack = [f.title, f.description].filter(Boolean).join(' ').toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { formTable } from '../collections';
|
||||
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';
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export const formsStore = {
|
||||
async createForm(data: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
fields?: FormField[];
|
||||
branching?: BranchingRule[];
|
||||
settings?: Partial<FormSettings>;
|
||||
}): Promise<Form> {
|
||||
const id = crypto.randomUUID();
|
||||
const newLocal: LocalForm = {
|
||||
id,
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
fields: data.fields ?? [],
|
||||
branching: data.branching ?? [],
|
||||
status: 'draft',
|
||||
settings: { ...DEFAULT_FORM_SETTINGS, ...(data.settings ?? {}) },
|
||||
responseCount: 0,
|
||||
visibility: 'private',
|
||||
};
|
||||
|
||||
const plaintextSnapshot = toForm(newLocal);
|
||||
await encryptRecord('forms', newLocal);
|
||||
await formTable.add(newLocal);
|
||||
return plaintextSnapshot;
|
||||
},
|
||||
|
||||
async updateForm(
|
||||
id: string,
|
||||
data: Partial<Pick<LocalForm, 'title' | 'description' | 'fields' | 'branching' | 'settings'>>
|
||||
) {
|
||||
const diff: Partial<LocalForm> = { ...data };
|
||||
await encryptRecord('forms', diff);
|
||||
await formTable.update(id, diff);
|
||||
},
|
||||
|
||||
async setStatus(id: string, status: FormStatus) {
|
||||
await formTable.update(id, { status });
|
||||
},
|
||||
|
||||
async deleteForm(id: string) {
|
||||
await formTable.update(id, { deletedAt: nowIso() });
|
||||
},
|
||||
|
||||
async addField(id: string, field: FormField) {
|
||||
const form = await formTable.get(id);
|
||||
if (!form) return;
|
||||
const fields = [...(form.fields ?? []), field];
|
||||
const diff: Partial<LocalForm> = { fields };
|
||||
await encryptRecord('forms', diff);
|
||||
await formTable.update(id, diff);
|
||||
},
|
||||
|
||||
async updateField(id: string, fieldId: string, patch: Partial<FormField>) {
|
||||
const form = await formTable.get(id);
|
||||
if (!form) return;
|
||||
const fields = (form.fields ?? []).map((f) =>
|
||||
f.id === fieldId ? { ...f, ...patch, id: f.id } : f
|
||||
);
|
||||
const diff: Partial<LocalForm> = { fields };
|
||||
await encryptRecord('forms', diff);
|
||||
await formTable.update(id, diff);
|
||||
},
|
||||
|
||||
async removeField(id: string, fieldId: string) {
|
||||
const form = await formTable.get(id);
|
||||
if (!form) return;
|
||||
const fields = (form.fields ?? []).filter((f) => f.id !== fieldId);
|
||||
const diff: Partial<LocalForm> = { fields };
|
||||
await encryptRecord('forms', diff);
|
||||
await formTable.update(id, diff);
|
||||
},
|
||||
|
||||
async reorderFields(id: string, fieldIds: string[]) {
|
||||
const form = await formTable.get(id);
|
||||
if (!form) return;
|
||||
const byId = new Map((form.fields ?? []).map((f) => [f.id, f]));
|
||||
const reordered = fieldIds.map((fid) => byId.get(fid)).filter((f): f is FormField => !!f);
|
||||
const diff: Partial<LocalForm> = { fields: reordered };
|
||||
await encryptRecord('forms', diff);
|
||||
await formTable.update(id, diff);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { formResponseTable, formTable } from '../collections';
|
||||
import { toFormResponse } from '../queries';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import type { AnswerValue, FormResponse, LocalFormResponse, ResponseStatus } from '../types';
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export const responsesStore = {
|
||||
/**
|
||||
* Record a response submitted via the in-app builder preview. The
|
||||
* public-submit pipeline (M3) writes server-side and round-trips
|
||||
* through mana-sync, so it does not call this store.
|
||||
*/
|
||||
async submitResponse(data: {
|
||||
formId: string;
|
||||
answers: Record<string, AnswerValue>;
|
||||
submitterEmail?: string | null;
|
||||
submitterName?: string | null;
|
||||
}): Promise<FormResponse> {
|
||||
const id = crypto.randomUUID();
|
||||
const now = nowIso();
|
||||
const newLocal: LocalFormResponse = {
|
||||
id,
|
||||
formId: data.formId,
|
||||
submittedAt: now,
|
||||
answers: data.answers,
|
||||
submitterEmail: data.submitterEmail ?? undefined,
|
||||
submitterName: data.submitterName ?? undefined,
|
||||
status: 'new',
|
||||
};
|
||||
|
||||
const plaintextSnapshot = toFormResponse(newLocal);
|
||||
await encryptRecord('formResponses', newLocal);
|
||||
await formResponseTable.add(newLocal);
|
||||
|
||||
// Bump denormalized counter on the parent form. Read-modify-write
|
||||
// is fine here — collisions resolve via field-level LWW in sync,
|
||||
// and the count is a UI-only signal (re-deriveable from a query).
|
||||
const parent = await formTable.get(data.formId);
|
||||
if (parent) {
|
||||
await formTable.update(data.formId, {
|
||||
responseCount: (parent.responseCount ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return plaintextSnapshot;
|
||||
},
|
||||
|
||||
async setStatus(id: string, status: ResponseStatus) {
|
||||
await formResponseTable.update(id, { status });
|
||||
},
|
||||
|
||||
async deleteResponse(id: string) {
|
||||
await formResponseTable.update(id, { deletedAt: nowIso() });
|
||||
},
|
||||
};
|
||||
204
apps/mana/apps/web/src/lib/modules/forms/types.ts
Normal file
204
apps/mana/apps/web/src/lib/modules/forms/types.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
/**
|
||||
* Forms module types.
|
||||
*
|
||||
* Two tables: `forms` (the schema definition + settings) and
|
||||
* `formResponses` (one row per submitted answer set). Plan: see
|
||||
* docs/plans/forms-module.md.
|
||||
*
|
||||
* Field semantics intentionally diverge from the Quiz module's options
|
||||
* (no `isCorrect` flag, no answer scoring). The plan to extract a shared
|
||||
* `@mana/shared-form-schema` package is a follow-up to M1.
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
import type { VisibilityLevel } from '@mana/shared-privacy';
|
||||
|
||||
// ─── Field-Type Catalogue ───────────────────────────────────
|
||||
|
||||
export type FieldType =
|
||||
| 'short_text'
|
||||
| 'long_text'
|
||||
| 'single_choice'
|
||||
| 'multi_choice'
|
||||
| 'number'
|
||||
| 'date'
|
||||
| 'email'
|
||||
| 'yes_no'
|
||||
| 'rating'
|
||||
| 'section'
|
||||
| 'consent';
|
||||
|
||||
export interface FieldOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface FieldConfig {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
ratingScale?: 5 | 10;
|
||||
}
|
||||
|
||||
export interface FormField {
|
||||
id: string;
|
||||
type: FieldType;
|
||||
label: string;
|
||||
helpText?: string;
|
||||
required: boolean;
|
||||
options?: FieldOption[];
|
||||
config?: FieldConfig;
|
||||
}
|
||||
|
||||
// ─── Branching ──────────────────────────────────────────────
|
||||
|
||||
export type BranchOperator = 'equals' | 'not_equals' | 'contains' | 'is_empty';
|
||||
export type BranchAction = 'show' | 'hide' | 'skip_to';
|
||||
|
||||
export interface BranchingRule {
|
||||
id: string;
|
||||
ifFieldId: string;
|
||||
ifOperator: BranchOperator;
|
||||
ifValue?: string | string[];
|
||||
thenAction: BranchAction;
|
||||
thenFieldIds?: string[];
|
||||
thenSkipToFieldId?: string;
|
||||
}
|
||||
|
||||
// ─── Settings ───────────────────────────────────────────────
|
||||
|
||||
export type AutoSyncTarget = 'contacts' | 'events' | 'feedback' | 'library' | 'space_member';
|
||||
|
||||
export interface AutoSyncConfig {
|
||||
target: AutoSyncTarget;
|
||||
mapping: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface FormSettings {
|
||||
submitButtonLabel: string;
|
||||
successMessage: string;
|
||||
allowMultipleSubmissions: boolean;
|
||||
requireEmail: boolean;
|
||||
anonymous: boolean;
|
||||
zkMode: boolean;
|
||||
closedAt?: string;
|
||||
responseLimit?: number;
|
||||
autoSync?: AutoSyncConfig;
|
||||
responsesPublic?: boolean;
|
||||
}
|
||||
|
||||
export type FormStatus = 'draft' | 'published' | 'closed';
|
||||
|
||||
// ─── Local (Dexie) Records ──────────────────────────────────
|
||||
|
||||
export interface LocalForm extends BaseRecord {
|
||||
title: string;
|
||||
description: string | null;
|
||||
fields: FormField[];
|
||||
branching: BranchingRule[];
|
||||
status: FormStatus;
|
||||
settings: FormSettings;
|
||||
responseCount: number;
|
||||
visibility?: VisibilityLevel;
|
||||
visibilityChangedAt?: string;
|
||||
visibilityChangedBy?: string;
|
||||
unlistedToken?: string;
|
||||
unlistedExpiresAt?: string | null;
|
||||
}
|
||||
|
||||
export type ResponseStatus = 'new' | 'reviewed' | 'archived' | 'spam';
|
||||
|
||||
export type AnswerValue = string | string[] | number | boolean | null;
|
||||
|
||||
export interface SubmitterMeta {
|
||||
userAgent?: string;
|
||||
referrer?: string;
|
||||
ipHash?: string;
|
||||
}
|
||||
|
||||
export interface SyncedTarget {
|
||||
target: AutoSyncTarget;
|
||||
recordId: string;
|
||||
}
|
||||
|
||||
export interface LocalFormResponse extends BaseRecord {
|
||||
formId: string;
|
||||
submittedAt: string;
|
||||
answers: Record<string, AnswerValue>;
|
||||
submitterEmail?: string;
|
||||
submitterName?: string;
|
||||
submitterMeta?: SubmitterMeta;
|
||||
status: ResponseStatus;
|
||||
syncedTargets?: SyncedTarget[];
|
||||
}
|
||||
|
||||
// ─── Domain Types ───────────────────────────────────────────
|
||||
|
||||
export interface Form {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
fields: FormField[];
|
||||
branching: BranchingRule[];
|
||||
status: FormStatus;
|
||||
settings: FormSettings;
|
||||
responseCount: number;
|
||||
visibility: VisibilityLevel;
|
||||
unlistedToken: string;
|
||||
unlistedExpiresAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FormResponse {
|
||||
id: string;
|
||||
formId: string;
|
||||
submittedAt: string;
|
||||
answers: Record<string, AnswerValue>;
|
||||
submitterEmail: string | null;
|
||||
submitterName: string | null;
|
||||
submitterMeta: SubmitterMeta | null;
|
||||
status: ResponseStatus;
|
||||
syncedTargets: SyncedTarget[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────
|
||||
|
||||
export const FIELD_TYPE_LABELS: Record<FieldType, { de: string; en: string }> = {
|
||||
short_text: { de: 'Kurzer Text', en: 'Short text' },
|
||||
long_text: { de: 'Langer Text', en: 'Long text' },
|
||||
single_choice: { de: 'Einfachauswahl', en: 'Single choice' },
|
||||
multi_choice: { de: 'Mehrfachauswahl', en: 'Multiple choice' },
|
||||
number: { de: 'Zahl', en: 'Number' },
|
||||
date: { de: 'Datum', en: 'Date' },
|
||||
email: { de: 'E-Mail', en: 'Email' },
|
||||
yes_no: { de: 'Ja / Nein', en: 'Yes / No' },
|
||||
rating: { de: 'Bewertung', en: 'Rating' },
|
||||
section: { de: 'Abschnitt', en: 'Section' },
|
||||
consent: { de: 'Einwilligung', en: 'Consent' },
|
||||
};
|
||||
|
||||
export const FORM_STATUS_LABELS: Record<FormStatus, { de: string; en: string }> = {
|
||||
draft: { de: 'Entwurf', en: 'Draft' },
|
||||
published: { de: 'Veröffentlicht', en: 'Published' },
|
||||
closed: { de: 'Geschlossen', en: 'Closed' },
|
||||
};
|
||||
|
||||
export const RESPONSE_STATUS_LABELS: Record<ResponseStatus, { de: string; en: string }> = {
|
||||
new: { de: 'Neu', en: 'New' },
|
||||
reviewed: { de: 'Gesichtet', en: 'Reviewed' },
|
||||
archived: { de: 'Archiviert', en: 'Archived' },
|
||||
spam: { de: 'Spam', en: 'Spam' },
|
||||
};
|
||||
|
||||
export const DEFAULT_FORM_SETTINGS: FormSettings = {
|
||||
submitButtonLabel: 'Senden',
|
||||
successMessage: 'Danke! Deine Antwort wurde übermittelt.',
|
||||
allowMultipleSubmissions: false,
|
||||
requireEmail: false,
|
||||
anonymous: false,
|
||||
zkMode: false,
|
||||
};
|
||||
12
apps/mana/apps/web/src/routes/(app)/forms/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/forms/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import ListView from '$lib/modules/forms/ListView.svelte';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Forms - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="forms">
|
||||
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />
|
||||
</RoutePage>
|
||||
Loading…
Add table
Add a link
Reference in a new issue