@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
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ import type { Component } from 'svelte'
3
+ import { getContext } from 'svelte'
4
+ import { getEditor } from 'svelte-lexical'
5
+ import { getQuoinContext, loadComponent } from '$lib/context.svelte.js'
6
+ import { getNodeByKey, isBlockNode } from './lexical-helpers.js'
7
+ import BlockField from './BlockField.svelte'
8
+ import type { BlockDef } from './block-defaults.js'
9
+
10
+ let {
11
+ blockType = '',
12
+ data = {},
13
+ nodeKey = '',
14
+ }: { blockType?: string; data?: Record<string, unknown>; nodeKey?: string } = $props()
15
+
16
+ const editor = getEditor()
17
+ const ctx = getQuoinContext()
18
+ const blocks = getContext<BlockDef[]>('quoin.lexical.blocks') ?? []
19
+
20
+ const def = $derived(blocks.find((b) => b.slug === blockType))
21
+ const customPath = $derived(def ? ctx.config.editorBlocks?.[blockType] : undefined)
22
+
23
+ // svelte-ignore state_referenced_locally
24
+ let model = $state<Record<string, unknown>>(structuredClone($state.snapshot(data)) ?? {})
25
+
26
+ function commit(snapshot: Record<string, unknown>): void {
27
+ editor.update(() => {
28
+ const node = getNodeByKey(nodeKey)
29
+ if (isBlockNode(node)) node.setData(snapshot)
30
+ })
31
+ }
32
+
33
+ let primed = false
34
+ $effect(() => {
35
+ const snap = $state.snapshot(model)
36
+ if (!primed) {
37
+ primed = true
38
+ return
39
+ }
40
+ const id = setTimeout(() => commit(snap), 250)
41
+ return () => clearTimeout(id)
42
+ })
43
+
44
+ let CustomComp = $state<Component<any> | null>(null)
45
+ let customError = $state(false)
46
+ $effect(() => {
47
+ if (customPath) {
48
+ customError = false
49
+ loadComponent(customPath, ctx.importMap)
50
+ .then((c) => (CustomComp = c))
51
+ .catch(() => {
52
+ CustomComp = null
53
+ customError = true
54
+ })
55
+ }
56
+ })
57
+
58
+ function setData(next: Record<string, unknown>): void {
59
+ model = next
60
+ }
61
+ </script>
62
+
63
+ <div class="my-3 rounded-md border bg-card p-3">
64
+ {#if !def}
65
+ <p class="text-sm text-destructive">Unknown block: {blockType}</p>
66
+ {:else}
67
+ <div class="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
68
+ {def.label}
69
+ </div>
70
+ {#if customPath}
71
+ {#if CustomComp}
72
+ {@const C = CustomComp}
73
+ <C schema={def.fields} data={model} {setData} {BlockField} />
74
+ {:else if customError}
75
+ <p class="text-sm text-destructive">Failed to load editor component for block "{blockType}".</p>
76
+ {/if}
77
+ {:else}
78
+ <div class="space-y-2">
79
+ {#each def.fields as f (f.name)}
80
+ <BlockField field={f} bind:data={model} />
81
+ {/each}
82
+ </div>
83
+ {/if}
84
+ {/if}
85
+ </div>
@@ -0,0 +1,102 @@
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 BlockHost from './BlockHost.svelte';
11
+
12
+ export type SerializedBlockNode = Spread<
13
+ {
14
+ type: 'block';
15
+ version: 1;
16
+ blockType: string;
17
+ data: Record<string, unknown>;
18
+ },
19
+ SerializedLexicalNode
20
+ >;
21
+
22
+ /**
23
+ * BlockNode is the single generic decorator node for all custom blocks.
24
+ * __blockType holds the block's slug; the block schema + optional editor
25
+ * component are resolved at render time by BlockHost.
26
+ */
27
+ export class BlockNode extends DecoratorNode<unknown> {
28
+ __blockType: string;
29
+ __data: Record<string, unknown>;
30
+
31
+ static getType(): string {
32
+ return 'block';
33
+ }
34
+
35
+ static clone(node: BlockNode): BlockNode {
36
+ return new BlockNode(node.__blockType, node.__data, node.__key);
37
+ }
38
+
39
+ constructor(blockType: string, data: Record<string, unknown>, key?: NodeKey) {
40
+ super(key);
41
+ this.__blockType = blockType;
42
+ this.__data = data ?? {};
43
+ }
44
+
45
+ createDOM(_config: EditorConfig): HTMLElement {
46
+ const div = document.createElement('div');
47
+ div.className = 'lexical-block';
48
+ div.setAttribute('contenteditable', 'false');
49
+ return div;
50
+ }
51
+
52
+ updateDOM(): boolean {
53
+ return false;
54
+ }
55
+
56
+ static importJSON(json: SerializedBlockNode): BlockNode {
57
+ return new BlockNode(json.blockType, json.data ?? {});
58
+ }
59
+
60
+ exportJSON(): SerializedBlockNode {
61
+ return {
62
+ ...super.exportJSON(),
63
+ type: 'block',
64
+ version: 1,
65
+ blockType: this.__blockType,
66
+ data: this.__data,
67
+ };
68
+ }
69
+
70
+ decorate(_editor: LexicalEditor, _config: EditorConfig) {
71
+ return {
72
+ componentClass: BlockHost,
73
+ updateProps: (props: Record<string, unknown>) => {
74
+ props.blockType = this.__blockType;
75
+ props.data = this.__data;
76
+ props.nodeKey = this.__key;
77
+ },
78
+ };
79
+ }
80
+
81
+ isInline(): boolean {
82
+ return false;
83
+ }
84
+
85
+ getData(): Record<string, unknown> {
86
+ return this.__data;
87
+ }
88
+
89
+ setData(data: Record<string, unknown>): this {
90
+ const writable = this.getWritable();
91
+ writable.__data = data;
92
+ return writable;
93
+ }
94
+ }
95
+
96
+ export function $createBlockNode(blockType: string, data: Record<string, unknown> = {}): BlockNode {
97
+ return new BlockNode(blockType, data);
98
+ }
99
+
100
+ export function $isBlockNode(node: LexicalNode | null | undefined): node is BlockNode {
101
+ return node instanceof BlockNode;
102
+ }
@@ -0,0 +1,40 @@
1
+ import type { FieldSchema } from '$lib/types/schema.js'
2
+
3
+ export interface BlockDef {
4
+ slug: string
5
+ label: string
6
+ icon?: string
7
+ fields: FieldSchema[]
8
+ }
9
+
10
+ /**
11
+ * Build an empty data object for a block from its field schema. Each field
12
+ * gets a type-appropriate zero value (or its declared defaultValue). Used when
13
+ * inserting a fresh block node.
14
+ */
15
+ export function defaultBlockData(fields: FieldSchema[]): Record<string, unknown> {
16
+ const out: Record<string, unknown> = {}
17
+ for (const f of fields) {
18
+ const dv = (f as { defaultValue?: unknown }).defaultValue
19
+ if (dv !== undefined) {
20
+ out[f.name] = dv
21
+ continue
22
+ }
23
+ switch (f.type as string) {
24
+ case 'checkbox':
25
+ out[f.name] = false
26
+ break
27
+ case 'number':
28
+ out[f.name] = null
29
+ break
30
+ case 'array':
31
+ case 'blocks':
32
+ case 'tags':
33
+ out[f.name] = []
34
+ break
35
+ default:
36
+ out[f.name] = ''
37
+ }
38
+ }
39
+ return out
40
+ }
@@ -15,6 +15,9 @@ export { $isCustomHTMLNode as isCustomHTMLNode } from './CustomHTMLNode.js';
15
15
  export { $createPullQuoteNode as createPullQuoteNode } from './PullQuoteNode.js';
16
16
  export { $createCustomHTMLNode as createCustomHTMLNode } from './CustomHTMLNode.js';
17
17
 
18
+ export { $isBlockNode as isBlockNode } from './BlockNode.js';
19
+ export { $createBlockNode as createBlockNode } from './BlockNode.js';
20
+
18
21
  export { $createYouTubeNode as createYouTubeNode } from 'svelte-lexical';
19
22
  export { $createImageNode as createImageNode } from 'svelte-lexical';
20
23
 
@@ -1,8 +1,10 @@
1
1
  import type { Klass, LexicalNode } from 'lexical';
2
2
  import { PullQuoteNode } from './PullQuoteNode.js';
3
3
  import { CustomHTMLNode } from './CustomHTMLNode.js';
4
+ import { BlockNode } from './BlockNode.js';
4
5
 
5
6
  export const customNodes: Array<Klass<LexicalNode>> = [
6
7
  PullQuoteNode,
7
8
  CustomHTMLNode,
9
+ BlockNode,
8
10
  ];
@@ -1,11 +1,26 @@
1
1
  <script lang="ts">
2
+ import { getContext } from 'svelte';
2
3
  import { getEditor } from 'svelte-lexical';
3
- import { insertNodes, createParagraphNode, createPullQuoteNode, createCustomHTMLNode, createYouTubeNode, createImageNode } from '../lexical-helpers.js';
4
- import { Plus, Youtube, Quote, Code2, Image as ImageIcon } from 'lucide-svelte';
4
+ import { insertNodes, createParagraphNode, createPullQuoteNode, createCustomHTMLNode, createYouTubeNode, createImageNode, createBlockNode } from '../lexical-helpers.js';
5
+ import { defaultBlockData } from '../block-defaults.js';
6
+ import type { BlockDef } from '../block-defaults.js';
7
+ import { Plus, Youtube, Quote, Code2, Image as ImageIcon, Blocks as BlocksIcon } from 'lucide-svelte';
5
8
  import MediaLibrary from '../../MediaLibrary.svelte';
6
9
 
7
10
  const editor = getEditor();
8
11
 
12
+ const customBlocks = getContext<BlockDef[]>('quoin.lexical.blocks') ?? [];
13
+
14
+ function insertBlock(b: BlockDef) {
15
+ editor.update(() => {
16
+ const node = createBlockNode(b.slug, defaultBlockData(b.fields));
17
+ insertNodes([node]);
18
+ const paragraph = createParagraphNode();
19
+ node.insertAfter(paragraph);
20
+ });
21
+ closeDropdown();
22
+ }
23
+
9
24
  let isOpen = $state(false);
10
25
  let showYoutubeDialog = $state(false);
11
26
  let showImageDialog = $state(false);
@@ -188,6 +203,16 @@ function handleImageKeydown(e: KeyboardEvent) {
188
203
  <Code2 class="h-4 w-4" />
189
204
  Custom HTML
190
205
  </button>
206
+ {#each customBlocks as b (b.slug)}
207
+ <button
208
+ type="button"
209
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
210
+ onclick={() => insertBlock(b)}
211
+ >
212
+ <BlocksIcon class="h-4 w-4" />
213
+ {b.label}
214
+ </button>
215
+ {/each}
191
216
  </div>
192
217
  {/if}
193
218
  </div>
@@ -42,6 +42,7 @@ export interface ResolvedQuoinConfig {
42
42
  slots: Record<string, string>
43
43
  views: Record<string, string>
44
44
  pages: Record<string, PageEntry>
45
+ editorBlocks: Record<string, string>
45
46
  }
46
47
 
47
48
  /** importMap entries are thunked dynamic imports. */
@@ -25,6 +25,8 @@ export interface FieldSchema {
25
25
  label: string
26
26
  required: boolean
27
27
  hidden?: boolean
28
+ /** Hides the field from admin edit/list views (API still returns it). */
29
+ adminHidden?: boolean
28
30
  /** Sub-variant for text fields (e.g. "password"). Phase 21 D-06.
29
31
  * Emitted by field.Text.ToJSON() when Text.Type != "". */
30
32
  variant?: string
@@ -93,6 +95,9 @@ export interface CollectionSchema {
93
95
  fields: FieldSchema[]
94
96
  admin?: AdminConfig
95
97
  versions?: VersionsSchema
98
+ /** Present when the collection is an upload collection (col.Upload != nil).
99
+ * Signals the admin to render a file dropzone on create/edit. */
100
+ upload?: { accept?: string[]; imageVariants?: boolean }
96
101
  }
97
102
 
98
103
  export interface GlobalSchema {
@@ -101,6 +106,16 @@ export interface GlobalSchema {
101
106
  fields: FieldSchema[]
102
107
  }
103
108
 
109
+ /**
110
+ * Auto-injected by the backend on every collection record. Not present in
111
+ * `collection.fields` (which only contains user-declared fields), so the
112
+ * admin synthesizes these entries when listing columns or rendering cells.
113
+ */
114
+ export const SYSTEM_FIELDS: FieldSchema[] = [
115
+ { name: 'createdAt', type: 'date', label: 'Created', required: false },
116
+ { name: 'updatedAt', type: 'date', label: 'Updated', required: false },
117
+ ]
118
+
104
119
  export interface SchemaResponse {
105
120
  collections: CollectionSchema[]
106
121
  globals: GlobalSchema[]
@@ -1,13 +1,15 @@
1
1
  <script lang="ts">
2
2
  import { page } from '$lib/router/index.svelte.js'
3
3
  import { schema, getCollectionByKey } from '$lib/stores/schema.svelte.js'
4
+ import { SYSTEM_FIELDS } from '$lib/types/schema.js'
4
5
  import { listRecords, deleteRecord } from '$lib/api/records.js'
5
6
  import RecordTable from '$lib/components/RecordTable.svelte'
6
7
  import RecordGrid from '$lib/components/RecordGrid.svelte'
7
8
  import Pagination from '$lib/components/Pagination.svelte'
8
9
  import DeleteDialog from '$lib/components/DeleteDialog.svelte'
9
10
  import { toast } from 'svelte-sonner'
10
- import { Search, Download, Columns3, ChevronUp, ChevronDown, LayoutGrid, List } from 'lucide-svelte'
11
+ import { Search, Columns3, ChevronUp, ChevronDown, LayoutGrid, List } from 'lucide-svelte'
12
+ import Slot from '$lib/Slot.svelte'
11
13
 
12
14
  type ViewMode = 'list' | 'grid'
13
15
 
@@ -32,6 +34,27 @@ let searchTimeout: ReturnType<typeof setTimeout>
32
34
  let visibleColumns = $state<string[] | null>(null)
33
35
  let loadedKey = $state<string | null>(null)
34
36
  let columnPickerOpen = $state(false)
37
+ let columnPickerEl = $state<HTMLDivElement | null>(null)
38
+
39
+ function handleColumnPickerOutsideClick(e: MouseEvent) {
40
+ if (!columnPickerOpen) return
41
+ if (columnPickerEl && !columnPickerEl.contains(e.target as Node)) {
42
+ columnPickerOpen = false
43
+ }
44
+ }
45
+
46
+ $effect(() => {
47
+ if (!columnPickerOpen) return
48
+ // Defer attachment by one tick so the click that opened the popup cannot
49
+ // be caught by this listener (the same mouseup/click chain).
50
+ const id = setTimeout(() => {
51
+ document.addEventListener('mousedown', handleColumnPickerOutsideClick)
52
+ }, 0)
53
+ return () => {
54
+ clearTimeout(id)
55
+ document.removeEventListener('mousedown', handleColumnPickerOutsideClick)
56
+ }
57
+ })
35
58
  let viewMode = $state<ViewMode>('list')
36
59
 
37
60
  function hasImageField(flds: any[]): boolean {
@@ -68,22 +91,42 @@ function flattenFields(flds: any[]): any[] {
68
91
  }
69
92
  return out
70
93
  }
71
- let allFields = $derived(collection ? flattenFields(collection.fields).filter((f: any) => !f.hidden) : [])
94
+ let allFields = $derived.by(() => {
95
+ if (!collection) return []
96
+ const userFields = flattenFields(collection.fields).filter((f: any) => !f.hidden)
97
+ const userNames = new Set(userFields.map((f: any) => f.name))
98
+ const systemExtras = SYSTEM_FIELDS.filter((f) => !userNames.has(f.name))
99
+ return [...userFields, ...systemExtras]
100
+ })
72
101
 
73
102
  $effect(() => {
74
103
  if (!collection) return
75
104
  if (loadedKey === collectionKey) return
105
+
106
+ // Reset per-collection state — pagination, search, and sort from the
107
+ // previous collection are meaningless here and cause blank pages.
108
+ currentPage = 1
109
+ searchQuery = ''
110
+ sort = ''
111
+
112
+ // Compute next visibleColumns into a local, then assign unconditionally.
113
+ // The previous version mutated visibleColumns inside the storage branch
114
+ // and only fell back to defaults when the result was empty, so collections
115
+ // without a localStorage entry inherited the prior collection's columns.
116
+ let next: string[] | null = null
76
117
  const stored = typeof localStorage !== 'undefined'
77
118
  ? localStorage.getItem(`cols:${collectionKey}`)
78
119
  : null
79
120
  if (stored) {
80
- try { visibleColumns = JSON.parse(stored) } catch { visibleColumns = null }
121
+ try { next = JSON.parse(stored) } catch { next = null }
81
122
  }
82
- if (!visibleColumns || !visibleColumns.length) {
83
- visibleColumns = collection.admin?.defaultColumns?.length
123
+ if (!next || !next.length) {
124
+ next = collection.admin?.defaultColumns?.length
84
125
  ? [...collection.admin.defaultColumns]
85
126
  : flattenFields(collection.fields).filter((f: any) => !f.hidden).slice(0, 4).map((f: any) => f.name)
86
127
  }
128
+ // Dedupe — guards against corrupt localStorage entries.
129
+ visibleColumns = Array.from(new Set(next))
87
130
  loadedKey = collectionKey
88
131
  })
89
132
 
@@ -110,10 +153,18 @@ function moveColumn(name: string, dir: -1 | 1) {
110
153
  }
111
154
 
112
155
  let orderedFields = $derived.by(() => {
113
- const selected = (visibleColumns ?? [])
114
- .map((n) => allFields.find((f: any) => f.name === n))
115
- .filter(Boolean)
116
- const rest = allFields.filter((f: any) => !(visibleColumns ?? []).includes(f.name))
156
+ // Dedupe by name. Stale localStorage or collisions can put the same
157
+ // column name in visibleColumns twice; keyed each blocks crash on dupes.
158
+ const seen = new Set<string>()
159
+ const selected: any[] = []
160
+ for (const n of visibleColumns ?? []) {
161
+ if (seen.has(n)) continue
162
+ const f = allFields.find((f: any) => f.name === n)
163
+ if (!f) continue
164
+ seen.add(n)
165
+ selected.push(f)
166
+ }
167
+ const rest = allFields.filter((f: any) => !seen.has(f.name))
117
168
  return [...selected, ...rest]
118
169
  })
119
170
 
@@ -228,7 +279,7 @@ async function confirmDelete() {
228
279
  </button>
229
280
  </div>
230
281
  {/if}
231
- <div class="relative">
282
+ <div class="relative" bind:this={columnPickerEl}>
232
283
  <button
233
284
  type="button"
234
285
  onclick={() => (columnPickerOpen = !columnPickerOpen)}
@@ -240,7 +291,6 @@ async function confirmDelete() {
240
291
  {#if columnPickerOpen}
241
292
  <div
242
293
  class="absolute right-0 z-20 mt-2 w-56 rounded-lg border border-border/80 bg-card p-2 shadow-lg"
243
- onmouseleave={() => (columnPickerOpen = false)}
244
294
  role="menu"
245
295
  >
246
296
  {#each orderedFields as f, idx (f.name)}
@@ -280,16 +330,8 @@ async function confirmDelete() {
280
330
  </div>
281
331
  {/if}
282
332
  </div>
283
- {#if collectionKey === 'subscribers'}
284
- <a
285
- href="/api/subscribers/export.csv"
286
- download
287
- class="inline-flex items-center gap-2 rounded-lg bg-brand-red px-3 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:opacity-90"
288
- >
289
- <Download class="h-4 w-4" />
290
- Export CSV
291
- </a>
292
- {/if}
333
+ <Slot name="collection.{collectionKey}.list-actions" />
334
+ <Slot name="collection.*.list-actions" />
293
335
  </div>
294
336
 
295
337
  <!-- Top pagination -->
@@ -5,6 +5,7 @@ import { resolve } from '$lib/router/index.svelte.js'
5
5
  import { schema, getCollectionByKey } from '$lib/stores/schema.svelte.js'
6
6
  import { createRecord } from '$lib/api/records.js'
7
7
  import DocumentEditLayout from '$lib/components/DocumentEditLayout.svelte'
8
+ import UploadCreateView from '$lib/components/UploadCreateView.svelte'
8
9
  import { toast } from 'svelte-sonner'
9
10
  import { onMount } from 'svelte'
10
11
 
@@ -57,6 +58,8 @@ async function handleSave(data: Record<string, any>) {
57
58
  <p class="text-sm text-muted-foreground">Creating draft...</p>
58
59
  </div>
59
60
  </div>
61
+ {:else if collection.upload}
62
+ <UploadCreateView {collection} />
60
63
  {:else}
61
64
  <DocumentEditLayout
62
65
  {collection}
@@ -0,0 +1,46 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Wrapper that renders either the consumer-supplied dashboard
4
+ * (via `views['dashboard.main']` in admin.config.ts) or the built-in
5
+ * generic DashboardView fallback.
6
+ */
7
+
8
+ import type { Component } from 'svelte'
9
+ import { getQuoinContext, loadComponent, resolveView } from '$lib/context.svelte.js'
10
+ import DashboardView from './DashboardView.svelte'
11
+
12
+ const ctx = getQuoinContext()
13
+ const overridePath = $derived(resolveView('dashboard.main', ctx.config))
14
+
15
+ let component = $state<Component<any> | null>(null)
16
+ let loadError = $state<string | null>(null)
17
+
18
+ $effect(() => {
19
+ component = null
20
+ loadError = null
21
+ if (!overridePath) return
22
+
23
+ loadComponent(overridePath, ctx.importMap)
24
+ .then((c) => {
25
+ component = c
26
+ })
27
+ .catch((err) => {
28
+ loadError = err instanceof Error ? err.message : String(err)
29
+ console.error('[quoin:DashboardSlot] Failed to load override:', err)
30
+ })
31
+ })
32
+ </script>
33
+
34
+ {#if !overridePath}
35
+ <DashboardView />
36
+ {:else if loadError}
37
+ <div class="p-6 text-red-600">
38
+ Failed to load dashboard override: {loadError}
39
+ </div>
40
+ <DashboardView />
41
+ {:else if component}
42
+ {@const Override = component}
43
+ <Override />
44
+ {:else}
45
+ <div class="p-6 text-muted-foreground">Loading dashboard…</div>
46
+ {/if}