@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.
- package/LICENSE +661 -0
- package/biome.json +62 -0
- package/dist/assets/index-C9Y5-AKj.js +33 -0
- package/dist/assets/index-uVdiUjty.css +1 -0
- package/dist/index.html +20 -0
- package/index.html +19 -0
- package/package.json +43 -0
- package/src/AdminRoot.svelte +98 -0
- package/src/app.css +211 -0
- package/src/lib/Slot.svelte +65 -0
- package/src/lib/api/auth.ts +26 -0
- package/src/lib/api/client.ts +73 -0
- package/src/lib/api/files.ts +56 -0
- package/src/lib/api/globals.ts +13 -0
- package/src/lib/api/records.ts +102 -0
- package/src/lib/api/schema.ts +7 -0
- package/src/lib/api/versions.ts +40 -0
- package/src/lib/components/AdminHeader.svelte +107 -0
- package/src/lib/components/AdminSidebar.svelte +262 -0
- package/src/lib/components/DeleteDialog.svelte +58 -0
- package/src/lib/components/DocumentEditLayout.svelte +263 -0
- package/src/lib/components/DynamicForm.svelte +74 -0
- package/src/lib/components/KpiCard.svelte +75 -0
- package/src/lib/components/MediaLibrary.svelte +311 -0
- package/src/lib/components/Pagination.svelte +78 -0
- package/src/lib/components/RangeFilter.svelte +41 -0
- package/src/lib/components/RecordGrid.svelte +123 -0
- package/src/lib/components/RecordTable.svelte +156 -0
- package/src/lib/components/cells/CheckboxCell.svelte +10 -0
- package/src/lib/components/cells/ColorCell.svelte +15 -0
- package/src/lib/components/cells/DateCell.svelte +8 -0
- package/src/lib/components/cells/RelationshipCell.svelte +20 -0
- package/src/lib/components/cells/RichTextCell.svelte +21 -0
- package/src/lib/components/cells/SelectCell.svelte +26 -0
- package/src/lib/components/cells/TextCell.svelte +8 -0
- package/src/lib/components/cells/UploadCell.svelte +34 -0
- package/src/lib/components/cells/index.ts +28 -0
- package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
- package/src/lib/components/doc/ApiView.svelte +181 -0
- package/src/lib/components/doc/Autosave.svelte +102 -0
- package/src/lib/components/doc/DocHeader.svelte +86 -0
- package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
- package/src/lib/components/doc/DocTabBar.svelte +26 -0
- package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
- package/src/lib/components/doc/PublishButton.svelte +114 -0
- package/src/lib/components/doc/ScheduleModal.svelte +110 -0
- package/src/lib/components/doc/VersionHistory.svelte +20 -0
- package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
- package/src/lib/components/fields/BlockCard.svelte +63 -0
- package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
- package/src/lib/components/fields/CheckboxField.svelte +27 -0
- package/src/lib/components/fields/ColorField.svelte +46 -0
- package/src/lib/components/fields/DateField.svelte +52 -0
- package/src/lib/components/fields/EmailField.svelte +30 -0
- package/src/lib/components/fields/FileField.svelte +280 -0
- package/src/lib/components/fields/JsonField.svelte +145 -0
- package/src/lib/components/fields/NumberField.svelte +44 -0
- package/src/lib/components/fields/PasswordField.svelte +38 -0
- package/src/lib/components/fields/RelationshipField.svelte +271 -0
- package/src/lib/components/fields/RichTextField.svelte +139 -0
- package/src/lib/components/fields/SelectField.svelte +33 -0
- package/src/lib/components/fields/SlugField.svelte +70 -0
- package/src/lib/components/fields/TabsField.svelte +56 -0
- package/src/lib/components/fields/TagsField.svelte +85 -0
- package/src/lib/components/fields/TextField.svelte +36 -0
- package/src/lib/components/fields/TextareaField.svelte +32 -0
- package/src/lib/components/fields/UploadField.svelte +166 -0
- package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
- package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
- package/src/lib/components/fields/index.ts +22 -0
- package/src/lib/components/fields/registry.ts +58 -0
- package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
- package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
- package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
- package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
- package/src/lib/components/lexical/lexical-helpers.ts +24 -0
- package/src/lib/components/lexical/nodes.ts +8 -0
- package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
- package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
- package/src/lib/components/versions/CompareSelector.svelte +31 -0
- package/src/lib/components/versions/FieldDiff.svelte +141 -0
- package/src/lib/components/versions/RestoreModal.svelte +67 -0
- package/src/lib/components/versions/StatusPill.svelte +21 -0
- package/src/lib/context.svelte.ts +156 -0
- package/src/lib/router/index.svelte.ts +282 -0
- package/src/lib/router/matcher.ts +52 -0
- package/src/lib/stores/branding.svelte.ts +74 -0
- package/src/lib/stores/schema.svelte.ts +17 -0
- package/src/lib/types/schema.ts +126 -0
- package/src/lib/utils/cn.ts +6 -0
- package/src/lib/utils/diff.ts +112 -0
- package/src/lib/utils/dirty.svelte.ts +50 -0
- package/src/lib/utils/format.ts +28 -0
- package/src/lib/utils/json-highlight.ts +34 -0
- package/src/lib/utils/slug.ts +8 -0
- package/src/main.ts +32 -0
- package/src/views/AdminLayout.svelte +73 -0
- package/src/views/AdsAnalyticsView.svelte +152 -0
- package/src/views/CollectionEditView.svelte +117 -0
- package/src/views/CollectionListView.svelte +347 -0
- package/src/views/CollectionNewView.svelte +68 -0
- package/src/views/CustomPageView.svelte +59 -0
- package/src/views/DashboardView.svelte +370 -0
- package/src/views/GlobalEditView.svelte +100 -0
- package/src/views/LoginView.svelte +231 -0
- package/src/views/NotFoundView.svelte +9 -0
- package/src/views/VersionDetailView.svelte +307 -0
- package/src/views/VersionsListView.svelte +201 -0
- package/tsconfig.json +25 -0
- 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>
|