@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.
Files changed (110) hide show
  1. package/LICENSE +661 -0
  2. package/biome.json +62 -0
  3. package/dist/assets/index-C9Y5-AKj.js +33 -0
  4. package/dist/assets/index-uVdiUjty.css +1 -0
  5. package/dist/index.html +20 -0
  6. package/index.html +19 -0
  7. package/package.json +43 -0
  8. package/src/AdminRoot.svelte +98 -0
  9. package/src/app.css +211 -0
  10. package/src/lib/Slot.svelte +65 -0
  11. package/src/lib/api/auth.ts +26 -0
  12. package/src/lib/api/client.ts +73 -0
  13. package/src/lib/api/files.ts +56 -0
  14. package/src/lib/api/globals.ts +13 -0
  15. package/src/lib/api/records.ts +102 -0
  16. package/src/lib/api/schema.ts +7 -0
  17. package/src/lib/api/versions.ts +40 -0
  18. package/src/lib/components/AdminHeader.svelte +107 -0
  19. package/src/lib/components/AdminSidebar.svelte +262 -0
  20. package/src/lib/components/DeleteDialog.svelte +58 -0
  21. package/src/lib/components/DocumentEditLayout.svelte +263 -0
  22. package/src/lib/components/DynamicForm.svelte +74 -0
  23. package/src/lib/components/KpiCard.svelte +75 -0
  24. package/src/lib/components/MediaLibrary.svelte +311 -0
  25. package/src/lib/components/Pagination.svelte +78 -0
  26. package/src/lib/components/RangeFilter.svelte +41 -0
  27. package/src/lib/components/RecordGrid.svelte +123 -0
  28. package/src/lib/components/RecordTable.svelte +156 -0
  29. package/src/lib/components/cells/CheckboxCell.svelte +10 -0
  30. package/src/lib/components/cells/ColorCell.svelte +15 -0
  31. package/src/lib/components/cells/DateCell.svelte +8 -0
  32. package/src/lib/components/cells/RelationshipCell.svelte +20 -0
  33. package/src/lib/components/cells/RichTextCell.svelte +21 -0
  34. package/src/lib/components/cells/SelectCell.svelte +26 -0
  35. package/src/lib/components/cells/TextCell.svelte +8 -0
  36. package/src/lib/components/cells/UploadCell.svelte +34 -0
  37. package/src/lib/components/cells/index.ts +28 -0
  38. package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
  39. package/src/lib/components/doc/ApiView.svelte +181 -0
  40. package/src/lib/components/doc/Autosave.svelte +102 -0
  41. package/src/lib/components/doc/DocHeader.svelte +86 -0
  42. package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
  43. package/src/lib/components/doc/DocTabBar.svelte +26 -0
  44. package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
  45. package/src/lib/components/doc/PublishButton.svelte +114 -0
  46. package/src/lib/components/doc/ScheduleModal.svelte +110 -0
  47. package/src/lib/components/doc/VersionHistory.svelte +20 -0
  48. package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
  49. package/src/lib/components/fields/BlockCard.svelte +63 -0
  50. package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
  51. package/src/lib/components/fields/CheckboxField.svelte +27 -0
  52. package/src/lib/components/fields/ColorField.svelte +46 -0
  53. package/src/lib/components/fields/DateField.svelte +52 -0
  54. package/src/lib/components/fields/EmailField.svelte +30 -0
  55. package/src/lib/components/fields/FileField.svelte +280 -0
  56. package/src/lib/components/fields/JsonField.svelte +145 -0
  57. package/src/lib/components/fields/NumberField.svelte +44 -0
  58. package/src/lib/components/fields/PasswordField.svelte +38 -0
  59. package/src/lib/components/fields/RelationshipField.svelte +271 -0
  60. package/src/lib/components/fields/RichTextField.svelte +139 -0
  61. package/src/lib/components/fields/SelectField.svelte +33 -0
  62. package/src/lib/components/fields/SlugField.svelte +70 -0
  63. package/src/lib/components/fields/TabsField.svelte +56 -0
  64. package/src/lib/components/fields/TagsField.svelte +85 -0
  65. package/src/lib/components/fields/TextField.svelte +36 -0
  66. package/src/lib/components/fields/TextareaField.svelte +32 -0
  67. package/src/lib/components/fields/UploadField.svelte +166 -0
  68. package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
  69. package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
  70. package/src/lib/components/fields/index.ts +22 -0
  71. package/src/lib/components/fields/registry.ts +58 -0
  72. package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
  73. package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
  74. package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
  75. package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
  76. package/src/lib/components/lexical/lexical-helpers.ts +24 -0
  77. package/src/lib/components/lexical/nodes.ts +8 -0
  78. package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
  79. package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
  80. package/src/lib/components/versions/CompareSelector.svelte +31 -0
  81. package/src/lib/components/versions/FieldDiff.svelte +141 -0
  82. package/src/lib/components/versions/RestoreModal.svelte +67 -0
  83. package/src/lib/components/versions/StatusPill.svelte +21 -0
  84. package/src/lib/context.svelte.ts +156 -0
  85. package/src/lib/router/index.svelte.ts +282 -0
  86. package/src/lib/router/matcher.ts +52 -0
  87. package/src/lib/stores/branding.svelte.ts +74 -0
  88. package/src/lib/stores/schema.svelte.ts +17 -0
  89. package/src/lib/types/schema.ts +126 -0
  90. package/src/lib/utils/cn.ts +6 -0
  91. package/src/lib/utils/diff.ts +112 -0
  92. package/src/lib/utils/dirty.svelte.ts +50 -0
  93. package/src/lib/utils/format.ts +28 -0
  94. package/src/lib/utils/json-highlight.ts +34 -0
  95. package/src/lib/utils/slug.ts +8 -0
  96. package/src/main.ts +32 -0
  97. package/src/views/AdminLayout.svelte +73 -0
  98. package/src/views/AdsAnalyticsView.svelte +152 -0
  99. package/src/views/CollectionEditView.svelte +117 -0
  100. package/src/views/CollectionListView.svelte +347 -0
  101. package/src/views/CollectionNewView.svelte +68 -0
  102. package/src/views/CustomPageView.svelte +59 -0
  103. package/src/views/DashboardView.svelte +370 -0
  104. package/src/views/GlobalEditView.svelte +100 -0
  105. package/src/views/LoginView.svelte +231 -0
  106. package/src/views/NotFoundView.svelte +9 -0
  107. package/src/views/VersionDetailView.svelte +307 -0
  108. package/src/views/VersionsListView.svelte +201 -0
  109. package/tsconfig.json +25 -0
  110. 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'