@quoin-cms/admin 0.1.0 → 0.2.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.
@@ -1,370 +1,109 @@
1
1
  <script lang="ts">
2
+ /**
3
+ * Default dashboard for the quoin admin.
4
+ *
5
+ * Consumer apps that want a custom dashboard register a view in
6
+ * `admin.config.ts`:
7
+ *
8
+ * views: { 'dashboard.main': './admin-overrides/views/MyDashboard.svelte' }
9
+ *
10
+ * AdminRoot resolves that override and renders it instead. This file is the
11
+ * fallback shown when no override is configured — it stays intentionally
12
+ * generic (welcome message, collection counts, recent edits) so it's useful
13
+ * for a freshly-scaffolded project without leaking app-specific concepts.
14
+ */
15
+
2
16
  import { resolve } from '$lib/router/index.svelte.js'
3
- import RangeFilter from '$lib/components/RangeFilter.svelte'
4
- import KpiCard from '$lib/components/KpiCard.svelte'
5
- import TimeSeriesChart from '$lib/components/charts/TimeSeriesChart.svelte'
17
+ import { schema } from '$lib/stores/schema.svelte.js'
18
+ import { branding } from '$lib/stores/branding.svelte.js'
6
19
  import Slot from '$lib/Slot.svelte'
7
20
 
8
- type Range = '24h' | '7d' | '30d'
9
-
10
- type Bucket = { day: string; count: number }
11
- type RangeEnvelope = {
12
- current: number
13
- previous: number
14
- buckets: Bucket[]
15
- }
16
- type TopAdRow = {
17
- adId: string
18
- title: string
19
- impressions: number
20
- clicks: number
21
- ctr: number
22
- }
23
- type TopPostRow = {
24
- postId: string
25
- title: string
26
- slug: string
27
- impressions: number
28
- }
29
- type RecentPostRow = {
30
- id: string
31
- title: string
32
- slug: string
33
- _status: string
34
- updatedAt: string
35
- authorName: string
36
- }
37
- type PostsTopEnvelope = {
38
- records: TopPostRow[]
39
- publishedCurrent: number
40
- publishedPrevious: number
41
- }
42
-
43
- let range = $state<Range>('7d')
44
-
45
- let imp = $state<RangeEnvelope | null>(null)
46
- let clk = $state<RangeEnvelope | null>(null)
47
- let postsTop = $state<PostsTopEnvelope | null>(null)
48
- let topAds = $state<TopAdRow[] | null>(null)
49
- let postsRecent = $state<RecentPostRow[] | null>(null)
50
-
51
- let adsForbidden = $state(false)
52
- let isLoading = $state(false)
53
- let networkErr = $state<string | null>(null)
21
+ type CountState = { value: number | null; error: boolean }
54
22
 
55
- type SortKey = 'ctr' | 'impressions' | 'clicks' | 'title'
56
- let topAdsSort = $state<SortKey>('ctr')
57
- let topAdsDir = $state<'asc' | 'desc'>('desc')
23
+ let counts = $state<Record<string, CountState>>({})
58
24
 
59
- async function fetchJson<T>(url: string): Promise<{ ok: true; data: T } | { ok: false; status: number }> {
25
+ async function fetchCount(key: string): Promise<CountState> {
60
26
  try {
61
- const res = await fetch(url, { credentials: 'include' })
62
- if (!res.ok) return { ok: false, status: res.status }
63
- const data = (await res.json()) as T
64
- return { ok: true, data }
65
- } catch (_err) {
66
- return { ok: false, status: 0 }
27
+ const res = await fetch(`/api/collections/${key}/records?limit=1`, {
28
+ credentials: 'include',
29
+ })
30
+ if (!res.ok) return { value: null, error: true }
31
+ const body = await res.json()
32
+ const total =
33
+ typeof body?.total === 'number'
34
+ ? body.total
35
+ : typeof body?.totalRecords === 'number'
36
+ ? body.totalRecords
37
+ : Array.isArray(body?.records)
38
+ ? body.records.length
39
+ : null
40
+ return { value: total, error: total === null }
41
+ } catch {
42
+ return { value: null, error: true }
67
43
  }
68
44
  }
69
45
 
70
- async function fetchAll(r: Range) {
71
- isLoading = true
72
- networkErr = null
73
-
74
- const [impRes, clkRes, adsTopRes, postsTopRes, recentRes] = await Promise.all([
75
- fetchJson<RangeEnvelope>(`/api/stats/ads/impressions?range=${r}`),
76
- fetchJson<RangeEnvelope>(`/api/stats/ads/clicks?range=${r}`),
77
- fetchJson<{ records: TopAdRow[] }>(`/api/stats/ads/top?range=${r}&limit=5`),
78
- fetchJson<PostsTopEnvelope>(`/api/stats/posts/top?range=${r}&limit=5`),
79
- fetchJson<{ records: RecentPostRow[] }>(`/api/stats/posts/recent?limit=10`),
80
- ])
81
-
82
- // Ad endpoints may 403 for editor role — fall back gracefully.
83
- const adsBlocked =
84
- (impRes.ok === false && impRes.status === 403) ||
85
- (clkRes.ok === false && clkRes.status === 403) ||
86
- (adsTopRes.ok === false && adsTopRes.status === 403)
87
- adsForbidden = adsBlocked
88
-
89
- imp = impRes.ok ? impRes.data : null
90
- clk = clkRes.ok ? clkRes.data : null
91
- topAds = adsTopRes.ok ? adsTopRes.data.records : null
92
- postsTop = postsTopRes.ok ? postsTopRes.data : null
93
- postsRecent = recentRes.ok ? recentRes.data.records : null
94
-
95
- isLoading = false
96
- }
97
-
98
46
  $effect(() => {
99
- void fetchAll(range)
100
- })
101
-
102
- // CTR derivation guard against div by zero.
103
- const ctrCurrent = $derived(
104
- imp && imp.current > 0 && clk ? clk.current / imp.current : imp ? 0 : null,
105
- )
106
- const ctrPrevious = $derived(
107
- imp && imp.previous > 0 && clk ? clk.previous / imp.previous : imp ? 0 : null,
108
- )
109
-
110
- const sortedTopAds = $derived.by(() => {
111
- if (!topAds) return [] as TopAdRow[]
112
- const arr = [...topAds]
113
- const dir = topAdsDir === 'desc' ? -1 : 1
114
- arr.sort((a, b) => {
115
- const av = (a as Record<string, unknown>)[topAdsSort]
116
- const bv = (b as Record<string, unknown>)[topAdsSort]
117
- if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir
118
- return String(av ?? '').localeCompare(String(bv ?? '')) * dir
119
- })
120
- return arr
121
- })
122
-
123
- function toggleSort(key: SortKey) {
124
- if (topAdsSort === key) {
125
- topAdsDir = topAdsDir === 'desc' ? 'asc' : 'desc'
126
- } else {
127
- topAdsSort = key
128
- topAdsDir = 'desc'
47
+ const cols = schema.collections
48
+ if (cols.length === 0) return
49
+ for (const c of cols) {
50
+ if (counts[c.key] !== undefined) continue
51
+ counts[c.key] = { value: null, error: false }
52
+ void fetchCount(c.key).then((s) => {
53
+ counts[c.key] = s
54
+ })
129
55
  }
130
- }
131
-
132
- // Relative time via native Intl.RelativeTimeFormat (no extra dep).
133
- const relFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
134
- function relativeFromNow(iso: string): string {
135
- const then = new Date(iso).getTime()
136
- if (Number.isNaN(then)) return iso
137
- const diffMs = then - Date.now()
138
- const abs = Math.abs(diffMs)
139
- const min = 60_000,
140
- hr = 3_600_000,
141
- day = 86_400_000
142
- if (abs < hr) return relFmt.format(Math.round(diffMs / min), 'minute')
143
- if (abs < day) return relFmt.format(Math.round(diffMs / hr), 'hour')
144
- if (abs < 30 * day) return relFmt.format(Math.round(diffMs / day), 'day')
145
- if (abs < 365 * day) return relFmt.format(Math.round(diffMs / (30 * day)), 'month')
146
- return relFmt.format(Math.round(diffMs / (365 * day)), 'year')
147
- }
148
-
149
- function ctrPct(value: number): string {
150
- return (value * 100).toFixed(2) + '%'
151
- }
56
+ })
152
57
  </script>
153
58
 
154
59
  <div class="mx-auto max-w-7xl space-y-6">
155
- <!-- Header + range filter -->
156
60
  <header class="flex flex-wrap items-start justify-between gap-4">
157
61
  <div>
158
62
  <h1
159
63
  class="text-2xl font-semibold tracking-tight"
160
64
  style="font-family: var(--font-display);"
161
65
  >
162
- Welcome back
66
+ Welcome to {branding.siteName || 'your CMS'}
163
67
  </h1>
164
- <p class="mt-1 text-sm text-muted-foreground">Operational overview</p>
68
+ <p class="mt-1 text-sm text-muted-foreground">
69
+ Manage your content from the sidebar, or override this dashboard via
70
+ <code class="font-mono text-xs">admin.config.ts</code> → <code class="font-mono text-xs">views['dashboard.main']</code>.
71
+ </p>
165
72
  </div>
166
73
  <div class="flex items-center gap-2">
167
74
  <Slot name="dashboard.actions" />
168
- <RangeFilter bind:value={range} />
169
75
  </div>
170
76
  </header>
171
77
 
172
- {#if networkErr}
173
- <div class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
174
- {networkErr}
78
+ {#if schema.collections.length === 0}
79
+ <div class="rounded-lg border border-dashed border-border bg-card p-12 text-center">
80
+ <p class="text-sm text-muted-foreground">
81
+ No collections registered yet. Add a <code class="font-mono text-xs">*quoin.Collection</code> in your Go config to get started.
82
+ </p>
175
83
  </div>
176
- {/if}
177
-
178
- <!-- KPI cards -->
179
- <section class="grid grid-cols-1 gap-4 md:grid-cols-4">
180
- {#if adsForbidden}
181
- <div class="rounded-lg border border-border/60 bg-card p-4 shadow-sm">
182
- <p class="text-sm font-medium text-muted-foreground">Total Impressions</p>
183
- <p class="mt-2 text-3xl font-semibold tracking-tight text-muted-foreground">—</p>
184
- <p class="mt-2 text-xs text-muted-foreground">Admin only</p>
185
- </div>
186
- <div class="rounded-lg border border-border/60 bg-card p-4 shadow-sm">
187
- <p class="text-sm font-medium text-muted-foreground">Total Clicks</p>
188
- <p class="mt-2 text-3xl font-semibold tracking-tight text-muted-foreground">—</p>
189
- <p class="mt-2 text-xs text-muted-foreground">Admin only</p>
190
- </div>
191
- <div class="rounded-lg border border-border/60 bg-card p-4 shadow-sm">
192
- <p class="text-sm font-medium text-muted-foreground">CTR</p>
193
- <p class="mt-2 text-3xl font-semibold tracking-tight text-muted-foreground">—</p>
194
- <p class="mt-2 text-xs text-muted-foreground">Admin only</p>
195
- </div>
196
- {:else}
197
- <KpiCard
198
- label="Total Impressions"
199
- current={imp ? imp.current : null}
200
- previous={imp ? imp.previous : null}
201
- format="int"
202
- />
203
- <KpiCard
204
- label="Total Clicks"
205
- current={clk ? clk.current : null}
206
- previous={clk ? clk.previous : null}
207
- format="int"
208
- />
209
- <KpiCard
210
- label="CTR"
211
- current={ctrCurrent}
212
- previous={ctrPrevious}
213
- format="percent"
214
- />
215
- {/if}
216
- <KpiCard
217
- label="Published Posts"
218
- current={postsTop ? postsTop.publishedCurrent : null}
219
- previous={postsTop ? postsTop.publishedPrevious : null}
220
- format="int"
221
- />
222
- </section>
223
-
224
- <!-- Charts -->
225
- <section class="grid grid-cols-1 gap-4 md:grid-cols-2">
226
- <div class="rounded-lg border border-border/60 bg-card p-4 shadow-sm">
227
- <h3 class="mb-3 text-sm font-semibold">Impressions over time</h3>
228
- {#if adsForbidden}
229
- <div class="flex h-72 items-center justify-center text-sm text-muted-foreground">
230
- Insufficient access
231
- </div>
232
- {:else}
233
- <TimeSeriesChart data={imp ? imp.buckets : []} />
234
- {/if}
235
- </div>
236
- <div class="rounded-lg border border-border/60 bg-card p-4 shadow-sm">
237
- <h3 class="mb-3 text-sm font-semibold">Clicks over time</h3>
238
- {#if adsForbidden}
239
- <div class="flex h-72 items-center justify-center text-sm text-muted-foreground">
240
- Insufficient access
241
- </div>
242
- {:else}
243
- <TimeSeriesChart data={clk ? clk.buckets : []} />
244
- {/if}
245
- </div>
246
- </section>
247
-
248
- <!-- Tables: Top Ads / Top Posts / Recent Posts -->
249
- <section class="grid grid-cols-1 gap-4 lg:grid-cols-3">
250
- <!-- Top Ads -->
251
- <div class="rounded-lg border border-border/60 bg-card shadow-sm">
252
- <header class="border-b border-border px-4 py-3">
253
- <h3 class="text-sm font-semibold">Top Ads</h3>
254
- <p class="text-xs text-muted-foreground">by CTR</p>
255
- </header>
256
- {#if adsForbidden}
257
- <p class="px-4 py-8 text-center text-sm text-muted-foreground">Insufficient access</p>
258
- {:else if !topAds || topAds.length === 0}
259
- <p class="px-4 py-8 text-center text-sm text-muted-foreground">No data for this range</p>
260
- {:else}
261
- <table class="w-full text-sm">
262
- <thead class="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
263
- <tr>
264
- <th class="cursor-pointer px-3 py-2" onclick={() => toggleSort('title')}>Title</th>
265
- <th class="cursor-pointer px-3 py-2 text-right" onclick={() => toggleSort('impressions')}>Imp</th>
266
- <th class="cursor-pointer px-3 py-2 text-right" onclick={() => toggleSort('clicks')}>Clk</th>
267
- <th class="cursor-pointer px-3 py-2 text-right" onclick={() => toggleSort('ctr')}>CTR</th>
268
- </tr>
269
- </thead>
270
- <tbody>
271
- {#each sortedTopAds as row (row.adId)}
272
- <tr class="border-t border-border/60">
273
- <td class="px-3 py-2 truncate max-w-[180px]">{row.title || '(untitled)'}</td>
274
- <td class="px-3 py-2 text-right tabular-nums">{row.impressions}</td>
275
- <td class="px-3 py-2 text-right tabular-nums">{row.clicks}</td>
276
- <td class="px-3 py-2 text-right tabular-nums">{ctrPct(row.ctr)}</td>
277
- </tr>
278
- {/each}
279
- </tbody>
280
- </table>
281
- {/if}
282
- </div>
283
-
284
- <!-- Top Posts -->
285
- <div class="rounded-lg border border-border/60 bg-card shadow-sm">
286
- <header class="border-b border-border px-4 py-3">
287
- <h3 class="text-sm font-semibold">Top Posts</h3>
288
- <p class="text-xs text-muted-foreground">by impressions</p>
289
- </header>
290
- {#if !postsTop || postsTop.records.length === 0}
291
- <p class="px-4 py-8 text-center text-sm text-muted-foreground">No data for this range</p>
292
- {:else}
293
- <table class="w-full text-sm">
294
- <thead class="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
295
- <tr>
296
- <th class="px-3 py-2">Title</th>
297
- <th class="px-3 py-2">Slug</th>
298
- <th class="px-3 py-2 text-right">Imp</th>
299
- </tr>
300
- </thead>
301
- <tbody>
302
- {#each postsTop.records as row (row.postId)}
303
- <tr class="border-t border-border/60">
304
- <td class="px-3 py-2 truncate max-w-[180px]">
305
- <a
306
- href={resolve(`/posts/${row.postId}`)}
307
- class="text-primary hover:underline"
308
- >
309
- {row.title || '(untitled)'}
310
- </a>
311
- </td>
312
- <td class="px-3 py-2 truncate max-w-[120px] text-muted-foreground">{row.slug}</td>
313
- <td class="px-3 py-2 text-right tabular-nums">{row.impressions}</td>
314
- </tr>
315
- {/each}
316
- </tbody>
317
- </table>
318
- {/if}
319
- </div>
320
-
321
- <!-- Recent Posts -->
322
- <div class="rounded-lg border border-border/60 bg-card shadow-sm">
323
- <header class="border-b border-border px-4 py-3">
324
- <h3 class="text-sm font-semibold">Recent Posts</h3>
325
- <p class="text-xs text-muted-foreground">last 10 updated</p>
326
- </header>
327
- {#if !postsRecent || postsRecent.length === 0}
328
- <p class="px-4 py-8 text-center text-sm text-muted-foreground">No recent posts</p>
329
- {:else}
330
- <table class="w-full text-sm">
331
- <thead class="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
332
- <tr>
333
- <th class="px-3 py-2">Title</th>
334
- <th class="px-3 py-2">Status</th>
335
- <th class="px-3 py-2">Updated</th>
336
- </tr>
337
- </thead>
338
- <tbody>
339
- {#each postsRecent as row (row.id)}
340
- <tr class="border-t border-border/60">
341
- <td class="px-3 py-2 truncate max-w-[180px]">
342
- <a
343
- href={resolve(`/posts/${row.id}`)}
344
- class="text-primary hover:underline"
345
- >
346
- {row.title || '(untitled)'}
347
- </a>
348
- </td>
349
- <td class="px-3 py-2">
350
- <span
351
- class="inline-flex rounded px-2 py-0.5 text-xs font-medium {row._status === 'published'
352
- ? 'bg-green-100 text-green-800'
353
- : 'bg-gray-100 text-gray-700'}"
354
- >
355
- {row._status}
356
- </span>
357
- </td>
358
- <td class="px-3 py-2 text-xs text-muted-foreground">{relativeFromNow(row.updatedAt)}</td>
359
- </tr>
360
- {/each}
361
- </tbody>
362
- </table>
363
- {/if}
364
- </div>
365
- </section>
366
-
367
- {#if isLoading}
368
- <p class="text-center text-xs text-muted-foreground">Loading…</p>
84
+ {:else}
85
+ <section class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
86
+ {#each schema.collections as col (col.key)}
87
+ {@const state = counts[col.key]}
88
+ <a
89
+ href={resolve(`/${col.key}`)}
90
+ class="group rounded-lg border border-border/60 bg-card p-4 shadow-sm transition-all hover:border-border hover:shadow"
91
+ >
92
+ <p class="text-sm font-medium text-muted-foreground">
93
+ {col.labelPlural || col.label}
94
+ </p>
95
+ <p class="mt-2 text-3xl font-semibold tracking-tight tabular-nums">
96
+ {#if state === undefined || state.value === null}
97
+ {state?.error ? '—' : '…'}
98
+ {:else}
99
+ {state.value}
100
+ {/if}
101
+ </p>
102
+ <p class="mt-2 text-xs text-muted-foreground group-hover:text-foreground">
103
+ View all →
104
+ </p>
105
+ </a>
106
+ {/each}
107
+ </section>
369
108
  {/if}
370
109
  </div>