@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,59 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Renders a consumer-declared custom page for `/admin/pages/:slug`.
|
|
4
|
+
*
|
|
5
|
+
* The consumer's `admin.config.ts` registers pages like:
|
|
6
|
+
*
|
|
7
|
+
* pages: {
|
|
8
|
+
* 'ads-analytics': {
|
|
9
|
+
* path: './admin-overrides/pages/AdsAnalytics.svelte',
|
|
10
|
+
* nav: { label: 'Ads Analytics', icon: 'chart-bar', group: 'Insights' },
|
|
11
|
+
* }
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* This view:
|
|
15
|
+
* 1. Reads :slug from the router
|
|
16
|
+
* 2. Looks up config.pages[slug] — if missing, renders NotFound
|
|
17
|
+
* 3. Dynamically imports the component via importMap and mounts it
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { onMount } from 'svelte'
|
|
21
|
+
import type { Component } from 'svelte'
|
|
22
|
+
import { routeStore } from '$lib/router/index.svelte.js'
|
|
23
|
+
import { getQuoinContext, loadComponent } from '$lib/context.svelte.js'
|
|
24
|
+
import NotFoundView from './NotFoundView.svelte'
|
|
25
|
+
|
|
26
|
+
const ctx = getQuoinContext()
|
|
27
|
+
const slug = $derived(routeStore.params.slug ?? '')
|
|
28
|
+
const entry = $derived(ctx.config.pages[slug])
|
|
29
|
+
|
|
30
|
+
let component = $state<Component<any> | null>(null)
|
|
31
|
+
let loadError = $state<string | null>(null)
|
|
32
|
+
|
|
33
|
+
// Re-load when the slug changes (consumer navigates between custom pages).
|
|
34
|
+
$effect(() => {
|
|
35
|
+
component = null
|
|
36
|
+
loadError = null
|
|
37
|
+
if (!entry) return
|
|
38
|
+
|
|
39
|
+
loadComponent(entry.path, ctx.importMap)
|
|
40
|
+
.then((c) => {
|
|
41
|
+
component = c
|
|
42
|
+
})
|
|
43
|
+
.catch((err) => {
|
|
44
|
+
loadError = err instanceof Error ? err.message : String(err)
|
|
45
|
+
console.error(`[quoin:CustomPageView] Failed to load "${slug}":`, err)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
{#if !entry}
|
|
51
|
+
<NotFoundView />
|
|
52
|
+
{:else if loadError}
|
|
53
|
+
<div class="p-6 text-red-600">Failed to load page: {loadError}</div>
|
|
54
|
+
{:else if component}
|
|
55
|
+
{@const PageComponent = component}
|
|
56
|
+
<PageComponent />
|
|
57
|
+
{:else}
|
|
58
|
+
<div class="p-6 text-muted-foreground">Loading…</div>
|
|
59
|
+
{/if}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
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'
|
|
6
|
+
import Slot from '$lib/Slot.svelte'
|
|
7
|
+
|
|
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)
|
|
54
|
+
|
|
55
|
+
type SortKey = 'ctr' | 'impressions' | 'clicks' | 'title'
|
|
56
|
+
let topAdsSort = $state<SortKey>('ctr')
|
|
57
|
+
let topAdsDir = $state<'asc' | 'desc'>('desc')
|
|
58
|
+
|
|
59
|
+
async function fetchJson<T>(url: string): Promise<{ ok: true; data: T } | { ok: false; status: number }> {
|
|
60
|
+
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 }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
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
|
+
$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'
|
|
129
|
+
}
|
|
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
|
+
}
|
|
152
|
+
</script>
|
|
153
|
+
|
|
154
|
+
<div class="mx-auto max-w-7xl space-y-6">
|
|
155
|
+
<!-- Header + range filter -->
|
|
156
|
+
<header class="flex flex-wrap items-start justify-between gap-4">
|
|
157
|
+
<div>
|
|
158
|
+
<h1
|
|
159
|
+
class="text-2xl font-semibold tracking-tight"
|
|
160
|
+
style="font-family: var(--font-display);"
|
|
161
|
+
>
|
|
162
|
+
Welcome back
|
|
163
|
+
</h1>
|
|
164
|
+
<p class="mt-1 text-sm text-muted-foreground">Operational overview</p>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="flex items-center gap-2">
|
|
167
|
+
<Slot name="dashboard.actions" />
|
|
168
|
+
<RangeFilter bind:value={range} />
|
|
169
|
+
</div>
|
|
170
|
+
</header>
|
|
171
|
+
|
|
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}
|
|
175
|
+
</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>
|
|
369
|
+
{/if}
|
|
370
|
+
</div>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { page } from '$lib/router/index.svelte.js'
|
|
3
|
+
import { schema } from '$lib/stores/schema.svelte.js'
|
|
4
|
+
import { getGlobal, updateGlobal } from '$lib/api/globals.js'
|
|
5
|
+
import DynamicForm from '$lib/components/DynamicForm.svelte'
|
|
6
|
+
import { toast } from 'svelte-sonner'
|
|
7
|
+
|
|
8
|
+
let globalKey = $derived(page.params.key ?? '')
|
|
9
|
+
let globalSchema = $derived(schema.globals.find((g) => g.key === globalKey))
|
|
10
|
+
|
|
11
|
+
let data = $state<Record<string, any>>({})
|
|
12
|
+
let formData = $state<Record<string, any>>({})
|
|
13
|
+
let isLoading = $state(true)
|
|
14
|
+
let isSubmitting = $state(false)
|
|
15
|
+
let errors = $state<Record<string, string>>({})
|
|
16
|
+
|
|
17
|
+
// Fetch global data whenever key changes and schema is available
|
|
18
|
+
$effect(() => {
|
|
19
|
+
if (globalKey && globalSchema) {
|
|
20
|
+
loadGlobal(globalKey)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
async function loadGlobal(k: string) {
|
|
25
|
+
isLoading = true
|
|
26
|
+
const result = await getGlobal(k)
|
|
27
|
+
isLoading = false
|
|
28
|
+
if (result.ok) {
|
|
29
|
+
data = result.data
|
|
30
|
+
formData = { ...result.data }
|
|
31
|
+
} else {
|
|
32
|
+
toast.error(result.error)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function handleSubmit() {
|
|
37
|
+
isSubmitting = true
|
|
38
|
+
errors = {}
|
|
39
|
+
|
|
40
|
+
const result = await updateGlobal(globalKey, formData)
|
|
41
|
+
isSubmitting = false
|
|
42
|
+
|
|
43
|
+
if (result.ok) {
|
|
44
|
+
data = result.data
|
|
45
|
+
formData = { ...result.data }
|
|
46
|
+
toast.success('Settings saved')
|
|
47
|
+
} else {
|
|
48
|
+
if (result.details) {
|
|
49
|
+
errors = result.details
|
|
50
|
+
}
|
|
51
|
+
toast.error(result.error)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
{#if !globalSchema}
|
|
57
|
+
<div class="flex h-full items-center justify-center">
|
|
58
|
+
<p class="text-muted-foreground">Loading...</p>
|
|
59
|
+
</div>
|
|
60
|
+
{:else if isLoading}
|
|
61
|
+
<div class="flex h-full items-center justify-center">
|
|
62
|
+
<p class="text-muted-foreground">Loading...</p>
|
|
63
|
+
</div>
|
|
64
|
+
{:else}
|
|
65
|
+
<header class="sticky top-0 z-10 border-b border-border bg-background">
|
|
66
|
+
<div class="flex items-center justify-between px-7 py-3.5">
|
|
67
|
+
<div class="flex min-w-0 items-center gap-2.5 text-[13px] text-muted-foreground">
|
|
68
|
+
<span class="font-medium text-foreground">{globalSchema.label}</span>
|
|
69
|
+
</div>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onclick={handleSubmit}
|
|
73
|
+
disabled={isSubmitting}
|
|
74
|
+
class="bg-primary px-4 py-2 text-xs font-semibold tracking-wide text-primary-foreground rounded-lg shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-60"
|
|
75
|
+
>
|
|
76
|
+
{isSubmitting ? 'Saving…' : 'Save'}
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div class="px-7 pb-5 pt-5">
|
|
81
|
+
<h1 class="font-display text-[28px] font-semibold leading-tight tracking-tight text-foreground">
|
|
82
|
+
{globalSchema.label}
|
|
83
|
+
</h1>
|
|
84
|
+
{#if globalSchema.description}
|
|
85
|
+
<p class="mt-1 text-sm text-muted-foreground">{globalSchema.description}</p>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
</header>
|
|
89
|
+
|
|
90
|
+
<div class="px-7 py-8">
|
|
91
|
+
<div class="max-w-2xl">
|
|
92
|
+
<DynamicForm
|
|
93
|
+
fields={globalSchema.fields}
|
|
94
|
+
initialData={data}
|
|
95
|
+
{errors}
|
|
96
|
+
bind:formData
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
{/if}
|