@svadmin/ui 0.46.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.
@@ -0,0 +1,154 @@
1
+ <script lang="ts">
2
+ import { Ellipsis } from '@lucide/svelte';
3
+ import type { Component, Snippet } from 'svelte';
4
+ import type { HTMLAttributes } from 'svelte/elements';
5
+ import { cn, type WithElementRef } from '../utils.js';
6
+ import * as DropdownMenu from './ui/dropdown-menu/index.js';
7
+ import { Button } from './ui/button/index.js';
8
+
9
+ export type RowActionItem = {
10
+ label: string;
11
+ icon?: Component<{ class?: string }>;
12
+ href?: string;
13
+ onclick?: (e: MouseEvent) => void;
14
+ variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
15
+ disabled?: boolean;
16
+ title?: string;
17
+ danger?: boolean;
18
+ hidden?: boolean;
19
+ };
20
+
21
+ type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
22
+ actions?: RowActionItem[];
23
+ maxVisible?: number;
24
+ moreLabel?: string;
25
+ children?: Snippet;
26
+ moreContent?: Snippet;
27
+ };
28
+
29
+ let {
30
+ actions = [],
31
+ maxVisible = 2,
32
+ moreLabel = 'More actions',
33
+ ref = $bindable(null),
34
+ class: className,
35
+ children,
36
+ moreContent,
37
+ ...restProps
38
+ }: Props = $props();
39
+
40
+ let overflowOpen = $state(false);
41
+ const availableActions = $derived(actions.filter((action) => !action.hidden));
42
+ const visibleLimit = $derived(
43
+ Number.isFinite(maxVisible)
44
+ ? Math.max(0, Math.floor(maxVisible))
45
+ : availableActions.length
46
+ );
47
+ const visibleActions = $derived(availableActions.slice(0, visibleLimit));
48
+ const overflowActions = $derived(availableActions.slice(visibleLimit));
49
+
50
+ function resolvedVariant(action: RowActionItem): RowActionItem['variant'] {
51
+ return action.variant ?? (action.danger ? 'destructive' : 'ghost');
52
+ }
53
+
54
+ function handleOverflowAction(action: RowActionItem, event: MouseEvent): void {
55
+ if (action.disabled) {
56
+ event.preventDefault();
57
+ return;
58
+ }
59
+ overflowOpen = false;
60
+ action.onclick?.(event);
61
+ }
62
+ </script>
63
+
64
+ <div
65
+ bind:this={ref}
66
+ data-slot="row-actions"
67
+ class={cn("inline-flex items-center gap-1.5", className)}
68
+ {...restProps}
69
+ >
70
+ {#if children}
71
+ {@render children()}
72
+ {/if}
73
+
74
+ {#each visibleActions as action (action)}
75
+ {#if action.href}
76
+ <Button
77
+ variant={resolvedVariant(action)}
78
+ size="sm"
79
+ href={action.href}
80
+ disabled={action.disabled}
81
+ title={action.title || action.label}
82
+ class="h-8 px-2 text-xs"
83
+ >
84
+ {#if action.icon}
85
+ {@const Icon = action.icon}
86
+ <Icon class="mr-1 size-3.5" />
87
+ {/if}
88
+ {action.label}
89
+ </Button>
90
+ {:else}
91
+ <Button
92
+ variant={resolvedVariant(action)}
93
+ size="sm"
94
+ onclick={action.onclick}
95
+ disabled={action.disabled}
96
+ title={action.title || action.label}
97
+ class="h-8 px-2 text-xs"
98
+ >
99
+ {#if action.icon}
100
+ {@const Icon = action.icon}
101
+ <Icon class="mr-1 size-3.5" />
102
+ {/if}
103
+ {action.label}
104
+ </Button>
105
+ {/if}
106
+ {/each}
107
+
108
+ {#if overflowActions.length > 0 || moreContent}
109
+ <DropdownMenu.Root bind:open={overflowOpen}>
110
+ <DropdownMenu.Trigger>
111
+ {#snippet child({ props })}
112
+ <Button {...props} variant="ghost" size="icon-sm" title={moreLabel} aria-label={moreLabel}>
113
+ <Ellipsis class="size-4" />
114
+ </Button>
115
+ {/snippet}
116
+ </DropdownMenu.Trigger>
117
+ <DropdownMenu.Content align="end" class="min-w-[8rem]">
118
+ {#each overflowActions as action (action)}
119
+ {#if action.href}
120
+ <Button
121
+ href={action.href}
122
+ variant={resolvedVariant(action)}
123
+ size="sm"
124
+ disabled={action.disabled}
125
+ onclick={(event) => handleOverflowAction(action, event)}
126
+ class="h-auto w-full justify-start rounded-md px-2 py-1.5 text-sm font-normal"
127
+ >
128
+ {#if action.icon}
129
+ {@const Icon = action.icon}
130
+ <Icon class="size-3.5" />
131
+ {/if}
132
+ {action.label}
133
+ </Button>
134
+ {:else}
135
+ <DropdownMenu.Item
136
+ onclick={(event) => handleOverflowAction(action, event)}
137
+ disabled={action.disabled}
138
+ destructive={action.danger || action.variant === 'destructive'}
139
+ >
140
+ {#if action.icon}
141
+ {@const Icon = action.icon}
142
+ <Icon class="size-3.5" />
143
+ {/if}
144
+ {action.label}
145
+ </DropdownMenu.Item>
146
+ {/if}
147
+ {/each}
148
+ {#if moreContent}
149
+ {@render moreContent()}
150
+ {/if}
151
+ </DropdownMenu.Content>
152
+ </DropdownMenu.Root>
153
+ {/if}
154
+ </div>
@@ -0,0 +1,26 @@
1
+ import type { Component, Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import { type WithElementRef } from '../utils.js';
4
+ export type RowActionItem = {
5
+ label: string;
6
+ icon?: Component<{
7
+ class?: string;
8
+ }>;
9
+ href?: string;
10
+ onclick?: (e: MouseEvent) => void;
11
+ variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
12
+ disabled?: boolean;
13
+ title?: string;
14
+ danger?: boolean;
15
+ hidden?: boolean;
16
+ };
17
+ type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
18
+ actions?: RowActionItem[];
19
+ maxVisible?: number;
20
+ moreLabel?: string;
21
+ children?: Snippet;
22
+ moreContent?: Snippet;
23
+ };
24
+ declare const RowActions: Component<Props, {}, "ref">;
25
+ type RowActions = ReturnType<typeof RowActions>;
26
+ export default RowActions;
@@ -2,14 +2,15 @@
2
2
  import { useNotification } from '@svadmin/core';
3
3
  import { useTranslation } from '@svadmin/core/i18n';
4
4
 
5
- import { AlertTriangle, KeyRound, Monitor, Shield, ShieldAlert, ShieldCheck, Smartphone, Trash2 } from '@lucide/svelte';
5
+ import { AlertTriangle, Monitor, Smartphone, Trash2 } from '@lucide/svelte';
6
6
  import type { Component } from 'svelte';
7
7
  import * as Alert from './ui/alert/index.js';
8
- import * as Card from './ui/card/index.js';
9
8
  import { Button } from './ui/button/index.js';
10
- import { Label } from './ui/label/index.js';
11
9
  import { Switch } from './ui/switch/index.js';
12
10
  import PasswordInput from './PasswordInput.svelte';
11
+ import SettingsGroup from './content/SettingsGroup.svelte';
12
+ import SettingsFieldRow from './content/SettingsFieldRow.svelte';
13
+ import DataState from './content/DataState.svelte';
13
14
 
14
15
  const i18n = useTranslation();
15
16
  const notification = useNotification();
@@ -76,41 +77,14 @@
76
77
  <p class="mt-1 text-sm text-muted-foreground">{i18n.t('settings.securityDescription')}</p>
77
78
  </div>
78
79
 
79
- <Card.Card>
80
- <Card.CardHeader>
81
- <Card.CardTitle class="flex items-center gap-2 text-base">
82
- {#if is2faEnabled}
83
- <ShieldCheck class="h-5 w-5 text-green-500" />
84
- {:else}
85
- <ShieldAlert class="h-5 w-5 text-amber-500" />
86
- {/if}
87
- {i18n.t('security.twoFactorAuth')}
88
- </Card.CardTitle>
89
- <Card.CardDescription>{i18n.t('security.twoFactorDescription')}</Card.CardDescription>
90
- </Card.CardHeader>
91
- <Card.CardContent>
92
- <div class="flex items-center justify-between gap-4 border-t pt-4">
93
- <div class="space-y-1">
94
- <Label for="two-factor" class="text-sm font-medium">{i18n.t('security.enable2fa')}</Label>
95
- <p class="text-xs text-muted-foreground">
96
- {is2faEnabled ? i18n.t('security.twoFactorActive') : i18n.t('security.twoFactorInactive')}
97
- </p>
98
- </div>
99
- <Switch id="two-factor" bind:checked={is2faEnabled} />
100
- </div>
101
- </Card.CardContent>
102
- </Card.Card>
103
-
104
- <Card.Card>
105
- <Card.CardHeader>
106
- <Card.CardTitle class="flex items-center gap-2 text-base">
107
- <KeyRound class="h-4 w-4 text-muted-foreground" />
108
- {i18n.t('profile.changePassword')}
109
- </Card.CardTitle>
110
- <Card.CardDescription>{i18n.t('security.passwordLastChanged', { time: lastChanged })}</Card.CardDescription>
111
- </Card.CardHeader>
112
- <Card.CardContent>
113
- <form onsubmit={handlePasswordChange} class="max-w-sm space-y-4">
80
+ <SettingsGroup title={i18n.t('security.twoFactorAuth')} description={i18n.t('security.twoFactorDescription')}>
81
+ <SettingsFieldRow label={i18n.t('security.enable2fa')} description={is2faEnabled ? i18n.t('security.twoFactorActive') : i18n.t('security.twoFactorInactive')}>
82
+ {#snippet control()}<Switch id="two-factor" bind:checked={is2faEnabled} />{/snippet}
83
+ </SettingsFieldRow>
84
+ </SettingsGroup>
85
+
86
+ <SettingsGroup title={i18n.t('profile.changePassword')} description={i18n.t('security.passwordLastChanged', { time: lastChanged })}>
87
+ <form onsubmit={handlePasswordChange} class="max-w-lg space-y-4">
114
88
  {#if passwordErrorMessage}
115
89
  <Alert.Root variant="destructive">
116
90
  <AlertTriangle class="h-4 w-4" />
@@ -124,23 +98,10 @@
124
98
 
125
99
  <Button type="submit">{i18n.t('profile.updatePassword')}</Button>
126
100
  </form>
127
- </Card.CardContent>
128
- </Card.Card>
129
-
130
- <Card.Card>
131
- <Card.CardHeader class="flex flex-row items-start justify-between gap-4">
132
- <div class="space-y-1">
133
- <Card.CardTitle class="flex items-center gap-2 text-base">
134
- <Shield class="h-4 w-4 text-muted-foreground" />
135
- {i18n.t('security.activeSessions')}
136
- </Card.CardTitle>
137
- <Card.CardDescription>{i18n.t('security.sessionsDescription')}</Card.CardDescription>
138
- </div>
139
- {#if sessions.length > 1}
140
- <Button variant="outline" size="sm" onclick={revokeAllOthers}>{i18n.t('security.revokeOthers')}</Button>
141
- {/if}
142
- </Card.CardHeader>
143
- <Card.CardContent>
101
+ </SettingsGroup>
102
+
103
+ <SettingsGroup title={i18n.t('security.activeSessions')} description={i18n.t('security.sessionsDescription')}>
104
+ {#snippet actions()}{#if sessions.length > 1}<Button variant="outline" size="sm" onclick={revokeAllOthers}>{i18n.t('security.revokeOthers')}</Button>{/if}{/snippet}
144
105
  <div class="divide-y">
145
106
  {#each sessions as session (session.id)}
146
107
  <div class="flex items-center justify-between gap-4 py-3">
@@ -168,9 +129,8 @@
168
129
  {/if}
169
130
  </div>
170
131
  {:else}
171
- <div class="py-6 text-center text-sm text-muted-foreground">{i18n.t('security.noSessions')}</div>
132
+ <DataState state="empty" title={i18n.t('security.noSessions')} />
172
133
  {/each}
173
134
  </div>
174
- </Card.CardContent>
175
- </Card.Card>
135
+ </SettingsGroup>
176
136
  </div>
@@ -115,6 +115,16 @@
115
115
  return content ? alias : "profile";
116
116
  });
117
117
 
118
+ const contentWidth = $derived(
119
+ activeKey === 'roles' || activeKey === 'audit'
120
+ ? 'max-w-[92rem]'
121
+ : activeKey === 'api'
122
+ ? 'max-w-[78rem]'
123
+ : activeKey === 'integrations'
124
+ ? 'max-w-[64rem]'
125
+ : 'max-w-3xl',
126
+ );
127
+
118
128
  // Default redirect to profile
119
129
  $effect(() => {
120
130
  if (getRoute() === "/settings") {
@@ -151,7 +161,7 @@
151
161
  </div>
152
162
  {#each resolvedSections as section, _i (_i)}
153
163
  <div>
154
- <h3 class="px-3 mb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
164
+ <h3 class="px-3 mb-1 text-xs font-medium text-muted-foreground">
155
165
  {i18n.t(section.group)}
156
166
  </h3>
157
167
  <div class="space-y-0.5">
@@ -175,7 +185,7 @@
175
185
  </nav>
176
186
 
177
187
  <!-- Right content area -->
178
- <div class="flex-1 p-6 lg:p-8 {activeKey === 'roles' || activeKey === 'audit' || activeKey === 'integrations' || activeKey === 'api' ? 'max-w-6xl' : 'max-w-3xl'}">
188
+ <div class="min-w-0 w-full flex-1 p-6 lg:p-8 {contentWidth}">
179
189
  {#if activeKey === "profile"}
180
190
  {#if profile}{@render profile()}{:else}<ProfilePage />{/if}
181
191
  {:else if activeKey === "appearance"}
@@ -11,6 +11,7 @@
11
11
  import MemberList from '../content/MemberList.svelte';
12
12
  import SectionHeader from '../content/SectionHeader.svelte';
13
13
  import SettingsGroup from '../content/SettingsGroup.svelte';
14
+ import WorkspaceLayout from '../content/WorkspaceLayout.svelte';
14
15
  import type { MemberSummary } from '../content/MemberList.svelte';
15
16
  const i18n = useTranslation();
16
17
  let companyName = $state('Acme Corporation');
@@ -31,11 +32,16 @@
31
32
  <Button onclick={save} disabled={saving} size="sm">{#if saving}<Loader2 class="size-3.5 animate-spin" />{/if}{i18n.t('common.save')}</Button>
32
33
  {/snippet}
33
34
 
35
+ {#snippet primary()}
36
+ <SettingsGroup title={i18n.t('account.basicSettings')} description={i18n.t('account.companyProfileDescription')} bodyClass="space-y-4"><div class="grid gap-4 sm:grid-cols-2"><div class="space-y-2"><Label for="company-name">{i18n.t('profile.companyName')}</Label><Input id="company-name" bind:value={companyName} /></div><div class="space-y-2"><Label for="company-industry">{i18n.t('profile.industry')}</Label><Input id="company-industry" bind:value={industry} /></div><div class="space-y-2 sm:col-span-2"><Label for="company-website">{i18n.t('profile.website')}</Label><Input id="company-website" bind:value={website} type="url" /></div></div><div class="space-y-2"><Label for="company-description">{i18n.t('profile.companyDescription')}</Label><Textarea id="company-description" bind:value={description} rows={4} /></div></SettingsGroup>
37
+ {/snippet}
38
+
39
+ {#snippet secondary()}
40
+ <SettingsGroup title={i18n.t('profile.companyName')} description={industry} bodyClass="space-y-4"><p class="text-lg font-semibold text-foreground">{companyName}</p><DescriptionList columns={1} items={[{ label: i18n.t('profile.website'), value: website, href: website }, { label: i18n.t('profile.employees'), value: '1,250' }, { label: i18n.t('profile.founded'), value: '2015' }]} /></SettingsGroup>
41
+ {/snippet}
42
+
34
43
  <ContentPageShell pageId="account-company-profile" width="wide">
35
44
  <ContentPageHeader title={i18n.t('account.companyProfile')} description={i18n.t('account.companyProfileDescription')} actions={headerActions} />
36
- <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
37
- <SettingsGroup title={i18n.t('account.basicSettings')} description={i18n.t('account.companyProfileDescription')} bodyClass="space-y-4"><div class="grid gap-4 sm:grid-cols-2"><div class="space-y-2"><Label for="company-name">{i18n.t('profile.companyName')}</Label><Input id="company-name" bind:value={companyName} /></div><div class="space-y-2"><Label for="company-industry">{i18n.t('profile.industry')}</Label><Input id="company-industry" bind:value={industry} /></div><div class="space-y-2 sm:col-span-2"><Label for="company-website">{i18n.t('profile.website')}</Label><Input id="company-website" bind:value={website} type="url" /></div></div><div class="space-y-2"><Label for="company-description">{i18n.t('profile.companyDescription')}</Label><Textarea id="company-description" bind:value={description} rows={4} /></div></SettingsGroup>
38
- <SettingsGroup title={i18n.t('profile.companyName')} description={industry} bodyClass="space-y-4"><p class="text-lg font-semibold text-foreground">{companyName}</p><DescriptionList columns={1} items={[{ label: i18n.t('profile.website'), value: website, href: website }, { label: i18n.t('profile.employees'), value: '1,250' }, { label: i18n.t('profile.founded'), value: '2015' }]} /></SettingsGroup>
39
- </div>
45
+ <WorkspaceLayout {primary} {secondary} secondaryWidth="20rem" />
40
46
  <section class="space-y-3"><SectionHeader title={i18n.t('profileSections.members')} /><MemberList {members} emptyTitle={i18n.t('common.noData')} emptyDescription={i18n.t('empty.description')} /></section>
41
47
  </ContentPageShell>
@@ -30,7 +30,7 @@
30
30
 
31
31
  <ContentPageShell pageId="account-settings-enterprise" width="wide">
32
32
  <ContentPageHeader title={i18n.t('account.settingsEnterprise')} description={i18n.t('account.settingsEnterpriseDescription')} />
33
- <div class="grid gap-4 lg:grid-cols-2">
33
+ <div class="grid items-start gap-4 lg:grid-cols-2">
34
34
  <SettingsGroup title={i18n.t('account.securityPolicy')} description="Set organization-wide controls for authentication and sessions." bodyClass="space-y-4">
35
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
36
  <SettingsFieldRow label={i18n.t('account.auditLogging')} description="Preserve an auditable record of organization-level changes." control={auditControl} separated />
@@ -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,22 @@
1
1
  <script lang="ts">
2
- import { Copy, KeyRound, Trash2 } from '@lucide/svelte';
2
+ import { Check, Copy, KeyRound, Trash2 } from '@lucide/svelte';
3
3
  import { useTranslation } from '@svadmin/core/i18n';
4
+ import { Badge } from '../ui/badge/index.js';
4
5
  import { Button } from '../ui/button/index.js';
5
6
  import DataState from './DataState.svelte';
6
7
  import type { DataStateKind } from './DataState.svelte';
7
- export interface ApiKeySummary { id: string; name: string; prefix: string; createdAt?: string; lastUsedAt?: string; }
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; }
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; }
9
18
  const i18n = useTranslation();
10
- let { keys = [], state, stateTitle, stateDescription, retry, retryLabel, loadingLabel, oncopy, onrevoke, class: className = '' }: Props = $props();
19
+ let { keys = [], state, stateTitle, stateDescription, retry, retryLabel, loadingLabel, copiedKeyId = null, oncopy, onrevoke, class: className = '' }: Props = $props();
11
20
  const resolvedState = $derived(state ?? (keys.length === 0 ? 'empty' : undefined));
12
21
  const isZh = $derived(i18n.locale === 'zh-CN');
13
22
  </script>
@@ -16,7 +25,23 @@
16
25
  {:else}
17
26
  <div class={'divide-y divide-border overflow-hidden rounded-lg border border-border bg-card ' + className}>
18
27
  {#each keys as apiKey (apiKey.id)}
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>
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>
20
45
  {/each}
21
46
  </div>
22
47
  {/if}
@@ -3,8 +3,10 @@ export interface ApiKeySummary {
3
3
  id: string;
4
4
  name: string;
5
5
  prefix: string;
6
+ maskedToken?: string;
6
7
  createdAt?: string;
7
8
  lastUsedAt?: string;
9
+ permissions?: string[];
8
10
  }
9
11
  interface Props {
10
12
  keys?: ApiKeySummary[];
@@ -14,6 +16,7 @@ interface Props {
14
16
  retry?: () => void;
15
17
  retryLabel?: string;
16
18
  loadingLabel?: string;
19
+ copiedKeyId?: string | null;
17
20
  oncopy?: (key: ApiKeySummary) => void;
18
21
  onrevoke?: (key: ApiKeySummary) => void;
19
22
  class?: string;
@@ -7,6 +7,7 @@
7
7
  secondary?: Snippet;
8
8
  summary?: Snippet;
9
9
  secondaryWidth?: string;
10
+ mobileOrder?: 'primary-first' | 'secondary-first';
10
11
  class?: string;
11
12
  }
12
13
 
@@ -15,14 +16,16 @@
15
16
  secondary,
16
17
  summary,
17
18
  secondaryWidth = '22rem',
19
+ mobileOrder = 'primary-first',
18
20
  class: className = '',
19
21
  }: Props = $props();
20
22
  </script>
21
23
 
22
24
  <div class={cn('flex flex-col gap-6', className)} data-svadmin-workspace-layout style:--workspace-secondary-width={secondaryWidth}>
23
- {#if summary}<div class="order-2 sm:order-1">{@render summary()}</div>{/if}
24
- <div class="order-1 grid min-w-0 gap-6 sm:order-2 lg:grid-cols-[minmax(0,1fr)_var(--workspace-secondary-width)]">
25
- <div class="min-w-0" data-svadmin-workspace-primary>{@render primary()}</div>
26
- {#if secondary}<aside class="min-w-0">{@render secondary()}</aside>{/if}
25
+ {#if summary}<div data-svadmin-workspace-summary>{@render summary()}</div>{/if}
26
+ <div class={cn('grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_var(--workspace-secondary-width)]', mobileOrder === 'secondary-first' && 'grid-rows-[auto_auto]')}>
27
+ {#if mobileOrder === 'secondary-first' && secondary}<aside class="order-1 min-w-0 xl:order-2">{@render secondary()}</aside>{/if}
28
+ <div class={cn('min-w-0', mobileOrder === 'secondary-first' ? 'order-2 xl:order-1' : 'order-1')} data-svadmin-workspace-primary>{@render primary()}</div>
29
+ {#if mobileOrder !== 'secondary-first' && secondary}<aside class="order-2 min-w-0 xl:order-2">{@render secondary()}</aside>{/if}
27
30
  </div>
28
31
  </div>
@@ -4,6 +4,7 @@ interface Props {
4
4
  secondary?: Snippet;
5
5
  summary?: Snippet;
6
6
  secondaryWidth?: string;
7
+ mobileOrder?: 'primary-first' | 'secondary-first';
7
8
  class?: string;
8
9
  }
9
10
  declare const WorkspaceLayout: import("svelte").Component<Props, {}, "">;
package/dist/index.d.ts CHANGED
@@ -194,3 +194,6 @@ export { default as TwoFactorStepper } from './components/content/TwoFactorStepp
194
194
  export { default as SystemErrorState } from './components/content/SystemErrorState.svelte';
195
195
  export { referenceDemoData } from './reference-data.js';
196
196
  export type { DemoMember, DemoProject, DemoTeam, DemoIntegration, DemoApiKey, DemoSecurityEvent, DemoNotification } from './reference-data.js';
197
+ export { default as RowActions } from './components/RowActions.svelte';
198
+ export type { RowActionItem } from './components/RowActions.svelte';
199
+ export { default as DetailDrawer } from './components/DetailDrawer.svelte';
package/dist/index.js CHANGED
@@ -195,3 +195,5 @@ export { default as OtpInput } from './components/content/OtpInput.svelte';
195
195
  export { default as TwoFactorStepper } from './components/content/TwoFactorStepper.svelte';
196
196
  export { default as SystemErrorState } from './components/content/SystemErrorState.svelte';
197
197
  export { referenceDemoData } from './reference-data.js';
198
+ export { default as RowActions } from './components/RowActions.svelte';
199
+ export { default as DetailDrawer } from './components/DetailDrawer.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.46.0",
3
+ "version": "0.47.1",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -96,6 +96,9 @@
96
96
  "bugs": {
97
97
  "url": "https://github.com/vibeunion/svadmin/issues"
98
98
  },
99
+ "publishConfig": {
100
+ "access": "public"
101
+ },
99
102
  "devDependencies": {
100
103
  "@happy-dom/global-registrator": "^20.11.2",
101
104
  "@sveltejs/vite-plugin-svelte": "^7.3.0",