@quoin-cms/admin 0.1.0 → 0.3.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 (37) hide show
  1. package/package.json +4 -1
  2. package/src/AdminRoot.svelte +7 -7
  3. package/src/lib/api/auth.ts +3 -6
  4. package/src/lib/api/files.ts +3 -2
  5. package/src/lib/components/AdminSidebar.svelte +23 -23
  6. package/src/lib/components/DocumentEditLayout.svelte +17 -0
  7. package/src/lib/components/DynamicForm.svelte +3 -3
  8. package/src/lib/components/MediaLibrary.svelte +55 -7
  9. package/src/lib/components/RecordTable.svelte +33 -21
  10. package/src/lib/components/UploadCreateView.svelte +173 -0
  11. package/src/lib/components/doc/ApiView.svelte +95 -103
  12. package/src/lib/components/doc/RenderJson.svelte +93 -0
  13. package/src/lib/components/fields/RichTextField.svelte +5 -0
  14. package/src/lib/components/fields/UploadField.svelte +26 -34
  15. package/src/lib/components/fields/UploadGalleryField.svelte +28 -37
  16. package/src/lib/components/lexical/BlockField.svelte +41 -0
  17. package/src/lib/components/lexical/BlockHost.svelte +85 -0
  18. package/src/lib/components/lexical/BlockNode.ts +102 -0
  19. package/src/lib/components/lexical/block-defaults.ts +40 -0
  20. package/src/lib/components/lexical/lexical-helpers.ts +3 -0
  21. package/src/lib/components/lexical/nodes.ts +2 -0
  22. package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +27 -2
  23. package/src/lib/context.svelte.ts +1 -0
  24. package/src/lib/types/schema.ts +15 -0
  25. package/src/views/CollectionListView.svelte +63 -21
  26. package/src/views/CollectionNewView.svelte +3 -0
  27. package/src/views/DashboardSlot.svelte +46 -0
  28. package/src/views/DashboardView.svelte +78 -339
  29. package/src/views/LoginView.svelte +47 -23
  30. package/biome.json +0 -62
  31. package/dist/assets/index-C9Y5-AKj.js +0 -33
  32. package/dist/assets/index-uVdiUjty.css +0 -1
  33. package/dist/index.html +0 -20
  34. package/index.html +0 -19
  35. package/src/views/AdsAnalyticsView.svelte +0 -152
  36. package/tsconfig.json +0 -25
  37. package/vite.config.ts +0 -80
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
- import { highlightJson } from '$lib/utils/json-highlight.js'
3
- import { Copy, Check, Pencil } from 'lucide-svelte'
2
+ import RenderJson from './RenderJson.svelte'
3
+ import { Copy, Check, Pencil, ExternalLink } from 'lucide-svelte'
4
4
  import { toast } from 'svelte-sonner'
5
5
 
6
6
  let {
@@ -17,39 +17,51 @@ let {
17
17
  onApply: () => void
18
18
  } = $props()
19
19
 
20
- let activeTab = $state<'json' | 'curl'>('json')
21
20
  let editing = $state(false)
22
21
  let draft = $state('')
23
22
  let parseError = $state<string | null>(null)
24
23
  let parsedDraft: Record<string, any> | null = null
25
24
  let copied = $state(false)
26
25
 
27
- let prettyJson = $derived(JSON.stringify(formData, null, 2))
28
- let highlightedJson = $derived(highlightJson(prettyJson))
26
+ // Live API inspector (edit mode only).
27
+ let authenticated = $state(true)
28
+ let liveData = $state<any>(null)
29
+ let liveError = $state<string | null>(null)
30
+ let loading = $state(false)
29
31
 
30
- let curlSnippet = $derived.by(() => {
31
- const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:5173'
32
- const url =
33
- mode === 'create'
34
- ? `${origin}/api/${collectionKey}`
35
- : `${origin}/api/${collectionKey}/${recordId}`
36
- const method = mode === 'create' ? 'POST' : 'PUT'
37
- const body = JSON.stringify(formData).replace(/'/g, "'\\''")
38
- return `curl -X ${method} '${url}' \\
39
- -H 'Content-Type: application/json' \\
40
- -H 'Cookie: <session>' \\
41
- -d '${body}'`
32
+ let origin = $derived(typeof window !== 'undefined' ? window.location.origin : '')
33
+ let isLive = $derived(mode === 'edit' && !!recordId)
34
+ let apiUrl = $derived(`${origin}/api/collections/${collectionKey}/records/${recordId}`)
35
+
36
+ // Re-fetch whenever the URL or auth mode changes.
37
+ $effect(() => {
38
+ if (!isLive) return
39
+ const url = apiUrl
40
+ const creds: RequestCredentials = authenticated ? 'include' : 'omit'
41
+ loading = true
42
+ liveError = null
43
+ fetch(url, { credentials: creds, headers: { Accept: 'application/json' } })
44
+ .then(async (res) => {
45
+ try {
46
+ liveData = await res.json()
47
+ } catch {
48
+ liveError = 'Failed to parse response'
49
+ }
50
+ })
51
+ .catch(() => (liveError = 'Request failed'))
52
+ .finally(() => (loading = false))
42
53
  })
43
54
 
44
- let highlightedCurl = $derived(highlightJson(curlSnippet))
55
+ // What the tree/copy operate on: live response in edit mode, form buffer in create.
56
+ let treeData = $derived(isLive ? liveData : formData)
57
+ let prettyJson = $derived(JSON.stringify(treeData ?? {}, null, 2))
45
58
 
46
59
  function startEdit() {
47
- draft = prettyJson
60
+ draft = JSON.stringify(formData, null, 2)
48
61
  parseError = null
49
62
  parsedDraft = null
50
63
  editing = true
51
64
  }
52
-
53
65
  function onDraftInput() {
54
66
  parseError = null
55
67
  parsedDraft = null
@@ -64,7 +76,6 @@ function onDraftInput() {
64
76
  parseError = (e as Error).message
65
77
  }
66
78
  }
67
-
68
79
  function applyChanges() {
69
80
  if (!parsedDraft) return
70
81
  for (const k of Object.keys(formData)) delete formData[k]
@@ -73,109 +84,90 @@ function applyChanges() {
73
84
  onApply()
74
85
  toast.success('JSON applied')
75
86
  }
76
-
77
87
  function cancelEdit() {
78
88
  editing = false
79
89
  draft = ''
80
90
  parseError = null
81
91
  parsedDraft = null
82
92
  }
83
-
84
- async function copyActive() {
85
- const text = activeTab === 'json' ? prettyJson : curlSnippet
93
+ async function copyJson() {
86
94
  try {
87
- await navigator.clipboard.writeText(text)
95
+ await navigator.clipboard.writeText(prettyJson)
88
96
  copied = true
89
97
  setTimeout(() => (copied = false), 1500)
90
98
  } catch {
91
99
  toast.error('Copy failed')
92
100
  }
93
101
  }
102
+ async function copyUrl() {
103
+ try {
104
+ await navigator.clipboard.writeText(apiUrl)
105
+ toast.success('URL copied')
106
+ } catch {
107
+ toast.error('Copy failed')
108
+ }
109
+ }
94
110
  </script>
95
111
 
96
112
  <div class="space-y-4 px-7 py-6">
97
- <div class="flex items-center justify-between">
98
- <div class="flex gap-1">
99
- <button
100
- type="button"
101
- onclick={() => (activeTab = 'json')}
102
- class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors
103
- {activeTab === 'json'
104
- ? 'bg-secondary text-foreground'
105
- : 'text-muted-foreground hover:text-foreground'}"
106
- >
107
- JSON
113
+ <div class="flex items-center justify-end gap-2">
114
+ {#if !editing}
115
+ <button type="button" onclick={startEdit}
116
+ class="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs text-foreground shadow-sm hover:bg-secondary">
117
+ <Pencil class="h-3.5 w-3.5" /> Edit JSON
108
118
  </button>
109
- <button
110
- type="button"
111
- onclick={() => (activeTab = 'curl')}
112
- class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors
113
- {activeTab === 'curl'
114
- ? 'bg-secondary text-foreground'
115
- : 'text-muted-foreground hover:text-foreground'}"
116
- >
117
- cURL
118
- </button>
119
- </div>
119
+ {/if}
120
+ <button type="button" onclick={copyJson}
121
+ class="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs text-foreground shadow-sm hover:bg-secondary">
122
+ {#if copied}<Check class="h-3.5 w-3.5 text-emerald-700" /> Copied{:else}<Copy class="h-3.5 w-3.5" /> Copy{/if}
123
+ </button>
124
+ </div>
125
+
126
+ {#if editing}
127
+ <textarea bind:value={draft} oninput={onDraftInput} spellcheck="false"
128
+ class="block min-h-[420px] w-full rounded-lg border border-border bg-card px-4 py-3 font-mono text-[12px] leading-relaxed text-foreground shadow-sm focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/10"></textarea>
129
+ {#if parseError}<p class="text-xs text-destructive">Invalid JSON: {parseError}</p>{/if}
120
130
  <div class="flex items-center gap-2">
121
- {#if activeTab === 'json' && !editing}
122
- <button
123
- type="button"
124
- onclick={startEdit}
125
- class="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs text-foreground shadow-sm hover:bg-secondary"
126
- >
127
- <Pencil class="h-3.5 w-3.5" />
128
- Edit JSON
129
- </button>
130
- {/if}
131
- <button
132
- type="button"
133
- onclick={copyActive}
134
- class="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs text-foreground shadow-sm hover:bg-secondary"
135
- >
136
- {#if copied}
137
- <Check class="h-3.5 w-3.5 text-emerald-700" />
138
- Copied
139
- {:else}
140
- <Copy class="h-3.5 w-3.5" />
141
- Copy
142
- {/if}
143
- </button>
131
+ <button type="button" onclick={applyChanges} disabled={!parsedDraft}
132
+ class="rounded-lg bg-primary px-4 py-2 text-xs font-semibold text-primary-foreground shadow-sm hover:bg-primary/90 disabled:opacity-50">Apply changes</button>
133
+ <button type="button" onclick={cancelEdit}
134
+ class="rounded-lg border border-border bg-card px-4 py-2 text-xs text-foreground hover:bg-secondary">Cancel</button>
144
135
  </div>
145
- </div>
136
+ {:else if isLive}
137
+ <div class="grid gap-6 lg:grid-cols-[340px_minmax(0,1fr)]">
138
+ <!-- Left: request controls -->
139
+ <div class="space-y-6">
140
+ <div class="space-y-1.5">
141
+ <span class="flex items-center gap-1.5 text-sm font-medium text-foreground">
142
+ API URL
143
+ <button type="button" onclick={copyUrl} class="text-muted-foreground hover:text-foreground"><Copy class="h-3.5 w-3.5" /></button>
144
+ </span>
145
+ <a href={apiUrl} target="_blank" rel="noopener noreferrer"
146
+ class="inline-flex items-start gap-1 break-all font-mono text-xs text-primary hover:underline">
147
+ {apiUrl}<ExternalLink class="mt-0.5 h-3 w-3 shrink-0" />
148
+ </a>
149
+ </div>
150
+ <label class="flex items-center gap-2 text-sm font-medium text-foreground">
151
+ <input type="checkbox" bind:checked={authenticated} class="h-4 w-4" /> Authenticated
152
+ </label>
153
+ </div>
146
154
 
147
- {#if activeTab === 'json'}
148
- {#if editing}
149
- <textarea
150
- bind:value={draft}
151
- oninput={onDraftInput}
152
- spellcheck="false"
153
- class="block min-h-[420px] w-full rounded-lg border border-border bg-card px-4 py-3 font-mono text-[12px] leading-relaxed text-foreground shadow-sm focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/10"
154
- ></textarea>
155
- {#if parseError}
156
- <p class="text-xs text-destructive">Invalid JSON: {parseError}</p>
157
- {/if}
158
- <div class="flex items-center gap-2">
159
- <button
160
- type="button"
161
- onclick={applyChanges}
162
- disabled={!parsedDraft}
163
- class="rounded-lg bg-primary px-4 py-2 text-xs font-semibold text-primary-foreground shadow-sm hover:bg-primary/90 disabled:opacity-50"
164
- >
165
- Apply changes
166
- </button>
167
- <button
168
- type="button"
169
- onclick={cancelEdit}
170
- class="rounded-lg border border-border bg-card px-4 py-2 text-xs text-foreground hover:bg-secondary"
171
- >
172
- Cancel
173
- </button>
155
+ <!-- Right: JSON tree -->
156
+ <div class="overflow-auto rounded-lg border border-border bg-card p-4 font-mono text-[12px] text-foreground shadow-sm">
157
+ {#if loading && liveData === null}
158
+ <p class="text-muted-foreground">Loading…</p>
159
+ {:else if liveError}
160
+ <p class="text-destructive">{liveError}</p>
161
+ {:else if treeData !== null && treeData !== undefined}
162
+ <RenderJson data={treeData} />
163
+ {/if}
174
164
  </div>
175
- {:else}
176
- <pre class="overflow-auto rounded-lg border border-border bg-card p-4 font-mono text-[12px] leading-relaxed text-foreground shadow-sm">{@html highlightedJson}</pre>
177
- {/if}
165
+ </div>
178
166
  {:else}
179
- <pre class="overflow-auto rounded-lg border border-border bg-card p-4 font-mono text-[12px] leading-relaxed text-foreground shadow-sm">{@html highlightedCurl}</pre>
167
+ <div class="overflow-auto rounded-lg border border-border bg-card p-4 font-mono text-[12px] text-foreground shadow-sm">
168
+ {#if treeData !== null && treeData !== undefined}
169
+ <RenderJson data={treeData} />
170
+ {/if}
171
+ </div>
180
172
  {/if}
181
173
  </div>
@@ -0,0 +1,93 @@
1
+ <script lang="ts">
2
+ // Recursive, collapsible JSON tree (ported from Payload's RenderJSON).
3
+ // Objects and arrays get a toggle; primitives render inline with type colors.
4
+ import { ChevronRight } from 'lucide-svelte'
5
+ import Self from './RenderJson.svelte'
6
+
7
+ let {
8
+ data,
9
+ keyName = undefined,
10
+ trailingComma = false,
11
+ }: {
12
+ data: any
13
+ keyName?: string
14
+ trailingComma?: boolean
15
+ } = $props()
16
+
17
+ let isOpen = $state(true)
18
+
19
+ function typeOf(v: any): string {
20
+ if (v === null || v === undefined) return 'null'
21
+ if (Array.isArray(v)) return 'array'
22
+ if (v instanceof Date) return 'date'
23
+ return typeof v
24
+ }
25
+
26
+ let t = $derived(typeOf(data))
27
+ let isContainer = $derived(t === 'object' || t === 'array')
28
+
29
+ let entries = $derived.by(() => {
30
+ if (t === 'array') {
31
+ return (data as any[]).map((v, i, a) => ({ k: String(i), v, last: i === a.length - 1, showKey: false }))
32
+ }
33
+ if (t === 'object') {
34
+ const ks = Object.keys(data)
35
+ return ks.map((k, i) => ({ k, v: data[k], last: i === ks.length - 1, showKey: true }))
36
+ }
37
+ return []
38
+ })
39
+ let isEmpty = $derived(isContainer && entries.length === 0)
40
+ let openBracket = $derived(t === 'array' ? '[' : '{')
41
+ let closeBracket = $derived(t === 'array' ? ']' : '}')
42
+
43
+ function primClass(v: any): string {
44
+ switch (typeOf(v)) {
45
+ case 'number': return 'text-violet-500'
46
+ case 'boolean': return 'text-amber-600'
47
+ case 'null': return 'text-muted-foreground'
48
+ case 'date': return 'text-sky-600'
49
+ default: return 'text-emerald-600'
50
+ }
51
+ }
52
+ function fmt(v: any): string {
53
+ if (v instanceof Date) return JSON.stringify(v.toISOString())
54
+ if (v === undefined) return 'null'
55
+ return JSON.stringify(v)
56
+ }
57
+ </script>
58
+
59
+ {#if isContainer}
60
+ <div class="leading-relaxed">
61
+ <button
62
+ type="button"
63
+ onclick={() => (isOpen = !isOpen)}
64
+ class="inline-flex items-center gap-0.5 text-left hover:opacity-80"
65
+ >
66
+ {#if !isEmpty}
67
+ <ChevronRight class="h-3 w-3 shrink-0 text-muted-foreground transition-transform {isOpen ? 'rotate-90' : ''}" />
68
+ {:else}
69
+ <span class="inline-block w-3"></span>
70
+ {/if}
71
+ <span>
72
+ {#if keyName !== undefined}<span class="text-sky-600">"{keyName}"</span>: {/if}{openBracket}{#if isEmpty}{closeBracket}{trailingComma ? ',' : ''}{:else if !isOpen}<span class="text-muted-foreground">…{closeBracket}{trailingComma ? ',' : ''}</span>{/if}
73
+ </span>
74
+ </button>
75
+
76
+ {#if !isEmpty && isOpen}
77
+ <ul class="ml-[7px] border-l border-border/50 pl-3">
78
+ {#each entries as e (e.k)}
79
+ <li>
80
+ {#if typeOf(e.v) === 'object' || typeOf(e.v) === 'array'}
81
+ <Self data={e.v} keyName={e.showKey ? e.k : undefined} trailingComma={!e.last} />
82
+ {:else}
83
+ <span>{#if e.showKey}<span class="text-sky-600">"{e.k}"</span>: {/if}<span class={primClass(e.v)}>{fmt(e.v)}</span>{e.last ? '' : ','}</span>
84
+ {/if}
85
+ </li>
86
+ {/each}
87
+ </ul>
88
+ <div>{closeBracket}{trailingComma ? ',' : ''}</div>
89
+ {/if}
90
+ </div>
91
+ {:else}
92
+ <span>{#if keyName !== undefined}<span class="text-sky-600">"{keyName}"</span>: {/if}<span class={primClass(data)}>{fmt(data)}</span>{trailingComma ? ',' : ''}</span>
93
+ {/if}
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { setContext } from 'svelte';
2
3
  import type { FieldSchema } from '$lib/types/schema.js';
3
4
  import {
4
5
  Composer,
@@ -34,6 +35,10 @@ let {
34
35
  error?: string;
35
36
  } = $props();
36
37
 
38
+ // Expose this field's custom blocks to the editor toolbar + block nodes.
39
+ // svelte-ignore state_referenced_locally
40
+ setContext('quoin.lexical.blocks', (field as { blocks?: unknown[] }).blocks ?? []);
41
+
37
42
  // Parse initial editor state from value
38
43
  function getInitialEditorState(): string | undefined {
39
44
  if (!value) return undefined;
@@ -1,11 +1,11 @@
1
1
  <script lang="ts">
2
2
  import type { FieldSchema } from '$lib/types/schema.js'
3
- import { uploadToCollection, type UploadRecord } from '$lib/api/files.js'
3
+ import type { UploadRecord } from '$lib/api/files.js'
4
4
  import { getRecord } from '$lib/api/records.js'
5
5
  import { formatFileSize } from '$lib/utils/format.js'
6
- import { toast } from 'svelte-sonner'
7
- import { Eye, Upload, X } from 'lucide-svelte'
6
+ import { Eye, FolderOpen, X } from 'lucide-svelte'
8
7
  import { onMount } from 'svelte'
8
+ import MediaLibrary from '../MediaLibrary.svelte'
9
9
 
10
10
  let {
11
11
  field,
@@ -17,10 +17,9 @@ let {
17
17
  error?: string
18
18
  } = $props()
19
19
 
20
- let isUploading = $state(false)
21
20
  let resolved = $state<UploadRecord | null>(null)
22
- let fileInput: HTMLInputElement
23
21
  let previewOpen = $state(false)
22
+ let libraryOpen = $state(false)
24
23
 
25
24
  /**
26
25
  * Resolve the field value (UUID string or already-expanded record) into a
@@ -57,24 +56,16 @@ function isImage(mimeType?: string): boolean {
57
56
  return !!mimeType && mimeType.startsWith('image/')
58
57
  }
59
58
 
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}`)
59
+ function handleMediaSelect(media: { id: string; url: string; alt: string; filename: string; mimeType: string; size: number }): void {
60
+ value = media.id
61
+ // Optimistic preview; the resolve effect re-fetches the authoritative record.
62
+ resolved = {
63
+ id: media.id,
64
+ url: media.url,
65
+ filename: media.filename,
66
+ mimeType: media.mimeType,
67
+ size: media.size,
68
+ alt: media.alt,
78
69
  }
79
70
  }
80
71
 
@@ -119,6 +110,13 @@ function closePreview(): void {
119
110
  {#if typeof resolved.size === 'number'}
120
111
  <p class="text-muted-foreground">{formatFileSize(resolved.size)}</p>
121
112
  {/if}
113
+ <button
114
+ type="button"
115
+ onclick={() => (libraryOpen = true)}
116
+ class="text-primary hover:underline"
117
+ >
118
+ Replace
119
+ </button>
122
120
  </div>
123
121
  <button
124
122
  type="button"
@@ -132,21 +130,15 @@ function closePreview(): void {
132
130
  {:else}
133
131
  <button
134
132
  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' : ''}"
133
+ onclick={() => (libraryOpen = true)}
134
+ class="inline-flex h-10 items-center gap-2 rounded-md border bg-background px-4 text-sm hover:bg-accent {error ? 'border-destructive' : ''}"
138
135
  >
139
- <Upload class="h-4 w-4" />
140
- {isUploading ? 'Uploading…' : 'Upload File'}
136
+ <FolderOpen class="h-4 w-4" />
137
+ Select File
141
138
  </button>
142
139
  {/if}
143
140
 
144
- <input
145
- bind:this={fileInput}
146
- type="file"
147
- class="hidden"
148
- onchange={handleUpload}
149
- />
141
+ <MediaLibrary bind:open={libraryOpen} collection={field.relatesTo} onSelect={handleMediaSelect} />
150
142
 
151
143
  {#if error}
152
144
  <p class="mt-1 text-xs text-destructive">{error}</p>
@@ -1,10 +1,10 @@
1
1
  <script lang="ts">
2
2
  import type { FieldSchema } from '$lib/types/schema.js'
3
- import { uploadToCollection, type UploadRecord } from '$lib/api/files.js'
3
+ import type { UploadRecord } from '$lib/api/files.js'
4
4
  import { listRecords } from '$lib/api/records.js'
5
- import { toast } from 'svelte-sonner'
6
- import { ChevronDown, ChevronUp, Upload, X } from 'lucide-svelte'
5
+ import { ChevronDown, ChevronUp, FolderOpen, X } from 'lucide-svelte'
7
6
  import { onMount } from 'svelte'
7
+ import MediaLibrary from '../MediaLibrary.svelte'
8
8
 
9
9
  let {
10
10
  field,
@@ -16,9 +16,8 @@ let {
16
16
  error?: string
17
17
  } = $props()
18
18
 
19
- let isUploading = $state(false)
20
19
  let resolved = $state<UploadRecord[]>([])
21
- let fileInput: HTMLInputElement
20
+ let libraryOpen = $state(false)
22
21
 
23
22
  /**
24
23
  * Resolve the bound value (array of UUID strings or expanded records) into
@@ -33,7 +32,9 @@ async function resolveValues(): Promise<void> {
33
32
  return
34
33
  }
35
34
  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(' || ')
35
+ // quoin's filter parser uses the literal keyword `OR` (not `||`); the
36
+ // Postgres backend rejects `||` with "invalid filter: unexpected character".
37
+ const filter = ids.map((id) => `id='${id.replace(/'/g, "''")}'`).join(' OR ')
37
38
  const result = await listRecords(field.relatesTo, {
38
39
  filter,
39
40
  perPage: ids.length,
@@ -58,31 +59,22 @@ $effect(() => {
58
59
  resolveValues()
59
60
  })
60
61
 
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
- }
62
+ function handleMediaSelect(media: { id: string; url: string; alt: string; filename: string; mimeType: string; size: number }): void {
63
+ const current = Array.isArray(value) ? value : []
64
+ const ids = current.map((v) => (typeof v === 'object' && v ? v.id : (v as string)))
65
+ if (ids.includes(media.id)) return // already in the gallery
66
+ value = [...current, media.id]
67
+ resolved = [
68
+ ...resolved,
69
+ {
70
+ id: media.id,
71
+ url: media.url,
72
+ filename: media.filename,
73
+ mimeType: media.mimeType,
74
+ size: media.size,
75
+ alt: media.alt,
76
+ },
77
+ ]
86
78
  }
87
79
 
88
80
  function removeAt(i: number): void {
@@ -150,15 +142,14 @@ function isImage(mimeType?: string): boolean {
150
142
 
151
143
  <button
152
144
  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' : ''}"
145
+ onclick={() => (libraryOpen = true)}
146
+ class="inline-flex h-10 items-center gap-2 rounded-md border bg-background px-4 text-sm hover:bg-accent {error ? 'border-destructive' : ''}"
156
147
  >
157
- <Upload class="h-4 w-4" />
158
- {isUploading ? 'Uploading…' : resolved.length > 0 ? 'Add Files' : 'Upload Files'}
148
+ <FolderOpen class="h-4 w-4" />
149
+ {resolved.length > 0 ? 'Add Files' : 'Select Files'}
159
150
  </button>
160
151
 
161
- <input bind:this={fileInput} type="file" multiple class="hidden" onchange={handleUpload} />
152
+ <MediaLibrary bind:open={libraryOpen} multiple collection={field.relatesTo} onSelect={handleMediaSelect} />
162
153
 
163
154
  {#if error}
164
155
  <p class="mt-1 text-xs text-destructive">{error}</p>
@@ -0,0 +1,41 @@
1
+ <script lang="ts">
2
+ import type { Component } from 'svelte'
3
+ import type { FieldSchema } from '$lib/types/schema.js'
4
+ import { getQuoinContext, resolveField, loadComponent } from '$lib/context.svelte.js'
5
+ import { resolveFieldComponent } from '../fields/registry.js'
6
+
7
+ let {
8
+ field,
9
+ data = $bindable({}),
10
+ }: { field: FieldSchema; data?: Record<string, unknown> } = $props()
11
+
12
+ const ctx = getQuoinContext()
13
+ const overridePath = $derived(resolveField(field.type, ctx.config))
14
+ const Builtin = $derived(resolveFieldComponent(field))
15
+ let Override = $state<Component<any> | null>(null)
16
+ let overrideFailed = $state(false)
17
+
18
+ $effect(() => {
19
+ if (overridePath) {
20
+ overrideFailed = false
21
+ loadComponent(overridePath, ctx.importMap)
22
+ .then((c) => (Override = c))
23
+ .catch(() => {
24
+ Override = null
25
+ overrideFailed = true
26
+ })
27
+ }
28
+ })
29
+ </script>
30
+
31
+ {#if overridePath && Override}
32
+ {@const C = Override}
33
+ <C {field} bind:value={data[field.name]} />
34
+ {:else if overridePath && !overrideFailed}
35
+ <!-- override still loading -->
36
+ {:else if Builtin}
37
+ {@const C = Builtin}
38
+ <C {field} bind:value={data[field.name]} />
39
+ {:else}
40
+ <p class="text-xs text-destructive">No widget for field type: {field.type}</p>
41
+ {/if}