@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,141 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ChevronDown, ChevronRight } from 'lucide-svelte'
|
|
3
|
+
import { diffWords, toStringValue, valuesEqual } from '$lib/utils/diff.js'
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
fieldName,
|
|
7
|
+
fieldLabel,
|
|
8
|
+
fieldType = 'text',
|
|
9
|
+
oldValue,
|
|
10
|
+
newValue,
|
|
11
|
+
}: {
|
|
12
|
+
fieldName: string
|
|
13
|
+
fieldLabel?: string
|
|
14
|
+
fieldType?: string
|
|
15
|
+
oldValue: unknown
|
|
16
|
+
newValue: unknown
|
|
17
|
+
} = $props()
|
|
18
|
+
|
|
19
|
+
let expanded = $state(false)
|
|
20
|
+
|
|
21
|
+
let isEqual = $derived(valuesEqual(oldValue, newValue))
|
|
22
|
+
let oldStr = $derived(toStringValue(oldValue))
|
|
23
|
+
let newStr = $derived(toStringValue(newValue))
|
|
24
|
+
let segments = $derived(diffWords(oldStr, newStr))
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
{#if !isEqual}
|
|
28
|
+
<div class="border-b border-stone-200">
|
|
29
|
+
<!-- label row -->
|
|
30
|
+
<div class="bg-stone-50 px-3 py-1.5">
|
|
31
|
+
<span class="text-[11px] font-semibold uppercase tracking-wide text-stone-500">
|
|
32
|
+
{fieldLabel || fieldName}
|
|
33
|
+
</span>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<!-- value row -->
|
|
37
|
+
{#if fieldType === 'file' || fieldType === 'upload'}
|
|
38
|
+
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-0">
|
|
39
|
+
<div class="p-3">
|
|
40
|
+
{#if oldStr}
|
|
41
|
+
<img src={oldStr} alt="old" class="h-16 w-16 rounded object-cover" />
|
|
42
|
+
{:else}
|
|
43
|
+
<span class="text-xs text-stone-400">No file</span>
|
|
44
|
+
{/if}
|
|
45
|
+
</div>
|
|
46
|
+
<div class="w-px self-stretch bg-stone-200"></div>
|
|
47
|
+
<div class="p-3">
|
|
48
|
+
{#if newStr}
|
|
49
|
+
<img src={newStr} alt="new" class="h-16 w-16 rounded object-cover" />
|
|
50
|
+
{:else}
|
|
51
|
+
<span class="text-xs text-stone-400">No file</span>
|
|
52
|
+
{/if}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
{:else if fieldType === 'checkbox'}
|
|
56
|
+
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-0">
|
|
57
|
+
<div class="p-3">
|
|
58
|
+
<span
|
|
59
|
+
class="rounded-full px-2 py-0.5 text-xs font-medium {oldStr === 'true'
|
|
60
|
+
? 'bg-green-100 text-green-800'
|
|
61
|
+
: 'bg-red-100 text-red-800'}"
|
|
62
|
+
>
|
|
63
|
+
{oldStr}
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="w-px self-stretch bg-stone-200"></div>
|
|
67
|
+
<div class="p-3">
|
|
68
|
+
<span
|
|
69
|
+
class="rounded-full px-2 py-0.5 text-xs font-medium {newStr === 'true'
|
|
70
|
+
? 'bg-green-100 text-green-800'
|
|
71
|
+
: 'bg-red-100 text-red-800'}"
|
|
72
|
+
>
|
|
73
|
+
{newStr}
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
{:else if fieldType === 'richtext'}
|
|
78
|
+
<div class="px-3 py-2">
|
|
79
|
+
<button
|
|
80
|
+
class="flex items-center gap-1 text-xs text-stone-600 hover:text-stone-900"
|
|
81
|
+
onclick={() => (expanded = !expanded)}
|
|
82
|
+
>
|
|
83
|
+
{#if expanded}
|
|
84
|
+
<ChevronDown size={14} />
|
|
85
|
+
{:else}
|
|
86
|
+
<ChevronRight size={14} />
|
|
87
|
+
{/if}
|
|
88
|
+
<span class="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
|
89
|
+
content changed
|
|
90
|
+
</span>
|
|
91
|
+
</button>
|
|
92
|
+
|
|
93
|
+
{#if expanded}
|
|
94
|
+
<div class="mt-2 grid grid-cols-[1fr_auto_1fr] gap-0">
|
|
95
|
+
<div class="p-2 text-sm leading-relaxed">
|
|
96
|
+
{#each segments as seg}
|
|
97
|
+
{#if seg.type === 'equal'}
|
|
98
|
+
<span>{seg.value}</span>
|
|
99
|
+
{:else if seg.type === 'delete'}
|
|
100
|
+
<span class="bg-red-100 line-through">{seg.value}</span>
|
|
101
|
+
{/if}
|
|
102
|
+
{/each}
|
|
103
|
+
</div>
|
|
104
|
+
<div class="w-px self-stretch bg-stone-200"></div>
|
|
105
|
+
<div class="p-2 text-sm leading-relaxed">
|
|
106
|
+
{#each segments as seg}
|
|
107
|
+
{#if seg.type === 'equal'}
|
|
108
|
+
<span>{seg.value}</span>
|
|
109
|
+
{:else if seg.type === 'add'}
|
|
110
|
+
<span class="bg-green-100">{seg.value}</span>
|
|
111
|
+
{/if}
|
|
112
|
+
{/each}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
{/if}
|
|
116
|
+
</div>
|
|
117
|
+
{:else}
|
|
118
|
+
<div class="grid grid-cols-[1fr_auto_1fr] gap-0">
|
|
119
|
+
<div class="p-3 text-sm leading-relaxed">
|
|
120
|
+
{#each segments as seg}
|
|
121
|
+
{#if seg.type === 'equal'}
|
|
122
|
+
<span>{seg.value}</span>
|
|
123
|
+
{:else if seg.type === 'delete'}
|
|
124
|
+
<span class="bg-red-100 line-through">{seg.value}</span>
|
|
125
|
+
{/if}
|
|
126
|
+
{/each}
|
|
127
|
+
</div>
|
|
128
|
+
<div class="w-px self-stretch bg-stone-200"></div>
|
|
129
|
+
<div class="p-3 text-sm leading-relaxed">
|
|
130
|
+
{#each segments as seg}
|
|
131
|
+
{#if seg.type === 'equal'}
|
|
132
|
+
<span>{seg.value}</span>
|
|
133
|
+
{:else if seg.type === 'add'}
|
|
134
|
+
<span class="bg-green-100">{seg.value}</span>
|
|
135
|
+
{/if}
|
|
136
|
+
{/each}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
{/if}
|
|
140
|
+
</div>
|
|
141
|
+
{/if}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let {
|
|
3
|
+
open = $bindable(false),
|
|
4
|
+
hasDrafts,
|
|
5
|
+
isRestoring,
|
|
6
|
+
onRestore,
|
|
7
|
+
}: {
|
|
8
|
+
open: boolean
|
|
9
|
+
hasDrafts: boolean
|
|
10
|
+
isRestoring: boolean
|
|
11
|
+
onRestore: (asDraft: boolean) => void
|
|
12
|
+
} = $props()
|
|
13
|
+
|
|
14
|
+
let restoreAsPublished = $state(false)
|
|
15
|
+
|
|
16
|
+
function handleRestore() {
|
|
17
|
+
onRestore(!restoreAsPublished)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function handleCancel() {
|
|
21
|
+
open = false
|
|
22
|
+
restoreAsPublished = false
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
{#if open}
|
|
27
|
+
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
|
28
|
+
<!-- backdrop -->
|
|
29
|
+
<button
|
|
30
|
+
class="absolute inset-0 bg-black/50"
|
|
31
|
+
onclick={handleCancel}
|
|
32
|
+
aria-label="Close modal"
|
|
33
|
+
></button>
|
|
34
|
+
|
|
35
|
+
<!-- modal -->
|
|
36
|
+
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
|
37
|
+
<h3 class="text-lg font-semibold text-stone-900">Restore Version</h3>
|
|
38
|
+
<p class="mt-2 text-sm text-stone-600">
|
|
39
|
+
This will replace current record data with data from this version.
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
{#if hasDrafts}
|
|
43
|
+
<label class="mt-4 flex items-center gap-2 text-sm text-stone-700">
|
|
44
|
+
<input type="checkbox" bind:checked={restoreAsPublished} class="rounded" />
|
|
45
|
+
Restore as published
|
|
46
|
+
</label>
|
|
47
|
+
{/if}
|
|
48
|
+
|
|
49
|
+
<div class="mt-6 flex justify-end gap-3">
|
|
50
|
+
<button
|
|
51
|
+
class="rounded-md px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-100"
|
|
52
|
+
onclick={handleCancel}
|
|
53
|
+
disabled={isRestoring}
|
|
54
|
+
>
|
|
55
|
+
Cancel
|
|
56
|
+
</button>
|
|
57
|
+
<button
|
|
58
|
+
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
|
59
|
+
onclick={handleRestore}
|
|
60
|
+
disabled={isRestoring}
|
|
61
|
+
>
|
|
62
|
+
{isRestoring ? 'Restoring...' : 'Restore'}
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
{/if}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { status = '', isLatest = false }: { status?: string; isLatest?: boolean } = $props()
|
|
3
|
+
|
|
4
|
+
let label = $derived.by(() => {
|
|
5
|
+
if (status === 'draft' && isLatest) return 'Current Draft'
|
|
6
|
+
if (status === 'draft') return 'Draft'
|
|
7
|
+
if (status === 'published' && isLatest) return 'Published'
|
|
8
|
+
if (status === 'published') return 'Previously Published'
|
|
9
|
+
return status || ''
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
let colorClass = $derived.by(() => {
|
|
13
|
+
if (status === 'draft' && isLatest) return 'bg-amber-100 text-amber-800'
|
|
14
|
+
if (status === 'published' && isLatest) return 'bg-green-100 text-green-800'
|
|
15
|
+
return 'bg-stone-100 text-stone-600'
|
|
16
|
+
})
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
{#if label}
|
|
20
|
+
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold {colorClass}">{label}</span>
|
|
21
|
+
{/if}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quoin admin runtime context.
|
|
3
|
+
*
|
|
4
|
+
* The context carries two things that every view, slot, and field needs
|
|
5
|
+
* access to without prop-drilling:
|
|
6
|
+
*
|
|
7
|
+
* - `config` — ResolvedQuoinConfig (apiBase, brand, fields, slots,
|
|
8
|
+
* views, pages — string path values)
|
|
9
|
+
* - `importMap` — { [absPath]: () => import(absPath) }
|
|
10
|
+
*
|
|
11
|
+
* The shell calls `setQuoinContext()` at boot; consumers use
|
|
12
|
+
* `getQuoinContext()` to read it, or the focused helpers below.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: only component PATHS are in config; actual component modules come
|
|
15
|
+
* from the importMap at resolution time. Using string paths keeps the
|
|
16
|
+
* config statically analyzable (Payload pattern).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { getContext, setContext } from 'svelte'
|
|
20
|
+
import type { Component } from 'svelte'
|
|
21
|
+
|
|
22
|
+
/* -------------------------------------------------------------------------- */
|
|
23
|
+
/* Types */
|
|
24
|
+
/* -------------------------------------------------------------------------- */
|
|
25
|
+
|
|
26
|
+
export interface PageEntry {
|
|
27
|
+
path: string
|
|
28
|
+
nav?: {
|
|
29
|
+
label: string
|
|
30
|
+
icon?: string
|
|
31
|
+
group?: string
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ResolvedQuoinConfig {
|
|
36
|
+
apiBase: string
|
|
37
|
+
basePath: string
|
|
38
|
+
buildOutDir: string
|
|
39
|
+
devPort: number
|
|
40
|
+
brand: { name: string; logo: string | null }
|
|
41
|
+
fields: Record<string, string>
|
|
42
|
+
slots: Record<string, string>
|
|
43
|
+
views: Record<string, string>
|
|
44
|
+
pages: Record<string, PageEntry>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** importMap entries are thunked dynamic imports. */
|
|
48
|
+
export type ComponentLoader = () => Promise<{ default: Component<any> }>
|
|
49
|
+
export type ImportMap = Record<string, ComponentLoader>
|
|
50
|
+
|
|
51
|
+
export interface QuoinContext {
|
|
52
|
+
config: ResolvedQuoinConfig
|
|
53
|
+
importMap: ImportMap
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* -------------------------------------------------------------------------- */
|
|
57
|
+
/* Context key + set/get */
|
|
58
|
+
/* -------------------------------------------------------------------------- */
|
|
59
|
+
|
|
60
|
+
const CONTEXT_KEY = Symbol('quoin.context')
|
|
61
|
+
|
|
62
|
+
export function setQuoinContext(ctx: QuoinContext): void {
|
|
63
|
+
setContext(CONTEXT_KEY, ctx)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getQuoinContext(): QuoinContext {
|
|
67
|
+
const ctx = getContext<QuoinContext | undefined>(CONTEXT_KEY)
|
|
68
|
+
if (!ctx) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
'quoin: context not initialized. Wrap your component tree in <AdminRoot> (or call setQuoinContext() directly in tests).',
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
return ctx
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* -------------------------------------------------------------------------- */
|
|
77
|
+
/* Lookup helpers */
|
|
78
|
+
/* -------------------------------------------------------------------------- */
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve a slot name to the component override, or null if no override.
|
|
82
|
+
*
|
|
83
|
+
* Matching rules (in order):
|
|
84
|
+
* 1. Exact key ("collection.posts.row-actions")
|
|
85
|
+
* 2. Wildcard form ("collection.*.row-actions") — any dot-segment can be `*`
|
|
86
|
+
*
|
|
87
|
+
* The wildcard check replaces EACH non-first segment in turn with `*` and
|
|
88
|
+
* looks it up. For the majority case (flat names like "dashboard.actions"),
|
|
89
|
+
* this reduces to one extra lookup.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveSlot(name: string, config: ResolvedQuoinConfig): string | null {
|
|
92
|
+
if (config.slots[name]) return config.slots[name]
|
|
93
|
+
|
|
94
|
+
const segments = name.split('.')
|
|
95
|
+
for (let i = 1; i < segments.length; i++) {
|
|
96
|
+
const withWildcard = [...segments]
|
|
97
|
+
withWildcard[i] = '*'
|
|
98
|
+
const key = withWildcard.join('.')
|
|
99
|
+
if (config.slots[key]) return config.slots[key]
|
|
100
|
+
}
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a field type to its component override (consumer's config.fields).
|
|
106
|
+
* Returns the override path if present; otherwise null and the caller falls
|
|
107
|
+
* back to the built-in registry.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveField(type: string, config: ResolvedQuoinConfig): string | null {
|
|
110
|
+
return config.fields[type] ?? null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve a view key like "collection.posts.edit" → consumer override path
|
|
115
|
+
* or null. Supports exact match + wildcards in the same style as resolveSlot.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveView(key: string, config: ResolvedQuoinConfig): string | null {
|
|
118
|
+
if (config.views[key]) return config.views[key]
|
|
119
|
+
|
|
120
|
+
const segments = key.split('.')
|
|
121
|
+
for (let i = 1; i < segments.length; i++) {
|
|
122
|
+
const withWildcard = [...segments]
|
|
123
|
+
withWildcard[i] = '*'
|
|
124
|
+
const fullWildcard = withWildcard.join('.')
|
|
125
|
+
if (config.views[fullWildcard]) return config.views[fullWildcard]
|
|
126
|
+
}
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Memoized component loader. importMap's dynamic imports return a Promise;
|
|
132
|
+
* we cache the resolved module once it's first loaded to avoid re-imports
|
|
133
|
+
* across re-renders.
|
|
134
|
+
*/
|
|
135
|
+
const moduleCache = new Map<string, Component<any>>()
|
|
136
|
+
|
|
137
|
+
export async function loadComponent(
|
|
138
|
+
path: string,
|
|
139
|
+
importMap: ImportMap,
|
|
140
|
+
): Promise<Component<any>> {
|
|
141
|
+
const cached = moduleCache.get(path)
|
|
142
|
+
if (cached) return cached
|
|
143
|
+
|
|
144
|
+
const loader = importMap[path]
|
|
145
|
+
if (!loader) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`quoin: component path "${path}" referenced in config but missing from importMap. ` +
|
|
148
|
+
`If you just edited admin.config.ts, restart the dev server or rerun build:admin.`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const mod = await loader()
|
|
153
|
+
const component = mod.default
|
|
154
|
+
moduleCache.set(path, component)
|
|
155
|
+
return component
|
|
156
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte 5 client-side router for @quoin-cms/admin.
|
|
3
|
+
*
|
|
4
|
+
* Pushstate-based. No SvelteKit dependency. The admin SPA mounts at a
|
|
5
|
+
* configurable base path (default `/admin`) and manages all navigation
|
|
6
|
+
* client-side. Go backend serves index.html for any URL under the base.
|
|
7
|
+
*
|
|
8
|
+
* Public API:
|
|
9
|
+
* routeStore — reactive Svelte 5 rune-store of current { path, params, query }
|
|
10
|
+
* navigate(to) — programmatic navigation, mirrors `goto()` from SvelteKit
|
|
11
|
+
* resolve(path) — prefix a path with the base, mirrors `resolve()` from SvelteKit
|
|
12
|
+
* startRouter() — boot the router (mount-time hook)
|
|
13
|
+
* stopRouter() — teardown (rare; mostly for tests)
|
|
14
|
+
*
|
|
15
|
+
* Route matching is kept in `matcher.ts` so callers can unit-test patterns
|
|
16
|
+
* without touching the history API.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { matchRoute, type RouteMatch, type RoutePattern } from './matcher.js'
|
|
20
|
+
|
|
21
|
+
/* -------------------------------------------------------------------------- */
|
|
22
|
+
/* Config */
|
|
23
|
+
/* -------------------------------------------------------------------------- */
|
|
24
|
+
|
|
25
|
+
let basePath = '/admin'
|
|
26
|
+
|
|
27
|
+
/** Set the base path (call once at boot). */
|
|
28
|
+
export function setBasePath(base: string): void {
|
|
29
|
+
basePath = base.endsWith('/') ? base.slice(0, -1) : base
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Get the current base path. */
|
|
33
|
+
export function getBasePath(): string {
|
|
34
|
+
return basePath
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* -------------------------------------------------------------------------- */
|
|
38
|
+
/* Reactive route state */
|
|
39
|
+
/* -------------------------------------------------------------------------- */
|
|
40
|
+
|
|
41
|
+
export interface RouteState {
|
|
42
|
+
/** Current path relative to basePath (e.g. "/collections/posts"). */
|
|
43
|
+
path: string
|
|
44
|
+
/** Named params matched from the active route pattern. */
|
|
45
|
+
params: Record<string, string>
|
|
46
|
+
/** Query string parsed from window.location.search. */
|
|
47
|
+
query: URLSearchParams
|
|
48
|
+
/** Full pathname including basePath (e.g. "/admin/collections/posts"). */
|
|
49
|
+
fullPath: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Svelte 5 rune-backed store. Components read via `route.path`, `route.params`
|
|
54
|
+
* inside `$effect`/`$derived` and re-render on navigation.
|
|
55
|
+
*/
|
|
56
|
+
class RouteStore {
|
|
57
|
+
#state = $state<RouteState>({
|
|
58
|
+
path: '/',
|
|
59
|
+
params: {},
|
|
60
|
+
query: new URLSearchParams(),
|
|
61
|
+
fullPath: basePath + '/',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
get path(): string {
|
|
65
|
+
return this.#state.path
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get params(): Record<string, string> {
|
|
69
|
+
return this.#state.params
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get query(): URLSearchParams {
|
|
73
|
+
return this.#state.query
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get fullPath(): string {
|
|
77
|
+
return this.#state.fullPath
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Internal: update from the current window.location. */
|
|
81
|
+
_sync(patterns: RoutePattern[] = []): void {
|
|
82
|
+
const { pathname, search } = window.location
|
|
83
|
+
const relative = stripBase(pathname)
|
|
84
|
+
const query = new URLSearchParams(search)
|
|
85
|
+
|
|
86
|
+
// Match the first pattern that fits; if none, params stays empty.
|
|
87
|
+
let params: Record<string, string> = {}
|
|
88
|
+
for (const pattern of patterns) {
|
|
89
|
+
const m = matchRoute(relative, pattern)
|
|
90
|
+
if (m) {
|
|
91
|
+
params = m.params
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.#state = { path: relative, params, query, fullPath: pathname }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Internal: explicit state update (used by navigate with param hint). */
|
|
100
|
+
_set(next: RouteState): void {
|
|
101
|
+
this.#state = next
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const routeStore = new RouteStore()
|
|
106
|
+
|
|
107
|
+
/* -------------------------------------------------------------------------- */
|
|
108
|
+
/* Navigation API */
|
|
109
|
+
/* -------------------------------------------------------------------------- */
|
|
110
|
+
|
|
111
|
+
/** Patterns are registered by AdminRoot so the store knows how to parse params. */
|
|
112
|
+
let registeredPatterns: RoutePattern[] = []
|
|
113
|
+
|
|
114
|
+
/** Register the patterns the app cares about (called once at boot). */
|
|
115
|
+
export function registerRoutes(patterns: RoutePattern[]): void {
|
|
116
|
+
registeredPatterns = patterns
|
|
117
|
+
routeStore._sync(registeredPatterns)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Navigate programmatically. `to` may be:
|
|
122
|
+
* - relative to basePath ("/login") — basePath is prepended
|
|
123
|
+
* - already prefixed (e.g. output of resolve(), "/admin/login") — used as-is
|
|
124
|
+
* - relative ("login") — basePath + "/" + to
|
|
125
|
+
*
|
|
126
|
+
* This reconciliation keeps `goto(resolve('/x'))` working like SvelteKit.
|
|
127
|
+
*/
|
|
128
|
+
export function navigate(to: string, options: { replace?: boolean } = {}): void {
|
|
129
|
+
const full = to.startsWith(basePath + '/') || to === basePath
|
|
130
|
+
? to
|
|
131
|
+
: to.startsWith('/')
|
|
132
|
+
? basePath + to
|
|
133
|
+
: basePath + '/' + to
|
|
134
|
+
|
|
135
|
+
// Skip no-op navigations (browser scrolls to top otherwise).
|
|
136
|
+
if (full === window.location.pathname + window.location.search) return
|
|
137
|
+
|
|
138
|
+
if (options.replace) {
|
|
139
|
+
window.history.replaceState({}, '', full)
|
|
140
|
+
} else {
|
|
141
|
+
window.history.pushState({}, '', full)
|
|
142
|
+
}
|
|
143
|
+
routeStore._sync(registeredPatterns)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Alias — matches SvelteKit's `goto()` so ported views keep their call sites. */
|
|
147
|
+
export const goto = navigate
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve a relative admin path to an absolute URL that preserves basePath.
|
|
151
|
+
* Mirrors SvelteKit's `$app/paths.resolve()` so porting route components is
|
|
152
|
+
* a one-word import swap.
|
|
153
|
+
*/
|
|
154
|
+
export function resolve(path: string): string {
|
|
155
|
+
if (path.startsWith(basePath)) return path
|
|
156
|
+
if (path.startsWith('/')) return basePath + path
|
|
157
|
+
return basePath + '/' + path
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* -------------------------------------------------------------------------- */
|
|
161
|
+
/* beforeNavigate — dirty-form guard */
|
|
162
|
+
/* -------------------------------------------------------------------------- */
|
|
163
|
+
|
|
164
|
+
type BeforeNavigateCallback = (cancel: () => void) => void
|
|
165
|
+
|
|
166
|
+
const beforeNavigateCallbacks = new Set<BeforeNavigateCallback>()
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Register a guard that fires before any navigation. Call the passed `cancel`
|
|
170
|
+
* fn to block the navigation (e.g. when a form is dirty).
|
|
171
|
+
* Returns an unregister function.
|
|
172
|
+
*/
|
|
173
|
+
export function beforeNavigate(cb: BeforeNavigateCallback): () => void {
|
|
174
|
+
beforeNavigateCallbacks.add(cb)
|
|
175
|
+
return () => beforeNavigateCallbacks.delete(cb)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function runBeforeGuards(): boolean {
|
|
179
|
+
let cancelled = false
|
|
180
|
+
const cancel = () => {
|
|
181
|
+
cancelled = true
|
|
182
|
+
}
|
|
183
|
+
for (const cb of beforeNavigateCallbacks) cb(cancel)
|
|
184
|
+
return !cancelled
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* -------------------------------------------------------------------------- */
|
|
188
|
+
/* Boot / teardown */
|
|
189
|
+
/* -------------------------------------------------------------------------- */
|
|
190
|
+
|
|
191
|
+
let clickHandler: ((e: MouseEvent) => void) | null = null
|
|
192
|
+
let popstateHandler: (() => void) | null = null
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Start the router. Installs a global click interceptor (for `<a>` links under
|
|
196
|
+
* the base path) and a popstate listener (for browser back/forward).
|
|
197
|
+
*/
|
|
198
|
+
export function startRouter(): void {
|
|
199
|
+
if (clickHandler || popstateHandler) return // already started
|
|
200
|
+
|
|
201
|
+
clickHandler = (e: MouseEvent) => {
|
|
202
|
+
if (e.defaultPrevented) return
|
|
203
|
+
if (e.button !== 0) return // only left click
|
|
204
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return // modifier → let browser handle
|
|
205
|
+
|
|
206
|
+
const target = (e.target as Element | null)?.closest('a')
|
|
207
|
+
if (!target) return
|
|
208
|
+
if (target.target && target.target !== '_self') return
|
|
209
|
+
if (target.hasAttribute('download')) return
|
|
210
|
+
if (target.hasAttribute('data-external')) return
|
|
211
|
+
|
|
212
|
+
const href = target.getAttribute('href')
|
|
213
|
+
if (!href) return
|
|
214
|
+
|
|
215
|
+
// Only intercept same-origin, same-base links.
|
|
216
|
+
const url = new URL(href, window.location.origin)
|
|
217
|
+
if (url.origin !== window.location.origin) return
|
|
218
|
+
if (!url.pathname.startsWith(basePath)) return
|
|
219
|
+
|
|
220
|
+
if (!runBeforeGuards()) {
|
|
221
|
+
e.preventDefault()
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
e.preventDefault()
|
|
226
|
+
window.history.pushState({}, '', url.pathname + url.search)
|
|
227
|
+
routeStore._sync(registeredPatterns)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
popstateHandler = () => {
|
|
231
|
+
routeStore._sync(registeredPatterns)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
window.addEventListener('click', clickHandler)
|
|
235
|
+
window.addEventListener('popstate', popstateHandler)
|
|
236
|
+
routeStore._sync(registeredPatterns)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function stopRouter(): void {
|
|
240
|
+
if (clickHandler) window.removeEventListener('click', clickHandler)
|
|
241
|
+
if (popstateHandler) window.removeEventListener('popstate', popstateHandler)
|
|
242
|
+
clickHandler = null
|
|
243
|
+
popstateHandler = null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* -------------------------------------------------------------------------- */
|
|
247
|
+
/* Helpers */
|
|
248
|
+
/* -------------------------------------------------------------------------- */
|
|
249
|
+
|
|
250
|
+
function stripBase(pathname: string): string {
|
|
251
|
+
if (pathname === basePath) return '/'
|
|
252
|
+
if (pathname.startsWith(basePath + '/')) return pathname.slice(basePath.length)
|
|
253
|
+
return pathname
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* -------------------------------------------------------------------------- */
|
|
257
|
+
/* SvelteKit-compat `page` object */
|
|
258
|
+
/* */
|
|
259
|
+
/* Preserves the `page.params.x` / `page.url.pathname` / `page.route.id` */
|
|
260
|
+
/* shape so views ported from SvelteKit need only swap the import path. */
|
|
261
|
+
/* Reactivity is inherited from routeStore's internal $state. */
|
|
262
|
+
/* -------------------------------------------------------------------------- */
|
|
263
|
+
|
|
264
|
+
export const page = {
|
|
265
|
+
get params(): Record<string, string> {
|
|
266
|
+
return routeStore.params
|
|
267
|
+
},
|
|
268
|
+
get url(): URL {
|
|
269
|
+
// Read routeStore.fullPath first to establish a reactive dependency
|
|
270
|
+
// on the underlying $state. Without this, Svelte consumers wouldn't
|
|
271
|
+
// re-evaluate when navigation changes the URL.
|
|
272
|
+
// biome-ignore lint/correctness/noUnusedVariables: reactivity trigger
|
|
273
|
+
const _trigger = routeStore.fullPath
|
|
274
|
+
return new URL(window.location.href)
|
|
275
|
+
},
|
|
276
|
+
get route(): { id: string } {
|
|
277
|
+
return { id: routeStore.path }
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export { matchRoute } from './matcher.js'
|
|
282
|
+
export type { RouteMatch, RoutePattern } from './matcher.js'
|