@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,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,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
|
+
'&': '&',
|
|
5
|
+
'<': '<',
|
|
6
|
+
'>': '>',
|
|
7
|
+
'"': '"',
|
|
8
|
+
"'": ''',
|
|
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
|
+
}
|
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}
|