@quoin-cms/admin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/LICENSE +661 -0
  2. package/biome.json +62 -0
  3. package/dist/assets/index-C9Y5-AKj.js +33 -0
  4. package/dist/assets/index-uVdiUjty.css +1 -0
  5. package/dist/index.html +20 -0
  6. package/index.html +19 -0
  7. package/package.json +43 -0
  8. package/src/AdminRoot.svelte +98 -0
  9. package/src/app.css +211 -0
  10. package/src/lib/Slot.svelte +65 -0
  11. package/src/lib/api/auth.ts +26 -0
  12. package/src/lib/api/client.ts +73 -0
  13. package/src/lib/api/files.ts +56 -0
  14. package/src/lib/api/globals.ts +13 -0
  15. package/src/lib/api/records.ts +102 -0
  16. package/src/lib/api/schema.ts +7 -0
  17. package/src/lib/api/versions.ts +40 -0
  18. package/src/lib/components/AdminHeader.svelte +107 -0
  19. package/src/lib/components/AdminSidebar.svelte +262 -0
  20. package/src/lib/components/DeleteDialog.svelte +58 -0
  21. package/src/lib/components/DocumentEditLayout.svelte +263 -0
  22. package/src/lib/components/DynamicForm.svelte +74 -0
  23. package/src/lib/components/KpiCard.svelte +75 -0
  24. package/src/lib/components/MediaLibrary.svelte +311 -0
  25. package/src/lib/components/Pagination.svelte +78 -0
  26. package/src/lib/components/RangeFilter.svelte +41 -0
  27. package/src/lib/components/RecordGrid.svelte +123 -0
  28. package/src/lib/components/RecordTable.svelte +156 -0
  29. package/src/lib/components/cells/CheckboxCell.svelte +10 -0
  30. package/src/lib/components/cells/ColorCell.svelte +15 -0
  31. package/src/lib/components/cells/DateCell.svelte +8 -0
  32. package/src/lib/components/cells/RelationshipCell.svelte +20 -0
  33. package/src/lib/components/cells/RichTextCell.svelte +21 -0
  34. package/src/lib/components/cells/SelectCell.svelte +26 -0
  35. package/src/lib/components/cells/TextCell.svelte +8 -0
  36. package/src/lib/components/cells/UploadCell.svelte +34 -0
  37. package/src/lib/components/cells/index.ts +28 -0
  38. package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
  39. package/src/lib/components/doc/ApiView.svelte +181 -0
  40. package/src/lib/components/doc/Autosave.svelte +102 -0
  41. package/src/lib/components/doc/DocHeader.svelte +86 -0
  42. package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
  43. package/src/lib/components/doc/DocTabBar.svelte +26 -0
  44. package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
  45. package/src/lib/components/doc/PublishButton.svelte +114 -0
  46. package/src/lib/components/doc/ScheduleModal.svelte +110 -0
  47. package/src/lib/components/doc/VersionHistory.svelte +20 -0
  48. package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
  49. package/src/lib/components/fields/BlockCard.svelte +63 -0
  50. package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
  51. package/src/lib/components/fields/CheckboxField.svelte +27 -0
  52. package/src/lib/components/fields/ColorField.svelte +46 -0
  53. package/src/lib/components/fields/DateField.svelte +52 -0
  54. package/src/lib/components/fields/EmailField.svelte +30 -0
  55. package/src/lib/components/fields/FileField.svelte +280 -0
  56. package/src/lib/components/fields/JsonField.svelte +145 -0
  57. package/src/lib/components/fields/NumberField.svelte +44 -0
  58. package/src/lib/components/fields/PasswordField.svelte +38 -0
  59. package/src/lib/components/fields/RelationshipField.svelte +271 -0
  60. package/src/lib/components/fields/RichTextField.svelte +139 -0
  61. package/src/lib/components/fields/SelectField.svelte +33 -0
  62. package/src/lib/components/fields/SlugField.svelte +70 -0
  63. package/src/lib/components/fields/TabsField.svelte +56 -0
  64. package/src/lib/components/fields/TagsField.svelte +85 -0
  65. package/src/lib/components/fields/TextField.svelte +36 -0
  66. package/src/lib/components/fields/TextareaField.svelte +32 -0
  67. package/src/lib/components/fields/UploadField.svelte +166 -0
  68. package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
  69. package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
  70. package/src/lib/components/fields/index.ts +22 -0
  71. package/src/lib/components/fields/registry.ts +58 -0
  72. package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
  73. package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
  74. package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
  75. package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
  76. package/src/lib/components/lexical/lexical-helpers.ts +24 -0
  77. package/src/lib/components/lexical/nodes.ts +8 -0
  78. package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
  79. package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
  80. package/src/lib/components/versions/CompareSelector.svelte +31 -0
  81. package/src/lib/components/versions/FieldDiff.svelte +141 -0
  82. package/src/lib/components/versions/RestoreModal.svelte +67 -0
  83. package/src/lib/components/versions/StatusPill.svelte +21 -0
  84. package/src/lib/context.svelte.ts +156 -0
  85. package/src/lib/router/index.svelte.ts +282 -0
  86. package/src/lib/router/matcher.ts +52 -0
  87. package/src/lib/stores/branding.svelte.ts +74 -0
  88. package/src/lib/stores/schema.svelte.ts +17 -0
  89. package/src/lib/types/schema.ts +126 -0
  90. package/src/lib/utils/cn.ts +6 -0
  91. package/src/lib/utils/diff.ts +112 -0
  92. package/src/lib/utils/dirty.svelte.ts +50 -0
  93. package/src/lib/utils/format.ts +28 -0
  94. package/src/lib/utils/json-highlight.ts +34 -0
  95. package/src/lib/utils/slug.ts +8 -0
  96. package/src/main.ts +32 -0
  97. package/src/views/AdminLayout.svelte +73 -0
  98. package/src/views/AdsAnalyticsView.svelte +152 -0
  99. package/src/views/CollectionEditView.svelte +117 -0
  100. package/src/views/CollectionListView.svelte +347 -0
  101. package/src/views/CollectionNewView.svelte +68 -0
  102. package/src/views/CustomPageView.svelte +59 -0
  103. package/src/views/DashboardView.svelte +370 -0
  104. package/src/views/GlobalEditView.svelte +100 -0
  105. package/src/views/LoginView.svelte +231 -0
  106. package/src/views/NotFoundView.svelte +9 -0
  107. package/src/views/VersionDetailView.svelte +307 -0
  108. package/src/views/VersionsListView.svelte +201 -0
  109. package/tsconfig.json +25 -0
  110. package/vite.config.ts +80 -0
@@ -0,0 +1,110 @@
1
+ <script lang="ts">
2
+ import { CalendarClock } from 'lucide-svelte'
3
+
4
+ let {
5
+ open = $bindable(false),
6
+ initialValue = '',
7
+ isSubmitting = false,
8
+ onSchedule,
9
+ }: {
10
+ open?: boolean
11
+ initialValue?: string
12
+ isSubmitting?: boolean
13
+ onSchedule: (isoDate: string) => void
14
+ } = $props()
15
+
16
+ // Default: 1 hour from now, formatted for datetime-local input (YYYY-MM-DDTHH:mm)
17
+ function defaultValue(): string {
18
+ const d = new Date(Date.now() + 60 * 60 * 1000)
19
+ const pad = (n: number) => String(n).padStart(2, '0')
20
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
21
+ }
22
+
23
+ // svelte-ignore state_referenced_locally
24
+ let value = $state(initialValue || defaultValue())
25
+ let error = $state('')
26
+
27
+ $effect(() => {
28
+ if (open && !value) value = defaultValue()
29
+ })
30
+
31
+ function handleSubmit() {
32
+ error = ''
33
+ if (!value) {
34
+ error = 'Pick a date and time'
35
+ return
36
+ }
37
+ const picked = new Date(value)
38
+ if (isNaN(picked.getTime())) {
39
+ error = 'Invalid date'
40
+ return
41
+ }
42
+ if (picked.getTime() <= Date.now()) {
43
+ error = 'Time must be in the future'
44
+ return
45
+ }
46
+ onSchedule(picked.toISOString())
47
+ }
48
+
49
+ function close() {
50
+ open = false
51
+ error = ''
52
+ }
53
+ </script>
54
+
55
+ {#if open}
56
+ <div
57
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
58
+ onclick={close}
59
+ role="presentation"
60
+ >
61
+ <div
62
+ class="w-full max-w-md rounded-lg border border-border bg-card shadow-xl"
63
+ onclick={(e) => e.stopPropagation()}
64
+ role="dialog"
65
+ aria-modal="true"
66
+ >
67
+ <div class="flex items-center gap-3 border-b border-border/60 px-5 py-4">
68
+ <div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
69
+ <CalendarClock class="h-5 w-5" />
70
+ </div>
71
+ <div>
72
+ <h2 class="text-sm font-semibold text-foreground">Schedule Publish</h2>
73
+ <p class="text-xs text-muted-foreground">Post will publish automatically at the selected time.</p>
74
+ </div>
75
+ </div>
76
+
77
+ <div class="px-5 py-5 space-y-2">
78
+ <label class="block text-xs font-medium text-foreground" for="schedule-datetime">Publish at</label>
79
+ <input
80
+ id="schedule-datetime"
81
+ type="datetime-local"
82
+ bind:value
83
+ class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
84
+ />
85
+ {#if error}
86
+ <p class="text-xs text-destructive">{error}</p>
87
+ {/if}
88
+ </div>
89
+
90
+ <div class="flex justify-end gap-2 border-t border-border/60 px-5 py-3">
91
+ <button
92
+ type="button"
93
+ onclick={close}
94
+ disabled={isSubmitting}
95
+ class="rounded-lg px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-secondary hover:text-foreground disabled:opacity-50"
96
+ >
97
+ Cancel
98
+ </button>
99
+ <button
100
+ type="button"
101
+ onclick={handleSubmit}
102
+ disabled={isSubmitting}
103
+ class="rounded-lg bg-primary px-4 py-1.5 text-xs font-semibold text-primary-foreground hover:bg-primary/90 disabled:opacity-60"
104
+ >
105
+ {isSubmitting ? 'Scheduling…' : 'Schedule'}
106
+ </button>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ {/if}
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ import { resolve } from '$lib/router/index.svelte.js'
3
+ import { History } from 'lucide-svelte'
4
+
5
+ let {
6
+ collectionKey,
7
+ recordId,
8
+ }: {
9
+ collectionKey: string
10
+ recordId: string
11
+ } = $props()
12
+ </script>
13
+
14
+ <a
15
+ href={resolve(`/${collectionKey}/${recordId}/versions`)}
16
+ class="inline-flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground"
17
+ >
18
+ <History class="h-3.5 w-3.5" />
19
+ View version history
20
+ </a>
@@ -0,0 +1,62 @@
1
+ <script lang="ts">
2
+ import BlockCard from './BlockCard.svelte';
3
+
4
+ interface Props {
5
+ field: { name: string; label?: string; labels?: { singular?: string; plural?: string } };
6
+ value: any[];
7
+ onChange: (next: any[]) => void;
8
+ }
9
+ let { field, value, onChange }: Props = $props();
10
+
11
+ const rows = $derived(Array.isArray(value) ? value : []);
12
+ const singular = $derived(field.labels?.singular ?? 'Item');
13
+
14
+ function genId(): string {
15
+ return crypto.randomUUID(); // browser UUIDv4 — server Sanitize will upgrade to v7 if absent
16
+ }
17
+
18
+ function add() {
19
+ onChange([...rows, { id: genId() }]);
20
+ }
21
+ function update(i: number, next: any) {
22
+ const out = [...rows];
23
+ out[i] = next;
24
+ onChange(out);
25
+ }
26
+ function remove(i: number) {
27
+ onChange(rows.filter((_, j) => j !== i));
28
+ }
29
+ function moveUp(i: number) {
30
+ if (i === 0) return;
31
+ const out = [...rows];
32
+ [out[i - 1], out[i]] = [out[i], out[i - 1]];
33
+ onChange(out);
34
+ }
35
+ function moveDown(i: number) {
36
+ if (i === rows.length - 1) return;
37
+ const out = [...rows];
38
+ [out[i], out[i + 1]] = [out[i + 1], out[i]];
39
+ onChange(out);
40
+ }
41
+ </script>
42
+
43
+ <div class="array-field">
44
+ {#if field.label}<label>{field.label}</label>{/if}
45
+ {#each rows as row, i (row.id)}
46
+ <BlockCard
47
+ title="{singular} #{i + 1}"
48
+ value={row}
49
+ canMoveUp={i > 0}
50
+ canMoveDown={i < rows.length - 1}
51
+ onChange={(next) => update(i, next)}
52
+ onMoveUp={() => moveUp(i)}
53
+ onMoveDown={() => moveDown(i)}
54
+ onDelete={() => remove(i)}
55
+ />
56
+ {/each}
57
+ <button type="button" onclick={add}>+ Add {singular}</button>
58
+ </div>
59
+
60
+ <style>
61
+ .array-field label { display: block; font-weight: 600; margin-bottom: 8px; }
62
+ </style>
@@ -0,0 +1,63 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ title: string; // e.g. "Block #1 · hero"
4
+ value: Record<string, any>;
5
+ canMoveUp: boolean;
6
+ canMoveDown: boolean;
7
+ onChange: (next: Record<string, any>) => void;
8
+ onMoveUp: () => void;
9
+ onMoveDown: () => void;
10
+ onDelete: () => void;
11
+ }
12
+ let { title, value, canMoveUp, canMoveDown, onChange, onMoveUp, onMoveDown, onDelete }: Props = $props();
13
+
14
+ // Strip `id` from the textarea (read-only system field).
15
+ const visible = $derived.by(() => {
16
+ const { id, ...rest } = value;
17
+ return rest;
18
+ });
19
+
20
+ let text = $state(JSON.stringify(visible, null, 2));
21
+ let parseError = $state('');
22
+
23
+ function save() {
24
+ try {
25
+ const parsed = JSON.parse(text);
26
+ parseError = '';
27
+ onChange({ ...parsed, id: value.id });
28
+ } catch (e: any) {
29
+ parseError = e.message;
30
+ }
31
+ }
32
+
33
+ // Re-sync textarea if value changes externally (e.g. reorder)
34
+ $effect(() => {
35
+ text = JSON.stringify(visible, null, 2);
36
+ });
37
+ </script>
38
+
39
+ <div class="card">
40
+ <header>
41
+ <span class="title">{title}</span>
42
+ <div class="actions">
43
+ <button type="button" disabled={!canMoveUp} onclick={onMoveUp} aria-label="Move up">▲</button>
44
+ <button type="button" disabled={!canMoveDown} onclick={onMoveDown} aria-label="Move down">▼</button>
45
+ <button type="button" onclick={onDelete} aria-label="Delete">×</button>
46
+ </div>
47
+ </header>
48
+ <textarea bind:value={text} rows="6" class:error={!!parseError}></textarea>
49
+ {#if parseError}
50
+ <p class="parse-error">{parseError}</p>
51
+ {/if}
52
+ <button type="button" onclick={save}>Save</button>
53
+ </div>
54
+
55
+ <style>
56
+ .card { border: 1px solid #ddd; border-radius: 4px; padding: 12px; margin-bottom: 12px; }
57
+ header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
58
+ .title { font-weight: 600; font-family: monospace; font-size: 13px; }
59
+ .actions button { margin-left: 4px; }
60
+ textarea { width: 100%; font-family: monospace; font-size: 13px; }
61
+ textarea.error { border-color: #c00; }
62
+ .parse-error { color: #c00; font-size: 12px; margin: 4px 0; }
63
+ </style>
@@ -0,0 +1,83 @@
1
+ <script lang="ts">
2
+ import BlockCard from './BlockCard.svelte';
3
+
4
+ interface BlockDef {
5
+ slug: string;
6
+ label: string;
7
+ fields?: any[];
8
+ }
9
+ interface Props {
10
+ field: {
11
+ name: string;
12
+ label?: string;
13
+ blocks: BlockDef[]; // resolved (registry pre-merged)
14
+ allowedBlocks?: string[];
15
+ };
16
+ value: any[];
17
+ onChange: (next: any[]) => void;
18
+ }
19
+ let { field, value, onChange }: Props = $props();
20
+
21
+ const rows = $derived(Array.isArray(value) ? value : []);
22
+ let pickerSlug = $state(field.blocks[0]?.slug ?? '');
23
+
24
+ function genId(): string {
25
+ return crypto.randomUUID();
26
+ }
27
+ function defaultsFor(slug: string): Record<string, any> {
28
+ return { id: genId(), blockType: slug };
29
+ }
30
+ function add() {
31
+ if (!pickerSlug) return;
32
+ onChange([...rows, defaultsFor(pickerSlug)]);
33
+ }
34
+ function update(i: number, next: any) {
35
+ const out = [...rows];
36
+ out[i] = next;
37
+ onChange(out);
38
+ }
39
+ function remove(i: number) {
40
+ onChange(rows.filter((_, j) => j !== i));
41
+ }
42
+ function moveUp(i: number) {
43
+ if (i === 0) return;
44
+ const out = [...rows];
45
+ [out[i - 1], out[i]] = [out[i], out[i - 1]];
46
+ onChange(out);
47
+ }
48
+ function moveDown(i: number) {
49
+ if (i === rows.length - 1) return;
50
+ const out = [...rows];
51
+ [out[i], out[i + 1]] = [out[i + 1], out[i]];
52
+ onChange(out);
53
+ }
54
+ </script>
55
+
56
+ <div class="blocks-field">
57
+ {#if field.label}<label>{field.label}</label>{/if}
58
+ {#each rows as row, i (row.id)}
59
+ <BlockCard
60
+ title="Block #{i + 1} · {row.blockType ?? '?'}"
61
+ value={row}
62
+ canMoveUp={i > 0}
63
+ canMoveDown={i < rows.length - 1}
64
+ onChange={(next) => update(i, next)}
65
+ onMoveUp={() => moveUp(i)}
66
+ onMoveDown={() => moveDown(i)}
67
+ onDelete={() => remove(i)}
68
+ />
69
+ {/each}
70
+ <div class="add-bar">
71
+ <select bind:value={pickerSlug}>
72
+ {#each field.blocks as b}
73
+ <option value={b.slug}>{b.label}</option>
74
+ {/each}
75
+ </select>
76
+ <button type="button" onclick={add}>+ Add Block</button>
77
+ </div>
78
+ </div>
79
+
80
+ <style>
81
+ .blocks-field label { display: block; font-weight: 600; margin-bottom: 8px; }
82
+ .add-bar { display: flex; gap: 8px; margin-top: 8px; }
83
+ </style>
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+
4
+ let {
5
+ field,
6
+ value = $bindable(),
7
+ error,
8
+ }: {
9
+ field: FieldSchema
10
+ value?: boolean
11
+ error?: string
12
+ } = $props()
13
+ </script>
14
+
15
+ <div>
16
+ <label class="flex items-center gap-2 cursor-pointer">
17
+ <input
18
+ type="checkbox"
19
+ bind:checked={value}
20
+ class="h-4 w-4 rounded border accent-primary"
21
+ />
22
+ <span class="text-sm font-medium">{field.label}</span>
23
+ </label>
24
+ {#if error}
25
+ <p class="mt-1 text-xs text-destructive">{error}</p>
26
+ {/if}
27
+ </div>
@@ -0,0 +1,46 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+
4
+ let {
5
+ field,
6
+ value = $bindable(),
7
+ error,
8
+ }: {
9
+ field: FieldSchema
10
+ value?: string
11
+ error?: string
12
+ } = $props()
13
+
14
+ // <input type="color"> requires a valid #rrggbb value
15
+ let colorValue = $derived(value && /^#[0-9a-fA-F]{6}$/.test(value) ? value : '#000000')
16
+
17
+ function handleColorInput(e: Event) {
18
+ value = (e.target as HTMLInputElement).value
19
+ }
20
+ </script>
21
+
22
+ <div>
23
+ <label for={field.name} class="mb-1.5 block text-sm font-medium">
24
+ {field.label}
25
+ {#if field.required}<span class="text-destructive">*</span>{/if}
26
+ </label>
27
+ <div class="flex items-center gap-2">
28
+ <input
29
+ type="color"
30
+ value={colorValue}
31
+ oninput={handleColorInput}
32
+ class="h-10 w-12 cursor-pointer rounded border p-1"
33
+ />
34
+ <input
35
+ id={field.name}
36
+ type="text"
37
+ bind:value
38
+ class="h-10 flex-1 rounded-md border bg-background px-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring {error ? 'border-destructive' : ''}"
39
+ placeholder="#000000"
40
+ maxlength={7}
41
+ />
42
+ </div>
43
+ {#if error}
44
+ <p class="mt-1 text-xs text-destructive">{error}</p>
45
+ {/if}
46
+ </div>
@@ -0,0 +1,52 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+
4
+ let {
5
+ field,
6
+ value = $bindable(),
7
+ error,
8
+ }: {
9
+ field: FieldSchema
10
+ value?: string | null
11
+ error?: string
12
+ } = $props()
13
+
14
+ // Convert stored ISO 8601 string to the YYYY-MM-DDTHH:mm format
15
+ // expected by <input type="datetime-local">.
16
+ function toLocalInput(iso: string | null | undefined): string {
17
+ if (!iso) return ''
18
+ const d = new Date(iso)
19
+ if (isNaN(d.getTime())) return ''
20
+ const pad = (n: number) => String(n).padStart(2, '0')
21
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
22
+ }
23
+
24
+ let inputValue = $derived(toLocalInput(value as any))
25
+
26
+ function onInput(e: Event) {
27
+ const v = (e.target as HTMLInputElement).value
28
+ if (!v) {
29
+ value = null
30
+ return
31
+ }
32
+ const d = new Date(v)
33
+ value = isNaN(d.getTime()) ? null : d.toISOString()
34
+ }
35
+ </script>
36
+
37
+ <div>
38
+ <label for={field.name} class="mb-1.5 block text-sm font-medium">
39
+ {field.label}
40
+ {#if field.required}<span class="text-destructive">*</span>{/if}
41
+ </label>
42
+ <input
43
+ id={field.name}
44
+ type="datetime-local"
45
+ value={inputValue}
46
+ oninput={onInput}
47
+ class="h-10 w-full rounded-md border bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring {error ? 'border-destructive' : ''}"
48
+ />
49
+ {#if error}
50
+ <p class="mt-1 text-xs text-destructive">{error}</p>
51
+ {/if}
52
+ </div>
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+
4
+ let {
5
+ field,
6
+ value = $bindable(),
7
+ error,
8
+ }: {
9
+ field: FieldSchema
10
+ value?: string
11
+ error?: string
12
+ } = $props()
13
+ </script>
14
+
15
+ <div>
16
+ <label for={field.name} class="mb-1.5 block text-sm font-medium">
17
+ {field.label}
18
+ {#if field.required}<span class="text-destructive">*</span>{/if}
19
+ </label>
20
+ <input
21
+ id={field.name}
22
+ type="email"
23
+ bind:value
24
+ class="h-10 w-full rounded-md border bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring {error ? 'border-destructive' : ''}"
25
+ placeholder={field.label}
26
+ />
27
+ {#if error}
28
+ <p class="mt-1 text-xs text-destructive">{error}</p>
29
+ {/if}
30
+ </div>