@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,152 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte'
3
+ import { toast } from 'svelte-sonner'
4
+ import TimeSeriesChart from '$lib/components/charts/TimeSeriesChart.svelte'
5
+
6
+ type SummaryRow = {
7
+ id: string
8
+ title: string
9
+ impressions: number
10
+ clicks: number
11
+ ctr: number
12
+ active: boolean
13
+ }
14
+ type DayPoint = { day: string; count: number }
15
+ type SeriesPoint = { day: string; dayDate: Date; count: number }
16
+
17
+ let summary = $state<SummaryRow[]>([])
18
+ let impressionsByDay = $state<DayPoint[]>([])
19
+ let clicksByDay = $state<DayPoint[]>([])
20
+ let isLoading = $state(false)
21
+
22
+ // Date range filter -- default last 30 days
23
+ function isoDate(d: Date): string {
24
+ return d.toISOString().slice(0, 10)
25
+ }
26
+ const today = new Date()
27
+ const thirtyAgo = new Date()
28
+ thirtyAgo.setDate(today.getDate() - 30)
29
+
30
+ let from = $state(isoDate(thirtyAgo))
31
+ let to = $state(isoDate(today))
32
+
33
+ let impressionsSeries = $derived<SeriesPoint[]>(
34
+ impressionsByDay.map((p) => ({ ...p, dayDate: new Date(p.day), count: Number(p.count) })),
35
+ )
36
+ let clicksSeries = $derived<SeriesPoint[]>(
37
+ clicksByDay.map((p) => ({ ...p, dayDate: new Date(p.day), count: Number(p.count) })),
38
+ )
39
+
40
+ function pickRangePreset(fromStr: string, toStr: string): string {
41
+ const fromD = new Date(fromStr)
42
+ const toD = new Date(toStr)
43
+ const diffDays = Math.round((toD.getTime() - fromD.getTime()) / (1000 * 60 * 60 * 24))
44
+ if (diffDays <= 1) return 'today'
45
+ if (diffDays <= 7) return '7d'
46
+ if (diffDays <= 30) return '30d'
47
+ return 'all'
48
+ }
49
+
50
+ async function fetchAnalytics() {
51
+ isLoading = true
52
+ const range = pickRangePreset(from, to)
53
+ try {
54
+ const res = await fetch(`/api/ads/analytics?range=${range}&from=${from}&to=${to}`, {
55
+ credentials: 'include',
56
+ })
57
+ if (!res.ok) {
58
+ toast.error(`Failed to load analytics (${res.status})`)
59
+ return
60
+ }
61
+ const data = await res.json()
62
+ summary = (data.summary ?? []) as SummaryRow[]
63
+ impressionsByDay = (data.impressionsByDay ?? []) as DayPoint[]
64
+ clicksByDay = (data.clicksByDay ?? []) as DayPoint[]
65
+ } catch (err) {
66
+ toast.error(`Network error: ${(err as Error).message}`)
67
+ } finally {
68
+ isLoading = false
69
+ }
70
+ }
71
+
72
+ onMount(() => {
73
+ fetchAnalytics()
74
+ })
75
+
76
+ function handleDateChange() {
77
+ fetchAnalytics()
78
+ }
79
+ </script>
80
+
81
+ <div class="space-y-6">
82
+ <header class="flex items-center justify-between">
83
+ <div>
84
+ <h1 class="text-2xl font-semibold tracking-tight">Ad Analytics</h1>
85
+ <p class="text-sm text-muted-foreground">Impressions, clicks, and CTR per ad.</p>
86
+ </div>
87
+ <div class="flex items-end gap-3">
88
+ <label class="flex flex-col text-xs text-muted-foreground">
89
+ From
90
+ <input
91
+ type="date"
92
+ bind:value={from}
93
+ onchange={handleDateChange}
94
+ class="mt-1 h-9 rounded-md border border-border bg-card px-2 text-sm"
95
+ />
96
+ </label>
97
+ <label class="flex flex-col text-xs text-muted-foreground">
98
+ To
99
+ <input
100
+ type="date"
101
+ bind:value={to}
102
+ onchange={handleDateChange}
103
+ class="mt-1 h-9 rounded-md border border-border bg-card px-2 text-sm"
104
+ />
105
+ </label>
106
+ </div>
107
+ </header>
108
+
109
+ <section class="rounded-lg border border-border bg-card p-4">
110
+ <h2 class="mb-3 text-sm font-semibold">Daily Impressions</h2>
111
+ <TimeSeriesChart data={impressionsByDay} emptyMessage="No impressions in range." />
112
+ </section>
113
+
114
+ <section class="rounded-lg border border-border bg-card p-4">
115
+ <h2 class="mb-3 text-sm font-semibold">Daily Clicks</h2>
116
+ <TimeSeriesChart data={clicksByDay} emptyMessage="No clicks in range." />
117
+ </section>
118
+
119
+ <section class="rounded-lg border border-border bg-card">
120
+ <header class="border-b border-border px-4 py-3">
121
+ <h2 class="text-sm font-semibold">Per-Ad Summary</h2>
122
+ </header>
123
+ {#if isLoading}
124
+ <div class="flex justify-center py-12">
125
+ <div class="h-6 w-6 rounded-full border-2 border-primary/20 border-t-primary animate-spin"></div>
126
+ </div>
127
+ {:else if summary.length === 0}
128
+ <p class="px-4 py-8 text-center text-sm text-muted-foreground">No data for selected range.</p>
129
+ {:else}
130
+ <table class="w-full text-sm">
131
+ <thead class="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
132
+ <tr>
133
+ <th class="px-4 py-2">Ad Title</th>
134
+ <th class="px-4 py-2 text-right">Impressions</th>
135
+ <th class="px-4 py-2 text-right">Clicks</th>
136
+ <th class="px-4 py-2 text-right">CTR %</th>
137
+ </tr>
138
+ </thead>
139
+ <tbody>
140
+ {#each summary as row}
141
+ <tr class="border-t border-border/60">
142
+ <td class="px-4 py-2">{row.title || '(untitled)'}</td>
143
+ <td class="px-4 py-2 text-right tabular-nums">{row.impressions}</td>
144
+ <td class="px-4 py-2 text-right tabular-nums">{row.clicks}</td>
145
+ <td class="px-4 py-2 text-right tabular-nums">{row.ctr}</td>
146
+ </tr>
147
+ {/each}
148
+ </tbody>
149
+ </table>
150
+ {/if}
151
+ </section>
152
+ </div>
@@ -0,0 +1,117 @@
1
+ <script lang="ts">
2
+ import { page } from '$lib/router/index.svelte.js'
3
+ import { goto } from '$lib/router/index.svelte.js'
4
+ import { resolve } from '$lib/router/index.svelte.js'
5
+ import { schema, getCollectionByKey } from '$lib/stores/schema.svelte.js'
6
+ import { getRecord, updateRecord, deleteRecord, acquireLock, releaseLock } from '$lib/api/records.js'
7
+ import DocumentEditLayout from '$lib/components/DocumentEditLayout.svelte'
8
+ import DeleteDialog from '$lib/components/DeleteDialog.svelte'
9
+ import { toast } from 'svelte-sonner'
10
+ import { onMount, onDestroy } from 'svelte'
11
+
12
+ let collectionKey = $derived(page.params.collection ?? '')
13
+ let id = $derived(page.params.id ?? '')
14
+ let collection = $derived(getCollectionByKey(schema.collections, collectionKey))
15
+
16
+ let record = $state<Record<string, any> | null>(null)
17
+ let isLoading = $state(true)
18
+ let isSubmitting = $state(false)
19
+ let errors = $state<Record<string, string>>({})
20
+ let deleteDialogOpen = $state(false)
21
+ let isDeleting = $state(false)
22
+ let lockInfo = $state<{ userName: string } | null>(null)
23
+ let lockRefreshInterval: ReturnType<typeof setInterval> | undefined
24
+
25
+ onMount(async () => {
26
+ const hasDrafts = collection?.versions?.drafts != null
27
+ const result = await getRecord(collectionKey, id, '*', { draft: hasDrafts })
28
+ isLoading = false
29
+ if (result.ok) {
30
+ record = result.data
31
+
32
+ // Acquire document lock
33
+ const lockResult = await acquireLock(collectionKey, id)
34
+ if (lockResult.ok && lockResult.data.locked && lockResult.data.lock) {
35
+ lockInfo = { userName: lockResult.data.lock.userName }
36
+ }
37
+ // Refresh lock every 2 minutes
38
+ lockRefreshInterval = setInterval(async () => {
39
+ await acquireLock(collectionKey, id)
40
+ }, 2 * 60 * 1000)
41
+ } else {
42
+ toast.error(result.error)
43
+ }
44
+ })
45
+
46
+ onDestroy(() => {
47
+ if (lockRefreshInterval) clearInterval(lockRefreshInterval)
48
+ releaseLock(collectionKey, id)
49
+ })
50
+
51
+ async function handleSave(data: Record<string, any>) {
52
+ isSubmitting = true
53
+ errors = {}
54
+ const isDraft = data._status === 'draft'
55
+ const result = await updateRecord(collectionKey, id, data, { draft: isDraft })
56
+ isSubmitting = false
57
+ if (result.ok) {
58
+ record = result.data
59
+ toast.success('Record updated')
60
+ } else {
61
+ if (result.details) errors = result.details
62
+ toast.error(result.error)
63
+ }
64
+ }
65
+
66
+ async function handleDelete() {
67
+ isDeleting = true
68
+ const result = await deleteRecord(collectionKey, id)
69
+ isDeleting = false
70
+ deleteDialogOpen = false
71
+ if (result.ok) {
72
+ toast.success('Record deleted')
73
+ await goto(resolve(`/${collectionKey}`))
74
+ } else {
75
+ toast.error(result.error)
76
+ }
77
+ }
78
+ </script>
79
+
80
+ {#if !collection}
81
+ <div class="flex h-full items-center justify-center">
82
+ <p class="text-muted-foreground">Collection not found</p>
83
+ </div>
84
+ {:else if isLoading}
85
+ <div class="flex h-full items-center justify-center">
86
+ <div class="flex flex-col items-center gap-3">
87
+ <div class="h-6 w-6 rounded-full border-2 border-primary/20 border-t-primary animate-spin-slow"></div>
88
+ <p class="text-sm text-muted-foreground">Loading...</p>
89
+ </div>
90
+ </div>
91
+ {:else if !record}
92
+ <div class="flex h-full items-center justify-center">
93
+ <p class="text-muted-foreground">Record not found</p>
94
+ </div>
95
+ {:else}
96
+ {#if lockInfo}
97
+ <div class="border-b border-amber-200 bg-amber-50 px-7 py-2.5 text-sm text-amber-800">
98
+ This document is being edited by <strong>{lockInfo.userName}</strong>. Your changes may be overwritten.
99
+ </div>
100
+ {/if}
101
+ <DocumentEditLayout
102
+ {collection}
103
+ {record}
104
+ mode="edit"
105
+ {isSubmitting}
106
+ {errors}
107
+ onSave={handleSave}
108
+ />
109
+
110
+ <DeleteDialog
111
+ bind:open={deleteDialogOpen}
112
+ title="Delete {collection.label}"
113
+ description="Are you sure you want to delete this record? This action cannot be undone."
114
+ {isDeleting}
115
+ onConfirm={handleDelete}
116
+ />
117
+ {/if}
@@ -0,0 +1,347 @@
1
+ <script lang="ts">
2
+ import { page } from '$lib/router/index.svelte.js'
3
+ import { schema, getCollectionByKey } from '$lib/stores/schema.svelte.js'
4
+ import { listRecords, deleteRecord } from '$lib/api/records.js'
5
+ import RecordTable from '$lib/components/RecordTable.svelte'
6
+ import RecordGrid from '$lib/components/RecordGrid.svelte'
7
+ import Pagination from '$lib/components/Pagination.svelte'
8
+ import DeleteDialog from '$lib/components/DeleteDialog.svelte'
9
+ import { toast } from 'svelte-sonner'
10
+ import { Search, Download, Columns3, ChevronUp, ChevronDown, LayoutGrid, List } from 'lucide-svelte'
11
+
12
+ type ViewMode = 'list' | 'grid'
13
+
14
+ let collectionKey = $derived(page.params.collection ?? '')
15
+ let collection = $derived(getCollectionByKey(schema.collections, collectionKey))
16
+
17
+ let records = $state<Record<string, any>[]>([])
18
+ let currentPage = $state(1)
19
+ let totalPages = $state(1)
20
+ let totalRecords = $state(0)
21
+ let perPage = $state(20)
22
+ let sort = $state('')
23
+ let searchQuery = $state('')
24
+ let isLoading = $state(false)
25
+
26
+ let deleteDialogOpen = $state(false)
27
+ let deleteTargetId = $state('')
28
+ let isDeleting = $state(false)
29
+
30
+ let searchTimeout: ReturnType<typeof setTimeout>
31
+
32
+ let visibleColumns = $state<string[] | null>(null)
33
+ let loadedKey = $state<string | null>(null)
34
+ let columnPickerOpen = $state(false)
35
+ let viewMode = $state<ViewMode>('list')
36
+
37
+ function hasImageField(flds: any[]): boolean {
38
+ return flattenFields(flds).some((f: any) => f.type === 'file' || f.type === 'upload')
39
+ }
40
+
41
+ $effect(() => {
42
+ if (!collection) return
43
+ if (typeof localStorage !== 'undefined') {
44
+ const stored = localStorage.getItem(`view:${collectionKey}`)
45
+ if (stored === 'grid' || stored === 'list') {
46
+ viewMode = stored
47
+ return
48
+ }
49
+ }
50
+ viewMode = collectionKey === 'media' ? 'grid' : 'list'
51
+ })
52
+
53
+ function setViewMode(mode: ViewMode) {
54
+ viewMode = mode
55
+ if (typeof localStorage !== 'undefined') {
56
+ localStorage.setItem(`view:${collectionKey}`, mode)
57
+ }
58
+ }
59
+
60
+ function flattenFields(flds: any[]): any[] {
61
+ const out: any[] = []
62
+ for (const f of flds) {
63
+ if (f.type === 'tabs') {
64
+ for (const t of (f.tabs ?? [])) out.push(...flattenFields(t.fields ?? []))
65
+ continue
66
+ }
67
+ out.push(f)
68
+ }
69
+ return out
70
+ }
71
+ let allFields = $derived(collection ? flattenFields(collection.fields).filter((f: any) => !f.hidden) : [])
72
+
73
+ $effect(() => {
74
+ if (!collection) return
75
+ if (loadedKey === collectionKey) return
76
+ const stored = typeof localStorage !== 'undefined'
77
+ ? localStorage.getItem(`cols:${collectionKey}`)
78
+ : null
79
+ if (stored) {
80
+ try { visibleColumns = JSON.parse(stored) } catch { visibleColumns = null }
81
+ }
82
+ if (!visibleColumns || !visibleColumns.length) {
83
+ visibleColumns = collection.admin?.defaultColumns?.length
84
+ ? [...collection.admin.defaultColumns]
85
+ : flattenFields(collection.fields).filter((f: any) => !f.hidden).slice(0, 4).map((f: any) => f.name)
86
+ }
87
+ loadedKey = collectionKey
88
+ })
89
+
90
+ function persistColumns(next: string[]) {
91
+ visibleColumns = next
92
+ if (typeof localStorage !== 'undefined') {
93
+ localStorage.setItem(`cols:${collectionKey}`, JSON.stringify(next))
94
+ }
95
+ }
96
+
97
+ function toggleColumn(name: string) {
98
+ const curr = visibleColumns ?? []
99
+ persistColumns(curr.includes(name) ? curr.filter((n) => n !== name) : [...curr, name])
100
+ }
101
+
102
+ function moveColumn(name: string, dir: -1 | 1) {
103
+ const curr = [...(visibleColumns ?? [])]
104
+ const i = curr.indexOf(name)
105
+ if (i < 0) return
106
+ const j = i + dir
107
+ if (j < 0 || j >= curr.length) return
108
+ ;[curr[i], curr[j]] = [curr[j], curr[i]]
109
+ persistColumns(curr)
110
+ }
111
+
112
+ let orderedFields = $derived.by(() => {
113
+ const selected = (visibleColumns ?? [])
114
+ .map((n) => allFields.find((f: any) => f.name === n))
115
+ .filter(Boolean)
116
+ const rest = allFields.filter((f: any) => !(visibleColumns ?? []).includes(f.name))
117
+ return [...selected, ...rest]
118
+ })
119
+
120
+ $effect(() => {
121
+ if (collection) {
122
+ fetchRecords()
123
+ }
124
+ })
125
+
126
+ async function fetchRecords() {
127
+ if (!collection) return
128
+ isLoading = true
129
+
130
+ const defaultSort = collection.admin?.defaultSort || '-createdAt'
131
+ const relationshipFields = collection.fields
132
+ .filter((f) => f.type === 'relationship')
133
+ .map((f) => f.name)
134
+ const result = await listRecords(collectionKey, {
135
+ page: currentPage,
136
+ perPage,
137
+ sort: sort || defaultSort,
138
+ search: searchQuery || undefined,
139
+ expand: relationshipFields.length ? relationshipFields.join(',') : undefined,
140
+ })
141
+
142
+ isLoading = false
143
+ if (result.ok) {
144
+ records = result.data.records
145
+ totalPages = result.data.totalPages
146
+ totalRecords = result.data.totalRecords
147
+ } else {
148
+ toast.error(result.error)
149
+ }
150
+ }
151
+
152
+ function handleSearch(e: Event) {
153
+ const target = e.target as HTMLInputElement
154
+ searchQuery = target.value
155
+ clearTimeout(searchTimeout)
156
+ searchTimeout = setTimeout(() => {
157
+ currentPage = 1
158
+ fetchRecords()
159
+ }, 300)
160
+ }
161
+
162
+ function handleSort(newSort: string) {
163
+ sort = newSort
164
+ currentPage = 1
165
+ fetchRecords()
166
+ }
167
+
168
+ function handlePageChange(newPage: number) {
169
+ currentPage = newPage
170
+ fetchRecords()
171
+ }
172
+
173
+ function handleDeleteClick(id: string) {
174
+ deleteTargetId = id
175
+ deleteDialogOpen = true
176
+ }
177
+
178
+ async function confirmDelete() {
179
+ isDeleting = true
180
+ const result = await deleteRecord(collectionKey, deleteTargetId)
181
+ isDeleting = false
182
+ deleteDialogOpen = false
183
+
184
+ if (result.ok) {
185
+ toast.success('Record deleted')
186
+ await fetchRecords()
187
+ } else {
188
+ toast.error(result.error)
189
+ }
190
+ }
191
+ </script>
192
+
193
+ {#if !collection}
194
+ <div class="flex h-full items-center justify-center">
195
+ <p class="text-muted-foreground">Collection not found</p>
196
+ </div>
197
+ {:else}
198
+ <div class="space-y-5">
199
+ <!-- Toolbar: search + collection-specific actions -->
200
+ <div class="flex items-center justify-between gap-3">
201
+ <div class="relative w-full max-w-sm">
202
+ <Search class="absolute left-3.5 top-3 h-4 w-4 text-muted-foreground/60" />
203
+ <input
204
+ type="text"
205
+ value={searchQuery}
206
+ oninput={handleSearch}
207
+ class="h-10 w-full rounded-lg border border-border/80 bg-card pl-10 pr-4 text-sm shadow-sm transition-all placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/10"
208
+ placeholder="Search {collection.labelPlural || collection.label}..."
209
+ />
210
+ </div>
211
+ {#if hasImageField(collection.fields)}
212
+ <div class="inline-flex items-center gap-0.5 rounded-lg border border-border/80 bg-card p-0.5 shadow-sm">
213
+ <button
214
+ type="button"
215
+ onclick={() => setViewMode('list')}
216
+ class="inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors {viewMode === 'list' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-secondary/60'}"
217
+ title="List view"
218
+ >
219
+ <List class="h-4 w-4" />
220
+ </button>
221
+ <button
222
+ type="button"
223
+ onclick={() => setViewMode('grid')}
224
+ class="inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors {viewMode === 'grid' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-secondary/60'}"
225
+ title="Grid view"
226
+ >
227
+ <LayoutGrid class="h-4 w-4" />
228
+ </button>
229
+ </div>
230
+ {/if}
231
+ <div class="relative">
232
+ <button
233
+ type="button"
234
+ onclick={() => (columnPickerOpen = !columnPickerOpen)}
235
+ class="inline-flex items-center gap-2 rounded-lg border border-border/80 bg-card px-3 py-2 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-secondary"
236
+ >
237
+ <Columns3 class="h-4 w-4" />
238
+ Columns
239
+ </button>
240
+ {#if columnPickerOpen}
241
+ <div
242
+ class="absolute right-0 z-20 mt-2 w-56 rounded-lg border border-border/80 bg-card p-2 shadow-lg"
243
+ onmouseleave={() => (columnPickerOpen = false)}
244
+ role="menu"
245
+ >
246
+ {#each orderedFields as f, idx (f.name)}
247
+ {@const checked = (visibleColumns ?? []).includes(f.name)}
248
+ {@const pos = (visibleColumns ?? []).indexOf(f.name)}
249
+ <div class="flex items-center gap-1 rounded px-1 py-0.5 hover:bg-secondary">
250
+ <label class="flex flex-1 cursor-pointer items-center gap-2 px-1 py-1 text-sm">
251
+ <input
252
+ type="checkbox"
253
+ {checked}
254
+ onchange={() => toggleColumn(f.name)}
255
+ />
256
+ <span>{f.label ?? f.name}</span>
257
+ </label>
258
+ {#if checked}
259
+ <button
260
+ type="button"
261
+ class="rounded p-0.5 text-muted-foreground hover:bg-muted disabled:opacity-30"
262
+ disabled={pos <= 0}
263
+ onclick={() => moveColumn(f.name, -1)}
264
+ title="Move up"
265
+ >
266
+ <ChevronUp class="h-3.5 w-3.5" />
267
+ </button>
268
+ <button
269
+ type="button"
270
+ class="rounded p-0.5 text-muted-foreground hover:bg-muted disabled:opacity-30"
271
+ disabled={pos >= (visibleColumns ?? []).length - 1}
272
+ onclick={() => moveColumn(f.name, 1)}
273
+ title="Move down"
274
+ >
275
+ <ChevronDown class="h-3.5 w-3.5" />
276
+ </button>
277
+ {/if}
278
+ </div>
279
+ {/each}
280
+ </div>
281
+ {/if}
282
+ </div>
283
+ {#if collectionKey === 'subscribers'}
284
+ <a
285
+ href="/api/subscribers/export.csv"
286
+ download
287
+ class="inline-flex items-center gap-2 rounded-lg bg-brand-red px-3 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:opacity-90"
288
+ >
289
+ <Download class="h-4 w-4" />
290
+ Export CSV
291
+ </a>
292
+ {/if}
293
+ </div>
294
+
295
+ <!-- Top pagination -->
296
+ <Pagination
297
+ page={currentPage}
298
+ {totalPages}
299
+ {totalRecords}
300
+ {perPage}
301
+ onPageChange={handlePageChange}
302
+ class="border-b border-border/60"
303
+ />
304
+
305
+ <!-- Table -->
306
+ {#if isLoading}
307
+ <div class="flex justify-center py-16">
308
+ <div class="flex flex-col items-center gap-3">
309
+ <div class="h-6 w-6 rounded-full border-2 border-primary/20 border-t-primary animate-spin-slow"></div>
310
+ <p class="text-sm text-muted-foreground">Loading...</p>
311
+ </div>
312
+ </div>
313
+ {:else if viewMode === 'grid'}
314
+ <RecordGrid
315
+ {collection}
316
+ {records}
317
+ onDelete={handleDeleteClick}
318
+ />
319
+ {:else}
320
+ <RecordTable
321
+ {collection}
322
+ {records}
323
+ {sort}
324
+ columnNames={visibleColumns}
325
+ onSort={handleSort}
326
+ onDelete={handleDeleteClick}
327
+ />
328
+ {/if}
329
+
330
+ <!-- Bottom pagination -->
331
+ <Pagination
332
+ page={currentPage}
333
+ {totalPages}
334
+ {totalRecords}
335
+ {perPage}
336
+ onPageChange={handlePageChange}
337
+ />
338
+ </div>
339
+
340
+ <DeleteDialog
341
+ bind:open={deleteDialogOpen}
342
+ title="Delete Record"
343
+ description="Are you sure you want to delete this record? This action cannot be undone."
344
+ {isDeleting}
345
+ onConfirm={confirmDelete}
346
+ />
347
+ {/if}
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import { page } from '$lib/router/index.svelte.js'
3
+ import { goto } from '$lib/router/index.svelte.js'
4
+ import { resolve } from '$lib/router/index.svelte.js'
5
+ import { schema, getCollectionByKey } from '$lib/stores/schema.svelte.js'
6
+ import { createRecord } from '$lib/api/records.js'
7
+ import DocumentEditLayout from '$lib/components/DocumentEditLayout.svelte'
8
+ import { toast } from 'svelte-sonner'
9
+ import { onMount } from 'svelte'
10
+
11
+ let collectionKey = $derived(page.params.collection ?? '')
12
+ let collection = $derived(getCollectionByKey(schema.collections, collectionKey))
13
+
14
+ let isSubmitting = $state(false)
15
+ let errors = $state<Record<string, string>>({})
16
+ let autoCreating = $state(false)
17
+
18
+ // If autosave is enabled, auto-create a draft to get an ID so autosave works
19
+ onMount(async () => {
20
+ if (!collection) return
21
+ const hasAutosave = collection.versions?.drafts?.autosave != null
22
+ if (hasAutosave) {
23
+ autoCreating = true
24
+ const result = await createRecord(collectionKey, { _status: 'draft' }, { draft: true })
25
+ autoCreating = false
26
+ if (result.ok && result.data.id) {
27
+ await goto(resolve(`/${collectionKey}/${result.data.id}`), { replaceState: true })
28
+ }
29
+ }
30
+ })
31
+
32
+ async function handleSave(data: Record<string, any>) {
33
+ if (!collection) return
34
+ isSubmitting = true
35
+ errors = {}
36
+ const isDraft = data._status === 'draft'
37
+ const result = await createRecord(collectionKey, data, { draft: isDraft })
38
+ isSubmitting = false
39
+ if (result.ok) {
40
+ toast.success('Record created')
41
+ await goto(resolve(`/${collectionKey}/${result.data.id}`))
42
+ } else {
43
+ if (result.details) errors = result.details
44
+ toast.error(result.error)
45
+ }
46
+ }
47
+ </script>
48
+
49
+ {#if !collection}
50
+ <div class="flex h-full items-center justify-center">
51
+ <p class="text-muted-foreground">Collection not found</p>
52
+ </div>
53
+ {:else if autoCreating}
54
+ <div class="flex h-full items-center justify-center">
55
+ <div class="flex flex-col items-center gap-3">
56
+ <div class="h-6 w-6 rounded-full border-2 border-primary/20 border-t-primary animate-spin-slow"></div>
57
+ <p class="text-sm text-muted-foreground">Creating draft...</p>
58
+ </div>
59
+ </div>
60
+ {:else}
61
+ <DocumentEditLayout
62
+ {collection}
63
+ mode="create"
64
+ {isSubmitting}
65
+ {errors}
66
+ onSave={handleSave}
67
+ />
68
+ {/if}