@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,73 @@
|
|
|
1
|
+
export type ApiResult<T> =
|
|
2
|
+
| { ok: true; data: T }
|
|
3
|
+
| { ok: false; error: string; details?: Record<string, string>; status: number }
|
|
4
|
+
|
|
5
|
+
async function request<T>(path: string, options: RequestInit = {}): Promise<ApiResult<T>> {
|
|
6
|
+
const url = `/api${path}`
|
|
7
|
+
const res = await fetch(url, {
|
|
8
|
+
...options,
|
|
9
|
+
credentials: 'include',
|
|
10
|
+
headers: {
|
|
11
|
+
...(options.body && typeof options.body === 'string'
|
|
12
|
+
? { 'Content-Type': 'application/json' }
|
|
13
|
+
: {}),
|
|
14
|
+
...options.headers,
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
try {
|
|
20
|
+
const err = await res.json()
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
error: err.error || 'Request failed',
|
|
24
|
+
details: err.details,
|
|
25
|
+
status: res.status,
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
error: `Request failed with status ${res.status}`,
|
|
31
|
+
status: res.status,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Handle 204 No Content
|
|
37
|
+
if (res.status === 204) {
|
|
38
|
+
return { ok: true, data: undefined as T }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const data = await res.json()
|
|
42
|
+
return { ok: true, data }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function get<T>(path: string): Promise<ApiResult<T>> {
|
|
46
|
+
return request<T>(path)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function post<T>(path: string, body?: unknown): Promise<ApiResult<T>> {
|
|
50
|
+
return request<T>(path, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function put<T>(path: string, body: unknown): Promise<ApiResult<T>> {
|
|
57
|
+
return request<T>(path, {
|
|
58
|
+
method: 'PUT',
|
|
59
|
+
body: JSON.stringify(body),
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function del<T>(path: string): Promise<ApiResult<T>> {
|
|
64
|
+
return request<T>(path, { method: 'DELETE' })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function upload<T>(path: string, formData: FormData): Promise<ApiResult<T>> {
|
|
68
|
+
return request<T>(path, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
body: formData,
|
|
71
|
+
// Don't set Content-Type for FormData -- browser sets it with boundary
|
|
72
|
+
})
|
|
73
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { upload, type ApiResult } from './client.js'
|
|
2
|
+
|
|
3
|
+
export interface UploadResult {
|
|
4
|
+
url: string
|
|
5
|
+
filename: string
|
|
6
|
+
mimeType: string
|
|
7
|
+
size: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function uploadFile(file: File): Promise<ApiResult<UploadResult>> {
|
|
11
|
+
const formData = new FormData()
|
|
12
|
+
formData.append('file', file)
|
|
13
|
+
return upload<UploadResult>('/upload/media', formData)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* UploadRecord is a record returned by `POST /api/upload/{collection}`.
|
|
18
|
+
* Combines the auto-injected upload metadata (url, filename, mimeType, size,
|
|
19
|
+
* width?, height?) with whatever user-declared fields the upload collection
|
|
20
|
+
* carries (alt, caption, etc.). The shape varies per collection — only
|
|
21
|
+
* the metadata fields are guaranteed.
|
|
22
|
+
*/
|
|
23
|
+
export interface UploadRecord {
|
|
24
|
+
id: string
|
|
25
|
+
url: string
|
|
26
|
+
filename: string
|
|
27
|
+
mimeType: string
|
|
28
|
+
size: number
|
|
29
|
+
width?: number
|
|
30
|
+
height?: number
|
|
31
|
+
[k: string]: unknown
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UploadToCollectionOptions {
|
|
35
|
+
/** Optional user-metadata form fields to include alongside `file`. */
|
|
36
|
+
extra?: Record<string, string>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* POST /api/upload/{collection} — multipart upload into an upload-flagged
|
|
41
|
+
* collection. The server persists bytes via the storage adapter, then
|
|
42
|
+
* creates a record with auto-injected metadata + any `extra` form fields.
|
|
43
|
+
* Returns the freshly-created record.
|
|
44
|
+
*/
|
|
45
|
+
export function uploadToCollection(
|
|
46
|
+
collection: string,
|
|
47
|
+
file: File,
|
|
48
|
+
opts: UploadToCollectionOptions = {},
|
|
49
|
+
): Promise<ApiResult<UploadRecord>> {
|
|
50
|
+
const fd = new FormData()
|
|
51
|
+
fd.append('file', file)
|
|
52
|
+
for (const [k, v] of Object.entries(opts.extra ?? {})) {
|
|
53
|
+
fd.append(k, v)
|
|
54
|
+
}
|
|
55
|
+
return upload<UploadRecord>(`/upload/${encodeURIComponent(collection)}`, fd)
|
|
56
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { get, put } from './client.js'
|
|
2
|
+
import type { ApiResult } from './client.js'
|
|
3
|
+
|
|
4
|
+
export function getGlobal(key: string): Promise<ApiResult<Record<string, any>>> {
|
|
5
|
+
return get<Record<string, any>>(`/globals/${key}`)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function updateGlobal(
|
|
9
|
+
key: string,
|
|
10
|
+
data: Record<string, any>
|
|
11
|
+
): Promise<ApiResult<Record<string, any>>> {
|
|
12
|
+
return put<Record<string, any>>(`/globals/${key}`, data)
|
|
13
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { get, post, put, del } from './client.js'
|
|
2
|
+
import type { PaginatedResponse } from '$lib/types/schema.js'
|
|
3
|
+
import type { ApiResult } from './client.js'
|
|
4
|
+
|
|
5
|
+
export interface ListParams {
|
|
6
|
+
page?: number
|
|
7
|
+
perPage?: number
|
|
8
|
+
sort?: string
|
|
9
|
+
filter?: string
|
|
10
|
+
search?: string
|
|
11
|
+
expand?: string
|
|
12
|
+
fields?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function listRecords(
|
|
16
|
+
key: string,
|
|
17
|
+
params: ListParams = {}
|
|
18
|
+
): Promise<ApiResult<PaginatedResponse<Record<string, any>>>> {
|
|
19
|
+
const searchParams = new URLSearchParams()
|
|
20
|
+
if (params.page) searchParams.set('page', String(params.page))
|
|
21
|
+
if (params.perPage) searchParams.set('perPage', String(params.perPage))
|
|
22
|
+
if (params.sort) searchParams.set('sort', params.sort)
|
|
23
|
+
if (params.filter) searchParams.set('filter', params.filter)
|
|
24
|
+
if (params.search) searchParams.set('search', params.search)
|
|
25
|
+
if (params.expand) searchParams.set('expand', params.expand)
|
|
26
|
+
if (params.fields) searchParams.set('fields', params.fields)
|
|
27
|
+
|
|
28
|
+
const qs = searchParams.toString()
|
|
29
|
+
return get<PaginatedResponse<Record<string, any>>>(
|
|
30
|
+
`/collections/${key}/records${qs ? `?${qs}` : ''}`
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getRecord(
|
|
35
|
+
key: string,
|
|
36
|
+
id: string,
|
|
37
|
+
expand?: string,
|
|
38
|
+
opts?: { draft?: boolean }
|
|
39
|
+
): Promise<ApiResult<Record<string, any>>> {
|
|
40
|
+
const params = new URLSearchParams()
|
|
41
|
+
if (expand) params.set('expand', expand)
|
|
42
|
+
if (opts?.draft) params.set('draft', 'true')
|
|
43
|
+
const qs = params.toString()
|
|
44
|
+
return get<Record<string, any>>(`/collections/${key}/records/${id}${qs ? '?' + qs : ''}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createRecord(
|
|
48
|
+
key: string,
|
|
49
|
+
data: Record<string, any>,
|
|
50
|
+
opts?: { draft?: boolean; autosave?: boolean }
|
|
51
|
+
): Promise<ApiResult<Record<string, any>>> {
|
|
52
|
+
const params = new URLSearchParams()
|
|
53
|
+
if (opts?.draft) params.set('draft', 'true')
|
|
54
|
+
if (opts?.autosave) params.set('autosave', 'true')
|
|
55
|
+
const qs = params.toString()
|
|
56
|
+
return post<Record<string, any>>(`/collections/${key}/records${qs ? '?' + qs : ''}`, data)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function updateRecord(
|
|
60
|
+
key: string,
|
|
61
|
+
id: string,
|
|
62
|
+
data: Record<string, any>,
|
|
63
|
+
opts?: { draft?: boolean; autosave?: boolean }
|
|
64
|
+
): Promise<ApiResult<Record<string, any>>> {
|
|
65
|
+
const params = new URLSearchParams()
|
|
66
|
+
if (opts?.draft) params.set('draft', 'true')
|
|
67
|
+
if (opts?.autosave) params.set('autosave', 'true')
|
|
68
|
+
const qs = params.toString()
|
|
69
|
+
return put<Record<string, any>>(`/collections/${key}/records/${id}${qs ? '?' + qs : ''}`, data)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function deleteRecord(key: string, id: string): Promise<ApiResult<void>> {
|
|
73
|
+
return del<void>(`/collections/${key}/records/${id}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function unpublishRecord(
|
|
77
|
+
key: string,
|
|
78
|
+
id: string,
|
|
79
|
+
): Promise<ApiResult<Record<string, any>>> {
|
|
80
|
+
return post<Record<string, any>>(`/collections/${key}/records/${id}/unpublish`, {})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface LockInfo {
|
|
84
|
+
locked: boolean
|
|
85
|
+
lock?: {
|
|
86
|
+
userId: string
|
|
87
|
+
userName: string
|
|
88
|
+
lockedAt: string
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function acquireLock(key: string, id: string): Promise<ApiResult<LockInfo>> {
|
|
93
|
+
return post<LockInfo>(`/collections/${key}/records/${id}/lock`, {})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function releaseLock(key: string, id: string): Promise<ApiResult<void>> {
|
|
97
|
+
return del<void>(`/collections/${key}/records/${id}/lock`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function checkLockStatus(key: string, id: string): Promise<ApiResult<LockInfo>> {
|
|
101
|
+
return get<LockInfo>(`/collections/${key}/records/${id}/lock`)
|
|
102
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { get, post, type ApiResult } from './client.js'
|
|
2
|
+
|
|
3
|
+
export interface Version {
|
|
4
|
+
id: string
|
|
5
|
+
parent: string
|
|
6
|
+
version: Record<string, any>
|
|
7
|
+
_status?: string
|
|
8
|
+
latest?: boolean
|
|
9
|
+
createdAt: string
|
|
10
|
+
updatedAt: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function listVersions(
|
|
14
|
+
collection: string,
|
|
15
|
+
recordId: string,
|
|
16
|
+
params?: { page?: number; limit?: number },
|
|
17
|
+
): Promise<ApiResult<{ versions: Version[]; totalRecords: number }>> {
|
|
18
|
+
const qs = new URLSearchParams()
|
|
19
|
+
if (params?.page) qs.set('page', String(params.page))
|
|
20
|
+
if (params?.limit) qs.set('limit', String(params.limit))
|
|
21
|
+
const suffix = qs.toString() ? `?${qs.toString()}` : ''
|
|
22
|
+
return get<{ versions: Version[]; totalRecords: number }>(`/collections/${collection}/records/${recordId}/versions${suffix}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getVersion(
|
|
26
|
+
collection: string,
|
|
27
|
+
recordId: string,
|
|
28
|
+
versionId: string,
|
|
29
|
+
): Promise<ApiResult<Version>> {
|
|
30
|
+
return get<Version>(`/collections/${collection}/records/${recordId}/versions/${versionId}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function restoreVersion(
|
|
34
|
+
collection: string,
|
|
35
|
+
recordId: string,
|
|
36
|
+
versionId: string,
|
|
37
|
+
asDraft = true,
|
|
38
|
+
): Promise<ApiResult<Record<string, any>>> {
|
|
39
|
+
return post<Record<string, any>>(`/collections/${collection}/records/${recordId}/versions/${versionId}/restore?draft=${asDraft}`, {})
|
|
40
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { page } from '$lib/router/index.svelte.js'
|
|
3
|
+
import { resolve } from '$lib/router/index.svelte.js'
|
|
4
|
+
import type { CollectionSchema, GlobalSchema } from '$lib/types/schema.js'
|
|
5
|
+
import { Plus, ChevronRight } from 'lucide-svelte'
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
collections,
|
|
9
|
+
globals,
|
|
10
|
+
sidebarOpen = $bindable(true),
|
|
11
|
+
}: {
|
|
12
|
+
collections: CollectionSchema[]
|
|
13
|
+
globals: GlobalSchema[]
|
|
14
|
+
sidebarOpen: boolean
|
|
15
|
+
} = $props()
|
|
16
|
+
|
|
17
|
+
let pathname = $derived(page.url.pathname)
|
|
18
|
+
|
|
19
|
+
let breadcrumbs = $derived.by(() => {
|
|
20
|
+
const parts = pathname.slice(resolve('/').length).split('/').filter(Boolean)
|
|
21
|
+
const crumbs: { label: string; href?: string }[] = []
|
|
22
|
+
|
|
23
|
+
if (parts.length === 0) {
|
|
24
|
+
crumbs.push({ label: 'Dashboard' })
|
|
25
|
+
return crumbs
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (parts[0] === 'files') {
|
|
29
|
+
crumbs.push({ label: 'Files' })
|
|
30
|
+
return crumbs
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (parts[0] === 'globals' && parts[1]) {
|
|
34
|
+
const g = globals.find((gl) => gl.key === parts[1])
|
|
35
|
+
crumbs.push({ label: 'Settings', href: undefined })
|
|
36
|
+
crumbs.push({ label: g?.label || parts[1] })
|
|
37
|
+
return crumbs
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (parts[0] === 'login') {
|
|
41
|
+
crumbs.push({ label: 'Login' })
|
|
42
|
+
return crumbs
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const col = collections.find((c) => c.key === parts[0])
|
|
46
|
+
if (col) {
|
|
47
|
+
crumbs.push({
|
|
48
|
+
label: col.labelPlural || col.label,
|
|
49
|
+
href: resolve(`/${col.key}`),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
if (parts[1] === 'new') {
|
|
53
|
+
crumbs.push({ label: 'New' })
|
|
54
|
+
} else if (parts[1]) {
|
|
55
|
+
crumbs.push({ label: 'Edit' })
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
crumbs.push({ label: parts[0] })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return crumbs
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
let showAddNew = $derived.by(() => {
|
|
65
|
+
const parts = pathname.slice(resolve('/').length).split('/').filter(Boolean)
|
|
66
|
+
if (parts.length === 1) {
|
|
67
|
+
return collections.some((c) => c.key === parts[0])
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
let currentCollectionKey = $derived.by(() => {
|
|
73
|
+
const parts = pathname.slice(resolve('/').length).split('/').filter(Boolean)
|
|
74
|
+
if (parts.length >= 1) {
|
|
75
|
+
const col = collections.find((c) => c.key === parts[0])
|
|
76
|
+
if (col) return col.key
|
|
77
|
+
}
|
|
78
|
+
return null
|
|
79
|
+
})
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<header class="flex h-14 items-center justify-between border-b border-border/60 bg-card/80 px-6 backdrop-blur-sm {sidebarOpen ? '' : 'pl-14'}">
|
|
83
|
+
<nav class="flex items-center gap-1.5 text-sm">
|
|
84
|
+
{#each breadcrumbs as crumb, i}
|
|
85
|
+
{#if i > 0}
|
|
86
|
+
<ChevronRight class="h-3 w-3 text-muted-foreground/50" />
|
|
87
|
+
{/if}
|
|
88
|
+
{#if crumb.href && i < breadcrumbs.length - 1}
|
|
89
|
+
<a href={crumb.href} class="text-muted-foreground transition-colors hover:text-foreground">
|
|
90
|
+
{crumb.label}
|
|
91
|
+
</a>
|
|
92
|
+
{:else}
|
|
93
|
+
<span class="font-medium text-foreground">{crumb.label}</span>
|
|
94
|
+
{/if}
|
|
95
|
+
{/each}
|
|
96
|
+
</nav>
|
|
97
|
+
|
|
98
|
+
{#if showAddNew && currentCollectionKey}
|
|
99
|
+
<a
|
|
100
|
+
href={resolve(`/${currentCollectionKey}/new`)}
|
|
101
|
+
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground shadow-sm transition-all hover:bg-primary/90 hover:shadow-md active:scale-[0.98]"
|
|
102
|
+
>
|
|
103
|
+
<Plus class="h-3.5 w-3.5" />
|
|
104
|
+
Add New
|
|
105
|
+
</a>
|
|
106
|
+
{/if}
|
|
107
|
+
</header>
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { page } from '$lib/router/index.svelte.js'
|
|
3
|
+
import { resolve } from '$lib/router/index.svelte.js'
|
|
4
|
+
import { logout } from '$lib/api/auth.js'
|
|
5
|
+
import { goto } from '$lib/router/index.svelte.js'
|
|
6
|
+
import { toast } from 'svelte-sonner'
|
|
7
|
+
import { branding } from '$lib/stores/branding.svelte.js'
|
|
8
|
+
import type { CollectionSchema, GlobalSchema, AuthUser } from '$lib/types/schema.js'
|
|
9
|
+
import { getQuoinContext } from '$lib/context.svelte.js'
|
|
10
|
+
import Slot from '$lib/Slot.svelte'
|
|
11
|
+
import {
|
|
12
|
+
FileText, LogOut, Settings, PanelLeftClose, PanelLeftOpen, BookOpen,
|
|
13
|
+
PenLine, Users, FolderTree, Tag, BookCopy, Trophy, Megaphone,
|
|
14
|
+
Mail, StickyNote, Menu, Image, FolderOpen, Tags,
|
|
15
|
+
MessageSquare, BarChart3, MousePointerClick, Puzzle, type Icon
|
|
16
|
+
} from 'lucide-svelte'
|
|
17
|
+
import type { Component } from 'svelte'
|
|
18
|
+
|
|
19
|
+
// Consumer-declared custom pages from admin.config.ts. Sidebar auto-renders
|
|
20
|
+
// nav entries for pages with a `nav` block; omit `nav` on a page to hide it
|
|
21
|
+
// (route remains accessible via direct URL).
|
|
22
|
+
const ctx = getQuoinContext()
|
|
23
|
+
const customPagesByGroup = $derived.by(() => {
|
|
24
|
+
const groups: Record<string, Array<{ slug: string; label: string; icon?: string }>> = {}
|
|
25
|
+
for (const [slug, entry] of Object.entries(ctx.config.pages)) {
|
|
26
|
+
if (!entry.nav) continue
|
|
27
|
+
const group = entry.nav.group ?? 'Tools'
|
|
28
|
+
if (!groups[group]) groups[group] = []
|
|
29
|
+
groups[group].push({ slug, label: entry.nav.label, icon: entry.nav.icon })
|
|
30
|
+
}
|
|
31
|
+
return groups
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const collectionIcons: Record<string, Component> = {
|
|
35
|
+
posts: PenLine,
|
|
36
|
+
authors: Users,
|
|
37
|
+
categories: FolderTree,
|
|
38
|
+
tags: Tag,
|
|
39
|
+
series: BookCopy,
|
|
40
|
+
competitions: Trophy,
|
|
41
|
+
ads: Megaphone,
|
|
42
|
+
subscribers: Mail,
|
|
43
|
+
pages: StickyNote,
|
|
44
|
+
menus: Menu,
|
|
45
|
+
media: Image,
|
|
46
|
+
media_folders: FolderOpen,
|
|
47
|
+
media_tags: Tags,
|
|
48
|
+
contact_messages: MessageSquare,
|
|
49
|
+
ad_impressions: BarChart3,
|
|
50
|
+
ad_clicks: MousePointerClick,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getIcon(key: string): Component {
|
|
54
|
+
return collectionIcons[key] || FileText
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let {
|
|
58
|
+
collections,
|
|
59
|
+
globals,
|
|
60
|
+
user,
|
|
61
|
+
open = $bindable(true),
|
|
62
|
+
}: {
|
|
63
|
+
collections: CollectionSchema[]
|
|
64
|
+
globals: GlobalSchema[]
|
|
65
|
+
user: AuthUser | null
|
|
66
|
+
open: boolean
|
|
67
|
+
} = $props()
|
|
68
|
+
|
|
69
|
+
// Group collections by admin.group
|
|
70
|
+
let grouped = $derived.by(() => {
|
|
71
|
+
const groups: Record<string, CollectionSchema[]> = {}
|
|
72
|
+
for (const col of collections) {
|
|
73
|
+
const group = col.admin?.group || 'Collections'
|
|
74
|
+
if (!groups[group]) groups[group] = []
|
|
75
|
+
groups[group].push(col)
|
|
76
|
+
}
|
|
77
|
+
for (const g of Object.values(groups)) {
|
|
78
|
+
g.sort((a, b) => a.label.localeCompare(b.label))
|
|
79
|
+
}
|
|
80
|
+
return groups
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
let groupNames = $derived(Object.keys(grouped).sort())
|
|
84
|
+
let currentPath = $derived(page.url.pathname)
|
|
85
|
+
|
|
86
|
+
function isActive(path: string): boolean {
|
|
87
|
+
return currentPath === path || currentPath.startsWith(`${path}/`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function handleLogout() {
|
|
91
|
+
const result = await logout()
|
|
92
|
+
if (result.ok) {
|
|
93
|
+
await goto(resolve('/login'))
|
|
94
|
+
} else {
|
|
95
|
+
toast.error('Logout failed')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
{#if open}
|
|
101
|
+
<aside class="flex h-full w-64 flex-col bg-sidebar-background text-sidebar-foreground">
|
|
102
|
+
<!-- Brand -->
|
|
103
|
+
<div class="flex h-16 items-center justify-between px-5">
|
|
104
|
+
<a href={resolve('/')} class="flex min-w-0 items-center gap-2.5">
|
|
105
|
+
<!--
|
|
106
|
+
Slot: consumer-provided logo component.
|
|
107
|
+
Default: BookOpen icon + siteName + "Admin" subtext.
|
|
108
|
+
Consumer override receives `{ siteName, logoUrl }` so they can
|
|
109
|
+
match styling to the library's layout expectations.
|
|
110
|
+
-->
|
|
111
|
+
<Slot name="branding.logo" siteName={branding.siteName} logoUrl={branding.logoUrl}>
|
|
112
|
+
{#snippet children()}
|
|
113
|
+
{#if branding.logoUrl}
|
|
114
|
+
<img src={branding.logoUrl} alt={branding.siteName} class="h-9 w-auto max-w-[180px] object-contain" />
|
|
115
|
+
{:else}
|
|
116
|
+
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-sidebar-accent">
|
|
117
|
+
<BookOpen class="h-4 w-4 text-sidebar-accent-foreground" />
|
|
118
|
+
</div>
|
|
119
|
+
<div class="flex flex-col">
|
|
120
|
+
<span class="text-sm font-semibold tracking-tight text-white" style="font-family: var(--font-display);">{branding.siteName}</span>
|
|
121
|
+
<span class="text-[10px] uppercase tracking-widest text-sidebar-muted">Admin</span>
|
|
122
|
+
</div>
|
|
123
|
+
{/if}
|
|
124
|
+
{/snippet}
|
|
125
|
+
</Slot>
|
|
126
|
+
</a>
|
|
127
|
+
<button onclick={() => (open = false)} class="rounded-md p-1.5 text-sidebar-muted transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" title="Collapse sidebar">
|
|
128
|
+
<PanelLeftClose class="h-4 w-4" />
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<!-- Divider -->
|
|
133
|
+
<div class="mx-4 border-t border-sidebar-border"></div>
|
|
134
|
+
|
|
135
|
+
<!-- Navigation -->
|
|
136
|
+
<nav class="flex-1 overflow-y-auto px-3 py-4 scrollbar-hide">
|
|
137
|
+
{#each groupNames as group}
|
|
138
|
+
<div class="mb-5">
|
|
139
|
+
<p class="mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.15em] text-sidebar-muted">
|
|
140
|
+
{group}
|
|
141
|
+
</p>
|
|
142
|
+
<ul class="space-y-0.5">
|
|
143
|
+
{#each grouped[group] as col}
|
|
144
|
+
{@const IconComponent = getIcon(col.key)}
|
|
145
|
+
<li>
|
|
146
|
+
<a
|
|
147
|
+
href={resolve(`/${col.key}`)}
|
|
148
|
+
class="group flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] transition-all duration-150
|
|
149
|
+
{isActive(resolve(`/${col.key}`))
|
|
150
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium shadow-sm'
|
|
151
|
+
: 'text-sidebar-foreground hover:bg-white/[0.04] hover:text-white'}"
|
|
152
|
+
>
|
|
153
|
+
<IconComponent class="h-4 w-4 shrink-0 opacity-60 group-hover:opacity-100 {isActive(resolve(`/${col.key}`)) ? 'opacity-100' : ''}" />
|
|
154
|
+
{col.labelPlural || col.label}
|
|
155
|
+
</a>
|
|
156
|
+
</li>
|
|
157
|
+
{/each}
|
|
158
|
+
</ul>
|
|
159
|
+
</div>
|
|
160
|
+
{/each}
|
|
161
|
+
|
|
162
|
+
<!-- Tools -->
|
|
163
|
+
<div class="mb-5">
|
|
164
|
+
<p class="mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.15em] text-sidebar-muted">
|
|
165
|
+
Tools
|
|
166
|
+
</p>
|
|
167
|
+
<ul class="space-y-0.5">
|
|
168
|
+
<li>
|
|
169
|
+
<a
|
|
170
|
+
href={resolve('/ads-analytics')}
|
|
171
|
+
class="group flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] transition-all duration-150
|
|
172
|
+
{isActive(resolve('/ads-analytics'))
|
|
173
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium shadow-sm'
|
|
174
|
+
: 'text-sidebar-foreground hover:bg-white/[0.04] hover:text-white'}"
|
|
175
|
+
>
|
|
176
|
+
<BarChart3 class="h-4 w-4 shrink-0 opacity-60 group-hover:opacity-100 {isActive(resolve('/ads-analytics')) ? 'opacity-100' : ''}" />
|
|
177
|
+
Ad Analytics
|
|
178
|
+
</a>
|
|
179
|
+
</li>
|
|
180
|
+
</ul>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<!-- Consumer-declared custom pages (from admin.config.ts) -->
|
|
184
|
+
{#each Object.entries(customPagesByGroup) as [group, pages]}
|
|
185
|
+
<div class="mb-5">
|
|
186
|
+
<p class="mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.15em] text-sidebar-muted">
|
|
187
|
+
{group}
|
|
188
|
+
</p>
|
|
189
|
+
<ul class="space-y-0.5">
|
|
190
|
+
{#each pages as pg}
|
|
191
|
+
{@const pgHref = resolve(`/pages/${pg.slug}`)}
|
|
192
|
+
<li>
|
|
193
|
+
<a
|
|
194
|
+
href={pgHref}
|
|
195
|
+
class="group flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] transition-all duration-150
|
|
196
|
+
{isActive(pgHref)
|
|
197
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium shadow-sm'
|
|
198
|
+
: 'text-sidebar-foreground hover:bg-white/[0.04] hover:text-white'}"
|
|
199
|
+
>
|
|
200
|
+
<Puzzle class="h-4 w-4 shrink-0 opacity-60 group-hover:opacity-100 {isActive(pgHref) ? 'opacity-100' : ''}" />
|
|
201
|
+
{pg.label}
|
|
202
|
+
</a>
|
|
203
|
+
</li>
|
|
204
|
+
{/each}
|
|
205
|
+
</ul>
|
|
206
|
+
</div>
|
|
207
|
+
{/each}
|
|
208
|
+
|
|
209
|
+
<!-- Globals -->
|
|
210
|
+
{#if globals.length > 0}
|
|
211
|
+
<div class="mb-5">
|
|
212
|
+
<p class="mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.15em] text-sidebar-muted">
|
|
213
|
+
Settings
|
|
214
|
+
</p>
|
|
215
|
+
<ul class="space-y-0.5">
|
|
216
|
+
{#each globals as g}
|
|
217
|
+
<li>
|
|
218
|
+
<a
|
|
219
|
+
href={resolve(`/globals/${g.key}`)}
|
|
220
|
+
class="group flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] transition-all duration-150
|
|
221
|
+
{isActive(resolve(`/globals/${g.key}`))
|
|
222
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium shadow-sm'
|
|
223
|
+
: 'text-sidebar-foreground hover:bg-white/[0.04] hover:text-white'}"
|
|
224
|
+
>
|
|
225
|
+
<Settings class="h-4 w-4 shrink-0 opacity-60 group-hover:opacity-100 {isActive(resolve(`/globals/${g.key}`)) ? 'opacity-100' : ''}" />
|
|
226
|
+
{g.label}
|
|
227
|
+
</a>
|
|
228
|
+
</li>
|
|
229
|
+
{/each}
|
|
230
|
+
</ul>
|
|
231
|
+
</div>
|
|
232
|
+
{/if}
|
|
233
|
+
</nav>
|
|
234
|
+
|
|
235
|
+
<!-- Footer -->
|
|
236
|
+
<div class="border-t border-sidebar-border px-4 py-3">
|
|
237
|
+
<div class="flex items-center justify-between">
|
|
238
|
+
<div class="flex items-center gap-2.5">
|
|
239
|
+
<div class="flex h-7 w-7 items-center justify-center rounded-full bg-sidebar-accent text-[11px] font-semibold text-sidebar-accent-foreground">
|
|
240
|
+
{user?.username?.charAt(0)?.toUpperCase() || '?'}
|
|
241
|
+
</div>
|
|
242
|
+
<span class="text-xs text-sidebar-foreground">{user?.username}</span>
|
|
243
|
+
</div>
|
|
244
|
+
<button
|
|
245
|
+
onclick={handleLogout}
|
|
246
|
+
class="rounded-md p-1.5 text-sidebar-muted transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
|
247
|
+
title="Logout"
|
|
248
|
+
>
|
|
249
|
+
<LogOut class="h-3.5 w-3.5" />
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
</aside>
|
|
254
|
+
{:else}
|
|
255
|
+
<button
|
|
256
|
+
onclick={() => (open = true)}
|
|
257
|
+
class="fixed left-3 top-4 z-50 rounded-lg border border-border bg-card p-2 shadow-md transition-colors hover:bg-secondary"
|
|
258
|
+
title="Open sidebar"
|
|
259
|
+
>
|
|
260
|
+
<PanelLeftOpen class="h-4 w-4" />
|
|
261
|
+
</button>
|
|
262
|
+
{/if}
|