@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.
Files changed (110) hide show
  1. package/LICENSE +661 -0
  2. package/biome.json +62 -0
  3. package/dist/assets/index-C9Y5-AKj.js +33 -0
  4. package/dist/assets/index-uVdiUjty.css +1 -0
  5. package/dist/index.html +20 -0
  6. package/index.html +19 -0
  7. package/package.json +43 -0
  8. package/src/AdminRoot.svelte +98 -0
  9. package/src/app.css +211 -0
  10. package/src/lib/Slot.svelte +65 -0
  11. package/src/lib/api/auth.ts +26 -0
  12. package/src/lib/api/client.ts +73 -0
  13. package/src/lib/api/files.ts +56 -0
  14. package/src/lib/api/globals.ts +13 -0
  15. package/src/lib/api/records.ts +102 -0
  16. package/src/lib/api/schema.ts +7 -0
  17. package/src/lib/api/versions.ts +40 -0
  18. package/src/lib/components/AdminHeader.svelte +107 -0
  19. package/src/lib/components/AdminSidebar.svelte +262 -0
  20. package/src/lib/components/DeleteDialog.svelte +58 -0
  21. package/src/lib/components/DocumentEditLayout.svelte +263 -0
  22. package/src/lib/components/DynamicForm.svelte +74 -0
  23. package/src/lib/components/KpiCard.svelte +75 -0
  24. package/src/lib/components/MediaLibrary.svelte +311 -0
  25. package/src/lib/components/Pagination.svelte +78 -0
  26. package/src/lib/components/RangeFilter.svelte +41 -0
  27. package/src/lib/components/RecordGrid.svelte +123 -0
  28. package/src/lib/components/RecordTable.svelte +156 -0
  29. package/src/lib/components/cells/CheckboxCell.svelte +10 -0
  30. package/src/lib/components/cells/ColorCell.svelte +15 -0
  31. package/src/lib/components/cells/DateCell.svelte +8 -0
  32. package/src/lib/components/cells/RelationshipCell.svelte +20 -0
  33. package/src/lib/components/cells/RichTextCell.svelte +21 -0
  34. package/src/lib/components/cells/SelectCell.svelte +26 -0
  35. package/src/lib/components/cells/TextCell.svelte +8 -0
  36. package/src/lib/components/cells/UploadCell.svelte +34 -0
  37. package/src/lib/components/cells/index.ts +28 -0
  38. package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
  39. package/src/lib/components/doc/ApiView.svelte +181 -0
  40. package/src/lib/components/doc/Autosave.svelte +102 -0
  41. package/src/lib/components/doc/DocHeader.svelte +86 -0
  42. package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
  43. package/src/lib/components/doc/DocTabBar.svelte +26 -0
  44. package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
  45. package/src/lib/components/doc/PublishButton.svelte +114 -0
  46. package/src/lib/components/doc/ScheduleModal.svelte +110 -0
  47. package/src/lib/components/doc/VersionHistory.svelte +20 -0
  48. package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
  49. package/src/lib/components/fields/BlockCard.svelte +63 -0
  50. package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
  51. package/src/lib/components/fields/CheckboxField.svelte +27 -0
  52. package/src/lib/components/fields/ColorField.svelte +46 -0
  53. package/src/lib/components/fields/DateField.svelte +52 -0
  54. package/src/lib/components/fields/EmailField.svelte +30 -0
  55. package/src/lib/components/fields/FileField.svelte +280 -0
  56. package/src/lib/components/fields/JsonField.svelte +145 -0
  57. package/src/lib/components/fields/NumberField.svelte +44 -0
  58. package/src/lib/components/fields/PasswordField.svelte +38 -0
  59. package/src/lib/components/fields/RelationshipField.svelte +271 -0
  60. package/src/lib/components/fields/RichTextField.svelte +139 -0
  61. package/src/lib/components/fields/SelectField.svelte +33 -0
  62. package/src/lib/components/fields/SlugField.svelte +70 -0
  63. package/src/lib/components/fields/TabsField.svelte +56 -0
  64. package/src/lib/components/fields/TagsField.svelte +85 -0
  65. package/src/lib/components/fields/TextField.svelte +36 -0
  66. package/src/lib/components/fields/TextareaField.svelte +32 -0
  67. package/src/lib/components/fields/UploadField.svelte +166 -0
  68. package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
  69. package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
  70. package/src/lib/components/fields/index.ts +22 -0
  71. package/src/lib/components/fields/registry.ts +58 -0
  72. package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
  73. package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
  74. package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
  75. package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
  76. package/src/lib/components/lexical/lexical-helpers.ts +24 -0
  77. package/src/lib/components/lexical/nodes.ts +8 -0
  78. package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
  79. package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
  80. package/src/lib/components/versions/CompareSelector.svelte +31 -0
  81. package/src/lib/components/versions/FieldDiff.svelte +141 -0
  82. package/src/lib/components/versions/RestoreModal.svelte +67 -0
  83. package/src/lib/components/versions/StatusPill.svelte +21 -0
  84. package/src/lib/context.svelte.ts +156 -0
  85. package/src/lib/router/index.svelte.ts +282 -0
  86. package/src/lib/router/matcher.ts +52 -0
  87. package/src/lib/stores/branding.svelte.ts +74 -0
  88. package/src/lib/stores/schema.svelte.ts +17 -0
  89. package/src/lib/types/schema.ts +126 -0
  90. package/src/lib/utils/cn.ts +6 -0
  91. package/src/lib/utils/diff.ts +112 -0
  92. package/src/lib/utils/dirty.svelte.ts +50 -0
  93. package/src/lib/utils/format.ts +28 -0
  94. package/src/lib/utils/json-highlight.ts +34 -0
  95. package/src/lib/utils/slug.ts +8 -0
  96. package/src/main.ts +32 -0
  97. package/src/views/AdminLayout.svelte +73 -0
  98. package/src/views/AdsAnalyticsView.svelte +152 -0
  99. package/src/views/CollectionEditView.svelte +117 -0
  100. package/src/views/CollectionListView.svelte +347 -0
  101. package/src/views/CollectionNewView.svelte +68 -0
  102. package/src/views/CustomPageView.svelte +59 -0
  103. package/src/views/DashboardView.svelte +370 -0
  104. package/src/views/GlobalEditView.svelte +100 -0
  105. package/src/views/LoginView.svelte +231 -0
  106. package/src/views/NotFoundView.svelte +9 -0
  107. package/src/views/VersionDetailView.svelte +307 -0
  108. package/src/views/VersionsListView.svelte +201 -0
  109. package/tsconfig.json +25 -0
  110. 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,10 @@
1
+ <script lang="ts">
2
+ import { Check, X } from 'lucide-svelte'
3
+ let { value }: { value: any } = $props()
4
+ </script>
5
+
6
+ {#if value}
7
+ <Check class="h-4 w-4 text-green-600" />
8
+ {:else}
9
+ <X class="h-4 w-4 text-muted-foreground" />
10
+ {/if}
@@ -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,8 @@
1
+ <script lang="ts">
2
+ import { formatDate } from '$lib/utils/format.js'
3
+ let { value }: { value: any } = $props()
4
+ </script>
5
+
6
+ <span class="text-sm text-muted-foreground">
7
+ {value ? formatDate(String(value)) : '-'}
8
+ </span>
@@ -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,8 @@
1
+ <script lang="ts">
2
+ import { truncate } from '$lib/utils/format.js'
3
+ let { value }: { value: any } = $props()
4
+ </script>
5
+
6
+ <span class="text-sm" title={String(value ?? '')}>
7
+ {truncate(String(value ?? ''), 60)}
8
+ </span>
@@ -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}