@svadmin/ui 0.45.0 → 0.47.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.
Files changed (59) hide show
  1. package/dist/components/AboutSettings.svelte +21 -96
  2. package/dist/components/ApiSettings.svelte +54 -234
  3. package/dist/components/AppearanceSettings.svelte +36 -165
  4. package/dist/components/AuthPageShell.svelte +32 -0
  5. package/dist/components/AuthPageShell.svelte.d.ts +11 -0
  6. package/dist/components/AutoForm.svelte +21 -3
  7. package/dist/components/DetailDrawer.svelte +74 -0
  8. package/dist/components/DetailDrawer.svelte.d.ts +16 -0
  9. package/dist/components/FieldRenderer.svelte +70 -2
  10. package/dist/components/FieldRenderer.svelte.d.ts +2 -0
  11. package/dist/components/ForgotPasswordPage.svelte +4 -68
  12. package/dist/components/IntegrationsSettings.svelte +22 -61
  13. package/dist/components/LoginPage.svelte +33 -124
  14. package/dist/components/NotificationsSettings.svelte +22 -103
  15. package/dist/components/PermissionMatrix.svelte +18 -9
  16. package/dist/components/RegisterPage.svelte +7 -62
  17. package/dist/components/RowActions.svelte +154 -0
  18. package/dist/components/RowActions.svelte.d.ts +26 -0
  19. package/dist/components/SecuritySettings.svelte +18 -58
  20. package/dist/components/SettingsPage.svelte +12 -2
  21. package/dist/components/SvadminLogo.svelte +1 -1
  22. package/dist/components/UpdatePasswordPage.svelte +3 -55
  23. package/dist/components/account/CompanyProfilePage.svelte +19 -7
  24. package/dist/components/account/SettingsEnterprisePage.svelte +25 -4
  25. package/dist/components/account/SettingsPlainPage.svelte +25 -3
  26. package/dist/components/account/SettingsSidebarPage.svelte +31 -2
  27. package/dist/components/account/UserProfilePage.svelte +5 -4
  28. package/dist/components/content/ApiKeyList.svelte +40 -6
  29. package/dist/components/content/ApiKeyList.svelte.d.ts +10 -0
  30. package/dist/components/content/DataState.svelte +25 -9
  31. package/dist/components/content/DataState.svelte.d.ts +2 -0
  32. package/dist/components/content/FileList.svelte +12 -4
  33. package/dist/components/content/FileList.svelte.d.ts +9 -0
  34. package/dist/components/content/FilterToolbar.svelte +10 -2
  35. package/dist/components/content/FilterToolbar.svelte.d.ts +1 -0
  36. package/dist/components/content/MemberList.svelte +13 -5
  37. package/dist/components/content/MemberList.svelte.d.ts +9 -0
  38. package/dist/components/content/MetricBlock.svelte +19 -2
  39. package/dist/components/content/MetricBlock.svelte.d.ts +2 -0
  40. package/dist/components/content/NetworkTable.svelte +9 -3
  41. package/dist/components/content/NetworkTable.svelte.d.ts +9 -0
  42. package/dist/components/content/NetworkUserCard.svelte +47 -7
  43. package/dist/components/content/NetworkUserCard.svelte.d.ts +13 -0
  44. package/dist/components/content/SectionHeader.svelte +3 -2
  45. package/dist/components/content/SectionHeader.svelte.d.ts +1 -0
  46. package/dist/components/content/SecurityEventTable.svelte +14 -5
  47. package/dist/components/content/SecurityEventTable.svelte.d.ts +9 -0
  48. package/dist/components/content/SettingsFieldRow.svelte +28 -0
  49. package/dist/components/content/SettingsFieldRow.svelte.d.ts +11 -0
  50. package/dist/components/content/SettingsGroup.svelte +33 -0
  51. package/dist/components/content/SettingsGroup.svelte.d.ts +12 -0
  52. package/dist/components/content/WorkspaceLayout.svelte +31 -0
  53. package/dist/components/content/WorkspaceLayout.svelte.d.ts +12 -0
  54. package/dist/components/content/index.d.ts +4 -1
  55. package/dist/components/content/index.js +3 -0
  56. package/dist/components/network/UserCardsNFTPage.svelte +25 -6
  57. package/dist/index.d.ts +9 -1
  58. package/dist/index.js +6 -0
  59. package/package.json +4 -1
@@ -9,6 +9,7 @@
9
9
  import DescriptionList from '../content/DescriptionList.svelte';
10
10
  import FileList from '../content/FileList.svelte';
11
11
  import IntegrationCard from '../content/IntegrationCard.svelte';
12
+ import WorkspaceLayout from '../content/WorkspaceLayout.svelte';
12
13
  import type { IntegrationSummary } from '../content/IntegrationCard.svelte';
13
14
  import { referenceDemoData } from '../../reference-data.js';
14
15
  const i18n = useTranslation();
@@ -22,8 +23,8 @@
22
23
 
23
24
  <ContentPageShell pageId="account-user-profile" width="wide">
24
25
  <div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><ContentPageHeader title={i18n.t('account.userProfile')} description={i18n.t('account.userProfileDescription')} /><Button onclick={save} disabled={saving} size="sm">{#if saving}<Loader2 class="size-3.5 animate-spin" />{/if}{i18n.t('common.save')}</Button></div>
25
- <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
26
- <div class="space-y-4"><Card.Card><Card.CardContent class="space-y-5 p-5"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.personalInfo')}</h2><p class="mt-1 text-sm text-muted-foreground">Keep your contact and working details current.</p></div><DescriptionList columns={2} items={[{ label: i18n.t('profile.name'), value: 'Alex Chen' }, { label: i18n.t('account.phone'), value: '+86 138 **** 9527' }, { label: i18n.t('account.dateOfBirth'), value: '1995-04-18' }, { label: i18n.t('account.address'), value: 'Shanghai, CN' }]} /></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-4 p-5"><div class="flex items-center justify-between gap-3"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.work')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.availableNow')}</p></div><Switch bind:checked={availableToHire} /></div><div class="flex flex-wrap gap-1.5">{#each ['TypeScript', 'Svelte', 'Node.js', 'Design Systems'] as skill (skill)}<span class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground">{skill}</span>{/each}</div></Card.CardContent></Card.Card><section class="space-y-3"><h2 class="text-base font-semibold text-foreground">{i18n.t('account.connections')}</h2>{#each integrations as integration (integration.id)}<IntegrationCard {integration} onconnect={() => toggle(integration.id)} />{/each}</section></div>
27
- <div class="space-y-4"><Card.Card><Card.CardContent class="space-y-3 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.myFiles')}</h2><FileList files={files} /></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-3 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.calendarAccounts')}</h2><DescriptionList items={[{ label: 'Work Calendar', value: 'alex@nebulalabs.io' }, { label: 'Personal Calendar', value: 'alex@example.com' }]} /></Card.CardContent></Card.Card></div>
28
- </div>
26
+ <WorkspaceLayout secondaryWidth="20rem">
27
+ {#snippet primary()}<div class="space-y-4"><Card.Card><Card.CardContent class="space-y-5 p-5"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.personalInfo')}</h2><p class="mt-1 text-sm text-muted-foreground">Keep your contact and working details current.</p></div><DescriptionList columns={2} items={[{ label: i18n.t('profile.name'), value: 'Alex Chen' }, { label: i18n.t('account.phone'), value: '+86 138 **** 9527' }, { label: i18n.t('account.dateOfBirth'), value: '1995-04-18' }, { label: i18n.t('account.address'), value: 'Shanghai, CN' }]} /></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-4 p-5"><div class="flex items-center justify-between gap-3"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.work')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.availableNow')}</p></div><Switch bind:checked={availableToHire} /></div><div class="flex flex-wrap gap-1.5">{#each ['TypeScript', 'Svelte', 'Node.js', 'Design Systems'] as skill (skill)}<span class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground">{skill}</span>{/each}</div></Card.CardContent></Card.Card><section class="space-y-3"><h2 class="text-base font-semibold text-foreground">{i18n.t('account.connections')}</h2>{#each integrations as integration (integration.id)}<IntegrationCard {integration} onconnect={() => toggle(integration.id)} />{/each}</section></div>{/snippet}
28
+ {#snippet secondary()}<div class="space-y-4"><Card.Card><Card.CardContent class="space-y-3 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.myFiles')}</h2><FileList files={files} /></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-3 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.calendarAccounts')}</h2><DescriptionList items={[{ label: 'Work Calendar', value: 'alex@nebulalabs.io' }, { label: 'Personal Calendar', value: 'alex@example.com' }]} /></Card.CardContent></Card.Card></div>{/snippet}
29
+ </WorkspaceLayout>
29
30
  </ContentPageShell>
@@ -1,13 +1,47 @@
1
1
  <script lang="ts">
2
- import { Copy, KeyRound, Trash2 } from '@lucide/svelte';
2
+ import { Check, Copy, KeyRound, Trash2 } from '@lucide/svelte';
3
+ import { useTranslation } from '@svadmin/core/i18n';
4
+ import { Badge } from '../ui/badge/index.js';
3
5
  import { Button } from '../ui/button/index.js';
4
- 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();
6
+ import DataState from './DataState.svelte';
7
+ import type { DataStateKind } from './DataState.svelte';
8
+ export interface ApiKeySummary {
9
+ id: string;
10
+ name: string;
11
+ prefix: string;
12
+ maskedToken?: string;
13
+ createdAt?: string;
14
+ lastUsedAt?: string;
15
+ permissions?: string[];
16
+ }
17
+ interface Props { keys?: ApiKeySummary[]; state?: DataStateKind; stateTitle?: string; stateDescription?: string; retry?: () => void; retryLabel?: string; loadingLabel?: string; copiedKeyId?: string | null; oncopy?: (key: ApiKeySummary) => void; onrevoke?: (key: ApiKeySummary) => void; class?: string; }
18
+ const i18n = useTranslation();
19
+ let { keys = [], state, stateTitle, stateDescription, retry, retryLabel, loadingLabel, copiedKeyId = null, oncopy, onrevoke, class: className = '' }: Props = $props();
20
+ const resolvedState = $derived(state ?? (keys.length === 0 ? 'empty' : undefined));
21
+ const isZh = $derived(i18n.locale === 'zh-CN');
7
22
  </script>
23
+ {#if resolvedState}
24
+ <DataState state={resolvedState} title={stateTitle} description={stateDescription} {retry} {retryLabel} {loadingLabel} class={className} />
25
+ {:else}
8
26
  <div class={'divide-y divide-border overflow-hidden rounded-lg border border-border bg-card ' + className}>
9
27
  {#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>
28
+ <div class="flex flex-wrap items-center gap-3 px-4 py-3">
29
+ <span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"><KeyRound class="size-4" /></span>
30
+ <div class="min-w-48 flex-1">
31
+ <p class="truncate text-sm font-medium text-foreground">{apiKey.name}</p>
32
+ <p class="truncate font-mono text-xs text-muted-foreground">{apiKey.maskedToken ?? `${apiKey.prefix}********`}</p>
33
+ {#if apiKey.permissions?.length}
34
+ <div class="mt-2 flex flex-wrap gap-1">
35
+ {#each apiKey.permissions as permission (permission)}<Badge variant="secondary" class="px-2 py-0 text-[10px]">{permission}</Badge>{/each}
36
+ </div>
37
+ {/if}
38
+ </div>
39
+ <div class="text-left text-xs text-muted-foreground sm:text-right"><p>{apiKey.createdAt ?? (isZh ? '最近创建' : 'Created recently')}</p><p>{apiKey.lastUsedAt ?? (isZh ? '从未使用' : 'Never used')}</p></div>
40
+ <div class="ml-auto flex items-center gap-1">
41
+ {#if oncopy}<Button variant="ghost" size="icon-sm" aria-label={i18n.t('common.copy') + ' ' + apiKey.name} onclick={() => oncopy?.(apiKey)}>{#if copiedKeyId === apiKey.id}<Check class="size-4 text-success" />{:else}<Copy class="size-4" />{/if}</Button>{/if}
42
+ {#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}
43
+ </div>
44
+ </div>
11
45
  {/each}
12
- {#if keys.length === 0}<div class="p-6 text-center text-sm text-muted-foreground">No API keys</div>{/if}
13
46
  </div>
47
+ {/if}
@@ -1,12 +1,22 @@
1
+ import type { DataStateKind } from './DataState.svelte';
1
2
  export interface ApiKeySummary {
2
3
  id: string;
3
4
  name: string;
4
5
  prefix: string;
6
+ maskedToken?: string;
5
7
  createdAt?: string;
6
8
  lastUsedAt?: string;
9
+ permissions?: string[];
7
10
  }
8
11
  interface Props {
9
12
  keys?: ApiKeySummary[];
13
+ state?: DataStateKind;
14
+ stateTitle?: string;
15
+ stateDescription?: string;
16
+ retry?: () => void;
17
+ retryLabel?: string;
18
+ loadingLabel?: string;
19
+ copiedKeyId?: string | null;
10
20
  oncopy?: (key: ApiKeySummary) => void;
11
21
  onrevoke?: (key: ApiKeySummary) => void;
12
22
  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>
@@ -1,3 +1,4 @@
1
+ import type { Snippet } from 'svelte';
1
2
  export interface NetworkUser {
2
3
  id: string;
3
4
  name: string;
@@ -6,10 +7,22 @@ export interface NetworkUser {
6
7
  avatar?: string;
7
8
  summary?: string;
8
9
  }
10
+ export interface NetworkMetric {
11
+ label: string;
12
+ value: string | number;
13
+ }
9
14
  interface Props {
10
15
  user: NetworkUser;
16
+ tags?: string[];
17
+ metrics?: NetworkMetric[];
18
+ verified?: boolean;
19
+ verifiedLabel?: string;
20
+ connected?: boolean;
21
+ connectLabel?: string;
22
+ connectedLabel?: string;
11
23
  onconnect?: () => void;
12
24
  onmessage?: () => void;
25
+ footer?: Snippet;
13
26
  class?: string;
14
27
  }
15
28
  declare const NetworkUserCard: import("svelte").Component<Props, {}, "">;
@@ -4,14 +4,15 @@
4
4
  title: string;
5
5
  description?: string;
6
6
  actions?: Snippet;
7
+ id?: string;
7
8
  class?: string;
8
9
  }
9
- let { title, description, actions, class: className = '' }: Props = $props();
10
+ let { title, description, actions, id, class: className = '' }: Props = $props();
10
11
  </script>
11
12
 
12
13
  <div class={'flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between ' + className}>
13
14
  <div class="min-w-0 space-y-1">
14
- <h2 class="text-base font-semibold text-foreground">{title}</h2>
15
+ <h2 {id} class="text-base font-semibold text-foreground">{title}</h2>
15
16
  {#if description}<p class="text-sm text-muted-foreground">{description}</p>{/if}
16
17
  </div>
17
18
  {#if actions}<div class="flex shrink-0 flex-wrap items-center gap-2">{@render actions()}</div>{/if}
@@ -3,6 +3,7 @@ interface Props {
3
3
  title: string;
4
4
  description?: string;
5
5
  actions?: Snippet;
6
+ id?: string;
6
7
  class?: string;
7
8
  }
8
9
  declare const SectionHeader: import("svelte").Component<Props, {}, "">;
@@ -1,18 +1,27 @@
1
1
  <script lang="ts">
2
2
  import * as Table from '../ui/table/index.js';
3
+ import { useTranslation } from '@svadmin/core/i18n';
3
4
  import StatusBadge from './StatusBadge.svelte';
5
+ import DataState from './DataState.svelte';
6
+ import type { DataStateKind } from './DataState.svelte';
4
7
  export interface SecurityEvent { id: string; event: string; actor?: string; location?: string; createdAt: string; severity: 'info' | 'warning' | 'danger'; }
5
- interface Props { events?: SecurityEvent[]; class?: string; }
6
- let { events = [], class: className = '' }: Props = $props();
8
+ interface Props { events?: SecurityEvent[]; state?: DataStateKind; stateTitle?: string; stateDescription?: string; emptyTitle?: string; emptyDescription?: string; retry?: () => void; retryLabel?: string; loadingLabel?: string; class?: string; }
9
+ const i18n = useTranslation();
10
+ let { events = [], state, stateTitle, stateDescription, emptyTitle, emptyDescription, retry, retryLabel, loadingLabel, class: className = '' }: Props = $props();
11
+ const resolvedState = $derived(state ?? (events.length === 0 ? 'empty' : undefined));
12
+ const isZh = $derived(i18n.locale === 'zh-CN');
7
13
  </script>
14
+ {#if resolvedState}
15
+ <DataState state={resolvedState} title={stateTitle ?? emptyTitle} description={stateDescription ?? emptyDescription} {retry} {retryLabel} {loadingLabel} class={className} />
16
+ {:else}
8
17
  <div class={'overflow-x-auto rounded-lg border border-border bg-card ' + className}>
9
18
  <Table.Root data-svadmin-datatable>
10
- <Table.Header data-svadmin-table-head><Table.Row><Table.Head>Event</Table.Head><Table.Head>Actor</Table.Head><Table.Head>Location</Table.Head><Table.Head>Time</Table.Head><Table.Head>Risk</Table.Head></Table.Row></Table.Header>
19
+ <Table.Header data-svadmin-table-head><Table.Row><Table.Head>{isZh ? '事件' : 'Event'}</Table.Head><Table.Head>{isZh ? '操作者' : 'Actor'}</Table.Head><Table.Head>{isZh ? '位置' : 'Location'}</Table.Head><Table.Head>{isZh ? '时间' : 'Time'}</Table.Head><Table.Head>{isZh ? '风险' : 'Risk'}</Table.Head></Table.Row></Table.Header>
11
20
  <Table.Body>
12
21
  {#each events as event (event.id)}
13
- <Table.Row data-svadmin-table-row><Table.Cell class="font-medium">{event.event}</Table.Cell><Table.Cell>{event.actor ?? 'System'}</Table.Cell><Table.Cell>{event.location ?? 'Unknown'}</Table.Cell><Table.Cell class="whitespace-nowrap">{event.createdAt}</Table.Cell><Table.Cell><StatusBadge status={event.severity} label={event.severity} /></Table.Cell></Table.Row>
22
+ <Table.Row data-svadmin-table-row><Table.Cell class="font-medium">{event.event}</Table.Cell><Table.Cell>{event.actor ?? (isZh ? '系统' : 'System')}</Table.Cell><Table.Cell>{event.location ?? (isZh ? '未知' : 'Unknown')}</Table.Cell><Table.Cell class="whitespace-nowrap">{event.createdAt}</Table.Cell><Table.Cell><StatusBadge status={event.severity} label={event.severity} /></Table.Cell></Table.Row>
14
23
  {/each}
15
24
  </Table.Body>
16
25
  </Table.Root>
17
- {#if events.length === 0}<div class="p-6 text-center text-sm text-muted-foreground">No security events</div>{/if}
18
26
  </div>
27
+ {/if}
@@ -1,3 +1,4 @@
1
+ import type { DataStateKind } from './DataState.svelte';
1
2
  export interface SecurityEvent {
2
3
  id: string;
3
4
  event: string;
@@ -8,6 +9,14 @@ export interface SecurityEvent {
8
9
  }
9
10
  interface Props {
10
11
  events?: SecurityEvent[];
12
+ state?: DataStateKind;
13
+ stateTitle?: string;
14
+ stateDescription?: string;
15
+ emptyTitle?: string;
16
+ emptyDescription?: string;
17
+ retry?: () => void;
18
+ retryLabel?: string;
19
+ loadingLabel?: string;
11
20
  class?: string;
12
21
  }
13
22
  declare const SecurityEventTable: import("svelte").Component<Props, {}, "">;