@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,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,7 @@
1
+ import { get } from './client.js'
2
+ import type { SchemaResponse } from '$lib/types/schema.js'
3
+ import type { ApiResult } from './client.js'
4
+
5
+ export function fetchSchema(): Promise<ApiResult<SchemaResponse>> {
6
+ return get<SchemaResponse>('/_schema')
7
+ }
@@ -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}