@svadmin/ui 0.59.0 → 0.60.1
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/dist/app.css +4 -6
- package/dist/app.theme.css +4 -6
- package/dist/components/AnomalyBadge.svelte +8 -4
- package/dist/components/ApiSettings.svelte +1 -1
- package/dist/components/AutoForm.svelte +5 -5
- package/dist/components/AutoSaveIndicator.svelte +6 -6
- package/dist/components/AutoTable.svelte +11 -10
- package/dist/components/BatchActionBar.svelte +7 -7
- package/dist/components/ConfigErrorScreen.svelte +2 -2
- package/dist/components/ConfirmDialog.svelte +4 -0
- package/dist/components/InferencerPanel.svelte +2 -2
- package/dist/components/InlineEdit.svelte +23 -13
- package/dist/components/InsightCard.svelte +17 -6
- package/dist/components/LiveIndicator.svelte +10 -4
- package/dist/components/MarkdownRenderer.svelte +25 -2
- package/dist/components/PermissionMatrix.svelte +1 -1
- package/dist/components/RecordDetailDrawer.svelte +3 -3
- package/dist/components/Sidebar.svelte +10 -6
- package/dist/components/TaskStatusBadge.svelte +1 -1
- package/dist/components/content/DataState.svelte +4 -4
- package/dist/components/content/FeedbackNotice.svelte +3 -3
- package/dist/components/content/FilterToolbar.svelte +7 -7
- package/dist/components/content/MetricBlock.svelte +4 -4
- package/dist/components/content/OtpInput.svelte +58 -4
- package/dist/components/fields/AvatarField.svelte +8 -8
- package/dist/components/fields/JsonField.svelte +1 -1
- package/dist/components/fields/RatingField.svelte +3 -3
- package/dist/components/profile/ProfileVariantSections.svelte +9 -8
- package/package.json +2 -2
package/dist/app.css
CHANGED
|
@@ -596,13 +596,11 @@ main {
|
|
|
596
596
|
50% { opacity: 0; }
|
|
597
597
|
}
|
|
598
598
|
|
|
599
|
-
/* Handle copied state for dynamically injected buttons
|
|
599
|
+
/* Handle copied state for dynamically injected buttons.
|
|
600
|
+
* The check icon itself is swapped in by MarkdownRenderer (createCheckIcon)
|
|
601
|
+
* with stroke="currentColor" so it inherits this semantic token color. */
|
|
600
602
|
.copy-btn[data-copied-id] {
|
|
601
|
-
color:
|
|
602
|
-
}
|
|
603
|
-
.copy-btn[data-copied-id] svg {
|
|
604
|
-
/* Swap to Check icon */
|
|
605
|
-
content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="%2310b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>');
|
|
603
|
+
color: var(--success);
|
|
606
604
|
}
|
|
607
605
|
|
|
608
606
|
/* ChatDialog Styles */
|
package/dist/app.theme.css
CHANGED
|
@@ -595,13 +595,11 @@ main {
|
|
|
595
595
|
50% { opacity: 0; }
|
|
596
596
|
}
|
|
597
597
|
|
|
598
|
-
/* Handle copied state for dynamically injected buttons
|
|
598
|
+
/* Handle copied state for dynamically injected buttons.
|
|
599
|
+
* The check icon itself is swapped in by MarkdownRenderer (createCheckIcon)
|
|
600
|
+
* with stroke="currentColor" so it inherits this semantic token color. */
|
|
599
601
|
.copy-btn[data-copied-id] {
|
|
600
|
-
color:
|
|
601
|
-
}
|
|
602
|
-
.copy-btn[data-copied-id] svg {
|
|
603
|
-
/* Swap to Check icon */
|
|
604
|
-
content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="%2310b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>');
|
|
602
|
+
color: var(--success);
|
|
605
603
|
}
|
|
606
604
|
|
|
607
605
|
/* ChatDialog Styles */
|
|
@@ -41,10 +41,13 @@
|
|
|
41
41
|
);
|
|
42
42
|
|
|
43
43
|
const percentLabel = $derived(`${Math.abs(diff * 100).toFixed(1)}%`);
|
|
44
|
+
const anomalyAriaLabel = $derived(
|
|
45
|
+
`Anomaly: current ${formatter(value)}, baseline ${formatter(baseline)} (${diff > 0 ? '+' : '-'}${percentLabel})`
|
|
46
|
+
);
|
|
44
47
|
</script>
|
|
45
48
|
|
|
46
49
|
{#if !isAnomaly}
|
|
47
|
-
<span class="text-muted-foreground text-sm font-medium {className}">
|
|
50
|
+
<span class="text-muted-foreground text-sm font-medium tabular-nums {className}">
|
|
48
51
|
{formatter(value)}
|
|
49
52
|
</span>
|
|
50
53
|
{:else}
|
|
@@ -53,15 +56,16 @@
|
|
|
53
56
|
variant="ghost"
|
|
54
57
|
size="sm"
|
|
55
58
|
class="p-0 h-auto hover:bg-transparent"
|
|
59
|
+
aria-label={anomalyAriaLabel}
|
|
56
60
|
>
|
|
57
61
|
<Badge
|
|
58
62
|
variant={isGood ? 'default' : 'destructive'}
|
|
59
|
-
class="flex items-center gap-1 font-mono text-xs px-1.5 py-0 {isGood ? 'bg-success hover:bg-success/90 text-success-foreground' : ''} {className}"
|
|
63
|
+
class="flex items-center gap-1 font-mono text-xs tabular-nums px-1.5 py-0 {isGood ? 'bg-success hover:bg-success/90 text-success-foreground' : ''} {className}"
|
|
60
64
|
>
|
|
61
65
|
{#if diff > 0}
|
|
62
|
-
<TrendingUp class="h-3 w-3" />
|
|
66
|
+
<TrendingUp class="h-3 w-3" aria-hidden="true" />
|
|
63
67
|
{:else}
|
|
64
|
-
<TrendingDown class="h-3 w-3" />
|
|
68
|
+
<TrendingDown class="h-3 w-3" aria-hidden="true" />
|
|
65
69
|
{/if}
|
|
66
70
|
{formatter(value)}
|
|
67
71
|
</Badge>
|
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
{#if loading}<DataState state="loading" />{:else if error}<DataState state="error" description={error} retry={loadCredentials} />{:else}<ApiKeyList class="rounded-none border-0" keys={keys.map((key) => ({ id: key.id, name: key.name, prefix: key.prefix, createdAt: new Date(key.createdAt).toLocaleString(), lastUsedAt: key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : undefined, permissions: key.permissions }))} stateTitle={i18n.t('api.noKeys')} stateDescription={credentialProvider ? i18n.t('api.generateDesc') : (isZh ? '配置 CredentialProvider 后可管理真实凭据。' : 'Configure CredentialProvider to manage real credentials.')} onrevoke={credentialProvider ? (key) => revokeKey(key.id) : undefined} />{/if}
|
|
173
173
|
</SettingsGroup>
|
|
174
174
|
<SettingsGroup title={i18n.t('api.webhooks')} description={i18n.t('api.webhooksDesc')} bodyClass="space-y-5">
|
|
175
|
-
<form class="grid min-w-0 gap-4 md:grid-cols-2" onsubmit={handleAddWebhook}><div class="space-y-2"><Label for="webhook-name">{i18n.t('api.webhookName')}</Label><Input id="webhook-name" bind:value={newWebhookName} placeholder="Order Events" disabled={!credentialProvider || submitting} /></div><div class="space-y-2"><Label for="webhook-url">{i18n.t('api.webhookUrl')}</Label><Input id="webhook-url" type="url" bind:value={newWebhookUrl} placeholder="https://api.example.com/hooks" disabled={!credentialProvider || submitting} /></div><div class="space-y-2"><Label for="webhook-event">{i18n.t('api.eventType')}</Label><select id="webhook-event" bind:value={newWebhookEvent} disabled={!credentialProvider || submitting} class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50"><option value="resource.created">resource.created</option><option value="resource.updated">resource.updated</option><option value="resource.deleted">resource.deleted</option><option value="auth.login">auth.login</option></select></div><div class="flex items-end"><Button type="submit" class="w-full" disabled={!credentialProvider || submitting || !newWebhookName.trim() || !newWebhookUrl.trim()}><Plus class="size-4" />{i18n.t('api.addWebhook')}</Button></div></form>
|
|
175
|
+
<form class="grid min-w-0 gap-4 md:grid-cols-2" onsubmit={handleAddWebhook}><div class="space-y-2"><Label for="webhook-name">{i18n.t('api.webhookName')}</Label><Input id="webhook-name" bind:value={newWebhookName} placeholder="Order Events" disabled={!credentialProvider || submitting} /></div><div class="space-y-2 md:col-span-2"><Label for="webhook-url">{i18n.t('api.webhookUrl')}</Label><Input id="webhook-url" type="url" bind:value={newWebhookUrl} placeholder="https://api.example.com/hooks" disabled={!credentialProvider || submitting} /></div><div class="space-y-2"><Label for="webhook-event">{i18n.t('api.eventType')}</Label><select id="webhook-event" bind:value={newWebhookEvent} disabled={!credentialProvider || submitting} class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50"><option value="resource.created">resource.created</option><option value="resource.updated">resource.updated</option><option value="resource.deleted">resource.deleted</option><option value="auth.login">auth.login</option></select></div><div class="flex items-end"><Button type="submit" class="w-full" disabled={!credentialProvider || submitting || !newWebhookName.trim() || !newWebhookUrl.trim()}><Plus class="size-4" />{i18n.t('api.addWebhook')}</Button></div></form>
|
|
176
176
|
{#if webhooks.length === 0}<DataState state="empty" title={i18n.t('api.noWebhooks')} description={credentialProvider ? i18n.t('api.webhooksDesc') : (isZh ? '配置 CredentialProvider 后可管理真实 Webhook。' : 'Configure CredentialProvider to manage real webhooks.')} />{:else}<div class="divide-y divide-border border-t border-border">{#each webhooks as hook (hook.id)}<div class="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between"><div class="min-w-0"><p class="text-sm font-medium text-foreground">{hook.name}</p><p class="truncate font-mono text-xs text-muted-foreground">{hook.url}</p></div><div class="flex items-center justify-between gap-3 sm:justify-end"><span class="rounded bg-muted px-2 py-1 font-mono text-xs text-muted-foreground">{hook.eventType}</span><Button variant="ghost" size="icon-sm" onclick={() => deleteWebhook(hook.id)} aria-label={i18n.t('common.delete') + ' ' + hook.name}><Trash2 class="size-4" /></Button></div></div>{/each}</div>{/if}
|
|
177
177
|
</SettingsGroup>
|
|
178
178
|
</div>
|
|
@@ -198,14 +198,14 @@
|
|
|
198
198
|
tooltip={i18n.t('common.back')}
|
|
199
199
|
onclick={() => guardNavigate(() => navigation.list(resourceName))}
|
|
200
200
|
>
|
|
201
|
-
<ArrowLeft class="h-5 w-5" />
|
|
201
|
+
<ArrowLeft class="h-5 w-5" aria-hidden="true" />
|
|
202
202
|
</TooltipButton>
|
|
203
203
|
<h1 class="text-xl font-semibold text-foreground">{pageTitle}</h1>
|
|
204
204
|
{#if headerContent}
|
|
205
205
|
{@render headerContent()}
|
|
206
206
|
{/if}
|
|
207
207
|
{#if form.isTainted()}
|
|
208
|
-
<Badge variant="outline" class="border-warning/30 bg-warning/10 text-warning-foreground">{i18n.t('common.unsaved')}</Badge>
|
|
208
|
+
<Badge variant="outline" role="status" aria-live="polite" class="border-warning/30 bg-warning/10 text-warning-foreground">{i18n.t('common.unsaved')}</Badge>
|
|
209
209
|
{/if}
|
|
210
210
|
</div>
|
|
211
211
|
{/if}
|
|
@@ -226,7 +226,7 @@
|
|
|
226
226
|
{#if submitError}
|
|
227
227
|
<div transition:slide={{ duration: 300, axis: 'y' }} class="svadmin-shake">
|
|
228
228
|
<Alert.Root variant="destructive">
|
|
229
|
-
<AlertCircle class="h-4 w-4" />
|
|
229
|
+
<AlertCircle class="h-4 w-4" aria-hidden="true" />
|
|
230
230
|
<Alert.Description>{submitError}</Alert.Description>
|
|
231
231
|
</Alert.Root>
|
|
232
232
|
</div>
|
|
@@ -301,9 +301,9 @@
|
|
|
301
301
|
{:else if !isReadonly}
|
|
302
302
|
<Button type="submit" size={isCompact ? 'sm' : 'default'} disabled={form.submitting}>
|
|
303
303
|
{#if form.submitting}
|
|
304
|
-
<Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
|
|
304
|
+
<Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" aria-hidden="true" />
|
|
305
305
|
{:else}
|
|
306
|
-
<Save class="h-4 w-4" data-icon="inline-start" />
|
|
306
|
+
<Save class="h-4 w-4" data-icon="inline-start" aria-hidden="true" />
|
|
307
307
|
{/if}
|
|
308
308
|
{i18n.t('common.save')}
|
|
309
309
|
</Button>
|
|
@@ -16,20 +16,20 @@
|
|
|
16
16
|
}>();
|
|
17
17
|
</script>
|
|
18
18
|
|
|
19
|
-
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
|
19
|
+
<div class="flex items-center gap-2 text-sm text-muted-foreground" role="status" aria-live="polite">
|
|
20
20
|
{#key status}
|
|
21
21
|
<div class="flex items-center gap-2" in:fly={{ y: -8, duration: 200 }} out:fade={{ duration: 150 }}>
|
|
22
22
|
{#if status === 'idle'}
|
|
23
|
-
<Cloud class="h-4 w-4" />
|
|
23
|
+
<Cloud class="h-4 w-4" aria-hidden="true" />
|
|
24
24
|
<span>{i18n.t('autoSave.idle')}</span>
|
|
25
25
|
{:else if status === 'loading'}
|
|
26
|
-
<Loader2 class="h-4 w-4 animate-spin text-primary" />
|
|
26
|
+
<Loader2 class="h-4 w-4 animate-spin text-primary" aria-hidden="true" />
|
|
27
27
|
<span>{i18n.t('autoSave.saving')}</span>
|
|
28
28
|
{:else if status === 'success'}
|
|
29
|
-
<Check class="h-4 w-4 text-
|
|
30
|
-
<span class="text-
|
|
29
|
+
<Check class="h-4 w-4 text-success" aria-hidden="true" />
|
|
30
|
+
<span class="text-success">{i18n.t('autoSave.saved')}</span>
|
|
31
31
|
{:else if status === 'error'}
|
|
32
|
-
<X class="h-4 w-4 text-destructive" />
|
|
32
|
+
<X class="h-4 w-4 text-destructive" aria-hidden="true" />
|
|
33
33
|
<span class="text-destructive" title={error}>{i18n.t('autoSave.error')}</span>
|
|
34
34
|
{/if}
|
|
35
35
|
</div>
|
|
@@ -1099,12 +1099,12 @@
|
|
|
1099
1099
|
<!-- Selection Banner (Enterprise Batch Actions) -->
|
|
1100
1100
|
{#if selectedCount > 0}
|
|
1101
1101
|
<div
|
|
1102
|
-
class="flex flex-col gap-3 border border-primary/20 bg-primary/10 px-3.5 py-2 rounded-lg text-sm text-foreground animate-in fade-in duration-200 sm:flex-row sm:items-center sm:justify-between"
|
|
1102
|
+
class="flex flex-col gap-3 border border-primary/20 bg-primary/10 px-3.5 py-2 rounded-lg text-sm text-foreground animate-in fade-in duration-200 motion-reduce:animate-none sm:flex-row sm:items-center sm:justify-between"
|
|
1103
1103
|
aria-label={i18n.t("common.selectedCount", { count: selectedCount })}
|
|
1104
1104
|
data-svadmin-batch-toolbar
|
|
1105
1105
|
>
|
|
1106
1106
|
<div class="flex min-w-0 items-center gap-2 text-sm">
|
|
1107
|
-
<span class="font-medium text-foreground">{i18n.t("common.selectedCount", { count: selectedCount })}</span>
|
|
1107
|
+
<span class="font-medium text-foreground tabular-nums">{i18n.t("common.selectedCount", { count: selectedCount })}</span>
|
|
1108
1108
|
{#if deleteManyMutation.isPending}
|
|
1109
1109
|
<span class="text-muted-foreground" role="status">{i18n.t("common.processing")}</span>
|
|
1110
1110
|
{/if}
|
|
@@ -1121,7 +1121,7 @@
|
|
|
1121
1121
|
disabled={deleteManyMutation.isPending}
|
|
1122
1122
|
onclick={confirmBatchDelete}
|
|
1123
1123
|
>
|
|
1124
|
-
<Trash2 class="h-3.5 w-3.5" data-icon="inline-start" /> {i18n.t("common.batchDelete", { count: selectedCount })}
|
|
1124
|
+
<Trash2 class="h-3.5 w-3.5" data-icon="inline-start" aria-hidden="true" /> {i18n.t("common.batchDelete", { count: selectedCount })}
|
|
1125
1125
|
</Button>
|
|
1126
1126
|
{/if}
|
|
1127
1127
|
<Button
|
|
@@ -1143,7 +1143,7 @@
|
|
|
1143
1143
|
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-[200px]">
|
|
1144
1144
|
{#if searchableFields.length > 0}
|
|
1145
1145
|
<div class="relative max-w-sm flex-1 sm:min-w-[220px]">
|
|
1146
|
-
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
1146
|
+
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
|
1147
1147
|
<Input
|
|
1148
1148
|
type="text"
|
|
1149
1149
|
value={searchText}
|
|
@@ -1159,10 +1159,10 @@
|
|
|
1159
1159
|
<Popover.Trigger>
|
|
1160
1160
|
{#snippet child({ props })}
|
|
1161
1161
|
<Button variant="outline" size="sm" class="h-9 px-3" {...props}>
|
|
1162
|
-
<FilterIcon class="h-4 w-4" data-icon="inline-start" />
|
|
1162
|
+
<FilterIcon class="h-4 w-4" data-icon="inline-start" aria-hidden="true" />
|
|
1163
1163
|
{i18n.t("common.filter")}
|
|
1164
1164
|
{#if activeFilterCount > 0}
|
|
1165
|
-
<Badge variant="secondary" class="ml-1 h-5 min-w-5 px-1">{activeFilterCount}</Badge>
|
|
1165
|
+
<Badge variant="secondary" class="ml-1 h-5 min-w-5 px-1 tabular-nums">{activeFilterCount}</Badge>
|
|
1166
1166
|
{/if}
|
|
1167
1167
|
</Button>
|
|
1168
1168
|
{/snippet}
|
|
@@ -1215,7 +1215,7 @@
|
|
|
1215
1215
|
<div class="flex items-center gap-1.5 shrink-0">
|
|
1216
1216
|
{#if !showHeader && canExport}
|
|
1217
1217
|
<TooltipButton tooltip={i18n.t("common.export")} variant="outline" size="sm" class="h-9 px-2.5" onclick={exportCSV}>
|
|
1218
|
-
<Download class="h-4 w-4" />
|
|
1218
|
+
<Download class="h-4 w-4" aria-hidden="true" />
|
|
1219
1219
|
</TooltipButton>
|
|
1220
1220
|
{/if}
|
|
1221
1221
|
|
|
@@ -1224,7 +1224,7 @@
|
|
|
1224
1224
|
<DropdownMenu.Trigger>
|
|
1225
1225
|
{#snippet child({ props })}
|
|
1226
1226
|
<TooltipButton tooltip={i18n.t("common.density")} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
|
|
1227
|
-
<Rows class="h-4 w-4" />
|
|
1227
|
+
<Rows class="h-4 w-4" aria-hidden="true" />
|
|
1228
1228
|
</TooltipButton>
|
|
1229
1229
|
{/snippet}
|
|
1230
1230
|
</DropdownMenu.Trigger>
|
|
@@ -1754,10 +1754,10 @@
|
|
|
1754
1754
|
{#if totalPages > 0}
|
|
1755
1755
|
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 px-1 py-2 text-sm text-muted-foreground">
|
|
1756
1756
|
<div class="flex shrink-0 items-center gap-3 leading-8">
|
|
1757
|
-
<span class="shrink-0 whitespace-nowrap">{i18n.t('common.total', { total: query.data?.total ?? 0 })}</span>
|
|
1757
|
+
<span class="shrink-0 whitespace-nowrap tabular-nums">{i18n.t('common.total', { total: query.data?.total ?? 0 })}</span>
|
|
1758
1758
|
<Select.Root
|
|
1759
1759
|
aria-label={i18n.t('common.perPage')}
|
|
1760
|
-
class="h-8 w-[78px] shrink-0"
|
|
1760
|
+
class="h-8 w-[78px] shrink-0 tabular-nums"
|
|
1761
1761
|
value={String(pagination.pageSize ?? 10)}
|
|
1762
1762
|
onchange={(e) => {
|
|
1763
1763
|
const size = Number((e.currentTarget as HTMLSelectElement).value);
|
|
@@ -1787,6 +1787,7 @@
|
|
|
1787
1787
|
<PaginationUI.Ellipsis />
|
|
1788
1788
|
{:else}
|
|
1789
1789
|
<PaginationUI.Link
|
|
1790
|
+
class="tabular-nums"
|
|
1790
1791
|
isActive={page === currentPage}
|
|
1791
1792
|
onclick={() => goToPage(page as number)}
|
|
1792
1793
|
>
|
|
@@ -44,15 +44,15 @@
|
|
|
44
44
|
{#if variant === 'floating'}
|
|
45
45
|
<div
|
|
46
46
|
class={cn(
|
|
47
|
-
'fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 rounded-lg border border-border/80 bg-background/95 px-4 py-2.5 shadow-2xl backdrop-blur-md transition-all duration-200 animate-in fade-in-0 slide-in-from-bottom-4',
|
|
47
|
+
'fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 rounded-lg border border-border/80 bg-background/95 px-4 py-2.5 shadow-2xl backdrop-blur-md transition-all duration-200 animate-in fade-in-0 slide-in-from-bottom-4 motion-reduce:animate-none',
|
|
48
48
|
className
|
|
49
49
|
)}
|
|
50
50
|
role="region"
|
|
51
51
|
aria-label="Batch Actions"
|
|
52
52
|
>
|
|
53
53
|
<div class="flex items-center gap-2 pr-2 border-r border-border/60">
|
|
54
|
-
<CheckCircle2 class="h-4 w-4 text-primary" />
|
|
55
|
-
<span class="text-xs font-medium text-foreground">{displayText}</span>
|
|
54
|
+
<CheckCircle2 class="h-4 w-4 text-primary" aria-hidden="true" />
|
|
55
|
+
<span class="text-xs font-medium text-foreground tabular-nums">{displayText}</span>
|
|
56
56
|
</div>
|
|
57
57
|
|
|
58
58
|
{#if actions}
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
onclick={onclear}
|
|
76
76
|
aria-label={clearLabel}
|
|
77
77
|
>
|
|
78
|
-
<X class="h-3.5 w-3.5 mr-1" />
|
|
78
|
+
<X class="h-3.5 w-3.5 mr-1" aria-hidden="true" />
|
|
79
79
|
{clearLabel}
|
|
80
80
|
</Button>
|
|
81
81
|
{/if}
|
|
@@ -91,14 +91,14 @@
|
|
|
91
91
|
aria-label="Batch Actions"
|
|
92
92
|
>
|
|
93
93
|
<div class="flex items-center gap-2">
|
|
94
|
-
<Badge variant="secondary" class="h-5 px-1.5 font-semibold text-[11px] bg-primary/15 text-primary border-primary/20">
|
|
94
|
+
<Badge variant="secondary" class="h-5 px-1.5 font-semibold text-[11px] tabular-nums bg-primary/15 text-primary border-primary/20">
|
|
95
95
|
{selectedCount}
|
|
96
96
|
</Badge>
|
|
97
|
-
<span class="font-medium text-foreground">{displayText}</span>
|
|
97
|
+
<span class="font-medium text-foreground tabular-nums">{displayText}</span>
|
|
98
98
|
{#if onclear}
|
|
99
99
|
<button
|
|
100
100
|
type="button"
|
|
101
|
-
class="text-muted-foreground hover:text-foreground underline underline-offset-2 ml-1 text-xs cursor-pointer focus:outline-none"
|
|
101
|
+
class="text-muted-foreground hover:text-foreground underline underline-offset-2 ml-1 text-xs cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded-xs"
|
|
102
102
|
onclick={onclear}
|
|
103
103
|
>
|
|
104
104
|
{clearLabel}
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
onclick={() => copyToClipboard(`${v.key}=`, v.key)}
|
|
82
82
|
>
|
|
83
83
|
{#if copied[v.key]}
|
|
84
|
-
<CheckCircle class="h-3.5 w-3.5 text-
|
|
84
|
+
<CheckCircle class="h-3.5 w-3.5 text-success" />
|
|
85
85
|
{:else}
|
|
86
86
|
<Copy class="h-3.5 w-3.5" />
|
|
87
87
|
{/if}
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
onclick={copyAll}
|
|
104
104
|
>
|
|
105
105
|
{#if copied['__all__']}
|
|
106
|
-
<CheckCircle class="h-3.5 w-3.5 text-
|
|
106
|
+
<CheckCircle class="h-3.5 w-3.5 text-success" />
|
|
107
107
|
<span class="text-xs">{i18n.t('common.copied')}</span>
|
|
108
108
|
{:else}
|
|
109
109
|
<Copy class="h-3.5 w-3.5" />
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import * as AlertDialog from './ui/alert-dialog/index.js';
|
|
3
3
|
import { Button } from './ui/button/index.js';
|
|
4
|
+
import { Loader2 } from '@lucide/svelte';
|
|
4
5
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
5
6
|
|
|
6
7
|
const i18n = useTranslation();
|
|
@@ -61,6 +62,9 @@
|
|
|
61
62
|
<AlertDialog.Action>
|
|
62
63
|
{#snippet child({ props })}
|
|
63
64
|
<Button variant={variantMap[variant as keyof typeof variantMap]} {...props} disabled={confirming} onclick={handleConfirm}>
|
|
65
|
+
{#if confirming}
|
|
66
|
+
<Loader2 class="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
|
|
67
|
+
{/if}
|
|
64
68
|
{resolvedConfirmText}
|
|
65
69
|
</Button>
|
|
66
70
|
{/snippet}
|
|
@@ -194,8 +194,8 @@
|
|
|
194
194
|
<span>Generated ResourceDefinition</span>
|
|
195
195
|
<Button variant="ghost" size="sm" class="h-6 text-xs gap-1" onclick={copyCode}>
|
|
196
196
|
{#if copied}
|
|
197
|
-
<Check class="h-3 w-3 text-
|
|
198
|
-
<span class="text-
|
|
197
|
+
<Check class="h-3 w-3 text-success" />
|
|
198
|
+
<span class="text-success">Copied!</span>
|
|
199
199
|
{:else}
|
|
200
200
|
<Copy class="h-3 w-3" />
|
|
201
201
|
Copy
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { useUpdate } from '@svadmin/core';
|
|
3
3
|
import type { FieldDefinition } from '@svadmin/core';
|
|
4
|
+
import { Loader2 } from '@lucide/svelte';
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
6
7
|
resourceName: string;
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
let saving = $state(false);
|
|
20
21
|
let savePending = false;
|
|
21
22
|
const displayValue = $derived(String(value ?? ''));
|
|
23
|
+
const fieldLabel = $derived(field.label || field.key);
|
|
22
24
|
|
|
23
25
|
const { mutation } = useUpdate({ get resource() { return resourceName; } });
|
|
24
26
|
|
|
@@ -75,24 +77,32 @@
|
|
|
75
77
|
</script>
|
|
76
78
|
|
|
77
79
|
{#if editing}
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
80
|
+
<div class="relative flex items-center w-full">
|
|
81
|
+
<input
|
|
82
|
+
bind:this={inputRef}
|
|
83
|
+
type={field.type === 'number' ? 'number' : 'text'}
|
|
84
|
+
bind:value={editValue}
|
|
85
|
+
onkeydown={handleKeydown}
|
|
86
|
+
onblur={handleBlur}
|
|
87
|
+
disabled={saving}
|
|
88
|
+
aria-label={fieldLabel}
|
|
89
|
+
class="h-7 w-full rounded border bg-background px-2 text-sm outline-none ring-1 ring-primary/50 focus:ring-2 focus:ring-primary transition-all disabled:opacity-60"
|
|
90
|
+
/>
|
|
91
|
+
{#if saving}
|
|
92
|
+
<span class="absolute right-2 flex items-center pointer-events-none" role="status" aria-label="Saving...">
|
|
93
|
+
<Loader2 class="size-3.5 animate-spin text-muted-foreground" aria-hidden="true" />
|
|
94
|
+
</span>
|
|
95
|
+
{/if}
|
|
96
|
+
</div>
|
|
87
97
|
{:else}
|
|
88
98
|
<span
|
|
89
|
-
class="inline-
|
|
99
|
+
class="inline-flex items-center cursor-pointer rounded px-1.5 py-0.5 -mx-1 text-sm text-foreground hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 transition-colors"
|
|
90
100
|
ondblclick={startEdit}
|
|
91
|
-
title=
|
|
101
|
+
title={`Double-click to edit ${fieldLabel}`}
|
|
92
102
|
role="button"
|
|
93
103
|
tabindex="0"
|
|
94
|
-
onkeydown={(e) => { if (e.key === 'Enter' || e.key === 'F2') startEdit(); }}
|
|
104
|
+
onkeydown={(e) => { if (e.key === 'Enter' || e.key === 'F2') { e.preventDefault(); startEdit(); } }}
|
|
95
105
|
>
|
|
96
|
-
{value
|
|
106
|
+
{value != null && value !== '' ? String(value) : '—'}
|
|
97
107
|
</span>
|
|
98
108
|
{/if}
|
|
@@ -95,25 +95,36 @@ ${scopedContext}`;
|
|
|
95
95
|
}
|
|
96
96
|
</script>
|
|
97
97
|
|
|
98
|
-
<div
|
|
98
|
+
<div
|
|
99
|
+
class="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col {className}"
|
|
100
|
+
role="region"
|
|
101
|
+
aria-label={title}
|
|
102
|
+
>
|
|
99
103
|
<div class="flex items-center justify-between p-4 border-b">
|
|
100
104
|
<div class="flex items-center gap-2 font-medium">
|
|
101
|
-
<Sparkles class="h-4 w-4 text-
|
|
105
|
+
<Sparkles class="h-4 w-4 text-warning" aria-hidden="true" />
|
|
102
106
|
{title}
|
|
103
107
|
</div>
|
|
104
|
-
<Button
|
|
105
|
-
|
|
108
|
+
<Button
|
|
109
|
+
variant="ghost"
|
|
110
|
+
size="icon-sm"
|
|
111
|
+
class="h-6 w-6"
|
|
112
|
+
onclick={getInsights}
|
|
113
|
+
disabled={isPredicting || !provider}
|
|
114
|
+
aria-label="Refresh AI Insights"
|
|
115
|
+
>
|
|
116
|
+
<RefreshCcw class="h-3 w-3 {isPredicting ? 'animate-spin' : ''}" aria-hidden="true" />
|
|
106
117
|
</Button>
|
|
107
118
|
</div>
|
|
108
119
|
|
|
109
|
-
<div class="p-4 flex-1">
|
|
120
|
+
<div class="p-4 flex-1" role="status" aria-live="polite" aria-busy={isPredicting}>
|
|
110
121
|
{#if !provider}
|
|
111
122
|
<div class="text-sm text-muted-foreground italic flex items-center justify-center h-full">
|
|
112
123
|
ChatProvider not configured.
|
|
113
124
|
</div>
|
|
114
125
|
{:else if isPredicting && !insightText}
|
|
115
126
|
<div class="flex items-center justify-center gap-2 text-sm text-muted-foreground h-full py-4">
|
|
116
|
-
<Loader2 class="h-4 w-4 animate-spin" /> Analyzing data...
|
|
127
|
+
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" /> Analyzing data...
|
|
117
128
|
</div>
|
|
118
129
|
{:else if !insightText}
|
|
119
130
|
<div class="text-sm text-muted-foreground italic flex items-center justify-center h-full">
|
|
@@ -38,11 +38,17 @@
|
|
|
38
38
|
|
|
39
39
|
{#key status}
|
|
40
40
|
<div transition:fade={{ duration: 200 }}>
|
|
41
|
-
<Badge
|
|
42
|
-
|
|
41
|
+
<Badge
|
|
42
|
+
variant={cfg.variant}
|
|
43
|
+
class="gap-1.5 select-none"
|
|
44
|
+
title={cfg.label}
|
|
45
|
+
role="status"
|
|
46
|
+
aria-label={`Connection status: ${cfg.label}${resource ? ` for ${resource}` : ''}`}
|
|
47
|
+
>
|
|
48
|
+
<span class="relative flex h-2 w-2" aria-hidden="true">
|
|
43
49
|
<span class="{cfg.dotClass} rounded-full h-2 w-2"></span>
|
|
44
50
|
{#if status === 'connected'}
|
|
45
|
-
<span class="absolute inset-0 rounded-full {cfg.dotClass} opacity-40 animate-ping"></span>
|
|
51
|
+
<span class="absolute inset-0 rounded-full {cfg.dotClass} opacity-40 animate-ping motion-reduce:animate-none"></span>
|
|
46
52
|
{/if}
|
|
47
53
|
</span>
|
|
48
54
|
<span class="text-[0.6875rem] font-semibold uppercase tracking-wide">{cfg.label}</span>
|
|
@@ -55,7 +61,7 @@
|
|
|
55
61
|
<span class="text-[0.625rem] opacity-70">
|
|
56
62
|
{lastEvent.type}
|
|
57
63
|
{#if timeSinceEvent !== null && timeSinceEvent < 60}
|
|
58
|
-
<span class="opacity-60">{timeSinceEvent}s ago</span>
|
|
64
|
+
<span class="opacity-60 tabular-nums">{timeSinceEvent}s ago</span>
|
|
59
65
|
{/if}
|
|
60
66
|
</span>
|
|
61
67
|
{/if}
|
|
@@ -106,12 +106,35 @@
|
|
|
106
106
|
const id = Math.random().toString(36);
|
|
107
107
|
copiedBlock = id;
|
|
108
108
|
button.setAttribute("data-copied-id", id);
|
|
109
|
+
// Swap to a semantic check icon; color comes from the .copy-btn CSS token.
|
|
110
|
+
button.querySelector("svg")?.replaceWith(createCheckIcon());
|
|
109
111
|
|
|
110
112
|
setTimeout(() => {
|
|
111
113
|
if (copiedBlock === id) copiedBlock = null;
|
|
112
114
|
}, 2000);
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
function createCheckIcon(): SVGSVGElement {
|
|
118
|
+
const svgNamespace = "http://www.w3.org/2000/svg";
|
|
119
|
+
const svg = document.createElementNS(svgNamespace, "svg");
|
|
120
|
+
svg.setAttribute("width", "14");
|
|
121
|
+
svg.setAttribute("height", "14");
|
|
122
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
123
|
+
svg.setAttribute("fill", "none");
|
|
124
|
+
svg.setAttribute("stroke", "currentColor");
|
|
125
|
+
svg.setAttribute("stroke-width", "2");
|
|
126
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
127
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
128
|
+
svg.setAttribute("aria-hidden", "true");
|
|
129
|
+
svg.classList.add("lucide", "lucide-check");
|
|
130
|
+
|
|
131
|
+
const polyline = document.createElementNS(svgNamespace, "polyline");
|
|
132
|
+
polyline.setAttribute("points", "20 6 9 17 4 12");
|
|
133
|
+
svg.appendChild(polyline);
|
|
134
|
+
|
|
135
|
+
return svg;
|
|
136
|
+
}
|
|
137
|
+
|
|
115
138
|
function createCopyIcon(): SVGSVGElement {
|
|
116
139
|
const svgNamespace = "http://www.w3.org/2000/svg";
|
|
117
140
|
const svg = document.createElementNS(svgNamespace, "svg");
|
|
@@ -161,7 +184,7 @@
|
|
|
161
184
|
|
|
162
185
|
const header = document.createElement("div");
|
|
163
186
|
header.className =
|
|
164
|
-
"flex items-center justify-between px-4 py-2 text-xs text-
|
|
187
|
+
"flex items-center justify-between px-4 py-2 text-xs text-background/70 border-b border-background/15";
|
|
165
188
|
|
|
166
189
|
const lang =
|
|
167
190
|
Array.from(pre.querySelector("code")?.classList || [])
|
|
@@ -173,7 +196,7 @@
|
|
|
173
196
|
const copyButton = document.createElement("button");
|
|
174
197
|
copyButton.type = "button";
|
|
175
198
|
copyButton.className =
|
|
176
|
-
"copy-btn hover:text-
|
|
199
|
+
"copy-btn hover:text-background transition-colors flex items-center gap-1";
|
|
177
200
|
copyButton.setAttribute("aria-label", "Copy code");
|
|
178
201
|
copyButton.appendChild(createCopyIcon());
|
|
179
202
|
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
|
|
68
68
|
<!-- Toast Feedback -->
|
|
69
69
|
{#if message}
|
|
70
|
-
<div class={`px-4 py-2 rounded-lg text-sm font-medium shadow-sm transition-all flex items-center gap-2 ${messageType === 'success' ? 'bg-
|
|
70
|
+
<div class={`px-4 py-2 rounded-lg text-sm font-medium shadow-sm transition-all flex items-center gap-2 ${messageType === 'success' ? 'bg-success/10 text-success ring-1 ring-success/20' : 'bg-destructive/10 text-destructive ring-1 ring-destructive/20'}`}>
|
|
71
71
|
{#if messageType === 'success'}
|
|
72
72
|
<Check class="w-4 h-4" />
|
|
73
73
|
{:else}
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
data-svadmin-record-detail
|
|
62
62
|
>
|
|
63
63
|
{#if permissionPending || (canReadRecord && query.isLoading)}
|
|
64
|
-
<div class="divide-y divide-border/60" aria-label={i18n.t('common.loading')}>
|
|
64
|
+
<div class="divide-y divide-border/60" role="status" aria-live="polite" aria-label={i18n.t('common.loading')} aria-busy="true">
|
|
65
65
|
{#each showFields.slice(0, 6) as field (field.key)}
|
|
66
66
|
<div class="grid gap-2 py-4 sm:grid-cols-[minmax(8rem,1fr)_2fr] sm:gap-6">
|
|
67
67
|
<Skeleton class="h-4 w-24" />
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
{#if canReadRecord && recordId != null && resource.canShow !== false}
|
|
105
105
|
<CanAccess resource={resourceName} action="show" params={{ id: recordId }}>
|
|
106
106
|
<Button variant="outline" onclick={() => navigateTo('show')}>
|
|
107
|
-
<Eye class="h-4 w-4" data-icon="inline-start" />
|
|
107
|
+
<Eye class="h-4 w-4" data-icon="inline-start" aria-hidden="true" />
|
|
108
108
|
{i18n.t('common.openFullDetail')}
|
|
109
109
|
</Button>
|
|
110
110
|
</CanAccess>
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
{#if canReadRecord && recordId != null && resource.canEdit !== false}
|
|
113
113
|
<CanAccess resource={resourceName} action="edit" params={{ id: recordId }}>
|
|
114
114
|
<Button onclick={() => navigateTo('edit')}>
|
|
115
|
-
<Pencil class="h-4 w-4" data-icon="inline-start" />
|
|
115
|
+
<Pencil class="h-4 w-4" data-icon="inline-start" aria-hidden="true" />
|
|
116
116
|
{i18n.t('common.edit')}
|
|
117
117
|
</Button>
|
|
118
118
|
</CanAccess>
|
|
@@ -285,12 +285,14 @@
|
|
|
285
285
|
});
|
|
286
286
|
</script>
|
|
287
287
|
|
|
288
|
-
{#snippet languageMenu()}
|
|
288
|
+
{#snippet languageMenu(collapsed: boolean)}
|
|
289
289
|
<select
|
|
290
290
|
aria-label={i18n.t('common.switchLanguage')}
|
|
291
291
|
value={i18n.locale}
|
|
292
292
|
onchange={(event) => selectLocale((event.target as HTMLSelectElement).value)}
|
|
293
|
-
class=
|
|
293
|
+
class={collapsed
|
|
294
|
+
? 'h-8 w-auto min-w-0 max-w-full appearance-none rounded-md border border-border bg-sidebar px-1 text-center text-[10px] font-semibold text-sidebar-foreground/70 outline-none focus:ring-2 focus:ring-ring'
|
|
295
|
+
: 'h-8 w-auto min-w-0 max-w-full rounded-md border border-border bg-sidebar px-1.5 text-[11px] font-semibold text-sidebar-foreground/70 outline-none focus:ring-2 focus:ring-ring'}
|
|
294
296
|
>
|
|
295
297
|
{#each i18n.getAvailableLocales() as locale (locale)}
|
|
296
298
|
<option value={locale}>{localeName(locale)}</option>
|
|
@@ -425,6 +427,8 @@
|
|
|
425
427
|
class="h-3 w-3 rounded-full ring-1 ring-offset-1 ring-offset-sidebar"
|
|
426
428
|
style="background-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}; --tw-ring-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}"
|
|
427
429
|
></span>
|
|
430
|
+
<!-- The dot previews the user-selected runtime theme swatch; the hex
|
|
431
|
+
fallback only covers the case where no registered theme matches. -->
|
|
428
432
|
</button>
|
|
429
433
|
{#if colorPickerOpen}
|
|
430
434
|
<div class="absolute bottom-full left-0 mb-1 z-50 w-40 rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95">
|
|
@@ -468,7 +472,7 @@
|
|
|
468
472
|
</div>
|
|
469
473
|
<div class="mt-1 flex items-center justify-between px-1">
|
|
470
474
|
<div class="flex items-center gap-0.5">
|
|
471
|
-
{@render languageMenu()}
|
|
475
|
+
{@render languageMenu(false)}
|
|
472
476
|
<TooltipButton tooltip={i18n.t('common.toggleTheme')} variant="ghost" size="icon-sm" onclick={toggleTheme} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
|
|
473
477
|
{#if getResolvedTheme() === 'dark'}
|
|
474
478
|
<Sun class="h-3.5 w-3.5" />
|
|
@@ -486,8 +490,8 @@
|
|
|
486
490
|
</div>
|
|
487
491
|
</div>
|
|
488
492
|
{:else if collapsed}
|
|
489
|
-
<div class="flex flex-col items-center gap-0.5 px-
|
|
490
|
-
{@render languageMenu()}
|
|
493
|
+
<div class="flex flex-col items-center gap-0.5 px-1 py-3">
|
|
494
|
+
{@render languageMenu(true)}
|
|
491
495
|
<Tooltip.Root>
|
|
492
496
|
<Tooltip.Trigger>
|
|
493
497
|
{#snippet child({ props }: { props: Record<string, unknown> })}
|
|
@@ -525,7 +529,7 @@
|
|
|
525
529
|
</div>
|
|
526
530
|
{:else}
|
|
527
531
|
<div class="flex justify-center gap-1 p-3">
|
|
528
|
-
{@render languageMenu()}
|
|
532
|
+
{@render languageMenu(false)}
|
|
529
533
|
<TooltipButton tooltip={i18n.t('common.toggleTheme')} variant="ghost" size="icon" onclick={toggleTheme} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
|
|
530
534
|
{#if getResolvedTheme() === 'dark'}
|
|
531
535
|
<Sun class="h-3.5 w-3.5" />
|
|
@@ -46,15 +46,15 @@
|
|
|
46
46
|
</div>
|
|
47
47
|
{:else if state === 'error' || state === 'forbidden'}
|
|
48
48
|
<Alert.Root variant={state === 'error' ? 'destructive' : 'warning'} class={className}>
|
|
49
|
-
{#if state === 'error'}<AlertTriangle class="size-4" />{:else}<LockKeyhole class="size-4" />{/if}
|
|
49
|
+
{#if state === 'error'}<AlertTriangle class="size-4" aria-hidden="true" />{:else}<LockKeyhole class="size-4" aria-hidden="true" />{/if}
|
|
50
50
|
<Alert.Title>{resolvedTitle}</Alert.Title>
|
|
51
51
|
<Alert.Description>{resolvedDescription}</Alert.Description>
|
|
52
|
-
{#if retry}<Button variant="outline" size="sm" class="mt-3" onclick={retry}><RefreshCw class="size-3.5" />{resolvedRetryLabel}</Button>{/if}
|
|
52
|
+
{#if retry}<Button variant="outline" size="sm" class="mt-3" onclick={retry}><RefreshCw class="size-3.5" aria-hidden="true" />{resolvedRetryLabel}</Button>{/if}
|
|
53
53
|
{#if action}<div class="mt-3">{@render action()}</div>{/if}
|
|
54
54
|
</Alert.Root>
|
|
55
55
|
{:else}
|
|
56
|
-
<div class={'flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card px-6 py-8 text-center ' + className}>
|
|
57
|
-
<Inbox class="size-8 text-muted-foreground" /><h3 class="mt-3 text-sm font-semibold text-foreground">{resolvedTitle}</h3><p class="mt-1 max-w-sm text-sm text-muted-foreground">{resolvedDescription}</p>
|
|
56
|
+
<div class={'flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card px-6 py-8 text-center ' + className} role="status" aria-live="polite">
|
|
57
|
+
<Inbox class="size-8 text-muted-foreground" aria-hidden="true" /><h3 class="mt-3 text-sm font-semibold text-foreground">{resolvedTitle}</h3><p class="mt-1 max-w-sm text-sm text-muted-foreground">{resolvedDescription}</p>
|
|
58
58
|
{#if action}<div class="mt-4">{@render action()}</div>{/if}
|
|
59
59
|
</div>
|
|
60
60
|
{/if}
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
>
|
|
48
48
|
<div class="flex min-w-0 flex-1 items-start gap-2.5">
|
|
49
49
|
{#if tone === 'danger'}
|
|
50
|
-
<CircleAlert class="mt-0.5 size-4 shrink-0" />
|
|
50
|
+
<CircleAlert class="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
|
51
51
|
{:else if tone === 'warning'}
|
|
52
|
-
<TriangleAlert class="mt-0.5 size-4 shrink-0" />
|
|
52
|
+
<TriangleAlert class="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
|
53
53
|
{:else}
|
|
54
|
-
<Info class="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
54
|
+
<Info class="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
55
55
|
{/if}
|
|
56
56
|
<p class="min-w-0 leading-5">{message}</p>
|
|
57
57
|
</div>
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
47
47
|
{#if showSearch}
|
|
48
48
|
<div class="relative min-w-0 flex-1">
|
|
49
|
-
<Search class={cn('pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground', isCompact ? 'size-3.5' : 'size-4')} />
|
|
49
|
+
<Search class={cn('pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground', isCompact ? 'size-3.5' : 'size-4')} aria-hidden="true" />
|
|
50
50
|
<Input
|
|
51
51
|
bind:value={query}
|
|
52
52
|
{placeholder}
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
title={clearLabel}
|
|
63
63
|
onclick={() => query = ''}
|
|
64
64
|
>
|
|
65
|
-
<X class="size-3.5" />
|
|
65
|
+
<X class="size-3.5" aria-hidden="true" />
|
|
66
66
|
</Button>
|
|
67
67
|
{/if}
|
|
68
68
|
</div>
|
|
@@ -81,17 +81,17 @@
|
|
|
81
81
|
aria-expanded={advancedOpen}
|
|
82
82
|
aria-controls={advancedPanelId}
|
|
83
83
|
>
|
|
84
|
-
<SlidersHorizontal class="size-3.5" />
|
|
84
|
+
<SlidersHorizontal class="size-3.5" aria-hidden="true" />
|
|
85
85
|
<span>{advancedLabel}</span>
|
|
86
86
|
{#if activeFilterCount > 0}
|
|
87
|
-
<Badge variant="secondary" class="ml-0.5 h-4 min-w-4 px-1 text-[10px] leading-none font-semibold">
|
|
87
|
+
<Badge variant="secondary" class="ml-0.5 h-4 min-w-4 px-1 text-[10px] leading-none font-semibold tabular-nums">
|
|
88
88
|
{activeFilterCount}
|
|
89
89
|
</Badge>
|
|
90
90
|
{/if}
|
|
91
91
|
{#if advancedOpen}
|
|
92
|
-
<ChevronUp class="size-3 text-muted-foreground ml-0.5" />
|
|
92
|
+
<ChevronUp class="size-3 text-muted-foreground ml-0.5" aria-hidden="true" />
|
|
93
93
|
{:else}
|
|
94
|
-
<ChevronDown class="size-3 text-muted-foreground ml-0.5" />
|
|
94
|
+
<ChevronDown class="size-3 text-muted-foreground ml-0.5" aria-hidden="true" />
|
|
95
95
|
{/if}
|
|
96
96
|
</Button>
|
|
97
97
|
{/if}
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
{/if}
|
|
103
103
|
</div>
|
|
104
104
|
{#if advanced && advancedOpen}
|
|
105
|
-
<div id={advancedPanelId} class="svadmin-filter-toolbar-advanced rounded-lg border border-border/60 bg-muted/20 p-3" aria-hidden="false">
|
|
105
|
+
<div id={advancedPanelId} role="region" aria-label={advancedLabel} class="svadmin-filter-toolbar-advanced rounded-lg border border-border/60 bg-muted/20 p-3" aria-hidden="false">
|
|
106
106
|
{@render advanced()}
|
|
107
107
|
</div>
|
|
108
108
|
{/if}
|
|
@@ -30,15 +30,15 @@
|
|
|
30
30
|
};
|
|
31
31
|
</script>
|
|
32
32
|
|
|
33
|
-
<div data-svadmin-metric-card class={'min-w-0 rounded-lg border border-border bg-card p-4 shadow-sm ' + className}>
|
|
33
|
+
<div data-svadmin-metric-card role="region" aria-label={label} class={'min-w-0 rounded-lg border border-border bg-card p-4 shadow-sm ' + className}>
|
|
34
34
|
<div class="flex items-start justify-between gap-3">
|
|
35
35
|
<p class="text-sm text-muted-foreground">{label}</p>
|
|
36
|
-
{#if icon}<span class="text-muted-foreground">{@render icon()}</span>{/if}
|
|
36
|
+
{#if icon}<span class="text-muted-foreground" aria-hidden="true">{@render icon()}</span>{/if}
|
|
37
37
|
</div>
|
|
38
38
|
{#if loading}
|
|
39
39
|
<Skeleton class="mt-3 h-8 w-24" />
|
|
40
40
|
{:else}
|
|
41
|
-
<p class="mt-2 truncate text-2xl font-semibold tracking-normal text-foreground">{value}</p>
|
|
42
|
-
{#if detail || trend}<div class="mt-2 flex flex-wrap gap-2 text-xs">{#if trend}<span class={'font-medium ' + trendClass[trendTone]}>{trend}</span>{/if}{#if detail}<span class="text-muted-foreground">{detail}</span>{/if}</div>{/if}
|
|
41
|
+
<p class="mt-2 truncate text-2xl font-semibold tracking-normal text-foreground tabular-nums">{value}</p>
|
|
42
|
+
{#if detail || trend}<div class="mt-2 flex flex-wrap gap-2 text-xs">{#if trend}<span class={'font-medium tabular-nums ' + trendClass[trendTone]}>{trend}</span>{/if}{#if detail}<span class="text-muted-foreground">{detail}</span>{/if}</div>{/if}
|
|
43
43
|
{/if}
|
|
44
44
|
</div>
|
|
@@ -1,22 +1,76 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { Input } from '../ui/input/index.js';
|
|
3
|
-
interface Props {
|
|
4
|
-
|
|
3
|
+
interface Props {
|
|
4
|
+
value?: string[];
|
|
5
|
+
length?: number;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
'aria-label'?: string;
|
|
8
|
+
class?: string;
|
|
9
|
+
}
|
|
10
|
+
let {
|
|
11
|
+
value = $bindable<string[]>([]),
|
|
12
|
+
length = 6,
|
|
13
|
+
disabled = false,
|
|
14
|
+
'aria-label': ariaLabel = 'One-time password',
|
|
15
|
+
class: className = '',
|
|
16
|
+
}: Props = $props();
|
|
17
|
+
|
|
5
18
|
const slots = $derived(Array.from({ length }, (_, index) => index));
|
|
19
|
+
|
|
6
20
|
function updateDigit(event: Event, index: number) {
|
|
7
21
|
const input = event.currentTarget as HTMLInputElement;
|
|
8
22
|
const digit = input.value.replace(/\D/g, '').slice(-1);
|
|
9
23
|
const next = [...value];
|
|
24
|
+
while (next.length < length) next.push('');
|
|
10
25
|
next[index] = digit;
|
|
11
26
|
value = next;
|
|
12
27
|
if (digit && index < length - 1) {
|
|
13
|
-
const
|
|
28
|
+
const inputs = input.parentElement?.querySelectorAll('input');
|
|
29
|
+
const sibling = inputs?.[index + 1] as HTMLInputElement | undefined;
|
|
14
30
|
sibling?.focus();
|
|
15
31
|
}
|
|
16
32
|
}
|
|
33
|
+
|
|
34
|
+
function handleKeydown(event: KeyboardEvent, index: number) {
|
|
35
|
+
const input = event.currentTarget as HTMLInputElement;
|
|
36
|
+
if (event.key === 'Backspace' && !input.value && index > 0) {
|
|
37
|
+
const inputs = input.parentElement?.querySelectorAll('input');
|
|
38
|
+
const prev = inputs?.[index - 1] as HTMLInputElement | undefined;
|
|
39
|
+
prev?.focus();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handlePaste(event: ClipboardEvent) {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
const pasted = event.clipboardData?.getData('text/plain') ?? '';
|
|
46
|
+
const digits = pasted.replace(/\D/g, '').slice(0, length).split('');
|
|
47
|
+
if (digits.length === 0) return;
|
|
48
|
+
const next = [...value];
|
|
49
|
+
while (next.length < length) next.push('');
|
|
50
|
+
digits.forEach((digit, i) => {
|
|
51
|
+
next[i] = digit;
|
|
52
|
+
});
|
|
53
|
+
value = next;
|
|
54
|
+
const targetIndex = Math.min(digits.length, length - 1);
|
|
55
|
+
const inputs = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll('input');
|
|
56
|
+
inputs?.[targetIndex]?.focus();
|
|
57
|
+
}
|
|
17
58
|
</script>
|
|
59
|
+
|
|
18
60
|
<div class={'flex justify-center gap-2 ' + className} role="group" aria-label={ariaLabel}>
|
|
19
61
|
{#each slots as index (index)}
|
|
20
|
-
<Input
|
|
62
|
+
<Input
|
|
63
|
+
class="size-12 text-center text-lg font-semibold tabular-nums"
|
|
64
|
+
value={value[index] ?? ''}
|
|
65
|
+
maxlength={1}
|
|
66
|
+
inputmode="numeric"
|
|
67
|
+
pattern="[0-9]*"
|
|
68
|
+
autocomplete="one-time-code"
|
|
69
|
+
aria-label={ariaLabel + ' digit ' + (index + 1)}
|
|
70
|
+
{disabled}
|
|
71
|
+
oninput={(event) => updateDigit(event, index)}
|
|
72
|
+
onkeydown={(event) => handleKeydown(event, index)}
|
|
73
|
+
onpaste={handlePaste}
|
|
74
|
+
/>
|
|
21
75
|
{/each}
|
|
22
76
|
</div>
|
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
const statusColors: Record<AvatarStatus, string> = {
|
|
48
|
-
online: 'bg-
|
|
49
|
-
success: 'bg-
|
|
50
|
-
busy: 'bg-
|
|
51
|
-
error: 'bg-
|
|
52
|
-
warning: 'bg-
|
|
53
|
-
away: 'bg-
|
|
54
|
-
offline: 'bg-
|
|
55
|
-
neutral: 'bg-
|
|
48
|
+
online: 'bg-success',
|
|
49
|
+
success: 'bg-success',
|
|
50
|
+
busy: 'bg-destructive',
|
|
51
|
+
error: 'bg-destructive',
|
|
52
|
+
warning: 'bg-warning',
|
|
53
|
+
away: 'bg-warning',
|
|
54
|
+
offline: 'bg-muted-foreground',
|
|
55
|
+
neutral: 'bg-muted-foreground',
|
|
56
56
|
};
|
|
57
57
|
|
|
58
58
|
function getInitials(value?: string | null): string {
|
|
@@ -63,15 +63,15 @@
|
|
|
63
63
|
{:else}
|
|
64
64
|
<div class={cn('field-rating inline-flex items-center gap-1.5', className)}>
|
|
65
65
|
<div
|
|
66
|
-
class="inline-flex items-center gap-0.5 text-
|
|
66
|
+
class="inline-flex items-center gap-0.5 text-warning"
|
|
67
67
|
role="img"
|
|
68
68
|
aria-label={`${numericValue} out of ${normalizedMax}`}
|
|
69
69
|
>
|
|
70
70
|
{#each stars as starType, idx (idx)}
|
|
71
71
|
{#if starType === 'full'}
|
|
72
|
-
<Star aria-hidden="true" class={cn(sizeClasses[size].icon, 'fill-
|
|
72
|
+
<Star aria-hidden="true" class={cn(sizeClasses[size].icon, 'fill-warning text-warning')} />
|
|
73
73
|
{:else if starType === 'half'}
|
|
74
|
-
<StarHalf aria-hidden="true" class={cn(sizeClasses[size].icon, 'fill-
|
|
74
|
+
<StarHalf aria-hidden="true" class={cn(sizeClasses[size].icon, 'fill-warning text-warning')} />
|
|
75
75
|
{:else}
|
|
76
76
|
<Star aria-hidden="true" class={cn(sizeClasses[size].icon, 'text-muted-foreground/30')} />
|
|
77
77
|
{/if}
|
|
@@ -32,15 +32,16 @@
|
|
|
32
32
|
];
|
|
33
33
|
const skills = ['TypeScript', 'Svelte', 'Node.js', 'GraphQL', 'Design Systems', 'Accessibility'];
|
|
34
34
|
const badges: { name: string; Icon: Component; tone: string }[] = [
|
|
35
|
-
|
|
36
|
-
{ name: '
|
|
37
|
-
{ name: '
|
|
38
|
-
{ name: '
|
|
35
|
+
// Decorative demo tones — use the chart palette, not status tokens.
|
|
36
|
+
{ name: 'Top Contributor', Icon: Medal, tone: 'bg-chart-1/10 text-chart-1' },
|
|
37
|
+
{ name: 'Early Adopter', Icon: Star, tone: 'bg-chart-2/10 text-chart-2' },
|
|
38
|
+
{ name: 'Community Mentor', Icon: Award, tone: 'bg-chart-3/10 text-chart-3' },
|
|
39
|
+
{ name: 'Launch Partner', Icon: Crown, tone: 'bg-chart-4/10 text-chart-4' },
|
|
39
40
|
];
|
|
40
41
|
const recentUploads: { name: string; size: string; Icon: Component; tone: string }[] = [
|
|
41
|
-
{ name: 'dashboard-spec.fig', size: '4.2 MB', Icon: Image, tone: 'bg-
|
|
42
|
-
{ name: 'release-notes.md', size: '18 KB', Icon: FileText, tone: 'bg-
|
|
43
|
-
{ name: 'assets-bundle.zip', size: '24 MB', Icon: FileArchive, tone: 'bg-
|
|
42
|
+
{ name: 'dashboard-spec.fig', size: '4.2 MB', Icon: Image, tone: 'bg-chart-5/10 text-chart-5' },
|
|
43
|
+
{ name: 'release-notes.md', size: '18 KB', Icon: FileText, tone: 'bg-chart-2/10 text-chart-2' },
|
|
44
|
+
{ name: 'assets-bundle.zip', size: '24 MB', Icon: FileArchive, tone: 'bg-chart-3/10 text-chart-3' },
|
|
44
45
|
];
|
|
45
46
|
const contributors = ['Mia Torres', 'Chen Wei', 'Ava Novak', 'Liam Ortiz', 'Sofia Petrova', '+8'];
|
|
46
47
|
|
|
@@ -252,7 +253,7 @@
|
|
|
252
253
|
<p class="text-xs text-muted-foreground">{item.label}</p>
|
|
253
254
|
<div class="mt-1 flex items-baseline gap-2">
|
|
254
255
|
<span class="text-xl font-bold text-foreground">{item.value}</span>
|
|
255
|
-
<span class="flex items-center gap-0.5 text-xs font-medium text-
|
|
256
|
+
<span class="flex items-center gap-0.5 text-xs font-medium text-success"><TrendingUp class="h-3 w-3" />{item.delta}</span>
|
|
256
257
|
</div>
|
|
257
258
|
</Card.CardContent>
|
|
258
259
|
</Card.Card>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.1",
|
|
4
4
|
"description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"./components/*": "./dist/components/*"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@svadmin/core": "^0.42.
|
|
42
|
+
"@svadmin/core": "^0.42.1",
|
|
43
43
|
"@tanstack/svelte-query": "^6.1.38",
|
|
44
44
|
"highlight.js": "^11.11.1",
|
|
45
45
|
"isomorphic-dompurify": "^3.22.0",
|