@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,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}
|