@quoin-cms/admin 0.1.0 → 0.3.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/package.json +4 -1
- package/src/AdminRoot.svelte +7 -7
- package/src/lib/api/auth.ts +3 -6
- package/src/lib/api/files.ts +3 -2
- package/src/lib/components/AdminSidebar.svelte +23 -23
- package/src/lib/components/DocumentEditLayout.svelte +17 -0
- package/src/lib/components/DynamicForm.svelte +3 -3
- package/src/lib/components/MediaLibrary.svelte +55 -7
- package/src/lib/components/RecordTable.svelte +33 -21
- package/src/lib/components/UploadCreateView.svelte +173 -0
- package/src/lib/components/doc/ApiView.svelte +95 -103
- package/src/lib/components/doc/RenderJson.svelte +93 -0
- package/src/lib/components/fields/RichTextField.svelte +5 -0
- package/src/lib/components/fields/UploadField.svelte +26 -34
- package/src/lib/components/fields/UploadGalleryField.svelte +28 -37
- package/src/lib/components/lexical/BlockField.svelte +41 -0
- package/src/lib/components/lexical/BlockHost.svelte +85 -0
- package/src/lib/components/lexical/BlockNode.ts +102 -0
- package/src/lib/components/lexical/block-defaults.ts +40 -0
- package/src/lib/components/lexical/lexical-helpers.ts +3 -0
- package/src/lib/components/lexical/nodes.ts +2 -0
- package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +27 -2
- package/src/lib/context.svelte.ts +1 -0
- package/src/lib/types/schema.ts +15 -0
- package/src/views/CollectionListView.svelte +63 -21
- package/src/views/CollectionNewView.svelte +3 -0
- package/src/views/DashboardSlot.svelte +46 -0
- package/src/views/DashboardView.svelte +78 -339
- package/src/views/LoginView.svelte +47 -23
- package/biome.json +0 -62
- package/dist/assets/index-C9Y5-AKj.js +0 -33
- package/dist/assets/index-uVdiUjty.css +0 -1
- package/dist/index.html +0 -20
- package/index.html +0 -19
- package/src/views/AdsAnalyticsView.svelte +0 -152
- package/tsconfig.json +0 -25
- package/vite.config.ts +0 -80
|
@@ -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
|
|
4
|
-
import
|
|
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
|
|
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
|
-
|
|
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
|
|
25
|
+
async function fetchCount(key: string): Promise<CountState> {
|
|
60
26
|
try {
|
|
61
|
-
const res = await fetch(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
66
|
+
Welcome to {branding.siteName || 'your CMS'}
|
|
163
67
|
</h1>
|
|
164
|
-
<p class="mt-1 text-sm text-muted-foreground">
|
|
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
|
|
173
|
-
<div class="rounded-
|
|
174
|
-
|
|
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
|
-
{
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { goto } from '$lib/router/index.svelte.js'
|
|
3
3
|
import { resolve } from '$lib/router/index.svelte.js'
|
|
4
|
-
import { login, register, getMe
|
|
4
|
+
import { login, register, getMe } from '$lib/api/auth.js'
|
|
5
5
|
import { toast } from 'svelte-sonner'
|
|
6
6
|
import { onMount } from 'svelte'
|
|
7
7
|
import { BookOpen } from 'lucide-svelte'
|
|
@@ -9,9 +9,10 @@ import { branding, loadBranding } from '$lib/stores/branding.svelte.js'
|
|
|
9
9
|
import Slot from '$lib/Slot.svelte'
|
|
10
10
|
|
|
11
11
|
let email = $state('')
|
|
12
|
+
let name = $state('')
|
|
12
13
|
let password = $state('')
|
|
13
14
|
let confirmPassword = $state('')
|
|
14
|
-
let
|
|
15
|
+
let isCreatingAccount = $state(false)
|
|
15
16
|
let isLoading = $state(false)
|
|
16
17
|
let isCheckingAuth = $state(true)
|
|
17
18
|
|
|
@@ -22,21 +23,19 @@ onMount(async () => {
|
|
|
22
23
|
await goto(resolve('/'))
|
|
23
24
|
return
|
|
24
25
|
}
|
|
25
|
-
const status = await getSetupStatus()
|
|
26
|
-
if (status.ok && status.data.needsSetup) {
|
|
27
|
-
isSetup = true
|
|
28
|
-
}
|
|
29
26
|
isCheckingAuth = false
|
|
30
27
|
})
|
|
31
28
|
|
|
32
29
|
async function handleLogin() {
|
|
33
|
-
|
|
30
|
+
const trimmedEmail = email.trim()
|
|
31
|
+
const trimmedPassword = password.trim()
|
|
32
|
+
if (!trimmedEmail || !trimmedPassword) {
|
|
34
33
|
toast.error('Please enter email and password')
|
|
35
34
|
return
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
isLoading = true
|
|
39
|
-
const result = await login(
|
|
38
|
+
const result = await login(trimmedEmail, trimmedPassword)
|
|
40
39
|
isLoading = false
|
|
41
40
|
|
|
42
41
|
if (result.ok) {
|
|
@@ -50,32 +49,41 @@ async function handleLogin() {
|
|
|
50
49
|
}
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
async function
|
|
54
|
-
|
|
52
|
+
async function handleCreateAccount() {
|
|
53
|
+
const trimmedName = name.trim()
|
|
54
|
+
const trimmedEmail = email.trim()
|
|
55
|
+
const trimmedPassword = password.trim()
|
|
56
|
+
const trimmedConfirmPassword = confirmPassword.trim()
|
|
57
|
+
|
|
58
|
+
if (!trimmedName) {
|
|
59
|
+
toast.error('Please enter your name')
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (!trimmedEmail || !trimmedPassword) {
|
|
55
63
|
toast.error('Please enter email and password')
|
|
56
64
|
return
|
|
57
65
|
}
|
|
58
|
-
if (
|
|
66
|
+
if (trimmedPassword !== trimmedConfirmPassword) {
|
|
59
67
|
toast.error('Passwords do not match')
|
|
60
68
|
return
|
|
61
69
|
}
|
|
62
|
-
if (
|
|
70
|
+
if (trimmedPassword.length < 6) {
|
|
63
71
|
toast.error('Password must be at least 6 characters')
|
|
64
72
|
return
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
isLoading = true
|
|
68
|
-
const result = await register(
|
|
76
|
+
const result = await register(trimmedEmail, trimmedPassword, trimmedName)
|
|
69
77
|
isLoading = false
|
|
70
78
|
|
|
71
79
|
if (result.ok) {
|
|
72
80
|
toast.success('Admin account created! Logging in...')
|
|
73
|
-
const loginResult = await login(
|
|
81
|
+
const loginResult = await login(trimmedEmail, trimmedPassword)
|
|
74
82
|
if (loginResult.ok) {
|
|
75
83
|
await goto(resolve('/'))
|
|
76
84
|
} else {
|
|
77
85
|
toast.error('Account created but login failed. Please try logging in.')
|
|
78
|
-
|
|
86
|
+
isCreatingAccount = false
|
|
79
87
|
}
|
|
80
88
|
} else {
|
|
81
89
|
toast.error(result.error)
|
|
@@ -83,7 +91,7 @@ async function handleSetup() {
|
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
function toggleMode() {
|
|
86
|
-
|
|
94
|
+
isCreatingAccount = !isCreatingAccount
|
|
87
95
|
confirmPassword = ''
|
|
88
96
|
}
|
|
89
97
|
</script>
|
|
@@ -149,20 +157,36 @@ function toggleMode() {
|
|
|
149
157
|
|
|
150
158
|
<div class="mb-8">
|
|
151
159
|
<h2 class="text-xl font-semibold tracking-tight" style="font-family: var(--font-display);">
|
|
152
|
-
{
|
|
160
|
+
{isCreatingAccount ? 'Create your account' : 'Sign in'}
|
|
153
161
|
</h2>
|
|
154
162
|
<p class="mt-1.5 text-sm text-muted-foreground">
|
|
155
|
-
{
|
|
163
|
+
{isCreatingAccount ? 'Create the first admin account to get started.' : 'Enter your credentials to continue.'}
|
|
156
164
|
</p>
|
|
157
165
|
</div>
|
|
158
166
|
|
|
159
167
|
<form
|
|
160
168
|
onsubmit={(e) => {
|
|
161
169
|
e.preventDefault();
|
|
162
|
-
|
|
170
|
+
isCreatingAccount ? handleCreateAccount() : handleLogin();
|
|
163
171
|
}}
|
|
164
172
|
class="space-y-5"
|
|
165
173
|
>
|
|
174
|
+
{#if isCreatingAccount}
|
|
175
|
+
<div>
|
|
176
|
+
<label for="name" class="mb-2 block text-[13px] font-medium text-foreground">
|
|
177
|
+
Name
|
|
178
|
+
</label>
|
|
179
|
+
<input
|
|
180
|
+
id="name"
|
|
181
|
+
type="text"
|
|
182
|
+
bind:value={name}
|
|
183
|
+
class="h-11 w-full rounded-lg border border-border bg-card px-3.5 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"
|
|
184
|
+
placeholder="Your name"
|
|
185
|
+
autocomplete="name"
|
|
186
|
+
/>
|
|
187
|
+
</div>
|
|
188
|
+
{/if}
|
|
189
|
+
|
|
166
190
|
<div>
|
|
167
191
|
<label for="email" class="mb-2 block text-[13px] font-medium text-foreground">
|
|
168
192
|
Email
|
|
@@ -187,11 +211,11 @@ function toggleMode() {
|
|
|
187
211
|
bind:value={password}
|
|
188
212
|
class="h-11 w-full rounded-lg border border-border bg-card px-3.5 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"
|
|
189
213
|
placeholder="Enter password"
|
|
190
|
-
autocomplete={
|
|
214
|
+
autocomplete={isCreatingAccount ? 'new-password' : 'current-password'}
|
|
191
215
|
/>
|
|
192
216
|
</div>
|
|
193
217
|
|
|
194
|
-
{#if
|
|
218
|
+
{#if isCreatingAccount}
|
|
195
219
|
<div>
|
|
196
220
|
<label for="confirmPassword" class="mb-2 block text-[13px] font-medium text-foreground">
|
|
197
221
|
Confirm Password
|
|
@@ -212,7 +236,7 @@ function toggleMode() {
|
|
|
212
236
|
disabled={isLoading}
|
|
213
237
|
class="h-11 w-full rounded-lg bg-primary text-sm font-medium text-primary-foreground shadow-sm transition-all hover:bg-primary/90 hover:shadow-md active:scale-[0.99] disabled:opacity-50 disabled:shadow-none"
|
|
214
238
|
>
|
|
215
|
-
{isLoading ? 'Please wait...' :
|
|
239
|
+
{isLoading ? 'Please wait...' : isCreatingAccount ? 'Create Account' : 'Sign In'}
|
|
216
240
|
</button>
|
|
217
241
|
</form>
|
|
218
242
|
|
|
@@ -222,7 +246,7 @@ function toggleMode() {
|
|
|
222
246
|
onclick={toggleMode}
|
|
223
247
|
class="text-[13px] text-muted-foreground transition-colors hover:text-primary"
|
|
224
248
|
>
|
|
225
|
-
{
|
|
249
|
+
{isCreatingAccount ? 'Already have an account? Sign in' : 'First time? Create admin account'}
|
|
226
250
|
</button>
|
|
227
251
|
</div>
|
|
228
252
|
</div>
|
package/biome.json
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
|
3
|
-
"vcs": {
|
|
4
|
-
"enabled": true,
|
|
5
|
-
"clientKind": "git",
|
|
6
|
-
"useIgnoreFile": false,
|
|
7
|
-
"defaultBranch": "main"
|
|
8
|
-
},
|
|
9
|
-
"files": {
|
|
10
|
-
"ignoreUnknown": false
|
|
11
|
-
},
|
|
12
|
-
"formatter": {
|
|
13
|
-
"enabled": true,
|
|
14
|
-
"indentStyle": "tab",
|
|
15
|
-
"lineWidth": 100
|
|
16
|
-
},
|
|
17
|
-
"linter": {
|
|
18
|
-
"enabled": true,
|
|
19
|
-
"rules": {
|
|
20
|
-
"recommended": true,
|
|
21
|
-
"suspicious": {
|
|
22
|
-
"noExplicitAny": "off"
|
|
23
|
-
},
|
|
24
|
-
"style": {
|
|
25
|
-
"noNonNullAssertion": "off"
|
|
26
|
-
},
|
|
27
|
-
"correctness": {
|
|
28
|
-
"noUnusedVariables": "warn"
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
},
|
|
32
|
-
"javascript": {
|
|
33
|
-
"formatter": {
|
|
34
|
-
"quoteStyle": "single",
|
|
35
|
-
"semicolons": "asNeeded",
|
|
36
|
-
"trailingCommas": "es5",
|
|
37
|
-
"arrowParentheses": "always"
|
|
38
|
-
}
|
|
39
|
-
},
|
|
40
|
-
"css": {
|
|
41
|
-
"parser": {
|
|
42
|
-
"cssModules": false,
|
|
43
|
-
"tailwindDirectives": true
|
|
44
|
-
},
|
|
45
|
-
"linter": {
|
|
46
|
-
"enabled": true
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
"overrides": [
|
|
50
|
-
{
|
|
51
|
-
"includes": ["**/*.svelte"],
|
|
52
|
-
"linter": {
|
|
53
|
-
"rules": {
|
|
54
|
-
"correctness": {
|
|
55
|
-
"noUnusedVariables": "off",
|
|
56
|
-
"noUnusedImports": "off"
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
]
|
|
62
|
-
}
|