@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,156 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { resolve } from '$lib/router/index.svelte.js'
|
|
3
|
+
import { goto } from '$lib/router/index.svelte.js'
|
|
4
|
+
import { cellComponents } from './cells/index.js'
|
|
5
|
+
import type { CollectionSchema, FieldSchema } from '$lib/types/schema.js'
|
|
6
|
+
import { ArrowUp, ArrowDown, Trash2 } from 'lucide-svelte'
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
collection,
|
|
10
|
+
records,
|
|
11
|
+
sort = '',
|
|
12
|
+
columnNames = null,
|
|
13
|
+
onSort,
|
|
14
|
+
onDelete,
|
|
15
|
+
}: {
|
|
16
|
+
collection: CollectionSchema
|
|
17
|
+
records: Record<string, any>[]
|
|
18
|
+
sort: string
|
|
19
|
+
columnNames?: string[] | null
|
|
20
|
+
onSort: (field: string) => void
|
|
21
|
+
onDelete: (id: string) => void
|
|
22
|
+
} = $props()
|
|
23
|
+
|
|
24
|
+
function flatten(flds: FieldSchema[]): FieldSchema[] {
|
|
25
|
+
const out: FieldSchema[] = []
|
|
26
|
+
for (const f of flds) {
|
|
27
|
+
if ((f as any).type === 'tabs') {
|
|
28
|
+
for (const t of ((f as any).tabs ?? [])) out.push(...flatten(t.fields ?? []))
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
if ((f as any).type === 'row') {
|
|
32
|
+
out.push(...flatten((f as any).fields ?? []))
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
out.push(f)
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
let flatFields = $derived(flatten(collection.fields))
|
|
40
|
+
|
|
41
|
+
let columns = $derived.by(() => {
|
|
42
|
+
if (columnNames && columnNames.length) {
|
|
43
|
+
return columnNames
|
|
44
|
+
.map((name) => flatFields.find((f) => f.name === name))
|
|
45
|
+
.filter((f): f is FieldSchema => f !== undefined)
|
|
46
|
+
}
|
|
47
|
+
if (collection.admin?.defaultColumns?.length) {
|
|
48
|
+
return collection.admin.defaultColumns
|
|
49
|
+
.map((name) => flatFields.find((f) => f.name === name))
|
|
50
|
+
.filter((f): f is FieldSchema => f !== undefined)
|
|
51
|
+
}
|
|
52
|
+
return flatFields.filter((f) => !f.hidden).slice(0, 4)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
let titleField = $derived(collection.admin?.useAsTitle || columns[0]?.name || 'id')
|
|
56
|
+
|
|
57
|
+
let sortField = $derived(sort.startsWith('-') ? sort.slice(1) : sort)
|
|
58
|
+
let sortDir = $derived(sort.startsWith('-') ? 'desc' : 'asc')
|
|
59
|
+
|
|
60
|
+
function handleSort(fieldName: string) {
|
|
61
|
+
if (sortField === fieldName) {
|
|
62
|
+
onSort(sortDir === 'asc' ? `-${fieldName}` : fieldName)
|
|
63
|
+
} else {
|
|
64
|
+
onSort(fieldName)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<div class="overflow-x-auto rounded-xl border border-border/60 bg-card shadow-sm">
|
|
70
|
+
<table class="w-full text-sm">
|
|
71
|
+
<thead>
|
|
72
|
+
<tr class="border-b border-border/60">
|
|
73
|
+
{#each columns as col}
|
|
74
|
+
<th class="px-4 py-3 text-left">
|
|
75
|
+
<button
|
|
76
|
+
class="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
|
|
77
|
+
onclick={() => handleSort(col.name)}
|
|
78
|
+
>
|
|
79
|
+
{col.label}
|
|
80
|
+
{#if sortField === col.name}
|
|
81
|
+
{#if sortDir === 'asc'}
|
|
82
|
+
<ArrowUp class="h-3 w-3 text-primary" />
|
|
83
|
+
{:else}
|
|
84
|
+
<ArrowDown class="h-3 w-3 text-primary" />
|
|
85
|
+
{/if}
|
|
86
|
+
{/if}
|
|
87
|
+
</button>
|
|
88
|
+
</th>
|
|
89
|
+
{/each}
|
|
90
|
+
<th class="w-12 px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground"></th>
|
|
91
|
+
</tr>
|
|
92
|
+
</thead>
|
|
93
|
+
<tbody>
|
|
94
|
+
{#if records.length === 0}
|
|
95
|
+
<tr>
|
|
96
|
+
<td colspan={columns.length + 1} class="px-4 py-16 text-center">
|
|
97
|
+
<div class="flex flex-col items-center gap-2">
|
|
98
|
+
<div class="h-12 w-12 rounded-xl border-2 border-dashed border-border/80 flex items-center justify-center">
|
|
99
|
+
<span class="text-lg text-muted-foreground/40">0</span>
|
|
100
|
+
</div>
|
|
101
|
+
<p class="text-sm text-muted-foreground">No records found</p>
|
|
102
|
+
</div>
|
|
103
|
+
</td>
|
|
104
|
+
</tr>
|
|
105
|
+
{:else}
|
|
106
|
+
{#each records as record, i (record.id)}
|
|
107
|
+
<tr
|
|
108
|
+
class="cursor-pointer border-b border-border/40 transition-colors last:border-b-0 hover:bg-secondary/50"
|
|
109
|
+
onclick={() => goto(resolve(`/${collection.key}/${record.id}`))}
|
|
110
|
+
>
|
|
111
|
+
{#each columns as col}
|
|
112
|
+
<td class="px-4 py-3">
|
|
113
|
+
{#if col.name === titleField}
|
|
114
|
+
<span class="font-medium text-foreground">
|
|
115
|
+
{#if cellComponents[col.type]}
|
|
116
|
+
{@const CellComponent = cellComponents[col.type]}
|
|
117
|
+
<CellComponent
|
|
118
|
+
value={record[col.name]}
|
|
119
|
+
fieldSchema={col}
|
|
120
|
+
{record}
|
|
121
|
+
/>
|
|
122
|
+
{:else}
|
|
123
|
+
{record[col.name] ?? '-'}
|
|
124
|
+
{/if}
|
|
125
|
+
</span>
|
|
126
|
+
{:else}
|
|
127
|
+
<span class="text-muted-foreground">
|
|
128
|
+
{#if cellComponents[col.type]}
|
|
129
|
+
{@const CellComponent = cellComponents[col.type]}
|
|
130
|
+
<CellComponent
|
|
131
|
+
value={record[col.name]}
|
|
132
|
+
fieldSchema={col}
|
|
133
|
+
{record}
|
|
134
|
+
/>
|
|
135
|
+
{:else}
|
|
136
|
+
{record[col.name] ?? '-'}
|
|
137
|
+
{/if}
|
|
138
|
+
</span>
|
|
139
|
+
{/if}
|
|
140
|
+
</td>
|
|
141
|
+
{/each}
|
|
142
|
+
<td class="px-4 py-3 text-right">
|
|
143
|
+
<button
|
|
144
|
+
onclick={(e) => { e.stopPropagation(); onDelete(record.id) }}
|
|
145
|
+
class="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
146
|
+
title="Delete"
|
|
147
|
+
>
|
|
148
|
+
<Trash2 class="h-3.5 w-3.5" />
|
|
149
|
+
</button>
|
|
150
|
+
</td>
|
|
151
|
+
</tr>
|
|
152
|
+
{/each}
|
|
153
|
+
{/if}
|
|
154
|
+
</tbody>
|
|
155
|
+
</table>
|
|
156
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { value }: { value: any } = $props()
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
{#if value}
|
|
6
|
+
<div class="flex items-center gap-1.5">
|
|
7
|
+
<span
|
|
8
|
+
class="inline-block h-4 w-4 rounded border"
|
|
9
|
+
style="background-color: {value}"
|
|
10
|
+
></span>
|
|
11
|
+
<span class="text-xs text-muted-foreground">{value}</span>
|
|
12
|
+
</div>
|
|
13
|
+
{:else}
|
|
14
|
+
<span class="text-sm text-muted-foreground">-</span>
|
|
15
|
+
{/if}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { value, fieldSchema }: { value: any; fieldSchema: any } = $props()
|
|
3
|
+
|
|
4
|
+
let display = $derived.by(() => {
|
|
5
|
+
if (!value) return '-'
|
|
6
|
+
if (fieldSchema?.relationType === 'manyToMany') {
|
|
7
|
+
if (Array.isArray(value)) {
|
|
8
|
+
return `${value.length} item${value.length !== 1 ? 's' : ''}`
|
|
9
|
+
}
|
|
10
|
+
return '-'
|
|
11
|
+
}
|
|
12
|
+
// BelongsTo - expanded object or just an ID
|
|
13
|
+
if (typeof value === 'object' && value !== null) {
|
|
14
|
+
return value.name || value.title || value.label || value.slug || value.id || '-'
|
|
15
|
+
}
|
|
16
|
+
return String(value)
|
|
17
|
+
})
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<span class="text-sm">{display}</span>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { truncate } from '$lib/utils/format.js'
|
|
3
|
+
let { value }: { value: any } = $props()
|
|
4
|
+
|
|
5
|
+
function extractText(node: any): string {
|
|
6
|
+
if (node == null) return ''
|
|
7
|
+
if (typeof node === 'string') return node
|
|
8
|
+
if (Array.isArray(node)) return node.map(extractText).join(' ')
|
|
9
|
+
if (typeof node === 'object') {
|
|
10
|
+
if (typeof node.text === 'string') return node.text
|
|
11
|
+
if (Array.isArray(node.content)) return node.content.map(extractText).join(' ')
|
|
12
|
+
}
|
|
13
|
+
return ''
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let plain = $derived(extractText(value).replace(/\s+/g, ' ').trim())
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<span class="text-sm" title={plain}>
|
|
20
|
+
{truncate(plain, 60) || '-'}
|
|
21
|
+
</span>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { value, record, fieldSchema }: { value: any; record?: Record<string, any>; fieldSchema?: any } = $props()
|
|
3
|
+
|
|
4
|
+
let isScheduled = $derived(
|
|
5
|
+
fieldSchema?.name === 'status' &&
|
|
6
|
+
value === 'published' &&
|
|
7
|
+
record?.publishedAt &&
|
|
8
|
+
new Date(record.publishedAt) > new Date()
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
let badgeClass = $derived.by(() => {
|
|
12
|
+
if (isScheduled) return 'bg-yellow-100 text-yellow-800'
|
|
13
|
+
if (value === 'published') return 'bg-green-100 text-green-800'
|
|
14
|
+
if (value === 'draft') return 'bg-gray-100 text-gray-800'
|
|
15
|
+
if (value === 'archived') return 'bg-red-100 text-red-800'
|
|
16
|
+
return 'border'
|
|
17
|
+
})
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
{#if value}
|
|
21
|
+
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {badgeClass}">
|
|
22
|
+
{isScheduled ? 'Scheduled' : value}
|
|
23
|
+
</span>
|
|
24
|
+
{:else}
|
|
25
|
+
<span class="text-sm text-muted-foreground">-</span>
|
|
26
|
+
{/if}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { value, fieldSchema }: { value: any; fieldSchema: any } = $props()
|
|
3
|
+
|
|
4
|
+
let display = $derived.by(() => {
|
|
5
|
+
if (!value) return '-'
|
|
6
|
+
if (fieldSchema?.multiple) {
|
|
7
|
+
if (Array.isArray(value)) {
|
|
8
|
+
return `${value.length} file${value.length !== 1 ? 's' : ''}`
|
|
9
|
+
}
|
|
10
|
+
return '-'
|
|
11
|
+
}
|
|
12
|
+
// Single upload - show filename or thumbnail
|
|
13
|
+
if (typeof value === 'object' && value !== null) {
|
|
14
|
+
return value.filename || '-'
|
|
15
|
+
}
|
|
16
|
+
return '-'
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
let isImage = $derived.by(() => {
|
|
20
|
+
if (!value || typeof value !== 'object' || fieldSchema?.multiple) return false
|
|
21
|
+
return value.mimeType?.startsWith('image/')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
let imageUrl = $derived.by(() => {
|
|
25
|
+
if (!isImage || typeof value !== 'object') return ''
|
|
26
|
+
return value.url || ''
|
|
27
|
+
})
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
{#if isImage && imageUrl}
|
|
31
|
+
<img src={imageUrl} alt="" class="h-8 w-8 rounded object-cover" />
|
|
32
|
+
{:else}
|
|
33
|
+
<span class="text-sm text-muted-foreground">{display}</span>
|
|
34
|
+
{/if}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import TextCell from './TextCell.svelte'
|
|
2
|
+
import CheckboxCell from './CheckboxCell.svelte'
|
|
3
|
+
import SelectCell from './SelectCell.svelte'
|
|
4
|
+
import DateCell from './DateCell.svelte'
|
|
5
|
+
import ColorCell from './ColorCell.svelte'
|
|
6
|
+
import RelationshipCell from './RelationshipCell.svelte'
|
|
7
|
+
import UploadCell from './UploadCell.svelte'
|
|
8
|
+
import RichTextCell from './RichTextCell.svelte'
|
|
9
|
+
import type { Component } from 'svelte'
|
|
10
|
+
|
|
11
|
+
export const cellComponents: Record<string, Component<any>> = {
|
|
12
|
+
text: TextCell,
|
|
13
|
+
textarea: TextCell,
|
|
14
|
+
slug: TextCell,
|
|
15
|
+
email: TextCell,
|
|
16
|
+
number: TextCell,
|
|
17
|
+
select: SelectCell,
|
|
18
|
+
checkbox: CheckboxCell,
|
|
19
|
+
date: DateCell,
|
|
20
|
+
color: ColorCell,
|
|
21
|
+
richtext: RichTextCell,
|
|
22
|
+
relationship: RelationshipCell,
|
|
23
|
+
upload: UploadCell,
|
|
24
|
+
file: UploadCell,
|
|
25
|
+
json: TextCell,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { TextCell, CheckboxCell, SelectCell, DateCell, ColorCell, RelationshipCell, UploadCell, RichTextCell }
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
type Point = { day: string; count: number }
|
|
3
|
+
|
|
4
|
+
const tickFmt = new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric' })
|
|
5
|
+
const ttFmt = new Intl.DateTimeFormat('en', { weekday: 'short', month: 'short', day: 'numeric' })
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
data,
|
|
9
|
+
emptyMessage = 'No data for this range',
|
|
10
|
+
color = '#D97706',
|
|
11
|
+
}: {
|
|
12
|
+
data: Point[]
|
|
13
|
+
emptyMessage?: string
|
|
14
|
+
color?: string
|
|
15
|
+
} = $props()
|
|
16
|
+
|
|
17
|
+
let hovered = $state<number | null>(null)
|
|
18
|
+
|
|
19
|
+
const W = 800
|
|
20
|
+
const H = 240
|
|
21
|
+
const padL = 36
|
|
22
|
+
const padR = 12
|
|
23
|
+
const padT = 16
|
|
24
|
+
const padB = 28
|
|
25
|
+
const innerW = W - padL - padR
|
|
26
|
+
const innerH = H - padT - padB
|
|
27
|
+
|
|
28
|
+
const points = $derived(
|
|
29
|
+
data.map((p) => {
|
|
30
|
+
const date = new Date(p.day)
|
|
31
|
+
return { ...p, label: tickFmt.format(date), tooltip: ttFmt.format(date) }
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const total = $derived(points.reduce((s, p) => s + p.count, 0))
|
|
36
|
+
const peak = $derived(points.length ? Math.max(...points.map((p) => p.count)) : 0)
|
|
37
|
+
const avg = $derived(points.length ? Math.round(total / points.length) : 0)
|
|
38
|
+
|
|
39
|
+
const yMax = $derived(niceCeiling(peak))
|
|
40
|
+
const yTicks = $derived(genTicks(yMax, 4))
|
|
41
|
+
|
|
42
|
+
function niceCeiling(v: number): number {
|
|
43
|
+
if (v <= 0) return 1
|
|
44
|
+
const exp = Math.pow(10, Math.floor(Math.log10(v)))
|
|
45
|
+
const f = v / exp
|
|
46
|
+
const nice = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
|
|
47
|
+
return nice * exp
|
|
48
|
+
}
|
|
49
|
+
function genTicks(max: number, count: number): number[] {
|
|
50
|
+
const step = max / count
|
|
51
|
+
return Array.from({ length: count + 1 }, (_, i) => Math.round(i * step))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pX(i: number): number {
|
|
55
|
+
if (points.length <= 1) return padL + innerW / 2
|
|
56
|
+
return padL + (i / (points.length - 1)) * innerW
|
|
57
|
+
}
|
|
58
|
+
function pY(count: number): number {
|
|
59
|
+
return padT + innerH - (count / yMax) * innerH
|
|
60
|
+
}
|
|
61
|
+
function tickY(v: number): number {
|
|
62
|
+
return padT + innerH - (v / yMax) * innerH
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const linePath = $derived(
|
|
66
|
+
points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${pX(i)} ${pY(p.count)}`).join(' '),
|
|
67
|
+
)
|
|
68
|
+
const areaPath = $derived(
|
|
69
|
+
points.length === 0
|
|
70
|
+
? ''
|
|
71
|
+
: `${linePath} L ${pX(points.length - 1)} ${padT + innerH} L ${pX(0)} ${padT + innerH} Z`,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const labelStep = $derived(Math.max(1, Math.ceil(points.length / 10)))
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
{#if data.length === 0}
|
|
78
|
+
<div
|
|
79
|
+
class="flex h-72 w-full items-center justify-center rounded-lg border border-dashed border-border/60 bg-muted/20 text-sm text-muted-foreground"
|
|
80
|
+
>
|
|
81
|
+
{emptyMessage}
|
|
82
|
+
</div>
|
|
83
|
+
{:else}
|
|
84
|
+
<div class="flex flex-col gap-3">
|
|
85
|
+
<div class="flex items-baseline gap-5 px-1 text-[12px] text-muted-foreground">
|
|
86
|
+
<span><span class="font-medium text-foreground">{total.toLocaleString()}</span> total</span>
|
|
87
|
+
<span>peak <span class="font-medium text-foreground">{peak.toLocaleString()}</span></span>
|
|
88
|
+
<span>avg <span class="font-medium text-foreground">{avg.toLocaleString()}</span></span>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="relative w-full">
|
|
91
|
+
<svg
|
|
92
|
+
viewBox="0 0 {W} {H}"
|
|
93
|
+
class="block h-64 w-full"
|
|
94
|
+
preserveAspectRatio="none"
|
|
95
|
+
onmouseleave={() => (hovered = null)}
|
|
96
|
+
>
|
|
97
|
+
<!-- Y grid + tick labels -->
|
|
98
|
+
{#each yTicks as t}
|
|
99
|
+
<line
|
|
100
|
+
x1={padL}
|
|
101
|
+
x2={W - padR}
|
|
102
|
+
y1={tickY(t)}
|
|
103
|
+
y2={tickY(t)}
|
|
104
|
+
stroke="currentColor"
|
|
105
|
+
stroke-opacity="0.08"
|
|
106
|
+
/>
|
|
107
|
+
<text
|
|
108
|
+
x={padL - 6}
|
|
109
|
+
y={tickY(t)}
|
|
110
|
+
dy="0.32em"
|
|
111
|
+
text-anchor="end"
|
|
112
|
+
class="fill-muted-foreground text-[10px]"
|
|
113
|
+
>{t}</text>
|
|
114
|
+
{/each}
|
|
115
|
+
|
|
116
|
+
<!-- Area fill -->
|
|
117
|
+
<path d={areaPath} fill={color} fill-opacity="0.15" />
|
|
118
|
+
|
|
119
|
+
<!-- Line -->
|
|
120
|
+
<path d={linePath} fill="none" stroke={color} stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
|
|
121
|
+
|
|
122
|
+
<!-- Points + hover hit areas -->
|
|
123
|
+
{#each points as p, i}
|
|
124
|
+
<circle cx={pX(i)} cy={pY(p.count)} r="3" fill={color} stroke="white" stroke-width="1.5" />
|
|
125
|
+
<rect
|
|
126
|
+
x={pX(i) - (innerW / Math.max(points.length - 1, 1)) / 2}
|
|
127
|
+
y={padT}
|
|
128
|
+
width={innerW / Math.max(points.length - 1, 1)}
|
|
129
|
+
height={innerH}
|
|
130
|
+
fill="transparent"
|
|
131
|
+
onmouseenter={() => (hovered = i)}
|
|
132
|
+
/>
|
|
133
|
+
{/each}
|
|
134
|
+
|
|
135
|
+
<!-- Hover guide -->
|
|
136
|
+
{#if hovered !== null}
|
|
137
|
+
<line
|
|
138
|
+
x1={pX(hovered)}
|
|
139
|
+
x2={pX(hovered)}
|
|
140
|
+
y1={padT}
|
|
141
|
+
y2={padT + innerH}
|
|
142
|
+
stroke={color}
|
|
143
|
+
stroke-opacity="0.35"
|
|
144
|
+
stroke-dasharray="3 3"
|
|
145
|
+
/>
|
|
146
|
+
<circle cx={pX(hovered)} cy={pY(points[hovered].count)} r="5" fill={color} stroke="white" stroke-width="2" />
|
|
147
|
+
{/if}
|
|
148
|
+
|
|
149
|
+
<!-- X-axis labels -->
|
|
150
|
+
{#each points as p, i}
|
|
151
|
+
{#if i % labelStep === 0 || i === points.length - 1}
|
|
152
|
+
<text
|
|
153
|
+
x={pX(i)}
|
|
154
|
+
y={H - padB / 3}
|
|
155
|
+
text-anchor="middle"
|
|
156
|
+
class="fill-muted-foreground text-[10px]"
|
|
157
|
+
>{p.label}</text>
|
|
158
|
+
{/if}
|
|
159
|
+
{/each}
|
|
160
|
+
|
|
161
|
+
<!-- Baseline -->
|
|
162
|
+
<line
|
|
163
|
+
x1={padL}
|
|
164
|
+
x2={W - padR}
|
|
165
|
+
y1={padT + innerH}
|
|
166
|
+
y2={padT + innerH}
|
|
167
|
+
stroke="currentColor"
|
|
168
|
+
stroke-opacity="0.2"
|
|
169
|
+
/>
|
|
170
|
+
</svg>
|
|
171
|
+
|
|
172
|
+
{#if hovered !== null}
|
|
173
|
+
{@const p = points[hovered]}
|
|
174
|
+
<div
|
|
175
|
+
class="pointer-events-none absolute -translate-x-1/2 -translate-y-full rounded-md bg-foreground px-2.5 py-1.5 text-[11px] leading-tight text-background shadow-lg"
|
|
176
|
+
style="left: {pX(hovered) / W * 100}%; top: {pY(p.count) / H * 100}%;"
|
|
177
|
+
>
|
|
178
|
+
<div class="font-semibold">{p.tooltip}</div>
|
|
179
|
+
<div>{p.count.toLocaleString()}</div>
|
|
180
|
+
</div>
|
|
181
|
+
{/if}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
{/if}
|