@svadmin/ui 0.45.0 → 0.46.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 (44) hide show
  1. package/dist/components/AuthPageShell.svelte +32 -0
  2. package/dist/components/AuthPageShell.svelte.d.ts +11 -0
  3. package/dist/components/ForgotPasswordPage.svelte +4 -68
  4. package/dist/components/LoginPage.svelte +33 -124
  5. package/dist/components/PermissionMatrix.svelte +5 -5
  6. package/dist/components/RegisterPage.svelte +7 -62
  7. package/dist/components/SvadminLogo.svelte +1 -1
  8. package/dist/components/UpdatePasswordPage.svelte +3 -55
  9. package/dist/components/account/CompanyProfilePage.svelte +11 -5
  10. package/dist/components/account/SettingsEnterprisePage.svelte +25 -4
  11. package/dist/components/account/SettingsPlainPage.svelte +25 -3
  12. package/dist/components/account/SettingsSidebarPage.svelte +31 -2
  13. package/dist/components/content/ApiKeyList.svelte +13 -4
  14. package/dist/components/content/ApiKeyList.svelte.d.ts +7 -0
  15. package/dist/components/content/DataState.svelte +25 -9
  16. package/dist/components/content/DataState.svelte.d.ts +2 -0
  17. package/dist/components/content/FileList.svelte +12 -4
  18. package/dist/components/content/FileList.svelte.d.ts +9 -0
  19. package/dist/components/content/FilterToolbar.svelte +10 -2
  20. package/dist/components/content/FilterToolbar.svelte.d.ts +1 -0
  21. package/dist/components/content/MemberList.svelte +13 -5
  22. package/dist/components/content/MemberList.svelte.d.ts +9 -0
  23. package/dist/components/content/MetricBlock.svelte +19 -2
  24. package/dist/components/content/MetricBlock.svelte.d.ts +2 -0
  25. package/dist/components/content/NetworkTable.svelte +9 -3
  26. package/dist/components/content/NetworkTable.svelte.d.ts +9 -0
  27. package/dist/components/content/NetworkUserCard.svelte +47 -7
  28. package/dist/components/content/NetworkUserCard.svelte.d.ts +13 -0
  29. package/dist/components/content/SectionHeader.svelte +3 -2
  30. package/dist/components/content/SectionHeader.svelte.d.ts +1 -0
  31. package/dist/components/content/SecurityEventTable.svelte +14 -5
  32. package/dist/components/content/SecurityEventTable.svelte.d.ts +9 -0
  33. package/dist/components/content/SettingsFieldRow.svelte +28 -0
  34. package/dist/components/content/SettingsFieldRow.svelte.d.ts +11 -0
  35. package/dist/components/content/SettingsGroup.svelte +33 -0
  36. package/dist/components/content/SettingsGroup.svelte.d.ts +12 -0
  37. package/dist/components/content/WorkspaceLayout.svelte +28 -0
  38. package/dist/components/content/WorkspaceLayout.svelte.d.ts +11 -0
  39. package/dist/components/content/index.d.ts +4 -1
  40. package/dist/components/content/index.js +3 -0
  41. package/dist/components/network/UserCardsNFTPage.svelte +25 -6
  42. package/dist/index.d.ts +6 -1
  43. package/dist/index.js +4 -0
  44. package/package.json +1 -1
@@ -4,9 +4,11 @@
4
4
  import { Input } from '../ui/input/index.js';
5
5
  import { Label } from '../ui/label/index.js';
6
6
  import { Switch } from '../ui/switch/index.js';
7
- import * as Card from '../ui/card/index.js';
8
7
  import ContentPageShell from '../content/ContentPageShell.svelte';
9
8
  import ContentPageHeader from '../content/ContentPageHeader.svelte';
9
+ import SectionHeader from '../content/SectionHeader.svelte';
10
+ import SettingsGroup from '../content/SettingsGroup.svelte';
11
+ import SettingsFieldRow from '../content/SettingsFieldRow.svelte';
10
12
  import IntegrationCard from '../content/IntegrationCard.svelte';
11
13
  import type { IntegrationSummary } from '../content/IntegrationCard.svelte';
12
14
  import SecurityEventTable from '../content/SecurityEventTable.svelte';
@@ -18,9 +20,28 @@
18
20
  const events: SecurityEvent[] = [{ id: '1', event: 'SSO policy updated', actor: 'Alex Chen', location: 'Shanghai, CN', createdAt: 'Today, 09:12', severity: 'info' }, { id: '2', event: 'Audit log retention changed', actor: 'Alex Chen', location: 'Shanghai, CN', createdAt: 'Yesterday, 16:40', severity: 'warning' }];
19
21
  </script>
20
22
 
23
+ {#snippet auditControl()}
24
+ <Switch bind:checked={auditLogging} aria-label={i18n.t('account.auditLogging')} />
25
+ {/snippet}
26
+
27
+ {#snippet ssoControl()}
28
+ <Switch bind:checked={ssoEnabled} aria-label={i18n.t('account.enableSso')} />
29
+ {/snippet}
30
+
21
31
  <ContentPageShell pageId="account-settings-enterprise" width="wide">
22
32
  <ContentPageHeader title={i18n.t('account.settingsEnterprise')} description={i18n.t('account.settingsEnterpriseDescription')} />
23
- <div class="grid gap-4 lg:grid-cols-2"><Card.Card><Card.CardContent class="space-y-4 p-5"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.securityPolicy')}</h2><p class="mt-1 text-sm text-muted-foreground">Set organization-wide controls for authentication and sessions.</p></div><div class="space-y-2"><Label for="session-timeout">{i18n.t('account.sessionTimeout')}</Label><Input id="session-timeout" value="30" /></div><div class="space-y-2"><Label for="retention">{i18n.t('account.dataRetention')}</Label><Input id="retention" value="365" /></div><div class="flex items-center justify-between border-t border-border pt-4"><span class="text-sm text-foreground">{i18n.t('account.auditLogging')}</span><Switch bind:checked={auditLogging} /></div><Button size="sm">{i18n.t('common.save')}</Button></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-4 p-5"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.singleSignOn')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.ssoDescription')}</p></div><div class="flex items-center justify-between border-t border-border pt-4"><span class="text-sm text-foreground">{i18n.t('account.enableSso')}</span><Switch bind:checked={ssoEnabled} /></div><Input value="https://idp.example.com/metadata" aria-label={i18n.t('account.ssoProvider')} /><Button variant="outline" size="sm">{i18n.t('common.test')}</Button></Card.CardContent></Card.Card></div>
24
- <section class="space-y-3"><h2 class="text-base font-semibold text-foreground">{i18n.t('account.connections')}</h2>{#each identity as integration (integration.id)}<IntegrationCard {integration} />{/each}</section>
25
- <section class="space-y-3"><h2 class="text-base font-semibold text-foreground">{i18n.t('account.securityLog')}</h2><SecurityEventTable events={events} /></section>
33
+ <div class="grid gap-4 lg:grid-cols-2">
34
+ <SettingsGroup title={i18n.t('account.securityPolicy')} description="Set organization-wide controls for authentication and sessions." bodyClass="space-y-4">
35
+ <div class="grid gap-4 sm:grid-cols-2"><div class="space-y-2"><Label for="session-timeout">{i18n.t('account.sessionTimeout')}</Label><Input id="session-timeout" value="30" /></div><div class="space-y-2"><Label for="retention">{i18n.t('account.dataRetention')}</Label><Input id="retention" value="365" /></div></div>
36
+ <SettingsFieldRow label={i18n.t('account.auditLogging')} description="Preserve an auditable record of organization-level changes." control={auditControl} separated />
37
+ <Button size="sm">{i18n.t('common.save')}</Button>
38
+ </SettingsGroup>
39
+ <SettingsGroup title={i18n.t('account.singleSignOn')} description={i18n.t('account.ssoDescription')} bodyClass="space-y-4">
40
+ <SettingsFieldRow label={i18n.t('account.enableSso')} description="Require the configured identity provider for organization members." control={ssoControl} />
41
+ <Input value="https://idp.example.com/metadata" aria-label={i18n.t('account.ssoProvider')} />
42
+ <Button variant="outline" size="sm">{i18n.t('common.test')}</Button>
43
+ </SettingsGroup>
44
+ </div>
45
+ <section class="space-y-3"><SectionHeader title={i18n.t('account.connections')} />{#each identity as integration (integration.id)}<IntegrationCard {integration} />{/each}</section>
46
+ <section class="space-y-3"><SectionHeader title={i18n.t('account.securityLog')} /><SecurityEventTable events={events} emptyTitle={i18n.t('common.noData')} emptyDescription={i18n.t('empty.description')} /></section>
26
47
  </ContentPageShell>
@@ -4,9 +4,10 @@
4
4
  import { Input } from '../ui/input/index.js';
5
5
  import { Label } from '../ui/label/index.js';
6
6
  import { Switch } from '../ui/switch/index.js';
7
- import * as Card from '../ui/card/index.js';
8
7
  import ContentPageShell from '../content/ContentPageShell.svelte';
9
8
  import ContentPageHeader from '../content/ContentPageHeader.svelte';
9
+ import SettingsGroup from '../content/SettingsGroup.svelte';
10
+ import SettingsFieldRow from '../content/SettingsFieldRow.svelte';
10
11
  const i18n = useTranslation();
11
12
  let displayName = $state('Alex Chen');
12
13
  let email = $state('alex@example.com');
@@ -16,7 +17,28 @@
16
17
  async function save() { saving = true; await new Promise((resolve) => setTimeout(resolve, 400)); saving = false; }
17
18
  </script>
18
19
 
20
+ {#snippet headerActions()}
21
+ <Button size="sm" onclick={save} disabled={saving}>{i18n.t('common.save')}</Button>
22
+ {/snippet}
23
+
24
+ {#snippet notificationControl()}
25
+ <Switch bind:checked={notifications} aria-label={i18n.t('settings.notifications')} />
26
+ {/snippet}
27
+
28
+ {#snippet twoFactorControl()}
29
+ <Switch bind:checked={twoFactor} aria-label={i18n.t('account.twoFactorAuth')} />
30
+ {/snippet}
31
+
19
32
  <ContentPageShell pageId="account-settings-plain" width="narrow">
20
- <div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><ContentPageHeader title={i18n.t('account.settingsPlain')} description={i18n.t('account.settingsPlainDescription')} /><Button size="sm" onclick={save} disabled={saving}>{i18n.t('common.save')}</Button></div>
21
- <Card.Card><Card.CardContent class="space-y-5 p-5"><div class="space-y-2"><Label for="settings-name">{i18n.t('profile.name')}</Label><Input id="settings-name" bind:value={displayName} /></div><div class="space-y-2"><Label for="settings-email">Email</Label><Input id="settings-email" bind:value={email} type="email" /></div><div class="flex items-center justify-between gap-4 border-t border-border pt-4"><div><p class="text-sm font-medium text-foreground">{i18n.t('settings.notifications')}</p><p class="mt-1 text-sm text-muted-foreground">Receive account and workflow updates.</p></div><Switch bind:checked={notifications} /></div><div class="flex items-center justify-between gap-4 border-t border-border pt-4"><div><p class="text-sm font-medium text-foreground">{i18n.t('account.twoFactorAuth')}</p><p class="mt-1 text-sm text-muted-foreground">Require an additional verification code when signing in.</p></div><Switch bind:checked={twoFactor} /></div></Card.CardContent></Card.Card>
33
+ <ContentPageHeader title={i18n.t('account.settingsPlain')} description={i18n.t('account.settingsPlainDescription')} actions={headerActions} />
34
+ <SettingsGroup title={i18n.t('account.basicSettings')} description={i18n.t('account.settingsPlainDescription')} bodyClass="space-y-5">
35
+ <div class="grid gap-4 sm:grid-cols-2">
36
+ <div class="space-y-2"><Label for="settings-name">{i18n.t('profile.name')}</Label><Input id="settings-name" bind:value={displayName} /></div>
37
+ <div class="space-y-2"><Label for="settings-email">Email</Label><Input id="settings-email" bind:value={email} type="email" /></div>
38
+ </div>
39
+ <div>
40
+ <SettingsFieldRow label={i18n.t('settings.notifications')} description="Receive account and workflow updates." control={notificationControl} />
41
+ <SettingsFieldRow label={i18n.t('account.twoFactorAuth')} description="Require an additional verification code when signing in." control={twoFactorControl} separated />
42
+ </div>
43
+ </SettingsGroup>
22
44
  </ContentPageShell>
@@ -3,9 +3,10 @@
3
3
  import { Button } from '../ui/button/index.js';
4
4
  import { Input } from '../ui/input/index.js';
5
5
  import { Switch } from '../ui/switch/index.js';
6
- import * as Card from '../ui/card/index.js';
7
6
  import ContentPageShell from '../content/ContentPageShell.svelte';
8
7
  import ContentPageHeader from '../content/ContentPageHeader.svelte';
8
+ import SettingsGroup from '../content/SettingsGroup.svelte';
9
+ import SettingsFieldRow from '../content/SettingsFieldRow.svelte';
9
10
  const i18n = useTranslation();
10
11
  const sections = [{ id: 'profile', title: i18n.t('account.basicSettings') }, { id: 'signin', title: i18n.t('account.socialSignIn') }, { id: 'preferences', title: i18n.t('account.preferences') }, { id: 'api', title: i18n.t('account.manageApi') }];
11
12
  let active = $state('profile');
@@ -13,7 +14,35 @@
13
14
  let available = $state(true);
14
15
  </script>
15
16
 
17
+ {#snippet visibilityControl()}
18
+ <Switch bind:checked={visible} aria-label={i18n.t('account.visibility')} />
19
+ {/snippet}
20
+
21
+ {#snippet availabilityControl()}
22
+ <Switch bind:checked={available} aria-label={i18n.t('account.availability')} />
23
+ {/snippet}
24
+
16
25
  <ContentPageShell pageId="account-settings-sidebar" width="wide">
17
26
  <ContentPageHeader title={i18n.t('account.settingsSidebar')} description={i18n.t('account.settingsSidebarDescription')} />
18
- <div class="grid gap-6 lg:grid-cols-[12rem_minmax(0,1fr)]"><nav class="flex gap-1 overflow-x-auto lg:flex-col">{#each sections as section (section.id)}<button type="button" class={'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm ' + (active === section.id ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:text-foreground')} onclick={() => active = section.id}>{section.title}</button>{/each}</nav><div class="space-y-4">{#if active === 'profile'}<Card.Card><Card.CardContent class="space-y-4 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.basicSettings')}</h2><Input value="Alex Chen" aria-label={i18n.t('profile.name')} /><Input value="Nebula Labs" aria-label="Company" /><div class="flex items-center justify-between border-t border-border pt-4"><span class="text-sm text-foreground">{i18n.t('account.visibility')}</span><Switch bind:checked={visible} /></div><div class="flex items-center justify-between"><span class="text-sm text-foreground">{i18n.t('account.availability')}</span><Switch bind:checked={available} /></div></Card.CardContent></Card.Card>{:else if active === 'signin'}<Card.Card><Card.CardContent class="space-y-4 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.socialSignIn')}</h2><p class="text-sm text-muted-foreground">Connect the providers your team trusts for sign in.</p><Button variant="outline">GitHub</Button><Button variant="outline">Google</Button></Card.CardContent></Card.Card>{:else if active === 'preferences'}<Card.Card><Card.CardContent class="space-y-4 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.preferences')}</h2><Input value="English" aria-label={i18n.t('account.language')} /><Input value="Asia/Shanghai" aria-label={i18n.t('account.timezone')} /><Input value="CNY" aria-label={i18n.t('account.currency')} /></Card.CardContent></Card.Card>{:else}<Card.Card><Card.CardContent class="space-y-4 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.manageApi')}</h2><Input readonly value="sv_demo_pub_9f3e********41ab" aria-label="API key" /><Button variant="outline">{i18n.t('common.copy')}</Button></Card.CardContent></Card.Card>{/if}</div></div>
27
+ <div class="grid gap-6 lg:grid-cols-[12rem_minmax(0,1fr)]">
28
+ <nav class="flex gap-1 overflow-x-auto lg:flex-col" aria-label={i18n.t('account.settingsSidebar')}>
29
+ {#each sections as section (section.id)}
30
+ <button type="button" class={'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm ' + (active === section.id ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground')} aria-current={active === section.id ? 'page' : undefined} onclick={() => active = section.id}>{section.title}</button>
31
+ {/each}
32
+ </nav>
33
+ <div class="min-w-0">
34
+ {#if active === 'profile'}
35
+ <SettingsGroup title={i18n.t('account.basicSettings')} description={i18n.t('account.settingsPlainDescription')} bodyClass="space-y-4">
36
+ <div class="grid gap-4 sm:grid-cols-2"><Input value="Alex Chen" aria-label={i18n.t('profile.name')} /><Input value="Nebula Labs" aria-label="Company" /></div>
37
+ <div><SettingsFieldRow label={i18n.t('account.visibility')} control={visibilityControl} separated /><SettingsFieldRow label={i18n.t('account.availability')} control={availabilityControl} separated /></div>
38
+ </SettingsGroup>
39
+ {:else if active === 'signin'}
40
+ <SettingsGroup title={i18n.t('account.socialSignIn')} description="Connect the providers your team trusts for sign in." bodyClass="flex flex-wrap gap-2"><Button variant="outline">GitHub</Button><Button variant="outline">Google</Button></SettingsGroup>
41
+ {:else if active === 'preferences'}
42
+ <SettingsGroup title={i18n.t('account.preferences')} bodyClass="grid gap-4 sm:grid-cols-3"><Input value="English" aria-label={i18n.t('account.language')} /><Input value="Asia/Shanghai" aria-label={i18n.t('account.timezone')} /><Input value="CNY" aria-label={i18n.t('account.currency')} /></SettingsGroup>
43
+ {:else}
44
+ <SettingsGroup title={i18n.t('account.manageApi')} description="Use scoped credentials and rotate them when access changes." bodyClass="flex flex-col gap-3 sm:flex-row"><Input readonly value="sv_demo_pub_9f3e********41ab" aria-label="API key" /><Button variant="outline">{i18n.t('common.copy')}</Button></SettingsGroup>
45
+ {/if}
46
+ </div>
47
+ </div>
19
48
  </ContentPageShell>
@@ -1,13 +1,22 @@
1
1
  <script lang="ts">
2
2
  import { Copy, KeyRound, Trash2 } from '@lucide/svelte';
3
+ import { useTranslation } from '@svadmin/core/i18n';
3
4
  import { Button } from '../ui/button/index.js';
5
+ import DataState from './DataState.svelte';
6
+ import type { DataStateKind } from './DataState.svelte';
4
7
  export interface ApiKeySummary { id: string; name: string; prefix: string; createdAt?: string; lastUsedAt?: string; }
5
- interface Props { keys?: ApiKeySummary[]; oncopy?: (key: ApiKeySummary) => void; onrevoke?: (key: ApiKeySummary) => void; class?: string; }
6
- let { keys = [], oncopy, onrevoke, class: className = '' }: Props = $props();
8
+ interface Props { keys?: ApiKeySummary[]; state?: DataStateKind; stateTitle?: string; stateDescription?: string; retry?: () => void; retryLabel?: string; loadingLabel?: string; oncopy?: (key: ApiKeySummary) => void; onrevoke?: (key: ApiKeySummary) => void; class?: string; }
9
+ const i18n = useTranslation();
10
+ let { keys = [], state, stateTitle, stateDescription, retry, retryLabel, loadingLabel, oncopy, onrevoke, class: className = '' }: Props = $props();
11
+ const resolvedState = $derived(state ?? (keys.length === 0 ? 'empty' : undefined));
12
+ const isZh = $derived(i18n.locale === 'zh-CN');
7
13
  </script>
14
+ {#if resolvedState}
15
+ <DataState state={resolvedState} title={stateTitle} description={stateDescription} {retry} {retryLabel} {loadingLabel} class={className} />
16
+ {:else}
8
17
  <div class={'divide-y divide-border overflow-hidden rounded-lg border border-border bg-card ' + className}>
9
18
  {#each keys as apiKey (apiKey.id)}
10
- <div class="flex flex-wrap items-center gap-3 px-4 py-3"><span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"><KeyRound class="size-4" /></span><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium text-foreground">{apiKey.name}</p><p class="font-mono text-xs text-muted-foreground">{apiKey.prefix}********</p></div><div class="text-right text-xs text-muted-foreground"><p>Created {apiKey.createdAt ?? 'Recently'}</p><p>Used {apiKey.lastUsedAt ?? 'Never'}</p></div><div class="flex items-center gap-1">{#if oncopy}<Button variant="ghost" size="icon-sm" aria-label={'Copy ' + apiKey.name} onclick={() => oncopy?.(apiKey)}><Copy class="size-4" /></Button>{/if}{#if onrevoke}<Button variant="ghost" size="icon-sm" aria-label={'Revoke ' + apiKey.name} onclick={() => onrevoke?.(apiKey)}><Trash2 class="size-4" /></Button>{/if}</div></div>
19
+ <div class="flex flex-wrap items-center gap-3 px-4 py-3"><span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"><KeyRound class="size-4" /></span><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium text-foreground">{apiKey.name}</p><p class="font-mono text-xs text-muted-foreground">{apiKey.prefix}********</p></div><div class="text-right text-xs text-muted-foreground"><p>{apiKey.createdAt ?? (isZh ? '最近创建' : 'Created recently')}</p><p>{apiKey.lastUsedAt ?? (isZh ? '从未使用' : 'Never used')}</p></div><div class="flex items-center gap-1">{#if oncopy}<Button variant="ghost" size="icon-sm" aria-label={i18n.t('common.copy') + ' ' + apiKey.name} onclick={() => oncopy?.(apiKey)}><Copy class="size-4" /></Button>{/if}{#if onrevoke}<Button variant="ghost" size="icon-sm" aria-label={i18n.t('common.delete') + ' ' + apiKey.name} onclick={() => onrevoke?.(apiKey)}><Trash2 class="size-4" /></Button>{/if}</div></div>
11
20
  {/each}
12
- {#if keys.length === 0}<div class="p-6 text-center text-sm text-muted-foreground">No API keys</div>{/if}
13
21
  </div>
22
+ {/if}
@@ -1,3 +1,4 @@
1
+ import type { DataStateKind } from './DataState.svelte';
1
2
  export interface ApiKeySummary {
2
3
  id: string;
3
4
  name: string;
@@ -7,6 +8,12 @@ export interface ApiKeySummary {
7
8
  }
8
9
  interface Props {
9
10
  keys?: ApiKeySummary[];
11
+ state?: DataStateKind;
12
+ stateTitle?: string;
13
+ stateDescription?: string;
14
+ retry?: () => void;
15
+ retryLabel?: string;
16
+ loadingLabel?: string;
10
17
  oncopy?: (key: ApiKeySummary) => void;
11
18
  onrevoke?: (key: ApiKeySummary) => void;
12
19
  class?: string;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { useTranslation } from '@svadmin/core/i18n';
3
4
  import { AlertTriangle, Inbox, LockKeyhole, RefreshCw } from '@lucide/svelte';
4
5
  import { Button } from '../ui/button/index.js';
5
6
  import * as Alert from '../ui/alert/index.js';
@@ -10,22 +11,37 @@
10
11
  title?: string;
11
12
  description?: string;
12
13
  retry?: () => void;
14
+ retryLabel?: string;
15
+ loadingLabel?: string;
13
16
  action?: Snippet;
14
17
  class?: string;
15
18
  }
16
- let { state, title, description, retry, action, class: className = '' }: Props = $props();
17
- const defaults = {
18
- loading: ['Loading', 'Fetching the latest data.'],
19
- empty: ['Nothing here yet', 'New records will appear here when they are available.'],
20
- error: ['Unable to load data', 'Try again or check the connection before continuing.'],
21
- forbidden: ['Access restricted', 'You do not have permission to view this content.'],
22
- } as const;
19
+ const i18n = useTranslation();
20
+ let {
21
+ state,
22
+ title,
23
+ description,
24
+ retry,
25
+ retryLabel,
26
+ loadingLabel,
27
+ action,
28
+ class: className = '',
29
+ }: Props = $props();
30
+ const isZh = $derived(i18n.locale === 'zh-CN');
31
+ const defaults = $derived({
32
+ loading: [i18n.t('common.loading'), i18n.t('common.loading')],
33
+ empty: [i18n.t('empty.title'), i18n.t('empty.description')],
34
+ error: [i18n.t('common.error'), i18n.t('common.retry')],
35
+ forbidden: [isZh ? '无权访问' : 'Access restricted', isZh ? '你没有查看此内容的权限。' : 'You do not have permission to view this content.'],
36
+ } as const);
23
37
  const resolvedTitle = $derived(title ?? defaults[state][0]);
24
38
  const resolvedDescription = $derived(description ?? defaults[state][1]);
39
+ const resolvedRetryLabel = $derived(retryLabel ?? i18n.t('common.retry'));
40
+ const resolvedLoadingLabel = $derived(loadingLabel ?? i18n.t('common.loading'));
25
41
  </script>
26
42
 
27
43
  {#if state === 'loading'}
28
- <div class={'space-y-3 rounded-lg border border-border bg-card p-5 ' + className} aria-busy="true">
44
+ <div class={'space-y-3 rounded-lg border border-border bg-card p-5 ' + className} role="status" aria-live="polite" aria-label={resolvedLoadingLabel} aria-busy="true">
29
45
  <Skeleton class="h-5 w-40" /><Skeleton class="h-4 w-full max-w-md" /><Skeleton class="h-20 w-full" />
30
46
  </div>
31
47
  {:else if state === 'error' || state === 'forbidden'}
@@ -33,7 +49,7 @@
33
49
  {#if state === 'error'}<AlertTriangle class="size-4" />{:else}<LockKeyhole class="size-4" />{/if}
34
50
  <Alert.Title>{resolvedTitle}</Alert.Title>
35
51
  <Alert.Description>{resolvedDescription}</Alert.Description>
36
- {#if retry}<Button variant="outline" size="sm" class="mt-3" onclick={retry}><RefreshCw class="size-3.5" />Retry</Button>{/if}
52
+ {#if retry}<Button variant="outline" size="sm" class="mt-3" onclick={retry}><RefreshCw class="size-3.5" />{resolvedRetryLabel}</Button>{/if}
37
53
  {#if action}<div class="mt-3">{@render action()}</div>{/if}
38
54
  </Alert.Root>
39
55
  {:else}
@@ -5,6 +5,8 @@ interface Props {
5
5
  title?: string;
6
6
  description?: string;
7
7
  retry?: () => void;
8
+ retryLabel?: string;
9
+ loadingLabel?: string;
8
10
  action?: Snippet;
9
11
  class?: string;
10
12
  }
@@ -1,13 +1,21 @@
1
1
  <script lang="ts">
2
2
  import { Download, File, Trash2 } from '@lucide/svelte';
3
+ import { useTranslation } from '@svadmin/core/i18n';
3
4
  import { Button } from '../ui/button/index.js';
5
+ import DataState from './DataState.svelte';
6
+ import type { DataStateKind } from './DataState.svelte';
4
7
  export interface FileItem { id?: string; name: string; size?: string; type?: string; updatedAt?: string; }
5
- interface Props { files?: FileItem[]; ondownload?: (file: FileItem) => void; onremove?: (file: FileItem) => void; class?: string; }
6
- let { files = [], ondownload, onremove, class: className = '' }: Props = $props();
8
+ interface Props { files?: FileItem[]; state?: DataStateKind; stateTitle?: string; stateDescription?: string; emptyTitle?: string; emptyDescription?: string; retry?: () => void; retryLabel?: string; loadingLabel?: string; ondownload?: (file: FileItem) => void; onremove?: (file: FileItem) => void; class?: string; }
9
+ const i18n = useTranslation();
10
+ let { files = [], state, stateTitle, stateDescription, emptyTitle, emptyDescription, retry, retryLabel, loadingLabel, ondownload, onremove, class: className = '' }: Props = $props();
11
+ const resolvedState = $derived(state ?? (files.length === 0 ? 'empty' : undefined));
7
12
  </script>
13
+ {#if resolvedState}
14
+ <DataState state={resolvedState} title={stateTitle ?? emptyTitle} description={stateDescription ?? emptyDescription} {retry} {retryLabel} {loadingLabel} class={className} />
15
+ {:else}
8
16
  <div class={'divide-y divide-border overflow-hidden rounded-lg border border-border bg-card ' + className}>
9
17
  {#each files as file, index (file.id ?? file.name + '-' + index)}
10
- <div class="flex items-center gap-3 px-4 py-3"><span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"><File class="size-4" /></span><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium text-foreground">{file.name}</p><p class="text-xs text-muted-foreground">{file.size ?? file.type ?? 'File'}{file.updatedAt ? ' / ' + file.updatedAt : ''}</p></div>{#if ondownload}<Button variant="ghost" size="icon-sm" aria-label={'Download ' + file.name} onclick={() => ondownload?.(file)}><Download class="size-4" /></Button>{/if}{#if onremove}<Button variant="ghost" size="icon-sm" aria-label={'Remove ' + file.name} onclick={() => onremove?.(file)}><Trash2 class="size-4" /></Button>{/if}</div>
18
+ <div class="flex items-center gap-3 px-4 py-3"><span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"><File class="size-4" /></span><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium text-foreground">{file.name}</p><p class="text-xs text-muted-foreground">{file.size ?? file.type ?? (i18n.locale === 'zh-CN' ? '文件' : 'File')}{file.updatedAt ? ' / ' + file.updatedAt : ''}</p></div>{#if ondownload}<Button variant="ghost" size="icon-sm" aria-label={(i18n.locale === 'zh-CN' ? '下载 ' : 'Download ') + file.name} onclick={() => ondownload?.(file)}><Download class="size-4" /></Button>{/if}{#if onremove}<Button variant="ghost" size="icon-sm" aria-label={(i18n.locale === 'zh-CN' ? '移除 ' : 'Remove ') + file.name} onclick={() => onremove?.(file)}><Trash2 class="size-4" /></Button>{/if}</div>
11
19
  {/each}
12
- {#if files.length === 0}<div class="p-6 text-center text-sm text-muted-foreground">No files</div>{/if}
13
20
  </div>
21
+ {/if}
@@ -1,3 +1,4 @@
1
+ import type { DataStateKind } from './DataState.svelte';
1
2
  export interface FileItem {
2
3
  id?: string;
3
4
  name: string;
@@ -7,6 +8,14 @@ export interface FileItem {
7
8
  }
8
9
  interface Props {
9
10
  files?: FileItem[];
11
+ state?: DataStateKind;
12
+ stateTitle?: string;
13
+ stateDescription?: string;
14
+ emptyTitle?: string;
15
+ emptyDescription?: string;
16
+ retry?: () => void;
17
+ retryLabel?: string;
18
+ loadingLabel?: string;
10
19
  ondownload?: (file: FileItem) => void;
11
20
  onremove?: (file: FileItem) => void;
12
21
  class?: string;
@@ -6,18 +6,26 @@
6
6
  interface Props {
7
7
  query?: string;
8
8
  placeholder?: string;
9
+ clearLabel?: string;
9
10
  filters?: Snippet;
10
11
  actions?: Snippet;
11
12
  class?: string;
12
13
  }
13
- let { query = $bindable(''), placeholder = 'Search', filters, actions, class: className = '' }: Props = $props();
14
+ let {
15
+ query = $bindable(''),
16
+ placeholder = 'Search',
17
+ clearLabel = 'Clear search',
18
+ filters,
19
+ actions,
20
+ class: className = '',
21
+ }: Props = $props();
14
22
  </script>
15
23
 
16
24
  <div class={'flex flex-col gap-2 sm:flex-row sm:items-center ' + className}>
17
25
  <div class="relative min-w-0 flex-1">
18
26
  <Search class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
19
27
  <Input bind:value={query} {placeholder} class="pl-9 pr-9" aria-label={placeholder} />
20
- {#if query}<Button variant="ghost" size="icon-xs" class="absolute right-1 top-1/2 -translate-y-1/2" aria-label="Clear search" onclick={() => query = ''}><X class="size-3.5" /></Button>{/if}
28
+ {#if query}<Button variant="ghost" size="icon-xs" class="absolute right-1 top-1/2 -translate-y-1/2" aria-label={clearLabel} title={clearLabel} onclick={() => query = ''}><X class="size-3.5" /></Button>{/if}
21
29
  </div>
22
30
  {#if filters}<div class="flex flex-wrap items-center gap-2">{@render filters()}</div>{/if}
23
31
  {#if actions}<div class="flex shrink-0 flex-wrap items-center gap-2">{@render actions()}</div>{/if}
@@ -2,6 +2,7 @@ import type { Snippet } from 'svelte';
2
2
  interface Props {
3
3
  query?: string;
4
4
  placeholder?: string;
5
+ clearLabel?: string;
5
6
  filters?: Snippet;
6
7
  actions?: Snippet;
7
8
  class?: string;
@@ -1,16 +1,24 @@
1
1
  <script lang="ts">
2
- import { MoreHorizontal, UserRound } from '@lucide/svelte';
2
+ import { MoreHorizontal } from '@lucide/svelte';
3
+ import { useTranslation } from '@svadmin/core/i18n';
3
4
  import { Avatar } from '../ui/avatar/index.js';
4
5
  import { Button } from '../ui/button/index.js';
5
6
  import StatusBadge from './StatusBadge.svelte';
7
+ import DataState from './DataState.svelte';
8
+ import type { DataStateKind } from './DataState.svelte';
6
9
  export interface MemberSummary { id: string; name: string; email?: string; role?: string; department?: string; status?: 'success' | 'warning' | 'danger' | 'info' | 'neutral'; avatar?: string; }
7
- interface Props { members?: MemberSummary[]; onaction?: (member: MemberSummary) => void; class?: string; }
8
- let { members = [], onaction, class: className = '' }: Props = $props();
10
+ interface Props { members?: MemberSummary[]; state?: DataStateKind; stateTitle?: string; stateDescription?: string; emptyTitle?: string; emptyDescription?: string; retry?: () => void; retryLabel?: string; loadingLabel?: string; onaction?: (member: MemberSummary) => void; class?: string; }
11
+ const i18n = useTranslation();
12
+ let { members = [], state, stateTitle, stateDescription, emptyTitle, emptyDescription, retry, retryLabel, loadingLabel, onaction, class: className = '' }: Props = $props();
13
+ const resolvedState = $derived(state ?? (members.length === 0 ? 'empty' : undefined));
9
14
  const initials = (name: string) => name.split(' ').map((part) => part[0]).join('').slice(0, 2).toUpperCase();
10
15
  </script>
16
+ {#if resolvedState}
17
+ <DataState state={resolvedState} title={stateTitle ?? emptyTitle} description={stateDescription ?? emptyDescription} {retry} {retryLabel} {loadingLabel} class={className} />
18
+ {:else}
11
19
  <div class={'divide-y divide-border overflow-hidden rounded-lg border border-border bg-card ' + className}>
12
20
  {#each members as member (member.id)}
13
- <div class="flex items-center gap-3 px-4 py-3"><Avatar src={member.avatar} alt={member.name} fallback={initials(member.name)} size="sm" /><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium text-foreground">{member.name}</p><p class="truncate text-xs text-muted-foreground">{member.email ?? member.department ?? 'Team member'}</p></div>{#if member.role}<span class="hidden text-xs text-muted-foreground sm:inline">{member.role}</span>{/if}{#if member.status}<StatusBadge status={member.status} label={member.status} />{/if}{#if onaction}<Button variant="ghost" size="icon-sm" aria-label={'Actions for ' + member.name} onclick={() => onaction?.(member)}><MoreHorizontal class="size-4" /></Button>{/if}</div>
21
+ <div class="flex items-center gap-3 px-4 py-3"><Avatar src={member.avatar} alt={member.name} fallback={initials(member.name)} size="sm" /><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium text-foreground">{member.name}</p><p class="truncate text-xs text-muted-foreground">{member.email ?? member.department ?? i18n.t('profileSections.members')}</p></div>{#if member.role}<span class="hidden text-xs text-muted-foreground sm:inline">{member.role}</span>{/if}{#if member.status}<StatusBadge status={member.status} label={member.status} />{/if}{#if onaction}<Button variant="ghost" size="icon-sm" aria-label={i18n.t('common.actions') + ' ' + member.name} onclick={() => onaction?.(member)}><MoreHorizontal class="size-4" /></Button>{/if}</div>
14
22
  {/each}
15
- {#if members.length === 0}<div class="flex flex-col items-center gap-2 p-6 text-center text-sm text-muted-foreground"><UserRound class="size-6" />No members</div>{/if}
16
23
  </div>
24
+ {/if}
@@ -1,3 +1,4 @@
1
+ import type { DataStateKind } from './DataState.svelte';
1
2
  export interface MemberSummary {
2
3
  id: string;
3
4
  name: string;
@@ -9,6 +10,14 @@ export interface MemberSummary {
9
10
  }
10
11
  interface Props {
11
12
  members?: MemberSummary[];
13
+ state?: DataStateKind;
14
+ stateTitle?: string;
15
+ stateDescription?: string;
16
+ emptyTitle?: string;
17
+ emptyDescription?: string;
18
+ retry?: () => void;
19
+ retryLabel?: string;
20
+ loadingLabel?: string;
12
21
  onaction?: (member: MemberSummary) => void;
13
22
  class?: string;
14
23
  }
@@ -1,16 +1,33 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import { Skeleton } from '../ui/skeleton/index.js';
4
+ export type MetricTrendTone = 'positive' | 'negative' | 'warning' | 'neutral';
4
5
  interface Props {
5
6
  label: string;
6
7
  value: string | number;
7
8
  detail?: string;
8
9
  trend?: string;
10
+ trendTone?: MetricTrendTone;
9
11
  icon?: Snippet;
10
12
  loading?: boolean;
11
13
  class?: string;
12
14
  }
13
- let { label, value, detail, trend, icon, loading = false, class: className = '' }: Props = $props();
15
+ let {
16
+ label,
17
+ value,
18
+ detail,
19
+ trend,
20
+ trendTone = 'neutral',
21
+ icon,
22
+ loading = false,
23
+ class: className = '',
24
+ }: Props = $props();
25
+ const trendClass: Record<MetricTrendTone, string> = {
26
+ positive: 'text-success',
27
+ negative: 'text-destructive',
28
+ warning: 'text-warning-foreground',
29
+ neutral: 'text-muted-foreground',
30
+ };
14
31
  </script>
15
32
 
16
33
  <div data-svadmin-metric-card class={'min-w-0 rounded-lg border border-border bg-card p-4 shadow-sm ' + className}>
@@ -22,6 +39,6 @@
22
39
  <Skeleton class="mt-3 h-8 w-24" />
23
40
  {:else}
24
41
  <p class="mt-2 truncate text-2xl font-semibold tracking-normal text-foreground">{value}</p>
25
- {#if detail || trend}<div class="mt-2 flex flex-wrap gap-2 text-xs">{#if trend}<span class="font-medium text-success">{trend}</span>{/if}{#if detail}<span class="text-muted-foreground">{detail}</span>{/if}</div>{/if}
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}
26
43
  {/if}
27
44
  </div>
@@ -1,9 +1,11 @@
1
1
  import type { Snippet } from 'svelte';
2
+ export type MetricTrendTone = 'positive' | 'negative' | 'warning' | 'neutral';
2
3
  interface Props {
3
4
  label: string;
4
5
  value: string | number;
5
6
  detail?: string;
6
7
  trend?: string;
8
+ trendTone?: MetricTrendTone;
7
9
  icon?: Snippet;
8
10
  loading?: boolean;
9
11
  class?: string;
@@ -1,10 +1,16 @@
1
1
  <script lang="ts" generics="T extends object">
2
2
  import type { Snippet } from 'svelte';
3
3
  import * as Table from '../ui/table/index.js';
4
+ import DataState from './DataState.svelte';
5
+ import type { DataStateKind } from './DataState.svelte';
4
6
  import type { NetworkColumn } from './NetworkTable.types.js';
5
- interface Props { rows?: T[]; columns: NetworkColumn<T>[]; row?: Snippet<[T]>; class?: string; }
6
- let { rows = [], columns, row, class: className = '' }: Props = $props();
7
+ interface Props { rows?: T[]; columns: NetworkColumn<T>[]; row?: Snippet<[T]>; state?: DataStateKind; stateTitle?: string; stateDescription?: string; emptyTitle?: string; emptyDescription?: string; retry?: () => void; retryLabel?: string; loadingLabel?: string; class?: string; }
8
+ let { rows = [], columns, row, state, stateTitle, stateDescription, emptyTitle, emptyDescription, retry, retryLabel, loadingLabel, class: className = '' }: Props = $props();
9
+ const resolvedState = $derived(state ?? (rows.length === 0 ? 'empty' : undefined));
7
10
  </script>
11
+ {#if resolvedState}
12
+ <DataState state={resolvedState} title={stateTitle ?? emptyTitle} description={stateDescription ?? emptyDescription} {retry} {retryLabel} {loadingLabel} class={className} />
13
+ {:else}
8
14
  <div class={'overflow-x-auto rounded-lg border border-border bg-card ' + className}>
9
15
  <Table.Root data-svadmin-datatable>
10
16
  <Table.Header data-svadmin-table-head><Table.Row>{#each columns as column (String(column.key))}<Table.Head>{column.label}</Table.Head>{/each}</Table.Row></Table.Header>
@@ -14,5 +20,5 @@
14
20
  {/each}
15
21
  </Table.Body>
16
22
  </Table.Root>
17
- {#if rows.length === 0}<div class="p-6 text-center text-sm text-muted-foreground">No records</div>{/if}
18
23
  </div>
24
+ {/if}
@@ -1,10 +1,19 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { DataStateKind } from './DataState.svelte';
2
3
  import type { NetworkColumn } from './NetworkTable.types.js';
3
4
  declare function $$render<T extends object>(): {
4
5
  props: {
5
6
  rows?: T[];
6
7
  columns: NetworkColumn<T>[];
7
8
  row?: Snippet<[T]>;
9
+ state?: DataStateKind;
10
+ stateTitle?: string;
11
+ stateDescription?: string;
12
+ emptyTitle?: string;
13
+ emptyDescription?: string;
14
+ retry?: () => void;
15
+ retryLabel?: string;
16
+ loadingLabel?: string;
8
17
  class?: string;
9
18
  };
10
19
  exports: {};
@@ -1,17 +1,57 @@
1
1
  <script lang="ts">
2
- import { MessageSquare, UserPlus } from '@lucide/svelte';
2
+ import type { Snippet } from 'svelte';
3
+ import { Check, MessageSquare, Plus } from '@lucide/svelte';
3
4
  import { Avatar } from '../ui/avatar/index.js';
5
+ import { Badge } from '../ui/badge/index.js';
4
6
  import { Button } from '../ui/button/index.js';
5
7
  import * as Card from '../ui/card/index.js';
6
8
  export interface NetworkUser { id: string; name: string; handle?: string; role?: string; avatar?: string; summary?: string; }
7
- interface Props { user: NetworkUser; onconnect?: () => void; onmessage?: () => void; class?: string; }
8
- let { user, onconnect, onmessage, class: className = '' }: Props = $props();
9
+ export interface NetworkMetric { label: string; value: string | number; }
10
+ interface Props {
11
+ user: NetworkUser;
12
+ tags?: string[];
13
+ metrics?: NetworkMetric[];
14
+ verified?: boolean;
15
+ verifiedLabel?: string;
16
+ connected?: boolean;
17
+ connectLabel?: string;
18
+ connectedLabel?: string;
19
+ onconnect?: () => void;
20
+ onmessage?: () => void;
21
+ footer?: Snippet;
22
+ class?: string;
23
+ }
24
+ let {
25
+ user,
26
+ tags = [],
27
+ metrics = [],
28
+ verified = false,
29
+ verifiedLabel = 'Verified',
30
+ connected = false,
31
+ connectLabel = 'Connect',
32
+ connectedLabel = 'Connected',
33
+ onconnect,
34
+ onmessage,
35
+ footer,
36
+ class: className = '',
37
+ }: Props = $props();
9
38
  const initials = $derived(user.name.split(' ').map((part) => part[0]).join('').slice(0, 2).toUpperCase());
10
39
  </script>
11
40
  <Card.Card class={className}>
12
- <Card.CardContent class="flex items-start gap-3 p-4">
13
- <Avatar src={user.avatar} alt={user.name} fallback={initials} size="lg" />
14
- <div class="min-w-0 flex-1"><h3 class="truncate text-sm font-semibold text-foreground">{user.name}</h3>{#if user.handle}<p class="truncate text-xs text-muted-foreground">{user.handle}</p>{/if}{#if user.role}<p class="mt-2 text-xs font-medium text-foreground">{user.role}</p>{/if}{#if user.summary}<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">{user.summary}</p>{/if}</div>
15
- <div class="flex shrink-0 gap-1">{#if onmessage}<Button variant="ghost" size="icon-sm" aria-label={'Message ' + user.name} onclick={onmessage}><MessageSquare class="size-4" /></Button>{/if}{#if onconnect}<Button variant="outline" size="icon-sm" aria-label={'Connect with ' + user.name} onclick={onconnect}><UserPlus class="size-4" /></Button>{/if}</div>
41
+ <Card.CardContent class="space-y-4 p-4">
42
+ <div class="flex items-start gap-3">
43
+ <Avatar src={user.avatar} alt={user.name} fallback={initials} size="lg" />
44
+ <div class="min-w-0 flex-1">
45
+ <div class="flex min-w-0 items-center gap-1.5"><h3 class="truncate text-sm font-semibold text-foreground">{user.name}</h3>{#if verified}<Check class="size-3.5 shrink-0 text-primary" aria-label={verifiedLabel} />{/if}</div>
46
+ {#if user.handle}<p class="truncate text-xs text-muted-foreground">{user.handle}</p>{/if}
47
+ {#if user.role}<p class="mt-2 text-xs font-medium text-foreground">{user.role}</p>{/if}
48
+ </div>
49
+ {#if onmessage}<Button variant="ghost" size="icon-sm" aria-label={'Message ' + user.name} onclick={onmessage}><MessageSquare class="size-4" /></Button>{/if}
50
+ </div>
51
+ {#if user.summary}<p class="text-sm leading-5 text-muted-foreground">{user.summary}</p>{/if}
52
+ {#if tags.length > 0}<div class="flex flex-wrap gap-1.5">{#each tags as tag (tag)}<Badge variant="outline">{tag}</Badge>{/each}</div>{/if}
53
+ {#if metrics.length > 0}<dl class="grid gap-2 border-t border-border pt-3 text-center" style:grid-template-columns={`repeat(${metrics.length}, minmax(0, 1fr))`}>{#each metrics as metric (metric.label)}<div class="min-w-0"><dd class="truncate text-sm font-semibold text-foreground">{metric.value}</dd><dt class="mt-1 truncate text-xs text-muted-foreground">{metric.label}</dt></div>{/each}</dl>{/if}
54
+ {#if onconnect}<Button size="sm" variant={connected ? 'outline' : 'default'} class="w-full" onclick={onconnect}>{#if connected}<Check class="size-3.5" />{connectedLabel}{:else}<Plus class="size-3.5" />{connectLabel}{/if}</Button>{/if}
55
+ {#if footer}<div class="border-t border-border pt-3">{@render footer()}</div>{/if}
16
56
  </Card.CardContent>
17
57
  </Card.Card>