@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,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>
|