@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,52 @@
1
+ /**
2
+ * Tiny path pattern matcher — no dependency, no regex in callers.
3
+ *
4
+ * pattern "/collections/:slug" matches "/collections/posts"
5
+ * pattern "/collections/:slug/records/:id" matches "/collections/posts/records/123"
6
+ * pattern "/pages/*" matches "/pages/anything/nested"
7
+ *
8
+ * Named segments `:name` capture into `params.name`. A trailing `*` captures
9
+ * the remainder into `params.rest`.
10
+ */
11
+
12
+ export interface RoutePattern {
13
+ pattern: string
14
+ }
15
+
16
+ export interface RouteMatch {
17
+ params: Record<string, string>
18
+ }
19
+
20
+ export function matchRoute(path: string, route: RoutePattern): RouteMatch | null {
21
+ const patternParts = splitPath(route.pattern)
22
+ const pathParts = splitPath(path)
23
+
24
+ const params: Record<string, string> = {}
25
+
26
+ for (let i = 0; i < patternParts.length; i++) {
27
+ const pp = patternParts[i]
28
+
29
+ if (pp === '*') {
30
+ params.rest = pathParts.slice(i).join('/')
31
+ return { params }
32
+ }
33
+
34
+ const pathSeg = pathParts[i]
35
+ if (pathSeg === undefined) return null
36
+
37
+ if (pp.startsWith(':')) {
38
+ params[pp.slice(1)] = decodeURIComponent(pathSeg)
39
+ continue
40
+ }
41
+
42
+ if (pp !== pathSeg) return null
43
+ }
44
+
45
+ // Exact length match (no trailing pattern segments, no leftover path segments).
46
+ if (patternParts.length !== pathParts.length) return null
47
+ return { params }
48
+ }
49
+
50
+ function splitPath(p: string): string[] {
51
+ return p.split('/').filter(Boolean)
52
+ }
@@ -0,0 +1,74 @@
1
+ import { getGlobal } from '$lib/api/globals.js'
2
+ import { getQuoinContext } from '$lib/context.svelte.js'
3
+
4
+ /**
5
+ * Reactive branding store.
6
+ *
7
+ * Source precedence (later overrides earlier):
8
+ * 1. Library fallback ("Quoin", no logo)
9
+ * 2. Consumer's `admin.config.ts` → config.brand
10
+ * 3. site-settings global in Postgres (via loadBranding())
11
+ *
12
+ * Layer 3 lets operators edit the site name / logo through the admin UI
13
+ * itself without redeploying. Layer 2 is for defaults that ship with the
14
+ * consumer's build.
15
+ */
16
+ interface Branding {
17
+ siteName: string
18
+ logoUrl: string | null
19
+ faviconUrl: string | null
20
+ }
21
+
22
+ export const branding = $state<Branding>({
23
+ siteName: 'Quoin',
24
+ logoUrl: null,
25
+ faviconUrl: null,
26
+ })
27
+
28
+ let loaded = false
29
+
30
+ /**
31
+ * Seed the branding store from the quoin context (config.brand). Call once
32
+ * from AdminRoot right after context is set, before site-settings load.
33
+ */
34
+ export function seedBrandingFromConfig(): void {
35
+ try {
36
+ const ctx = getQuoinContext()
37
+ if (ctx.config.brand.name) branding.siteName = ctx.config.brand.name
38
+ if (ctx.config.brand.logo) branding.logoUrl = ctx.config.brand.logo
39
+ } catch {
40
+ // Context not yet set — caller should have seeded after setQuoinContext().
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Fetch branding overrides from site-settings global. Called once on admin
46
+ * app mount. Falls back silently if site-settings isn't present or the
47
+ * consumer's schema doesn't define it.
48
+ */
49
+ export async function loadBranding(): Promise<void> {
50
+ if (loaded) return
51
+ loaded = true
52
+
53
+ const result = await getGlobal('site-settings')
54
+ if (!result.ok) return
55
+
56
+ const data = result.data ?? {}
57
+
58
+ if (typeof data.siteName === 'string' && data.siteName.trim()) {
59
+ branding.siteName = data.siteName.trim()
60
+ }
61
+
62
+ // Logo is a file field — returns { url, filename } object or null
63
+ if (data.logo && typeof data.logo === 'object' && typeof data.logo.url === 'string') {
64
+ branding.logoUrl = data.logo.url
65
+ } else if (typeof data.logo === 'string' && data.logo) {
66
+ branding.logoUrl = data.logo
67
+ }
68
+
69
+ if (data.favicon && typeof data.favicon === 'object' && typeof data.favicon.url === 'string') {
70
+ branding.faviconUrl = data.favicon.url
71
+ } else if (typeof data.favicon === 'string' && data.favicon) {
72
+ branding.faviconUrl = data.favicon
73
+ }
74
+ }
@@ -0,0 +1,17 @@
1
+ import type { CollectionSchema, GlobalSchema } from '$lib/types/schema.js'
2
+
3
+ export interface SchemaData {
4
+ collections: CollectionSchema[]
5
+ globals: GlobalSchema[]
6
+ }
7
+
8
+ // Module-level reactive state — shared across all components.
9
+ // This avoids Svelte context timing issues entirely.
10
+ export const schema: SchemaData = $state({ collections: [], globals: [] })
11
+
12
+ export function getCollectionByKey(
13
+ collections: CollectionSchema[],
14
+ key: string
15
+ ): CollectionSchema | undefined {
16
+ return collections.find((c) => c.key === key)
17
+ }
@@ -0,0 +1,126 @@
1
+ export type FieldType =
2
+ | 'text'
3
+ | 'textarea'
4
+ | 'number'
5
+ | 'email'
6
+ | 'select'
7
+ | 'checkbox'
8
+ | 'date'
9
+ | 'richtext'
10
+ | 'relationship'
11
+ | 'file'
12
+ | 'upload'
13
+ | 'slug'
14
+ | 'color'
15
+ | 'json'
16
+ | 'tags'
17
+ | 'tabs'
18
+ | 'row'
19
+ | 'url'
20
+ | 'uuid'
21
+
22
+ export interface FieldSchema {
23
+ name: string
24
+ type: FieldType
25
+ label: string
26
+ required: boolean
27
+ hidden?: boolean
28
+ /** Sub-variant for text fields (e.g. "password"). Phase 21 D-06.
29
+ * Emitted by field.Text.ToJSON() when Text.Type != "". */
30
+ variant?: string
31
+ // Text/Textarea
32
+ minLength?: number
33
+ maxLength?: number
34
+ pattern?: string
35
+ defaultValue?: any
36
+ unique?: boolean
37
+ indexed?: boolean
38
+ // Number
39
+ min?: number
40
+ max?: number
41
+ integerOnly?: boolean
42
+ // Select
43
+ options?: { value: string; label: string }[]
44
+ // Relationship / Upload
45
+ relatesTo?: string
46
+ relationType?: 'belongsTo' | 'manyToMany'
47
+ /** Discriminator emitted by field.Upload.ToJSON; absent for plain field.Relationship. */
48
+ _upload?: boolean
49
+ // File
50
+ multiple?: boolean
51
+ accept?: string[]
52
+ maxSizeKB?: number
53
+ maxFiles?: number
54
+ // Slug
55
+ fromField?: string
56
+ // Tags
57
+ maxItems?: number
58
+ // Visual grouping
59
+ group?: string
60
+ // Tabs (only when type === 'tabs')
61
+ tabs?: { label: string; fields: FieldSchema[] }[]
62
+ // Row (only when type === 'row') — children rendered side-by-side
63
+ fields?: FieldSchema[]
64
+ }
65
+
66
+ export interface TabsFieldSchema extends FieldSchema {
67
+ type: 'tabs'
68
+ tabs: { label: string; fields: FieldSchema[] }[]
69
+ }
70
+
71
+ export interface VersionsSchema {
72
+ maxPerDoc: number
73
+ drafts?: {
74
+ autosave?: { interval: number }
75
+ validate?: boolean
76
+ schedulePublish?: boolean
77
+ }
78
+ }
79
+
80
+ export interface AdminConfig {
81
+ useAsTitle?: string
82
+ defaultColumns?: string[]
83
+ defaultSort?: string
84
+ group?: string
85
+ sidebarFields?: string[]
86
+ description?: string
87
+ }
88
+
89
+ export interface CollectionSchema {
90
+ key: string
91
+ label: string
92
+ labelPlural?: string
93
+ fields: FieldSchema[]
94
+ admin?: AdminConfig
95
+ versions?: VersionsSchema
96
+ }
97
+
98
+ export interface GlobalSchema {
99
+ key: string
100
+ label: string
101
+ fields: FieldSchema[]
102
+ }
103
+
104
+ export interface SchemaResponse {
105
+ collections: CollectionSchema[]
106
+ globals: GlobalSchema[]
107
+ }
108
+
109
+ export interface PaginatedResponse<T> {
110
+ records: T[]
111
+ page: number
112
+ perPage: number
113
+ totalRecords: number
114
+ totalPages: number
115
+ }
116
+
117
+ export interface AuthUser {
118
+ id: string
119
+ username: string
120
+ role: string
121
+ }
122
+
123
+ export interface ApiError {
124
+ error: string
125
+ details?: Record<string, string>
126
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,112 @@
1
+ export interface DiffSegment {
2
+ type: 'equal' | 'add' | 'delete'
3
+ value: string
4
+ }
5
+
6
+ export function valuesEqual(a: unknown, b: unknown): boolean {
7
+ if (a === b) return true
8
+ if (a == null && b == null) return true
9
+ if (a == null || b == null) return false
10
+ if (typeof a === 'object' && typeof b === 'object') {
11
+ return JSON.stringify(a) === JSON.stringify(b)
12
+ }
13
+ return false
14
+ }
15
+
16
+ export function toStringValue(val: unknown): string {
17
+ if (val == null) return ''
18
+ if (typeof val === 'boolean') return val ? 'true' : 'false'
19
+ if (Array.isArray(val)) return val.join(', ')
20
+
21
+ // JSON string — try to parse and re-process
22
+ if (typeof val === 'string' && val.startsWith('{')) {
23
+ try {
24
+ const parsed = JSON.parse(val)
25
+ return toStringValue(parsed)
26
+ } catch {
27
+ return val
28
+ }
29
+ }
30
+
31
+ if (typeof val === 'object') {
32
+ const obj = val as Record<string, any>
33
+ // Lexical/TipTap richtext: extract plain text from node tree
34
+ if (obj.root && obj.root.children) {
35
+ return extractTextFromNodes(obj.root.children)
36
+ }
37
+ // File object: show filename
38
+ if (obj.url && obj.filename) return obj.filename
39
+ if (obj.url) return obj.url
40
+ return JSON.stringify(val, null, 2)
41
+ }
42
+ return String(val)
43
+ }
44
+
45
+ function extractTextFromNodes(nodes: any[]): string {
46
+ const lines: string[] = []
47
+ for (const node of nodes) {
48
+ if (node.text) {
49
+ lines.push(node.text)
50
+ } else if (node.children) {
51
+ const childText = extractTextFromNodes(node.children)
52
+ if (childText) lines.push(childText)
53
+ }
54
+ }
55
+ return lines.join('\n')
56
+ }
57
+
58
+ function tokenize(str: string): string[] {
59
+ return str.match(/\S+|\s+/g) ?? []
60
+ }
61
+
62
+ export function diffWords(oldStr: string, newStr: string): DiffSegment[] {
63
+ const oldTokens = tokenize(oldStr)
64
+ const newTokens = tokenize(newStr)
65
+ const oldLen = oldTokens.length
66
+ const newLen = newTokens.length
67
+
68
+ // Build LCS matrix
69
+ const dp: number[][] = Array.from({ length: oldLen + 1 }, () => Array(newLen + 1).fill(0))
70
+ for (let i = 1; i <= oldLen; i++) {
71
+ for (let j = 1; j <= newLen; j++) {
72
+ if (oldTokens[i - 1] === newTokens[j - 1]) {
73
+ dp[i][j] = dp[i - 1][j - 1] + 1
74
+ } else {
75
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
76
+ }
77
+ }
78
+ }
79
+
80
+ // Backtrack
81
+ const segments: DiffSegment[] = []
82
+ let i = oldLen
83
+ let j = newLen
84
+
85
+ const raw: DiffSegment[] = []
86
+ while (i > 0 || j > 0) {
87
+ if (i > 0 && j > 0 && oldTokens[i - 1] === newTokens[j - 1]) {
88
+ raw.push({ type: 'equal', value: oldTokens[i - 1] })
89
+ i--
90
+ j--
91
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
92
+ raw.push({ type: 'add', value: newTokens[j - 1] })
93
+ j--
94
+ } else {
95
+ raw.push({ type: 'delete', value: oldTokens[i - 1] })
96
+ i--
97
+ }
98
+ }
99
+
100
+ raw.reverse()
101
+
102
+ // Merge consecutive same-type segments
103
+ for (const seg of raw) {
104
+ if (segments.length > 0 && segments[segments.length - 1].type === seg.type) {
105
+ segments[segments.length - 1].value += seg.value
106
+ } else {
107
+ segments.push({ ...seg })
108
+ }
109
+ }
110
+
111
+ return segments
112
+ }
@@ -0,0 +1,50 @@
1
+ import { onMount } from 'svelte'
2
+ import { beforeNavigate } from '$lib/router/index.svelte.js'
3
+
4
+ function deepEqual(a: unknown, b: unknown): boolean {
5
+ if (a === b) return true
6
+ if (typeof a !== typeof b) return false
7
+ if (a === null || b === null) return false
8
+ if (typeof a !== 'object') return false
9
+ const ak = Object.keys(a as object)
10
+ const bk = Object.keys(b as object)
11
+ if (ak.length !== bk.length) return false
12
+ for (const k of ak) {
13
+ if (!deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false
14
+ }
15
+ return true
16
+ }
17
+
18
+ export function useDirtyState<T extends Record<string, unknown>>(
19
+ getCurrent: () => T,
20
+ getInitial: () => T
21
+ ) {
22
+ const isDirty = $derived.by(() => !deepEqual(getCurrent(), getInitial()))
23
+ let bypass = false
24
+
25
+ onMount(() => {
26
+ function handler(e: BeforeUnloadEvent) {
27
+ if (!isDirty || bypass) return
28
+ e.preventDefault()
29
+ e.returnValue = ''
30
+ }
31
+ window.addEventListener('beforeunload', handler)
32
+ return () => window.removeEventListener('beforeunload', handler)
33
+ })
34
+
35
+ beforeNavigate(({ cancel }) => {
36
+ if (bypass) return
37
+ if (isDirty && !confirm('You have unsaved changes. Leave anyway?')) {
38
+ cancel()
39
+ }
40
+ })
41
+
42
+ return {
43
+ get isDirty() {
44
+ return isDirty
45
+ },
46
+ allowNavigation() {
47
+ bypass = true
48
+ },
49
+ }
50
+ }
@@ -0,0 +1,28 @@
1
+ export function formatDate(dateStr: string): string {
2
+ if (!dateStr) return ''
3
+ try {
4
+ const date = new Date(dateStr)
5
+ return date.toLocaleDateString('en-US', {
6
+ year: 'numeric',
7
+ month: 'short',
8
+ day: 'numeric',
9
+ hour: '2-digit',
10
+ minute: '2-digit',
11
+ })
12
+ } catch {
13
+ return dateStr
14
+ }
15
+ }
16
+
17
+ export function formatFileSize(bytes: number): string {
18
+ if (bytes === 0) return '0 B'
19
+ const k = 1024
20
+ const sizes = ['B', 'KB', 'MB', 'GB']
21
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
22
+ return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
23
+ }
24
+
25
+ export function truncate(str: string, maxLength: number = 50): string {
26
+ if (!str || str.length <= maxLength) return str || ''
27
+ return `${str.slice(0, maxLength)}...`
28
+ }
@@ -0,0 +1,34 @@
1
+ // Tiny JSON syntax highlighter — returns an HTML string with span wrappers.
2
+ // No external deps. Use for read-only display only (assumes input is valid JSON).
3
+ const ESC: Record<string, string> = {
4
+ '&': '&amp;',
5
+ '<': '&lt;',
6
+ '>': '&gt;',
7
+ '"': '&quot;',
8
+ "'": '&#39;',
9
+ }
10
+
11
+ function escape(s: string): string {
12
+ return s.replace(/[&<>"']/g, (c) => ESC[c])
13
+ }
14
+
15
+ export function highlightJson(json: string): string {
16
+ const tokenRE =
17
+ /("(?:\\.|[^"\\])*"\s*:?)|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g
18
+ const escaped = escape(json)
19
+ return escaped.replace(tokenRE, (match) => {
20
+ if (match.startsWith('"')) {
21
+ if (match.trimEnd().endsWith(':')) {
22
+ return `<span class="text-amber-700">${match}</span>`
23
+ }
24
+ return `<span class="text-emerald-700">${match}</span>`
25
+ }
26
+ if (match === 'true' || match === 'false') {
27
+ return `<span class="text-blue-700">${match}</span>`
28
+ }
29
+ if (match === 'null') {
30
+ return `<span class="text-stone-500">${match}</span>`
31
+ }
32
+ return `<span class="text-violet-700">${match}</span>`
33
+ })
34
+ }
@@ -0,0 +1,8 @@
1
+ export function slugify(text: string): string {
2
+ return text
3
+ .toLowerCase()
4
+ .replace(/[^\w\s-]/g, '')
5
+ .replace(/[\s_]+/g, '-')
6
+ .replace(/^-+|-+$/g, '')
7
+ .trim()
8
+ }
package/src/main.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { mount } from 'svelte'
2
+ import AdminRoot from './AdminRoot.svelte'
3
+ import { setBasePath, startRouter } from './lib/router/index.svelte.js'
4
+ import './app.css'
5
+
6
+ /**
7
+ * Svelte SPA entry point. Boots the client-side router and mounts AdminRoot.
8
+ *
9
+ * Config comes from virtual modules injected by @quoin-cms/cli's Vite plugin:
10
+ * - `virtual:quoin-config` → ResolvedQuoinConfig
11
+ * - `virtual:quoin-import-map` → { [absPath]: () => import(absPath) }
12
+ *
13
+ * When the admin package is run standalone (from inside @quoin-cms/admin for
14
+ * library development), the library's own vite.config.ts provides stub
15
+ * modules so `pnpm dev` still works against a local Go backend.
16
+ */
17
+
18
+ // @ts-expect-error — virtual module resolved by @quoin-cms/cli / vite plugin
19
+ import config from 'virtual:quoin-config'
20
+ // @ts-expect-error — virtual module resolved by @quoin-cms/cli / vite plugin
21
+ import importMap from 'virtual:quoin-import-map'
22
+
23
+ setBasePath(config.basePath)
24
+ startRouter()
25
+
26
+ const target = document.getElementById('app')
27
+ if (!target) throw new Error('#app mount target not found in index.html')
28
+
29
+ mount(AdminRoot, {
30
+ target,
31
+ props: { config, importMap },
32
+ })
@@ -0,0 +1,73 @@
1
+ <script lang="ts">
2
+ import { goto } from '$lib/router/index.svelte.js'
3
+ import { resolve } from '$lib/router/index.svelte.js'
4
+ import { getMe } from '$lib/api/auth.js'
5
+ import { fetchSchema } from '$lib/api/schema.js'
6
+ import { schema } from '$lib/stores/schema.svelte.js'
7
+ import { loadBranding } from '$lib/stores/branding.svelte.js'
8
+ import AdminSidebar from '$lib/components/AdminSidebar.svelte'
9
+ import AdminHeader from '$lib/components/AdminHeader.svelte'
10
+ import { onMount } from 'svelte'
11
+ import { page } from '$lib/router/index.svelte.js'
12
+ import type { AuthUser } from '$lib/types/schema.js'
13
+ import { BookOpen } from 'lucide-svelte'
14
+
15
+ let { children } = $props()
16
+
17
+ let isDocPage = $derived(/^\/(admin\/)?[^/]+\/(new|[^/]+)$/.test(page.url.pathname.replace(/^\/admin/, '')))
18
+
19
+ let isLoading = $state(true)
20
+ let user = $state<AuthUser | null>(null)
21
+ let sidebarOpen = $state(true)
22
+
23
+ onMount(async () => {
24
+ const meResult = await getMe()
25
+ if (!meResult.ok) {
26
+ await goto(resolve('/login'))
27
+ return
28
+ }
29
+ user = meResult.data
30
+
31
+ const schemaResult = await fetchSchema()
32
+ if (!schemaResult.ok) {
33
+ await goto(resolve('/login'))
34
+ return
35
+ }
36
+ schema.collections = schemaResult.data.collections
37
+ schema.globals = schemaResult.data.globals
38
+
39
+ // Fetch branding (logo, site name) from site-settings global
40
+ loadBranding()
41
+
42
+ isLoading = false
43
+ })
44
+ </script>
45
+
46
+ {#if isLoading}
47
+ <div class="flex h-screen flex-col items-center justify-center gap-4 bg-background">
48
+ <div class="flex items-center gap-3">
49
+ <div class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
50
+ <BookOpen class="h-5 w-5 text-primary animate-spin-slow" />
51
+ </div>
52
+ </div>
53
+ <p class="text-sm text-muted-foreground">Loading admin panel...</p>
54
+ </div>
55
+ {:else}
56
+ <div class="flex h-screen overflow-hidden bg-background">
57
+ <AdminSidebar collections={schema.collections} globals={schema.globals} {user} bind:open={sidebarOpen} />
58
+ <div class="flex flex-1 flex-col overflow-hidden">
59
+ {#if !isDocPage}
60
+ <AdminHeader collections={schema.collections} globals={schema.globals} bind:sidebarOpen />
61
+ {/if}
62
+ <main class="flex-1 overflow-y-auto {isDocPage ? '' : 'p-6'}">
63
+ {#if isDocPage}
64
+ {@render children()}
65
+ {:else}
66
+ <div class="mx-auto max-w-7xl animate-fade-in">
67
+ {@render children()}
68
+ </div>
69
+ {/if}
70
+ </main>
71
+ </div>
72
+ </div>
73
+ {/if}