@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,271 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+ import { listRecords, getRecord } from '$lib/api/records.js'
4
+ import { schema, getCollectionByKey } from '$lib/stores/schema.svelte.js'
5
+ import { X, Search } from 'lucide-svelte'
6
+ import { onMount } from 'svelte'
7
+
8
+ let {
9
+ field,
10
+ value = $bindable(),
11
+ error,
12
+ }: {
13
+ field: FieldSchema
14
+ value?: any
15
+ error?: string
16
+ } = $props()
17
+
18
+ let searchQuery = $state('')
19
+ let options = $state<Record<string, any>[]>([])
20
+ let isOpen = $state(false)
21
+ let isLoading = $state(false)
22
+ let searchTimeout: ReturnType<typeof setTimeout>
23
+ let loadedRecord = $state<Record<string, any> | null>(null)
24
+ let loadedRecords = $state<Record<string, any>[]>([])
25
+
26
+ let isManyToMany = $derived(field.relationType === 'manyToMany')
27
+
28
+ // Selected items for ManyToMany
29
+ let selectedItems = $derived.by(() => {
30
+ if (!isManyToMany) return []
31
+ if (Array.isArray(value)) {
32
+ // If we have loaded records, use them
33
+ if (loadedRecords.length > 0) return loadedRecords
34
+ return value
35
+ }
36
+ return []
37
+ })
38
+
39
+ // Load related records if values are just IDs
40
+ onMount(async () => {
41
+ if (!field.relatesTo) return
42
+
43
+ if (!isManyToMany && value && typeof value === 'string') {
44
+ // BelongsTo: single ID
45
+ const result = await getRecord(field.relatesTo, value)
46
+ if (result.ok) {
47
+ loadedRecord = result.data
48
+ }
49
+ } else if (isManyToMany && Array.isArray(value) && value.length > 0) {
50
+ // ManyToMany: array of IDs
51
+ const hasIdStrings = value.some((v) => typeof v === 'string')
52
+ if (hasIdStrings) {
53
+ const ids = value.map((v) => (typeof v === 'string' ? v : v.id))
54
+ const records = await Promise.all(
55
+ ids.map(async (id) => {
56
+ const result = await getRecord(field.relatesTo!, id)
57
+ return result.ok ? result.data : null
58
+ })
59
+ )
60
+ loadedRecords = records.filter((r): r is Record<string, any> => r !== null)
61
+ }
62
+ }
63
+ })
64
+
65
+ // Find the field on the target collection to filter against.
66
+ // Payload pattern: prefer admin.useAsTitle; fall back to common text fields.
67
+ function getSearchField(): string {
68
+ if (!field.relatesTo) return 'name'
69
+ const target = getCollectionByKey(schema.collections, field.relatesTo)
70
+ if (target?.admin?.useAsTitle) return target.admin.useAsTitle
71
+ const fallback = ['name', 'title', 'label', 'slug']
72
+ for (const f of fallback) {
73
+ if (target?.fields?.some((fd: any) => fd.name === f)) return f
74
+ }
75
+ return 'name'
76
+ }
77
+
78
+ async function search(query: string) {
79
+ if (!field.relatesTo) return
80
+ isLoading = true
81
+ const searchField = getSearchField()
82
+ const params: Record<string, any> = {
83
+ perPage: 20,
84
+ // Only select id + useAsTitle — avoids SQL errors when some
85
+ // display-candidate columns don't exist on the target table.
86
+ fields: `id,${searchField}`,
87
+ }
88
+ if (query.trim()) {
89
+ // Escape single quotes to keep filter expression valid
90
+ const safe = query.trim().replace(/'/g, "''")
91
+ params.filter = `${searchField}~'${safe}'`
92
+ }
93
+ const result = await listRecords(field.relatesTo, params)
94
+ isLoading = false
95
+ if (result.ok) {
96
+ options = result.data.records
97
+ }
98
+ }
99
+
100
+ function handleSearch(e: Event) {
101
+ const target = e.target as HTMLInputElement
102
+ searchQuery = target.value
103
+ clearTimeout(searchTimeout)
104
+ searchTimeout = setTimeout(() => search(searchQuery), 300)
105
+ }
106
+
107
+ function openDropdown() {
108
+ isOpen = true
109
+ search(searchQuery)
110
+ }
111
+
112
+ function getDisplayName(item: Record<string, any>): string {
113
+ return item.name || item.title || item.label || item.slug || item.id || '-'
114
+ }
115
+
116
+ function selectItem(item: Record<string, any>) {
117
+ if (isManyToMany) {
118
+ const currentIds = (Array.isArray(value) ? value : []).map((v: any) =>
119
+ typeof v === 'object' ? v.id : v
120
+ )
121
+ if (!currentIds.includes(item.id)) {
122
+ value = [...(Array.isArray(value) ? value : []), item]
123
+ loadedRecords = [...loadedRecords, item]
124
+ }
125
+ } else {
126
+ value = item.id
127
+ loadedRecord = item
128
+ isOpen = false
129
+ }
130
+ searchQuery = ''
131
+ }
132
+
133
+ function removeItem(itemId: string) {
134
+ if (isManyToMany && Array.isArray(value)) {
135
+ value = value.filter((v: any) => {
136
+ const id = typeof v === 'object' ? v.id : v
137
+ return id !== itemId
138
+ })
139
+ // Also remove from loaded records
140
+ loadedRecords = loadedRecords.filter((r) => r.id !== itemId)
141
+ }
142
+ }
143
+
144
+ function clearSelection() {
145
+ value = isManyToMany ? [] : null
146
+ }
147
+
148
+ // Display text for BelongsTo
149
+ let belongsToDisplay = $derived.by(() => {
150
+ if (isManyToMany) return ''
151
+ if (!value) return ''
152
+
153
+ if (typeof value === 'object') return getDisplayName(value)
154
+ // Just an ID - try loaded record first, then options
155
+ if (loadedRecord) return getDisplayName(loadedRecord)
156
+ const found = options.find((o) => o.id === value)
157
+ return found ? getDisplayName(found) : value
158
+ })
159
+ </script>
160
+
161
+ <div>
162
+ <label for={field.name} class="mb-1.5 block text-sm font-medium">
163
+ {field.label}
164
+ {#if field.required}<span class="text-destructive">*</span>{/if}
165
+ </label>
166
+
167
+ {#if isManyToMany}
168
+ <!-- ManyToMany: multi-select combobox (chips inline with search) -->
169
+ <div class="relative">
170
+ <div
171
+ class="flex min-h-10 w-full flex-wrap items-center gap-1 rounded-md border bg-background px-2 py-1.5 text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-ring {error ? 'border-destructive' : ''}"
172
+ >
173
+ {#each selectedItems as item}
174
+ {@const id = typeof item === 'object' ? item.id : item}
175
+ {@const name = typeof item === 'object' ? getDisplayName(item) : item}
176
+ <span class="inline-flex items-center gap-1 rounded-full border bg-secondary px-2 py-0.5 text-xs">
177
+ {name}
178
+ <button type="button" onclick={() => removeItem(id)} class="hover:text-destructive" aria-label="Remove {name}">
179
+ <X class="h-3 w-3" />
180
+ </button>
181
+ </span>
182
+ {/each}
183
+ <input
184
+ type="text"
185
+ value={searchQuery}
186
+ oninput={handleSearch}
187
+ onfocus={openDropdown}
188
+ class="min-w-[120px] flex-1 bg-transparent px-1 py-0.5 text-sm outline-none"
189
+ placeholder={selectedItems.length === 0 ? `Select ${field.relatesTo}...` : 'Add more...'}
190
+ />
191
+ </div>
192
+
193
+ {#if isOpen}
194
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
195
+ <div class="fixed inset-0 z-40" onclick={() => (isOpen = false)} onkeydown={() => {}}></div>
196
+ <div class="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-md border bg-popover shadow-lg">
197
+ {#if isLoading}
198
+ <p class="px-3 py-2 text-sm text-muted-foreground">Loading...</p>
199
+ {:else if options.length === 0}
200
+ <p class="px-3 py-2 text-sm text-muted-foreground">No results</p>
201
+ {:else}
202
+ {#each options as opt}
203
+ <button
204
+ type="button"
205
+ class="flex w-full items-center px-3 py-2 text-left text-sm hover:bg-accent"
206
+ onclick={() => selectItem(opt)}
207
+ >
208
+ {getDisplayName(opt)}
209
+ </button>
210
+ {/each}
211
+ {/if}
212
+ </div>
213
+ {/if}
214
+ </div>
215
+ {:else}
216
+ <!-- BelongsTo: single-select combobox -->
217
+ <div class="relative">
218
+ {#if belongsToDisplay}
219
+ <div
220
+ class="flex h-10 w-full items-center justify-between rounded-md border bg-background px-3 text-sm {error ? 'border-destructive' : ''}"
221
+ >
222
+ <span>{belongsToDisplay}</span>
223
+ <button
224
+ type="button"
225
+ onclick={clearSelection}
226
+ class="text-muted-foreground hover:text-destructive"
227
+ aria-label="Clear selection"
228
+ >
229
+ <X class="h-4 w-4" />
230
+ </button>
231
+ </div>
232
+ {:else}
233
+ <Search class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
234
+ <input
235
+ type="text"
236
+ value={searchQuery}
237
+ oninput={handleSearch}
238
+ onfocus={openDropdown}
239
+ class="h-10 w-full rounded-md border bg-background pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring {error ? 'border-destructive' : ''}"
240
+ placeholder="Select {field.relatesTo}..."
241
+ />
242
+ {/if}
243
+
244
+ {#if isOpen && !belongsToDisplay}
245
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
246
+ <div class="fixed inset-0 z-40" onclick={() => (isOpen = false)} onkeydown={() => {}}></div>
247
+ <div class="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-md border bg-popover shadow-lg">
248
+ {#if isLoading}
249
+ <p class="px-3 py-2 text-sm text-muted-foreground">Loading...</p>
250
+ {:else if options.length === 0}
251
+ <p class="px-3 py-2 text-sm text-muted-foreground">No results</p>
252
+ {:else}
253
+ {#each options as opt}
254
+ <button
255
+ type="button"
256
+ class="flex w-full items-center px-3 py-2 text-left text-sm hover:bg-accent"
257
+ onclick={() => selectItem(opt)}
258
+ >
259
+ {getDisplayName(opt)}
260
+ </button>
261
+ {/each}
262
+ {/if}
263
+ </div>
264
+ {/if}
265
+ </div>
266
+ {/if}
267
+
268
+ {#if error}
269
+ <p class="mt-1 text-xs text-destructive">{error}</p>
270
+ {/if}
271
+ </div>
@@ -0,0 +1,139 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js';
3
+ import {
4
+ Composer,
5
+ ContentEditable,
6
+ RichTextPlugin,
7
+ HistoryPlugin,
8
+ ListPlugin,
9
+ LinkPlugin,
10
+ AutoFocusPlugin,
11
+ OnChangePlugin,
12
+ HeadingNode,
13
+ QuoteNode,
14
+ ListNode,
15
+ ListItemNode,
16
+ AutoLinkNode,
17
+ LinkNode,
18
+ CodeNode,
19
+ CodeHighlightNode,
20
+ ImageNode,
21
+ YouTubeNode,
22
+ } from 'svelte-lexical';
23
+ import type { EditorState, LexicalEditor, EditorThemeClasses } from 'lexical';
24
+ import { customNodes } from '../lexical/nodes.js';
25
+ import EditorToolbar from '../lexical/toolbar/EditorToolbar.svelte';
26
+
27
+ let {
28
+ field,
29
+ value = $bindable(),
30
+ error,
31
+ }: {
32
+ field: FieldSchema;
33
+ value?: any;
34
+ error?: string;
35
+ } = $props();
36
+
37
+ // Parse initial editor state from value
38
+ function getInitialEditorState(): string | undefined {
39
+ if (!value) return undefined;
40
+ if (typeof value === 'string') {
41
+ try {
42
+ JSON.parse(value);
43
+ return value;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ if (typeof value === 'object') {
49
+ return JSON.stringify(value);
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ const initialEditorState = getInitialEditorState();
55
+
56
+ const theme: EditorThemeClasses = {
57
+ paragraph: 'mb-2',
58
+ heading: {
59
+ h1: 'text-2xl font-bold mb-3',
60
+ h2: 'text-xl font-bold mb-2',
61
+ h3: 'text-lg font-semibold mb-2',
62
+ },
63
+ list: {
64
+ ul: 'list-disc pl-6 mb-2',
65
+ ol: 'list-decimal pl-6 mb-2',
66
+ listitem: 'mb-1',
67
+ nested: {
68
+ listitem: 'list-none',
69
+ },
70
+ },
71
+ quote: 'border-l-4 border-muted-foreground/30 pl-4 italic mb-2',
72
+ code: 'bg-muted rounded px-1 py-0.5 font-mono text-sm block mb-2',
73
+ link: 'text-primary underline',
74
+ text: {
75
+ bold: 'font-bold',
76
+ italic: 'italic',
77
+ underline: 'underline',
78
+ strikethrough: 'line-through',
79
+ code: 'bg-muted rounded px-1 font-mono text-sm',
80
+ },
81
+ };
82
+
83
+ // svelte-ignore state_referenced_locally
84
+ const initialConfig = {
85
+ namespace: field.name,
86
+ nodes: [
87
+ HeadingNode,
88
+ QuoteNode,
89
+ ListNode,
90
+ ListItemNode,
91
+ AutoLinkNode,
92
+ LinkNode,
93
+ CodeNode,
94
+ CodeHighlightNode,
95
+ ImageNode,
96
+ YouTubeNode,
97
+ ...customNodes,
98
+ ],
99
+ theme,
100
+ onError: (error: Error) => console.error('[Lexical]', error),
101
+ editorState: initialEditorState,
102
+ };
103
+
104
+ function handleOnChange(editorState: EditorState, _editor: LexicalEditor) {
105
+ const json = editorState.toJSON();
106
+ value = JSON.stringify(json);
107
+ }
108
+ </script>
109
+
110
+ <div>
111
+ <!-- svelte-ignore a11y_label_has_associated_control -->
112
+ <label class="mb-1.5 block text-sm font-medium">
113
+ {field.label}
114
+ {#if field.required}<span class="text-destructive">*</span>{/if}
115
+ </label>
116
+
117
+ <div class="rounded-md border {error ? 'border-destructive' : ''}">
118
+ <Composer {initialConfig}>
119
+ <EditorToolbar />
120
+ <div class="relative">
121
+ <RichTextPlugin />
122
+ <ContentEditable className="max-w-none px-4 py-3 min-h-[200px] focus:outline-none" />
123
+ </div>
124
+ <HistoryPlugin />
125
+ <ListPlugin />
126
+ <LinkPlugin />
127
+ <AutoFocusPlugin />
128
+ <OnChangePlugin
129
+ onChange={handleOnChange}
130
+ ignoreHistoryMergeTagChange={true}
131
+ ignoreSelectionChange={true}
132
+ />
133
+ </Composer>
134
+ </div>
135
+
136
+ {#if error}
137
+ <p class="mt-1 text-xs text-destructive">{error}</p>
138
+ {/if}
139
+ </div>
@@ -0,0 +1,33 @@
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
+ <select
21
+ id={field.name}
22
+ bind:value
23
+ 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' : ''}"
24
+ >
25
+ <option value="">Select {field.label}</option>
26
+ {#each field.options || [] as opt}
27
+ <option value={opt.value}>{opt.label || opt.value}</option>
28
+ {/each}
29
+ </select>
30
+ {#if error}
31
+ <p class="mt-1 text-xs text-destructive">{error}</p>
32
+ {/if}
33
+ </div>
@@ -0,0 +1,70 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+ import { slugify } from '$lib/utils/slug.js'
4
+ import { Lock, Unlock } from 'lucide-svelte'
5
+
6
+ let {
7
+ field,
8
+ value = $bindable(),
9
+ error,
10
+ formData = {},
11
+ }: {
12
+ field: FieldSchema
13
+ value?: string
14
+ error?: string
15
+ formData: Record<string, any>
16
+ } = $props()
17
+
18
+ let isLocked = $state(true)
19
+
20
+ // Auto-generate slug from source field
21
+ $effect(() => {
22
+ if (isLocked && field.fromField && formData[field.fromField]) {
23
+ value = slugify(String(formData[field.fromField]))
24
+ }
25
+ })
26
+
27
+ function toggleLock() {
28
+ isLocked = !isLocked
29
+ if (isLocked && field.fromField && formData[field.fromField]) {
30
+ value = slugify(String(formData[field.fromField]))
31
+ }
32
+ }
33
+ </script>
34
+
35
+ <div>
36
+ <label for={field.name} class="mb-1.5 block text-sm font-medium">
37
+ {field.label}
38
+ {#if field.required}<span class="text-destructive">*</span>{/if}
39
+ </label>
40
+ <div class="flex items-center gap-2">
41
+ <input
42
+ id={field.name}
43
+ type="text"
44
+ bind:value
45
+ disabled={isLocked}
46
+ class="h-10 flex-1 rounded-md border bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60 {error ? 'border-destructive' : ''}"
47
+ placeholder={field.label}
48
+ />
49
+ <button
50
+ type="button"
51
+ onclick={toggleLock}
52
+ class="flex h-10 w-10 items-center justify-center rounded-md border hover:bg-accent"
53
+ title={isLocked ? 'Edit manually' : 'Auto-generate'}
54
+ >
55
+ {#if isLocked}
56
+ <Lock class="h-4 w-4" />
57
+ {:else}
58
+ <Unlock class="h-4 w-4" />
59
+ {/if}
60
+ </button>
61
+ </div>
62
+ {#if field.fromField}
63
+ <p class="mt-1 text-xs text-muted-foreground">
64
+ {isLocked ? 'Auto-generated from ' + field.fromField : 'Manual editing enabled'}
65
+ </p>
66
+ {/if}
67
+ {#if error}
68
+ <p class="mt-1 text-xs text-destructive">{error}</p>
69
+ {/if}
70
+ </div>
@@ -0,0 +1,56 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema, TabsFieldSchema } from '$lib/types/schema.js'
3
+ import { resolveFieldComponent } from './registry.js'
4
+ import type { Snippet } from 'svelte'
5
+
6
+ let {
7
+ field,
8
+ formData = $bindable({}),
9
+ errors = {},
10
+ children,
11
+ }: {
12
+ field: TabsFieldSchema
13
+ formData?: Record<string, any>
14
+ errors?: Record<string, string>
15
+ children?: Snippet
16
+ } = $props()
17
+
18
+ let activeTab = $state(0)
19
+ </script>
20
+
21
+ <div class="rounded-lg border border-border/60 bg-card">
22
+ <div class="flex border-b border-border/60">
23
+ {#each field.tabs as tab, i}
24
+ <button
25
+ type="button"
26
+ onclick={() => (activeTab = i)}
27
+ class="px-4 py-2.5 text-sm font-medium transition-colors {activeTab === i
28
+ ? 'border-b-2 border-primary text-foreground'
29
+ : 'text-muted-foreground hover:text-foreground'}"
30
+ >
31
+ {tab.label}
32
+ </button>
33
+ {/each}
34
+ </div>
35
+
36
+ <div class="space-y-4 p-4">
37
+ {#if children}
38
+ {@render children()}
39
+ {:else if field.tabs[activeTab]}
40
+ {#each field.tabs[activeTab].fields as child (child.name)}
41
+ {@const Component = resolveFieldComponent(child)}
42
+ {#if Component}
43
+ <Component
44
+ field={child}
45
+ bind:value={formData[child.name]}
46
+ error={errors[child.name]}
47
+ />
48
+ {:else}
49
+ <div class="text-xs text-muted-foreground">
50
+ Unsupported field type: {child.type}
51
+ </div>
52
+ {/if}
53
+ {/each}
54
+ {/if}
55
+ </div>
56
+ </div>
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+ import { X } from 'lucide-svelte'
4
+
5
+ let {
6
+ field,
7
+ value = $bindable(),
8
+ error,
9
+ }: {
10
+ field: FieldSchema
11
+ value?: string[]
12
+ error?: string
13
+ } = $props()
14
+
15
+ let tags = $derived(Array.isArray(value) ? value : [])
16
+ let inputValue = $state('')
17
+
18
+ function addTag(raw: string) {
19
+ const trimmed = raw.trim()
20
+ if (!trimmed) return
21
+ if (tags.includes(trimmed)) return
22
+ if (field.maxItems && tags.length >= field.maxItems) return
23
+ value = [...tags, trimmed]
24
+ inputValue = ''
25
+ }
26
+
27
+ function removeTag(index: number) {
28
+ value = tags.filter((_, i) => i !== index)
29
+ }
30
+
31
+ function handleKeydown(e: KeyboardEvent) {
32
+ if (e.key === 'Enter' || e.key === ',') {
33
+ e.preventDefault()
34
+ addTag(inputValue)
35
+ }
36
+ if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
37
+ removeTag(tags.length - 1)
38
+ }
39
+ }
40
+
41
+ function handlePaste(e: ClipboardEvent) {
42
+ const text = e.clipboardData?.getData('text')
43
+ if (text?.includes(',')) {
44
+ e.preventDefault()
45
+ const parts = text.split(',')
46
+ for (const part of parts) {
47
+ addTag(part)
48
+ }
49
+ }
50
+ }
51
+ </script>
52
+
53
+ <div>
54
+ <label for={field.name} class="mb-1.5 block text-sm font-medium">
55
+ {field.label}
56
+ {#if field.required}<span class="text-destructive">*</span>{/if}
57
+ </label>
58
+ <div
59
+ class="flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-md border bg-background px-3 py-1.5 focus-within:ring-2 focus-within:ring-ring {error ? 'border-destructive' : ''}"
60
+ >
61
+ {#each tags as tag, i}
62
+ <span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
63
+ {tag}
64
+ <button type="button" onclick={() => removeTag(i)} class="hover:text-destructive">
65
+ <X class="h-3 w-3" />
66
+ </button>
67
+ </span>
68
+ {/each}
69
+ <input
70
+ id={field.name}
71
+ type="text"
72
+ bind:value={inputValue}
73
+ onkeydown={handleKeydown}
74
+ onpaste={handlePaste}
75
+ class="min-w-[120px] flex-1 bg-transparent py-1 text-sm outline-none placeholder:text-muted-foreground"
76
+ placeholder={tags.length === 0 ? `Add ${field.label.toLowerCase()}...` : 'Add more...'}
77
+ />
78
+ </div>
79
+ {#if field.maxItems}
80
+ <p class="mt-1 text-xs text-muted-foreground">{tags.length}/{field.maxItems} items</p>
81
+ {/if}
82
+ {#if error}
83
+ <p class="mt-1 text-xs text-destructive">{error}</p>
84
+ {/if}
85
+ </div>
@@ -0,0 +1,36 @@
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="text"
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
+ minlength={field.minLength}
27
+ maxlength={field.maxLength}
28
+ pattern={field.pattern}
29
+ />
30
+ {#if field.maxLength}
31
+ <p class="mt-1 text-xs text-muted-foreground">Max {field.maxLength} characters</p>
32
+ {/if}
33
+ {#if error}
34
+ <p class="mt-1 text-xs text-destructive">{error}</p>
35
+ {/if}
36
+ </div>