@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,58 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { AlertTriangle } from 'lucide-svelte'
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
open = $bindable(false),
|
|
6
|
+
title = 'Confirm Delete',
|
|
7
|
+
description = 'Are you sure? This action cannot be undone.',
|
|
8
|
+
isDeleting = false,
|
|
9
|
+
onConfirm,
|
|
10
|
+
}: {
|
|
11
|
+
open: boolean
|
|
12
|
+
title?: string
|
|
13
|
+
description?: string
|
|
14
|
+
isDeleting?: boolean
|
|
15
|
+
onConfirm: () => void
|
|
16
|
+
} = $props()
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
{#if open}
|
|
20
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
21
|
+
<div
|
|
22
|
+
class="fixed inset-0 z-50 flex items-center justify-center"
|
|
23
|
+
onkeydown={(e) => e.key === 'Escape' && (open = false)}
|
|
24
|
+
>
|
|
25
|
+
<!-- Backdrop -->
|
|
26
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
27
|
+
<div class="fixed inset-0 bg-black/40 backdrop-blur-[2px]" onclick={() => (open = false)}></div>
|
|
28
|
+
|
|
29
|
+
<!-- Dialog -->
|
|
30
|
+
<div class="relative z-10 w-full max-w-md rounded-xl border border-border/60 bg-card p-6 shadow-lg">
|
|
31
|
+
<div class="flex items-start gap-4">
|
|
32
|
+
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-destructive/10">
|
|
33
|
+
<AlertTriangle class="h-5 w-5 text-destructive" />
|
|
34
|
+
</div>
|
|
35
|
+
<div>
|
|
36
|
+
<h3 class="font-semibold" style="font-family: var(--font-display);">{title}</h3>
|
|
37
|
+
<p class="mt-1.5 text-sm leading-relaxed text-muted-foreground">{description}</p>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="mt-6 flex justify-end gap-2.5">
|
|
41
|
+
<button
|
|
42
|
+
onclick={() => (open = false)}
|
|
43
|
+
class="h-9 rounded-lg border border-border/60 bg-card px-4 text-sm font-medium shadow-sm transition-all hover:bg-secondary"
|
|
44
|
+
disabled={isDeleting}
|
|
45
|
+
>
|
|
46
|
+
Cancel
|
|
47
|
+
</button>
|
|
48
|
+
<button
|
|
49
|
+
onclick={onConfirm}
|
|
50
|
+
disabled={isDeleting}
|
|
51
|
+
class="h-9 rounded-lg bg-destructive px-4 text-sm font-medium text-destructive-foreground shadow-sm transition-all hover:bg-destructive/90 disabled:opacity-50"
|
|
52
|
+
>
|
|
53
|
+
{isDeleting ? 'Deleting...' : 'Delete'}
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
{/if}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import DocHeader from './doc/DocHeader.svelte'
|
|
3
|
+
import Autosave from './doc/Autosave.svelte'
|
|
4
|
+
import ApiView from './doc/ApiView.svelte'
|
|
5
|
+
import VersionHistory from './doc/VersionHistory.svelte'
|
|
6
|
+
import ScheduleModal from './doc/ScheduleModal.svelte'
|
|
7
|
+
import DynamicForm from './DynamicForm.svelte'
|
|
8
|
+
import { resolveFieldComponent } from './fields/index.js'
|
|
9
|
+
import { useDirtyState } from '$lib/utils/dirty.svelte.js'
|
|
10
|
+
import { unpublishRecord } from '$lib/api/records.js'
|
|
11
|
+
import { toast } from 'svelte-sonner'
|
|
12
|
+
import type { CollectionSchema, FieldSchema } from '$lib/types/schema.js'
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
collection,
|
|
16
|
+
record = null,
|
|
17
|
+
mode,
|
|
18
|
+
isSubmitting = false,
|
|
19
|
+
errors = {},
|
|
20
|
+
onSave,
|
|
21
|
+
}: {
|
|
22
|
+
collection: CollectionSchema
|
|
23
|
+
record?: Record<string, any> | null
|
|
24
|
+
mode: 'create' | 'edit'
|
|
25
|
+
isSubmitting?: boolean
|
|
26
|
+
errors?: Record<string, string>
|
|
27
|
+
onSave: (data: Record<string, any>) => void
|
|
28
|
+
} = $props()
|
|
29
|
+
|
|
30
|
+
function flatten(flds: FieldSchema[]): FieldSchema[] {
|
|
31
|
+
const out: FieldSchema[] = []
|
|
32
|
+
for (const f of flds) {
|
|
33
|
+
if (f.type === 'tabs') {
|
|
34
|
+
const tabs = (f as any).tabs ?? []
|
|
35
|
+
for (const t of tabs) out.push(...flatten(t.fields ?? []))
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (f.type === 'row') {
|
|
39
|
+
out.push(...flatten((f as any).fields ?? []))
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
out.push(f)
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildInitial(flds: FieldSchema[], src: Record<string, any>): Record<string, any> {
|
|
48
|
+
const data: Record<string, any> = {}
|
|
49
|
+
for (const f of flatten(flds)) {
|
|
50
|
+
if (src[f.name] !== undefined && src[f.name] !== null) {
|
|
51
|
+
let val = src[f.name]
|
|
52
|
+
if (f.type === 'tags' && typeof val === 'string') {
|
|
53
|
+
try { val = JSON.parse(val) } catch { val = [] }
|
|
54
|
+
}
|
|
55
|
+
data[f.name] = val
|
|
56
|
+
} else if ((f as any).defaultValue !== undefined) {
|
|
57
|
+
data[f.name] = (f as any).defaultValue
|
|
58
|
+
} else if (f.type === 'checkbox') {
|
|
59
|
+
data[f.name] = false
|
|
60
|
+
} else if (f.type === 'tags') {
|
|
61
|
+
data[f.name] = []
|
|
62
|
+
} else if (f.type === 'number' || f.type === 'file' || f.type === 'relationship' || f.type === 'richtext') {
|
|
63
|
+
data[f.name] = null
|
|
64
|
+
} else {
|
|
65
|
+
data[f.name] = ''
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return data
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Build formData synchronously so child fields (RichTextField etc.) mount with
|
|
72
|
+
// real values, not stale defaults. The $effect below handles reactive updates
|
|
73
|
+
// when the user navigates to a different record.
|
|
74
|
+
// svelte-ignore state_referenced_locally
|
|
75
|
+
let formData = $state<Record<string, any>>(buildInitial(collection.fields, record ?? {}))
|
|
76
|
+
// svelte-ignore state_referenced_locally
|
|
77
|
+
let initialData = $state<Record<string, any>>(buildInitial(collection.fields, record ?? {}))
|
|
78
|
+
// svelte-ignore state_referenced_locally
|
|
79
|
+
let baselineKey = $state<string | null>(record?.id ?? '__new__')
|
|
80
|
+
let headerMode = $state<'edit' | 'api'>('edit')
|
|
81
|
+
function setHeaderMode(m: 'edit' | 'api') {
|
|
82
|
+
headerMode = m
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Rebuild only when navigating to a different record.
|
|
86
|
+
$effect(() => {
|
|
87
|
+
const key = record?.id ?? '__new__'
|
|
88
|
+
if (baselineKey !== key) {
|
|
89
|
+
formData = buildInitial(collection.fields, record ?? {})
|
|
90
|
+
initialData = buildInitial(collection.fields, record ?? {})
|
|
91
|
+
baselineKey = key
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const dirty = useDirtyState(
|
|
96
|
+
() => formData,
|
|
97
|
+
() => initialData
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
let liveTitle = $derived.by(() => {
|
|
101
|
+
const titleField = collection.admin?.useAsTitle
|
|
102
|
+
if (titleField && formData[titleField]) return String(formData[titleField])
|
|
103
|
+
return mode === 'create' ? `New ${collection.label}` : (record?.id ?? collection.label)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
let hasDrafts = $derived(collection.versions?.drafts != null)
|
|
107
|
+
let hasAutosave = $derived(collection.versions?.drafts?.autosave != null)
|
|
108
|
+
let hasSchedulePublish = $derived(collection.versions?.drafts?.schedulePublish === true)
|
|
109
|
+
let hasStatusField = $derived(hasDrafts)
|
|
110
|
+
|
|
111
|
+
let scheduleModalOpen = $state(false)
|
|
112
|
+
let isScheduling = $state(false)
|
|
113
|
+
|
|
114
|
+
let status = $derived(formData._status ?? record?._status ?? 'draft')
|
|
115
|
+
let saveLabel = $derived.by(() => {
|
|
116
|
+
if (!hasDrafts) return mode === 'create' ? 'Save' : 'Save'
|
|
117
|
+
if (mode === 'create') return status === 'published' ? 'Publish' : 'Create draft'
|
|
118
|
+
return status === 'published' ? 'Publish changes' : 'Save draft'
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// Tabs collected from a field of type "tabs" if present
|
|
122
|
+
let tabsField = $derived(
|
|
123
|
+
collection.fields.find((f: FieldSchema) => f.type === 'tabs') as any
|
|
124
|
+
)
|
|
125
|
+
let tabLabels = $derived<{ label: string }[]>(
|
|
126
|
+
tabsField ? (tabsField.tabs ?? []).map((t: any) => ({ label: t.label })) : []
|
|
127
|
+
)
|
|
128
|
+
let activeTab = $state(0)
|
|
129
|
+
|
|
130
|
+
let sidebarFields = $derived(collection.admin?.sidebarFields ?? [])
|
|
131
|
+
let sidebarSet = $derived(new Set(sidebarFields))
|
|
132
|
+
let sideFields = $derived(collection.fields.filter((f: FieldSchema) => !f.hidden && sidebarSet.has(f.name)))
|
|
133
|
+
|
|
134
|
+
function handleSave() {
|
|
135
|
+
const out: Record<string, any> = {}
|
|
136
|
+
for (const k of Object.keys(formData)) {
|
|
137
|
+
const v = formData[k]
|
|
138
|
+
if (v === undefined || v === null || v === '') continue
|
|
139
|
+
out[k] = v
|
|
140
|
+
}
|
|
141
|
+
initialData = { ...formData }
|
|
142
|
+
dirty.allowNavigation()
|
|
143
|
+
onSave(out)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function handleSaveAs(newStatus: 'draft' | 'published' | 'archived') {
|
|
147
|
+
if (hasDrafts) formData._status = newStatus
|
|
148
|
+
handleSave()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function handleUnpublish() {
|
|
152
|
+
if (!record?.id) return
|
|
153
|
+
const result = await unpublishRecord(collection.key, record.id)
|
|
154
|
+
if (result.ok) {
|
|
155
|
+
formData._status = 'draft'
|
|
156
|
+
initialData = { ...formData }
|
|
157
|
+
record = result.data
|
|
158
|
+
toast.success('Record unpublished')
|
|
159
|
+
} else {
|
|
160
|
+
toast.error(result.error)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function openSchedule() {
|
|
165
|
+
scheduleModalOpen = true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function handleSchedule(isoDate: string) {
|
|
169
|
+
isScheduling = true
|
|
170
|
+
formData.publishAt = isoDate
|
|
171
|
+
formData._status = 'draft'
|
|
172
|
+
scheduleModalOpen = false
|
|
173
|
+
try {
|
|
174
|
+
handleSave()
|
|
175
|
+
toast.success(`Scheduled to publish at ${new Date(isoDate).toLocaleString()}`)
|
|
176
|
+
} finally {
|
|
177
|
+
isScheduling = false
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
</script>
|
|
181
|
+
|
|
182
|
+
{#snippet autosaveMeta()}
|
|
183
|
+
{#if hasAutosave && headerMode === 'edit' && record?.id}
|
|
184
|
+
<Autosave
|
|
185
|
+
collectionKey={collection.key}
|
|
186
|
+
recordId={record.id}
|
|
187
|
+
{formData}
|
|
188
|
+
interval={collection.versions?.drafts?.autosave?.interval ?? 2000}
|
|
189
|
+
/>
|
|
190
|
+
{/if}
|
|
191
|
+
{/snippet}
|
|
192
|
+
|
|
193
|
+
<DocHeader
|
|
194
|
+
{collection}
|
|
195
|
+
{liveTitle}
|
|
196
|
+
{record}
|
|
197
|
+
{mode}
|
|
198
|
+
{status}
|
|
199
|
+
{saveLabel}
|
|
200
|
+
isDirty={dirty.isDirty}
|
|
201
|
+
{isSubmitting}
|
|
202
|
+
onSave={handleSave}
|
|
203
|
+
onSaveAs={handleSaveAs}
|
|
204
|
+
onUnpublish={handleUnpublish}
|
|
205
|
+
onSchedule={openSchedule}
|
|
206
|
+
tabs={tabLabels}
|
|
207
|
+
bind:activeTab
|
|
208
|
+
{headerMode}
|
|
209
|
+
{setHeaderMode}
|
|
210
|
+
{hasDrafts}
|
|
211
|
+
{hasSchedulePublish}
|
|
212
|
+
metaExtra={autosaveMeta}
|
|
213
|
+
/>
|
|
214
|
+
|
|
215
|
+
<ScheduleModal
|
|
216
|
+
bind:open={scheduleModalOpen}
|
|
217
|
+
initialValue={formData.publishAt ?? ''}
|
|
218
|
+
isSubmitting={isScheduling}
|
|
219
|
+
onSchedule={handleSchedule}
|
|
220
|
+
/>
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
{#if headerMode === 'api'}
|
|
224
|
+
<ApiView
|
|
225
|
+
bind:formData
|
|
226
|
+
collectionKey={collection.key}
|
|
227
|
+
recordId={record?.id ?? null}
|
|
228
|
+
{mode}
|
|
229
|
+
onApply={() => (headerMode = 'edit')}
|
|
230
|
+
/>
|
|
231
|
+
{:else}
|
|
232
|
+
<div class="grid gap-9 px-7 py-8 {sideFields.length > 0 || (collection.versions && mode === 'edit' && record?.id) ? 'lg:grid-cols-[1fr_320px]' : ''}">
|
|
233
|
+
<div class="min-w-0">
|
|
234
|
+
<DynamicForm
|
|
235
|
+
fields={collection.fields}
|
|
236
|
+
initialData={record ?? {}}
|
|
237
|
+
{errors}
|
|
238
|
+
{sidebarFields}
|
|
239
|
+
{tabsField}
|
|
240
|
+
{activeTab}
|
|
241
|
+
bind:formData
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
{#if sideFields.length > 0 || (collection.versions && mode === 'edit' && record?.id)}
|
|
246
|
+
<aside class="space-y-5 lg:sticky lg:top-[260px] lg:self-start">
|
|
247
|
+
{#each sideFields as f (f.name)}
|
|
248
|
+
{@const FieldComponent = resolveFieldComponent(f)}
|
|
249
|
+
{#if FieldComponent}
|
|
250
|
+
<FieldComponent field={f} bind:value={formData[f.name]} error={errors[f.name]} {formData} />
|
|
251
|
+
{/if}
|
|
252
|
+
{/each}
|
|
253
|
+
|
|
254
|
+
{#if collection.versions && mode === 'edit' && record?.id}
|
|
255
|
+
<VersionHistory
|
|
256
|
+
collectionKey={collection.key}
|
|
257
|
+
recordId={record.id}
|
|
258
|
+
/>
|
|
259
|
+
{/if}
|
|
260
|
+
</aside>
|
|
261
|
+
{/if}
|
|
262
|
+
</div>
|
|
263
|
+
{/if}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { FieldSchema } from '$lib/types/schema.js'
|
|
3
|
+
import { resolveFieldComponent } from './fields/index.js'
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
fields,
|
|
7
|
+
initialData = {},
|
|
8
|
+
errors = {},
|
|
9
|
+
sidebarFields = [],
|
|
10
|
+
tabsField = undefined,
|
|
11
|
+
activeTab = 0,
|
|
12
|
+
formData = $bindable<Record<string, any>>({}),
|
|
13
|
+
}: {
|
|
14
|
+
fields: FieldSchema[]
|
|
15
|
+
initialData?: Record<string, any>
|
|
16
|
+
errors?: Record<string, string>
|
|
17
|
+
sidebarFields?: string[]
|
|
18
|
+
tabsField?: any
|
|
19
|
+
activeTab?: number
|
|
20
|
+
formData?: Record<string, any>
|
|
21
|
+
} = $props()
|
|
22
|
+
|
|
23
|
+
let sidebarFieldSet = $derived(new Set(sidebarFields))
|
|
24
|
+
let mainFields = $derived(
|
|
25
|
+
fields.filter((f) => !f.hidden && !sidebarFieldSet.has(f.name))
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
// formData is owned by the parent (DocumentEditLayout) and bound in.
|
|
29
|
+
// DynamicForm is now a pure renderer.
|
|
30
|
+
let localErrors = $state<Record<string, string>>({})
|
|
31
|
+
let mergedErrors = $derived({ ...errors, ...localErrors })
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
{#snippet fieldRenderer(f: FieldSchema)}
|
|
35
|
+
{@const FieldComponent = resolveFieldComponent(f)}
|
|
36
|
+
{#if FieldComponent}
|
|
37
|
+
<FieldComponent
|
|
38
|
+
field={f}
|
|
39
|
+
bind:value={formData[f.name]}
|
|
40
|
+
error={mergedErrors[f.name]}
|
|
41
|
+
{formData}
|
|
42
|
+
/>
|
|
43
|
+
{:else}
|
|
44
|
+
<p class="text-sm text-muted-foreground">Unknown field type: {f.type}</p>
|
|
45
|
+
{/if}
|
|
46
|
+
{/snippet}
|
|
47
|
+
|
|
48
|
+
<div class="space-y-6">
|
|
49
|
+
{#each mainFields as f, i (f.name || `${f.type}-${i}`)}
|
|
50
|
+
{#if f.type === 'tabs'}
|
|
51
|
+
{#if tabsField && tabsField.tabs && tabsField.tabs[activeTab]}
|
|
52
|
+
{#each (tabsField.tabs[activeTab].fields ?? []) as cf (cf.name)}
|
|
53
|
+
{#if cf.type === 'row'}
|
|
54
|
+
<div class="grid gap-4" style="grid-template-columns: repeat({(cf.fields ?? []).length}, minmax(0, 1fr));">
|
|
55
|
+
{#each (cf.fields ?? []).filter((x: FieldSchema) => !x.hidden) as rf (rf.name)}
|
|
56
|
+
{@render fieldRenderer(rf)}
|
|
57
|
+
{/each}
|
|
58
|
+
</div>
|
|
59
|
+
{:else}
|
|
60
|
+
{@render fieldRenderer(cf)}
|
|
61
|
+
{/if}
|
|
62
|
+
{/each}
|
|
63
|
+
{/if}
|
|
64
|
+
{:else if f.type === 'row'}
|
|
65
|
+
<div class="grid gap-4" style="grid-template-columns: repeat({(f.fields ?? []).length}, minmax(0, 1fr));">
|
|
66
|
+
{#each (f.fields ?? []).filter((x: FieldSchema) => !x.hidden) as rf (rf.name)}
|
|
67
|
+
{@render fieldRenderer(rf)}
|
|
68
|
+
{/each}
|
|
69
|
+
</div>
|
|
70
|
+
{:else}
|
|
71
|
+
{@render fieldRenderer(f)}
|
|
72
|
+
{/if}
|
|
73
|
+
{/each}
|
|
74
|
+
</div>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
type Format = 'int' | 'percent' | 'pp'
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
label,
|
|
6
|
+
current,
|
|
7
|
+
previous,
|
|
8
|
+
format = 'int',
|
|
9
|
+
}: {
|
|
10
|
+
label: string
|
|
11
|
+
current: number | null
|
|
12
|
+
previous: number | null
|
|
13
|
+
format?: Format
|
|
14
|
+
} = $props()
|
|
15
|
+
|
|
16
|
+
const intFmt = new Intl.NumberFormat()
|
|
17
|
+
|
|
18
|
+
const headline = $derived.by(() => {
|
|
19
|
+
if (current === null) return '—'
|
|
20
|
+
if (format === 'int') return intFmt.format(current)
|
|
21
|
+
if (format === 'percent') return (current * 100).toFixed(2) + '%'
|
|
22
|
+
// pp: treat current as percentage-point value already
|
|
23
|
+
return current.toFixed(2) + 'pp'
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
type Delta = {
|
|
27
|
+
text: string
|
|
28
|
+
tone: 'up' | 'down' | 'flat' | 'none'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const delta = $derived.by<Delta>(() => {
|
|
32
|
+
if (previous === null || previous === 0 || current === null) {
|
|
33
|
+
return { text: '—', tone: 'none' }
|
|
34
|
+
}
|
|
35
|
+
if (format === 'pp') {
|
|
36
|
+
const diff = current - previous
|
|
37
|
+
if (diff === 0) return { text: '—', tone: 'flat' }
|
|
38
|
+
const sign = diff > 0 ? '+' : ''
|
|
39
|
+
return {
|
|
40
|
+
text: sign + diff.toFixed(2) + 'pp',
|
|
41
|
+
tone: diff > 0 ? 'up' : 'down',
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const pct = ((current - previous) / previous) * 100
|
|
45
|
+
if (pct === 0) return { text: '—', tone: 'flat' }
|
|
46
|
+
const sign = pct > 0 ? '+' : ''
|
|
47
|
+
return {
|
|
48
|
+
text: sign + pct.toFixed(1) + '%',
|
|
49
|
+
tone: pct > 0 ? 'up' : 'down',
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const arrow = $derived(delta.tone === 'up' ? '▲' : delta.tone === 'down' ? '▼' : '')
|
|
54
|
+
|
|
55
|
+
const chipClass = $derived(
|
|
56
|
+
delta.tone === 'up'
|
|
57
|
+
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
58
|
+
: delta.tone === 'down'
|
|
59
|
+
? 'border-red-200 bg-red-50 text-red-700'
|
|
60
|
+
: 'border-border/60 bg-muted text-muted-foreground',
|
|
61
|
+
)
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<div class="rounded-lg border border-border/60 bg-card p-4 shadow-sm">
|
|
65
|
+
<p class="text-sm font-medium text-muted-foreground">{label}</p>
|
|
66
|
+
<p class="mt-2 text-3xl font-semibold tracking-tight text-foreground">{headline}</p>
|
|
67
|
+
<div class="mt-2">
|
|
68
|
+
<span
|
|
69
|
+
class="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium {chipClass}"
|
|
70
|
+
>
|
|
71
|
+
{#if arrow}<span aria-hidden="true">{arrow}</span>{/if}
|
|
72
|
+
<span>{delta.text}</span>
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|