@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.
- package/package.json +4 -1
- package/src/AdminRoot.svelte +7 -7
- package/src/lib/api/auth.ts +3 -6
- package/src/lib/api/files.ts +3 -2
- package/src/lib/components/AdminSidebar.svelte +23 -23
- package/src/lib/components/DocumentEditLayout.svelte +17 -0
- package/src/lib/components/DynamicForm.svelte +3 -3
- package/src/lib/components/MediaLibrary.svelte +55 -7
- package/src/lib/components/RecordTable.svelte +33 -21
- package/src/lib/components/UploadCreateView.svelte +173 -0
- package/src/lib/components/doc/ApiView.svelte +95 -103
- package/src/lib/components/doc/RenderJson.svelte +93 -0
- package/src/lib/components/fields/RichTextField.svelte +5 -0
- package/src/lib/components/fields/UploadField.svelte +26 -34
- package/src/lib/components/fields/UploadGalleryField.svelte +28 -37
- package/src/lib/components/lexical/BlockField.svelte +41 -0
- package/src/lib/components/lexical/BlockHost.svelte +85 -0
- package/src/lib/components/lexical/BlockNode.ts +102 -0
- package/src/lib/components/lexical/block-defaults.ts +40 -0
- package/src/lib/components/lexical/lexical-helpers.ts +3 -0
- package/src/lib/components/lexical/nodes.ts +2 -0
- package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +27 -2
- package/src/lib/context.svelte.ts +1 -0
- package/src/lib/types/schema.ts +15 -0
- package/src/views/CollectionListView.svelte +63 -21
- package/src/views/CollectionNewView.svelte +3 -0
- package/src/views/DashboardSlot.svelte +46 -0
- package/src/views/DashboardView.svelte +78 -339
- package/src/views/LoginView.svelte +47 -23
- package/biome.json +0 -62
- package/dist/assets/index-C9Y5-AKj.js +0 -33
- package/dist/assets/index-uVdiUjty.css +0 -1
- package/dist/index.html +0 -20
- package/index.html +0 -19
- package/src/views/AdsAnalyticsView.svelte +0 -152
- package/tsconfig.json +0 -25
- package/vite.config.ts +0 -80
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
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
|
-
|
|
28
|
-
let
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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-
|
|
98
|
-
|
|
99
|
-
<button
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
</
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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={() =>
|
|
136
|
-
|
|
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
|
-
<
|
|
140
|
-
|
|
136
|
+
<FolderOpen class="h-4 w-4" />
|
|
137
|
+
Select File
|
|
141
138
|
</button>
|
|
142
139
|
{/if}
|
|
143
140
|
|
|
144
|
-
<
|
|
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 {
|
|
3
|
+
import type { UploadRecord } from '$lib/api/files.js'
|
|
4
4
|
import { listRecords } from '$lib/api/records.js'
|
|
5
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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={() =>
|
|
154
|
-
|
|
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
|
-
<
|
|
158
|
-
{
|
|
148
|
+
<FolderOpen class="h-4 w-4" />
|
|
149
|
+
{resolved.length > 0 ? 'Add Files' : 'Select Files'}
|
|
159
150
|
</button>
|
|
160
151
|
|
|
161
|
-
<
|
|
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}
|