@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,32 @@
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
+ <textarea
21
+ id={field.name}
22
+ bind:value
23
+ rows={4}
24
+ class="w-full rounded-md border bg-background px-3 py-2 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
+ ></textarea>
29
+ {#if error}
30
+ <p class="mt-1 text-xs text-destructive">{error}</p>
31
+ {/if}
32
+ </div>
@@ -0,0 +1,166 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+ import { uploadToCollection, type UploadRecord } from '$lib/api/files.js'
4
+ import { getRecord } from '$lib/api/records.js'
5
+ import { formatFileSize } from '$lib/utils/format.js'
6
+ import { toast } from 'svelte-sonner'
7
+ import { Eye, Upload, X } from 'lucide-svelte'
8
+ import { onMount } from 'svelte'
9
+
10
+ let {
11
+ field,
12
+ value = $bindable(),
13
+ error,
14
+ }: {
15
+ field: FieldSchema
16
+ value?: string | UploadRecord | null
17
+ error?: string
18
+ } = $props()
19
+
20
+ let isUploading = $state(false)
21
+ let resolved = $state<UploadRecord | null>(null)
22
+ let fileInput: HTMLInputElement
23
+ let previewOpen = $state(false)
24
+
25
+ /**
26
+ * Resolve the field value (UUID string or already-expanded record) into a
27
+ * UploadRecord we can render previews from. Hits GET /api/collections/{relatesTo}/records/{id}.
28
+ */
29
+ async function resolveValue(): Promise<void> {
30
+ if (!value) {
31
+ resolved = null
32
+ return
33
+ }
34
+ if (typeof value === 'object' && value && 'url' in value) {
35
+ resolved = value as UploadRecord
36
+ return
37
+ }
38
+ if (typeof value === 'string' && field.relatesTo) {
39
+ const result = await getRecord(field.relatesTo, value)
40
+ if (result.ok) {
41
+ resolved = result.data as UploadRecord
42
+ } else {
43
+ resolved = null
44
+ }
45
+ }
46
+ }
47
+
48
+ onMount(() => {
49
+ resolveValue()
50
+ })
51
+ $effect(() => {
52
+ // Re-resolve when the value or relatesTo changes externally.
53
+ resolveValue()
54
+ })
55
+
56
+ function isImage(mimeType?: string): boolean {
57
+ return !!mimeType && mimeType.startsWith('image/')
58
+ }
59
+
60
+ async function handleUpload(e: Event): Promise<void> {
61
+ const target = e.target as HTMLInputElement
62
+ const file = target.files?.[0]
63
+ if (!file) return
64
+ if (!field.relatesTo) {
65
+ toast.error('field.Upload missing relatesTo')
66
+ return
67
+ }
68
+ isUploading = true
69
+ const result = await uploadToCollection(field.relatesTo, file)
70
+ isUploading = false
71
+ target.value = ''
72
+ if (result.ok) {
73
+ value = result.data.id
74
+ resolved = result.data
75
+ toast.success(`Uploaded ${file.name}`)
76
+ } else {
77
+ toast.error(`Upload failed: ${result.error}`)
78
+ }
79
+ }
80
+
81
+ function clearValue(): void {
82
+ value = null
83
+ resolved = null
84
+ }
85
+
86
+ function openPreview(): void {
87
+ previewOpen = true
88
+ }
89
+ function closePreview(): void {
90
+ previewOpen = false
91
+ }
92
+ </script>
93
+
94
+ <div>
95
+ <!-- svelte-ignore a11y_label_has_associated_control -->
96
+ <label class="mb-1.5 block text-sm font-medium">
97
+ {field.label}
98
+ {#if field.required}<span class="text-destructive">*</span>{/if}
99
+ </label>
100
+
101
+ {#if resolved}
102
+ <div class="mb-2 flex items-start gap-3 rounded-md border bg-muted/30 p-2">
103
+ {#if isImage(resolved.mimeType) && resolved.url}
104
+ <button
105
+ type="button"
106
+ onclick={openPreview}
107
+ class="group relative h-16 w-16 shrink-0 overflow-hidden rounded"
108
+ >
109
+ <img src={resolved.url} alt={(resolved.alt as string | undefined) ?? resolved.filename} class="h-full w-full object-cover" />
110
+ <div class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
111
+ <Eye class="h-5 w-5 text-white" />
112
+ </div>
113
+ </button>
114
+ {:else}
115
+ <div class="flex h-16 w-16 shrink-0 items-center justify-center rounded bg-muted text-lg">📄</div>
116
+ {/if}
117
+ <div class="flex-1 space-y-1 text-xs">
118
+ <p class="font-medium">{resolved.filename}</p>
119
+ {#if typeof resolved.size === 'number'}
120
+ <p class="text-muted-foreground">{formatFileSize(resolved.size)}</p>
121
+ {/if}
122
+ </div>
123
+ <button
124
+ type="button"
125
+ onclick={clearValue}
126
+ class="shrink-0 rounded p-1 text-muted-foreground hover:text-destructive"
127
+ aria-label="Clear"
128
+ >
129
+ <X class="h-4 w-4" />
130
+ </button>
131
+ </div>
132
+ {:else}
133
+ <button
134
+ type="button"
135
+ onclick={() => fileInput.click()}
136
+ disabled={isUploading}
137
+ class="inline-flex h-10 items-center gap-2 rounded-md border bg-background px-4 text-sm hover:bg-accent disabled:opacity-50 {error ? 'border-destructive' : ''}"
138
+ >
139
+ <Upload class="h-4 w-4" />
140
+ {isUploading ? 'Uploading…' : 'Upload File'}
141
+ </button>
142
+ {/if}
143
+
144
+ <input
145
+ bind:this={fileInput}
146
+ type="file"
147
+ class="hidden"
148
+ onchange={handleUpload}
149
+ />
150
+
151
+ {#if error}
152
+ <p class="mt-1 text-xs text-destructive">{error}</p>
153
+ {/if}
154
+ </div>
155
+
156
+ {#if previewOpen && resolved && resolved.url}
157
+ <div
158
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
159
+ onclick={closePreview}
160
+ role="button"
161
+ tabindex="0"
162
+ onkeydown={(e) => e.key === 'Escape' && closePreview()}
163
+ >
164
+ <img src={resolved.url} alt={(resolved.alt as string | undefined) ?? resolved.filename} class="max-h-[90vh] max-w-[90vw] rounded object-contain" />
165
+ </div>
166
+ {/if}
@@ -0,0 +1,21 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+ import UploadField from './UploadField.svelte'
4
+ import UploadGalleryField from './UploadGalleryField.svelte'
5
+
6
+ let {
7
+ field,
8
+ value = $bindable(),
9
+ error,
10
+ }: {
11
+ field: FieldSchema
12
+ value?: any
13
+ error?: string
14
+ } = $props()
15
+ </script>
16
+
17
+ {#if field.relationType === 'manyToMany'}
18
+ <UploadGalleryField {field} bind:value {error} />
19
+ {:else}
20
+ <UploadField {field} bind:value {error} />
21
+ {/if}
@@ -0,0 +1,166 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+ import { uploadToCollection, type UploadRecord } from '$lib/api/files.js'
4
+ import { listRecords } from '$lib/api/records.js'
5
+ import { toast } from 'svelte-sonner'
6
+ import { ChevronDown, ChevronUp, Upload, X } from 'lucide-svelte'
7
+ import { onMount } from 'svelte'
8
+
9
+ let {
10
+ field,
11
+ value = $bindable(),
12
+ error,
13
+ }: {
14
+ field: FieldSchema
15
+ value?: Array<string | UploadRecord> | null
16
+ error?: string
17
+ } = $props()
18
+
19
+ let isUploading = $state(false)
20
+ let resolved = $state<UploadRecord[]>([])
21
+ let fileInput: HTMLInputElement
22
+
23
+ /**
24
+ * Resolve the bound value (array of UUID strings or expanded records) into
25
+ * a UploadRecord[] in the same order the user has chosen. We bulk-fetch via
26
+ * `id='X' || id='Y' || ...` because quoin's filter parser supports that
27
+ * disjunction shape (mirrors how relationship gallery does it).
28
+ */
29
+ async function resolveValues(): Promise<void> {
30
+ const arr = Array.isArray(value) ? value : []
31
+ if (arr.length === 0 || !field.relatesTo) {
32
+ resolved = []
33
+ return
34
+ }
35
+ const ids = arr.map((v) => (typeof v === 'object' && v ? v.id : (v as string)))
36
+ const filter = ids.map((id) => `id='${id.replace(/'/g, "''")}'`).join(' || ')
37
+ const result = await listRecords(field.relatesTo, {
38
+ filter,
39
+ perPage: ids.length,
40
+ })
41
+ if (!result.ok) {
42
+ resolved = []
43
+ return
44
+ }
45
+ const map = new Map<string, UploadRecord>()
46
+ for (const r of result.data.records as UploadRecord[]) {
47
+ map.set(r.id, r)
48
+ }
49
+ resolved = ids
50
+ .map((id) => map.get(id))
51
+ .filter((r): r is UploadRecord => r !== undefined)
52
+ }
53
+
54
+ onMount(() => {
55
+ resolveValues()
56
+ })
57
+ $effect(() => {
58
+ resolveValues()
59
+ })
60
+
61
+ async function handleUpload(e: Event): Promise<void> {
62
+ const target = e.target as HTMLInputElement
63
+ const files = target.files
64
+ if (!files?.length) return
65
+ if (!field.relatesTo) {
66
+ toast.error('field.Upload missing relatesTo')
67
+ return
68
+ }
69
+ isUploading = true
70
+ try {
71
+ // Sequential upload to keep ordering deterministic.
72
+ for (const file of files) {
73
+ const result = await uploadToCollection(field.relatesTo, file)
74
+ if (result.ok) {
75
+ value = [...(Array.isArray(value) ? value : []), result.data.id]
76
+ resolved = [...resolved, result.data]
77
+ toast.success(`Uploaded ${file.name}`)
78
+ } else {
79
+ toast.error(`Upload failed for ${file.name}: ${result.error}`)
80
+ }
81
+ }
82
+ } finally {
83
+ isUploading = false
84
+ target.value = ''
85
+ }
86
+ }
87
+
88
+ function removeAt(i: number): void {
89
+ if (!Array.isArray(value)) return
90
+ value = value.filter((_, idx) => idx !== i)
91
+ resolved = resolved.filter((_, idx) => idx !== i)
92
+ }
93
+
94
+ function moveUp(i: number): void {
95
+ if (!Array.isArray(value) || i === 0) return
96
+ const v = [...value]
97
+ ;[v[i - 1], v[i]] = [v[i], v[i - 1]]
98
+ value = v
99
+ const r = [...resolved]
100
+ ;[r[i - 1], r[i]] = [r[i], r[i - 1]]
101
+ resolved = r
102
+ }
103
+
104
+ function moveDown(i: number): void {
105
+ if (!Array.isArray(value) || i === value.length - 1) return
106
+ const v = [...value]
107
+ ;[v[i], v[i + 1]] = [v[i + 1], v[i]]
108
+ value = v
109
+ const r = [...resolved]
110
+ ;[r[i], r[i + 1]] = [r[i + 1], r[i]]
111
+ resolved = r
112
+ }
113
+
114
+ function isImage(mimeType?: string): boolean {
115
+ return !!mimeType && mimeType.startsWith('image/')
116
+ }
117
+ </script>
118
+
119
+ <div>
120
+ <!-- svelte-ignore a11y_label_has_associated_control -->
121
+ <label class="mb-1.5 block text-sm font-medium">
122
+ {field.label}
123
+ {#if field.required}<span class="text-destructive">*</span>{/if}
124
+ </label>
125
+
126
+ {#if resolved.length > 0}
127
+ <div class="mb-2 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
128
+ {#each resolved as item, i (item.id)}
129
+ <div class="group relative aspect-square overflow-hidden rounded border">
130
+ {#if isImage(item.mimeType) && item.url}
131
+ <img src={item.url} alt={(item.alt as string | undefined) ?? item.filename} class="h-full w-full object-cover" />
132
+ {:else}
133
+ <div class="flex h-full w-full items-center justify-center bg-muted text-2xl">📄</div>
134
+ {/if}
135
+ <div class="absolute inset-x-0 bottom-0 flex justify-between bg-black/60 opacity-0 transition-opacity group-hover:opacity-100">
136
+ <button type="button" onclick={() => moveUp(i)} class="p-1 text-white" aria-label="Move up">
137
+ <ChevronUp class="h-3 w-3" />
138
+ </button>
139
+ <button type="button" onclick={() => moveDown(i)} class="p-1 text-white" aria-label="Move down">
140
+ <ChevronDown class="h-3 w-3" />
141
+ </button>
142
+ <button type="button" onclick={() => removeAt(i)} class="p-1 text-white" aria-label="Remove">
143
+ <X class="h-3 w-3" />
144
+ </button>
145
+ </div>
146
+ </div>
147
+ {/each}
148
+ </div>
149
+ {/if}
150
+
151
+ <button
152
+ type="button"
153
+ onclick={() => fileInput.click()}
154
+ disabled={isUploading}
155
+ class="inline-flex h-10 items-center gap-2 rounded-md border bg-background px-4 text-sm hover:bg-accent disabled:opacity-50 {error ? 'border-destructive' : ''}"
156
+ >
157
+ <Upload class="h-4 w-4" />
158
+ {isUploading ? 'Uploading…' : resolved.length > 0 ? 'Add Files' : 'Upload Files'}
159
+ </button>
160
+
161
+ <input bind:this={fileInput} type="file" multiple class="hidden" onchange={handleUpload} />
162
+
163
+ {#if error}
164
+ <p class="mt-1 text-xs text-destructive">{error}</p>
165
+ {/if}
166
+ </div>
@@ -0,0 +1,22 @@
1
+ import TabsField from './TabsField.svelte'
2
+ import { fieldComponents, resolveFieldComponent } from './registry.js'
3
+
4
+ fieldComponents.tabs = TabsField
5
+
6
+ export { fieldComponents, resolveFieldComponent }
7
+ export { default as PasswordField } from './PasswordField.svelte'
8
+ export { default as TextField } from './TextField.svelte'
9
+ export { default as TextareaField } from './TextareaField.svelte'
10
+ export { default as NumberField } from './NumberField.svelte'
11
+ export { default as EmailField } from './EmailField.svelte'
12
+ export { default as SelectField } from './SelectField.svelte'
13
+ export { default as CheckboxField } from './CheckboxField.svelte'
14
+ export { default as DateField } from './DateField.svelte'
15
+ export { default as RichTextField } from './RichTextField.svelte'
16
+ export { default as RelationshipField } from './RelationshipField.svelte'
17
+ export { default as UploadField } from './UploadField.svelte'
18
+ export { default as SlugField } from './SlugField.svelte'
19
+ export { default as ColorField } from './ColorField.svelte'
20
+ export { default as JsonField } from './JsonField.svelte'
21
+ export { default as TagsField } from './TagsField.svelte'
22
+ export { TabsField }
@@ -0,0 +1,58 @@
1
+ import type { Component } from 'svelte'
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+ import TextField from './TextField.svelte'
4
+ import TextareaField from './TextareaField.svelte'
5
+ import NumberField from './NumberField.svelte'
6
+ import EmailField from './EmailField.svelte'
7
+ import SelectField from './SelectField.svelte'
8
+ import CheckboxField from './CheckboxField.svelte'
9
+ import DateField from './DateField.svelte'
10
+ import RichTextField from './RichTextField.svelte'
11
+ import RelationshipField from './RelationshipField.svelte'
12
+ import FileField from './FileField.svelte'
13
+ import UploadFieldDispatch from './UploadFieldDispatch.svelte'
14
+ import SlugField from './SlugField.svelte'
15
+ import ColorField from './ColorField.svelte'
16
+ import JsonField from './JsonField.svelte'
17
+ import TagsField from './TagsField.svelte'
18
+ import ArrayFieldEditor from './ArrayFieldEditor.svelte'
19
+ import BlocksFieldEditor from './BlocksFieldEditor.svelte'
20
+ import PasswordField from './PasswordField.svelte'
21
+
22
+ export const fieldComponents: Record<string, Component<any>> = {
23
+ text: TextField,
24
+ textarea: TextareaField,
25
+ number: NumberField,
26
+ email: EmailField,
27
+ select: SelectField,
28
+ checkbox: CheckboxField,
29
+ date: DateField,
30
+ richtext: RichTextField,
31
+ relationship: RelationshipField,
32
+ file: FileField,
33
+ upload: UploadFieldDispatch,
34
+ slug: SlugField,
35
+ color: ColorField,
36
+ json: JsonField,
37
+ tags: TagsField,
38
+ array: ArrayFieldEditor,
39
+ blocks: BlocksFieldEditor,
40
+ url: TextField,
41
+ uuid: TextField,
42
+ }
43
+
44
+ /**
45
+ * Resolve a field schema to its renderer component.
46
+ *
47
+ * Type-aware dispatch: text fields whose `variant` discriminator is set route
48
+ * to specialised components (e.g. `variant === 'password'` → PasswordField).
49
+ * All other types fall back to the flat `fieldComponents` map.
50
+ *
51
+ * Phase 21 D-06 — pairs with field.Text{Type:"password"} on the Go side.
52
+ */
53
+ export function resolveFieldComponent(
54
+ field: Pick<FieldSchema, 'type' | 'variant'>,
55
+ ): Component<any> | undefined {
56
+ if (field.type === 'text' && field.variant === 'password') return PasswordField
57
+ return fieldComponents[field.type]
58
+ }
@@ -0,0 +1,52 @@
1
+ <script lang="ts">
2
+ import { getEditor } from 'svelte-lexical';
3
+ import { getNodeByKey, isCustomHTMLNode } from './lexical-helpers.js';
4
+
5
+ let { html = '', nodeKey = '' } = $props();
6
+
7
+ let showPreview = $state(false);
8
+ // svelte-ignore state_referenced_locally
9
+ let editHtml = $state(html);
10
+
11
+ const editor = getEditor();
12
+
13
+ function updateNode() {
14
+ editor.update(() => {
15
+ const node = getNodeByKey(nodeKey);
16
+ if (isCustomHTMLNode(node)) {
17
+ node.setHtml(editHtml);
18
+ }
19
+ });
20
+ }
21
+
22
+ function handleInput() {
23
+ updateNode();
24
+ }
25
+ </script>
26
+
27
+ <div class="border border-dashed border-muted-foreground/50 rounded p-3 my-2">
28
+ <div class="flex items-center justify-between mb-2">
29
+ <span class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Custom HTML</span>
30
+ <button
31
+ type="button"
32
+ class="text-xs px-2 py-0.5 rounded bg-muted hover:bg-accent"
33
+ onclick={() => (showPreview = !showPreview)}
34
+ >
35
+ {showPreview ? 'Edit' : 'Preview'}
36
+ </button>
37
+ </div>
38
+
39
+ {#if showPreview}
40
+ <div class="border rounded p-2 bg-background min-h-[60px]">
41
+ {@html html}
42
+ </div>
43
+ {:else}
44
+ <textarea
45
+ class="w-full min-h-[80px] font-mono text-sm bg-muted/30 rounded p-2 border-none resize-y focus:outline-none focus:ring-1 focus:ring-primary"
46
+ bind:value={editHtml}
47
+ oninput={handleInput}
48
+ placeholder="Enter HTML code..."
49
+ spellcheck="false"
50
+ ></textarea>
51
+ {/if}
52
+ </div>
@@ -0,0 +1,94 @@
1
+ import { DecoratorNode } from 'lexical';
2
+ import type {
3
+ EditorConfig,
4
+ LexicalEditor,
5
+ LexicalNode,
6
+ NodeKey,
7
+ SerializedLexicalNode,
8
+ Spread,
9
+ } from 'lexical';
10
+ import CustomHTMLComponent from './CustomHTMLComponent.svelte';
11
+
12
+ export type SerializedCustomHTMLNode = Spread<
13
+ {
14
+ type: 'custom-html';
15
+ version: 1;
16
+ html: string;
17
+ },
18
+ SerializedLexicalNode
19
+ >;
20
+
21
+ export class CustomHTMLNode extends DecoratorNode<unknown> {
22
+ __html: string;
23
+
24
+ static getType(): string {
25
+ return 'custom-html';
26
+ }
27
+
28
+ static clone(node: CustomHTMLNode): CustomHTMLNode {
29
+ return new CustomHTMLNode(node.__html, node.__key);
30
+ }
31
+
32
+ constructor(html: string, key?: NodeKey) {
33
+ super(key);
34
+ this.__html = html;
35
+ }
36
+
37
+ createDOM(_config: EditorConfig): HTMLElement {
38
+ const div = document.createElement('div');
39
+ div.className = 'lexical-custom-html';
40
+ div.setAttribute('contenteditable', 'false');
41
+ return div;
42
+ }
43
+
44
+ updateDOM(): boolean {
45
+ return false;
46
+ }
47
+
48
+ static importJSON(json: SerializedCustomHTMLNode): CustomHTMLNode {
49
+ return new CustomHTMLNode(json.html);
50
+ }
51
+
52
+ exportJSON(): SerializedCustomHTMLNode {
53
+ return {
54
+ ...super.exportJSON(),
55
+ type: 'custom-html',
56
+ version: 1,
57
+ html: this.__html,
58
+ };
59
+ }
60
+
61
+ decorate(_editor: LexicalEditor, _config: EditorConfig) {
62
+ return {
63
+ componentClass: CustomHTMLComponent,
64
+ updateProps: (props: Record<string, unknown>) => {
65
+ props.html = this.__html;
66
+ props.nodeKey = this.__key;
67
+ },
68
+ };
69
+ }
70
+
71
+ isInline(): boolean {
72
+ return false;
73
+ }
74
+
75
+ getHtml(): string {
76
+ return this.__html;
77
+ }
78
+
79
+ setHtml(html: string): this {
80
+ const writable = this.getWritable();
81
+ writable.__html = html;
82
+ return writable;
83
+ }
84
+ }
85
+
86
+ export function $createCustomHTMLNode(html: string): CustomHTMLNode {
87
+ return new CustomHTMLNode(html);
88
+ }
89
+
90
+ export function $isCustomHTMLNode(
91
+ node: LexicalNode | null | undefined,
92
+ ): node is CustomHTMLNode {
93
+ return node instanceof CustomHTMLNode;
94
+ }
@@ -0,0 +1,73 @@
1
+ <script lang="ts">
2
+ import { getEditor } from 'svelte-lexical';
3
+ import { getNodeByKey, isPullQuoteNode } from './lexical-helpers.js';
4
+
5
+ let { text = '', attribution = '', nodeKey = '' } = $props();
6
+
7
+ let editing = $state(false);
8
+ // svelte-ignore state_referenced_locally
9
+ let editText = $state(text);
10
+ // svelte-ignore state_referenced_locally
11
+ let editAttribution = $state(attribution);
12
+
13
+ const editor = getEditor();
14
+
15
+ function startEditing() {
16
+ editText = text;
17
+ editAttribution = attribution;
18
+ editing = true;
19
+ }
20
+
21
+ function saveChanges() {
22
+ editing = false;
23
+ editor.update(() => {
24
+ const node = getNodeByKey(nodeKey);
25
+ if (isPullQuoteNode(node)) {
26
+ node.setText(editText);
27
+ node.setAttribution(editAttribution);
28
+ }
29
+ });
30
+ }
31
+
32
+ function handleKeydown(e: KeyboardEvent) {
33
+ if (e.key === 'Enter' && !e.shiftKey) {
34
+ e.preventDefault();
35
+ saveChanges();
36
+ }
37
+ if (e.key === 'Escape') {
38
+ editing = false;
39
+ }
40
+ }
41
+ </script>
42
+
43
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
44
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
45
+ <div
46
+ class="border-l-4 border-primary pl-4 py-2 my-2 bg-muted/20 rounded-r cursor-pointer"
47
+ onclick={startEditing}
48
+ >
49
+ {#if editing}
50
+ <textarea
51
+ class="w-full bg-transparent italic text-lg resize-none focus:outline-none border-none p-0"
52
+ bind:value={editText}
53
+ onblur={saveChanges}
54
+ onkeydown={handleKeydown}
55
+ rows="2"
56
+ placeholder="Enter quote text..."
57
+ ></textarea>
58
+ <input
59
+ class="w-full bg-transparent text-sm text-muted-foreground mt-2 focus:outline-none border-none p-0"
60
+ bind:value={editAttribution}
61
+ onblur={saveChanges}
62
+ onkeydown={handleKeydown}
63
+ placeholder="Attribution (optional)"
64
+ />
65
+ {:else}
66
+ <blockquote class="italic text-lg">
67
+ {text || 'Click to add quote text...'}
68
+ </blockquote>
69
+ {#if attribution}
70
+ <cite class="block mt-2 text-sm text-muted-foreground not-italic">-- {attribution}</cite>
71
+ {/if}
72
+ {/if}
73
+ </div>