@svadmin/ui 0.42.2 → 0.43.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 (78) hide show
  1. package/dist/app.css +482 -840
  2. package/dist/app.theme.css +482 -840
  3. package/dist/components/ErrorPage.svelte +4 -56
  4. package/dist/components/Layout.svelte +1 -1
  5. package/dist/components/Sidebar.svelte +1 -0
  6. package/dist/components/TwoFactorAuthPage.svelte +44 -232
  7. package/dist/components/account/CompanyProfilePage.svelte +21 -142
  8. package/dist/components/account/GetStartedPage.svelte +33 -115
  9. package/dist/components/account/GetStartedPage.svelte.d.ts +1 -2
  10. package/dist/components/account/SecurityLogPage.svelte +16 -131
  11. package/dist/components/account/SettingsEnterprisePage.svelte +15 -275
  12. package/dist/components/account/SettingsPlainPage.svelte +10 -175
  13. package/dist/components/account/SettingsSidebarPage.svelte +11 -304
  14. package/dist/components/account/TeamMembersPage.svelte +18 -187
  15. package/dist/components/account/UserProfilePage.svelte +19 -196
  16. package/dist/components/account/UserProfilePage.svelte.d.ts +1 -2
  17. package/dist/components/content/ApiKeyList.svelte +13 -0
  18. package/dist/components/content/ApiKeyList.svelte.d.ts +16 -0
  19. package/dist/components/content/ContentPageHeader.svelte +31 -0
  20. package/dist/components/content/ContentPageHeader.svelte.d.ts +12 -0
  21. package/dist/components/content/ContentPageShell.svelte +39 -0
  22. package/dist/components/content/ContentPageShell.svelte.d.ts +14 -0
  23. package/dist/components/content/DataState.svelte +44 -0
  24. package/dist/components/content/DataState.svelte.d.ts +13 -0
  25. package/dist/components/content/DescriptionList.svelte +27 -0
  26. package/dist/components/content/DescriptionList.svelte.d.ts +14 -0
  27. package/dist/components/content/FileList.svelte +13 -0
  28. package/dist/components/content/FileList.svelte.d.ts +16 -0
  29. package/dist/components/content/FilterToolbar.svelte +24 -0
  30. package/dist/components/content/FilterToolbar.svelte.d.ts +11 -0
  31. package/dist/components/content/ImportDropzone.svelte +19 -0
  32. package/dist/components/content/ImportDropzone.svelte.d.ts +10 -0
  33. package/dist/components/content/IntegrationCard.svelte +16 -0
  34. package/dist/components/content/IntegrationCard.svelte.d.ts +15 -0
  35. package/dist/components/content/MemberList.svelte +16 -0
  36. package/dist/components/content/MemberList.svelte.d.ts +17 -0
  37. package/dist/components/content/MetricBlock.svelte +27 -0
  38. package/dist/components/content/MetricBlock.svelte.d.ts +13 -0
  39. package/dist/components/content/NetworkTable.svelte +18 -0
  40. package/dist/components/content/NetworkTable.svelte.d.ts +31 -0
  41. package/dist/components/content/NetworkTable.types.d.ts +4 -0
  42. package/dist/components/content/NetworkTable.types.js +1 -0
  43. package/dist/components/content/NetworkUserCard.svelte +17 -0
  44. package/dist/components/content/NetworkUserCard.svelte.d.ts +17 -0
  45. package/dist/components/content/OtpInput.svelte +22 -0
  46. package/dist/components/content/OtpInput.svelte.d.ts +10 -0
  47. package/dist/components/content/PageToolbar.svelte +18 -0
  48. package/dist/components/content/PageToolbar.svelte.d.ts +10 -0
  49. package/dist/components/content/ProjectCard.svelte +39 -0
  50. package/dist/components/content/ProjectCard.svelte.d.ts +20 -0
  51. package/dist/components/content/SectionHeader.svelte +18 -0
  52. package/dist/components/content/SectionHeader.svelte.d.ts +10 -0
  53. package/dist/components/content/SecurityEventTable.svelte +18 -0
  54. package/dist/components/content/SecurityEventTable.svelte.d.ts +15 -0
  55. package/dist/components/content/StatusBadge.svelte +19 -0
  56. package/dist/components/content/StatusBadge.svelte.d.ts +9 -0
  57. package/dist/components/content/SystemErrorState.svelte +18 -0
  58. package/dist/components/content/SystemErrorState.svelte.d.ts +10 -0
  59. package/dist/components/content/TeamCard.svelte +26 -0
  60. package/dist/components/content/TeamCard.svelte.d.ts +23 -0
  61. package/dist/components/content/TwoFactorStepper.svelte +11 -0
  62. package/dist/components/content/TwoFactorStepper.svelte.d.ts +8 -0
  63. package/dist/components/content/index.d.ts +32 -0
  64. package/dist/components/content/index.js +22 -0
  65. package/dist/components/network/TeamCrewTablePage.svelte +29 -181
  66. package/dist/components/network/UserCardsNFTPage.svelte +24 -137
  67. package/dist/components/profile/ActivityTimeline.svelte +17 -91
  68. package/dist/components/profile/ProfileCard.svelte +4 -4
  69. package/dist/components/profile/ProjectsGrid.svelte +28 -106
  70. package/dist/components/profile/ProjectsGrid.svelte.d.ts +2 -12
  71. package/dist/components/profile/PublicProfilePage.svelte +31 -181
  72. package/dist/components/profile/TeamsShowcase.svelte +12 -114
  73. package/dist/components/profile/TeamsShowcase.svelte.d.ts +2 -12
  74. package/dist/index.d.ts +36 -0
  75. package/dist/index.js +24 -0
  76. package/dist/reference-data.d.ts +171 -0
  77. package/dist/reference-data.js +39 -0
  78. package/package.json +1 -1
@@ -1,197 +1,28 @@
1
1
  <script lang="ts">
2
2
  import { useTranslation } from '@svadmin/core/i18n';
3
- import * as Card from '../ui/card/index.js';
3
+ import { Copy, Link2, Send, UserPlus } from '@lucide/svelte';
4
4
  import { Button } from '../ui/button/index.js';
5
5
  import { Input } from '../ui/input/index.js';
6
6
  import { Label } from '../ui/label/index.js';
7
- import { Search, UserPlus, MoreHorizontal, Link2, Copy, Send } from '@lucide/svelte';
8
-
7
+ import * as Card from '../ui/card/index.js';
8
+ import ContentPageShell from '../content/ContentPageShell.svelte';
9
+ import ContentPageHeader from '../content/ContentPageHeader.svelte';
10
+ import FilterToolbar from '../content/FilterToolbar.svelte';
11
+ import MemberList from '../content/MemberList.svelte';
12
+ import type { MemberSummary } from '../content/MemberList.svelte';
13
+ import { referenceDemoData } from '../../reference-data.js';
9
14
  const i18n = useTranslation();
10
-
11
- interface TeamMember {
12
- id: string;
13
- name: string;
14
- email: string;
15
- role: 'Admin' | 'Editor' | 'Viewer';
16
- avatar?: string;
17
- department: string;
18
- status: 'active' | 'invited' | 'inactive';
19
- lastActive: string;
20
- }
21
-
22
- let searchQuery = $state('');
15
+ let query = $state('');
23
16
  let inviteEmail = $state('');
24
- let inviteRole = $state('Viewer');
25
- let linkCopied = $state(false);
26
17
  let inviteStatus = $state('');
27
-
28
- function focusInvite() {
29
- document.getElementById('invite-email')?.focus();
30
- }
31
-
32
- function copyInviteLink() {
33
- navigator.clipboard?.writeText('https://acme.com/invite/xK7fQ2').catch(() => {});
34
- linkCopied = true;
35
- setTimeout(() => linkCopied = false, 2000);
36
- }
37
-
38
- let members = $state<TeamMember[]>([
39
- { id: '1', name: 'Alex Chen', email: 'alex@acme.com', role: 'Admin', department: 'Engineering', status: 'active', lastActive: 'Just now' },
40
- { id: '2', name: 'Sarah Kim', email: 'sarah@acme.com', role: 'Editor', department: 'Design', status: 'active', lastActive: '5 min ago' },
41
- { id: '3', name: 'Mike Johnson', email: 'mike@acme.com', role: 'Viewer', department: 'Marketing', status: 'invited', lastActive: 'Pending' },
42
- { id: '4', name: 'Lisa Wang', email: 'lisa@acme.com', role: 'Editor', department: 'Engineering', status: 'active', lastActive: '1 hour ago' },
43
- { id: '5', name: 'Tom Brown', email: 'tom@acme.com', role: 'Viewer', department: 'Sales', status: 'inactive', lastActive: '2 weeks ago' },
44
- { id: '6', name: 'Emma Davis', email: 'emma@acme.com', role: 'Admin', department: 'Engineering', status: 'active', lastActive: '10 min ago' },
45
- { id: '7', name: 'James Wilson', email: 'james@acme.com', role: 'Editor', department: 'Infrastructure', status: 'active', lastActive: '30 min ago' },
46
- { id: '8', name: 'Yuki Tanaka', email: 'yuki@acme.com', role: 'Editor', department: 'Engineering', status: 'invited', lastActive: 'Pending' },
47
- { id: '9', name: 'Priya Nair', email: 'priya@acme.com', role: 'Viewer', department: 'Data', status: 'active', lastActive: '2 hours ago' },
48
- { id: '10', name: 'Carlos Mendez', email: 'carlos@acme.com', role: 'Editor', department: 'Engineering', status: 'active', lastActive: '1 day ago' },
49
- { id: '11', name: 'Hana Sato', email: 'hana@acme.com', role: 'Viewer', department: 'Design', status: 'active', lastActive: '3 hours ago' },
50
- { id: '12', name: 'Oscar Lund', email: 'oscar@acme.com', role: 'Viewer', department: 'Infrastructure', status: 'inactive', lastActive: '1 month ago' },
51
- { id: '13', name: 'Mia Torres', email: 'mia@acme.com', role: 'Editor', department: 'Marketing', status: 'invited', lastActive: 'Pending' },
52
- { id: '14', name: 'Chen Wei', email: 'chen@acme.com', role: 'Admin', department: 'Engineering', status: 'active', lastActive: '15 min ago' },
53
- ]);
54
-
55
- function sendInvite() {
56
- const email = inviteEmail.trim();
57
- if (!email) return;
58
- const name = email.split('@')[0].split(/[._-]/).filter(Boolean).map(part => part[0]?.toUpperCase() + part.slice(1)).join(' ') || email;
59
- members = [...members, {
60
- id: `invite-${Date.now()}`,
61
- name,
62
- email,
63
- role: inviteRole as TeamMember['role'],
64
- department: 'Pending assignment',
65
- status: 'invited',
66
- lastActive: 'Pending',
67
- }];
68
- inviteStatus = email;
69
- inviteEmail = '';
70
- }
71
-
72
- const filtered = $derived(
73
- searchQuery
74
- ? members.filter(m => m.name.toLowerCase().includes(searchQuery.toLowerCase()) || m.email.toLowerCase().includes(searchQuery.toLowerCase()) || m.department.toLowerCase().includes(searchQuery.toLowerCase()))
75
- : members
76
- );
77
-
78
- const roleColors = {
79
- 'Admin': 'bg-red-500/10 text-red-600',
80
- 'Editor': 'bg-blue-500/10 text-blue-600',
81
- 'Viewer': 'bg-green-500/10 text-green-600',
82
- };
83
-
84
- const statusColors = {
85
- 'active': 'bg-green-500/10 text-green-600',
86
- 'invited': 'bg-amber-500/10 text-amber-600',
87
- 'inactive': 'bg-muted text-muted-foreground',
88
- };
89
-
90
- const initials = (name: string) => name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
91
-
92
- function statusLabel(status: TeamMember['status']) {
93
- if (status === 'active') return i18n.t('account.active');
94
- if (status === 'invited') return i18n.t('account.invited');
95
- return i18n.t('account.inactive');
96
- }
18
+ let members = $state<MemberSummary[]>(referenceDemoData.members.map((member) => ({ ...member, status: member.status === 'active' ? 'success' : member.status === 'invited' ? 'warning' : 'neutral' })));
19
+ const filtered = $derived(query ? members.filter((member) => `${member.name} ${member.email} ${member.department}`.toLowerCase().includes(query.toLowerCase())) : members);
20
+ function sendInvite() { const email = inviteEmail.trim(); if (!email) return; members = [...members, { id: `invite-${Date.now()}`, name: email.split('@')[0], email, role: 'Viewer', department: 'Pending assignment', status: 'warning' }]; inviteStatus = email; inviteEmail = ''; }
97
21
  </script>
98
22
 
99
- <div class="space-y-6" data-svadmin-content-page="account">
100
- <div class="flex items-start justify-between gap-4">
101
- <div>
102
- <h2 class="text-xl font-semibold text-foreground">{i18n.t('account.teamMembersCount', { count: members.length })}</h2>
103
- <p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.teamMembersDescription')}</p>
104
- </div>
105
- <Button size="sm" onclick={focusInvite}>
106
- <UserPlus class="h-4 w-4 mr-1" />{i18n.t('account.inviteMember')}
107
- </Button>
108
- </div>
109
-
110
- <div class="relative">
111
- <Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
112
- <Input placeholder={i18n.t('common.search')} bind:value={searchQuery} class="pl-9" />
113
- </div>
114
-
115
- <!-- Invite people -->
116
- <div class="grid gap-4 lg:grid-cols-2">
117
- <Card.Card class="border-border/60">
118
- <Card.CardHeader class="pb-3">
119
- <Card.CardTitle class="text-base">{i18n.t('account.invitePeople')}</Card.CardTitle>
120
- <Card.CardDescription>{i18n.t('account.invitePeopleDescription')}</Card.CardDescription>
121
- </Card.CardHeader>
122
- <Card.CardContent class="space-y-3">
123
- <div class="grid gap-3 sm:grid-cols-[1fr_140px]">
124
- <div class="space-y-1.5">
125
- <Label for="invite-email">Email</Label>
126
- <Input id="invite-email" type="email" placeholder="name@example.com" bind:value={inviteEmail} />
127
- </div>
128
- <div class="space-y-1.5">
129
- <Label for="invite-role">{i18n.t('account.role')}</Label>
130
- <select id="invite-role" bind:value={inviteRole} class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
131
- <option>Admin</option>
132
- <option>Editor</option>
133
- <option>Viewer</option>
134
- </select>
135
- </div>
136
- </div>
137
- <div class="flex justify-end">
138
- <Button size="sm" disabled={!inviteEmail} onclick={sendInvite}>
139
- <Send class="h-3.5 w-3.5 mr-1" />{i18n.t('account.sendInvite')}
140
- </Button>
141
- </div>
142
- {#if inviteStatus}
143
- <p class="text-xs text-green-600" role="status">{i18n.t('account.invited')}: {inviteStatus}</p>
144
- {/if}
145
- </Card.CardContent>
146
- </Card.Card>
147
-
148
- <Card.Card class="border-border/60">
149
- <Card.CardHeader class="pb-3">
150
- <Card.CardTitle class="flex items-center gap-2 text-base">
151
- <Link2 class="h-4 w-4 text-muted-foreground" />{i18n.t('account.inviteWithLink')}
152
- </Card.CardTitle>
153
- <Card.CardDescription>{i18n.t('account.inviteLinkHint')}</Card.CardDescription>
154
- </Card.CardHeader>
155
- <Card.CardContent class="space-y-3">
156
- <div class="flex items-center gap-2">
157
- <Input readonly value="https://acme.com/invite/xK7fQ2" class="flex-1 font-mono text-xs" />
158
- <Button size="sm" variant="outline" onclick={copyInviteLink}>
159
- <Copy class="h-3.5 w-3.5 mr-1" />{linkCopied ? '✓' : ''} {i18n.t('account.copyLink')}
160
- </Button>
161
- </div>
162
- </Card.CardContent>
163
- </Card.Card>
164
- </div>
165
-
166
- <!-- Members grid -->
167
- <div class="grid gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
168
- {#each filtered as member (member.id)}
169
- <Card.Card class="border-border/60 hover:border-primary/30 transition-colors">
170
- <Card.CardContent class="p-4">
171
- <div class="flex items-start gap-3">
172
- <div class="shrink-0">
173
- <div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary text-sm font-semibold">
174
- {initials(member.name)}
175
- </div>
176
- </div>
177
- <div class="flex-1 min-w-0">
178
- <div class="flex items-center gap-2">
179
- <h4 class="text-sm font-semibold text-foreground truncate">{member.name}</h4>
180
- </div>
181
- <p class="text-xs text-muted-foreground truncate">{member.email}</p>
182
- <div class="mt-2 flex flex-wrap gap-1.5">
183
- <span class="rounded-full px-2 py-0.5 text-[10px] font-semibold {roleColors[member.role]}">{member.role}</span>
184
- <span class="rounded-full px-2 py-0.5 text-[10px] font-semibold {statusColors[member.status]}">{statusLabel(member.status)}</span>
185
- </div>
186
- <div class="mt-2 flex items-center justify-between">
187
- <span class="text-xs text-muted-foreground">{member.department}</span>
188
- <span class="text-xs text-muted-foreground">{member.lastActive}</span>
189
- </div>
190
- </div>
191
- <Button variant="ghost" size="icon-sm" aria-label={member.name} title={member.name}><MoreHorizontal class="h-4 w-4" /></Button>
192
- </div>
193
- </Card.CardContent>
194
- </Card.Card>
195
- {/each}
196
- </div>
197
- </div>
23
+ <ContentPageShell pageId="account-team-members" width="wide">
24
+ <div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><ContentPageHeader title={i18n.t("account.teamMembers")} description={i18n.t("account.teamMembersDescription")} /><Button size="sm" onclick={() => document.getElementById("invite-email")?.focus()}><UserPlus class="size-3.5" />{i18n.t("account.inviteMember")}</Button></div>
25
+ <FilterToolbar bind:query placeholder={i18n.t('common.search')} />
26
+ <div class="grid gap-4 lg:grid-cols-2"><Card.Card><Card.CardContent class="space-y-3 p-5"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.invitePeople')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.invitePeopleDescription')}</p></div><div class="flex flex-col gap-2 sm:flex-row"><div class="min-w-0 flex-1 space-y-2"><Label for="invite-email">Email</Label><Input id="invite-email" bind:value={inviteEmail} type="email" placeholder="name@example.com" /></div><Button class="self-end" size="sm" disabled={!inviteEmail} onclick={sendInvite}><Send class="size-3.5" />{i18n.t('account.sendInvite')}</Button></div>{#if inviteStatus}<p class="text-xs text-success" role="status">{i18n.t('account.invited')}: {inviteStatus}</p>{/if}</Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-3 p-5"><div class="flex items-center gap-2"><Link2 class="size-4 text-muted-foreground" /><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.inviteWithLink')}</h2></div><p class="text-sm text-muted-foreground">{i18n.t('account.inviteLinkHint')}</p><div class="flex gap-2"><Input readonly value="https://acme.com/invite/xK7fQ2" class="font-mono text-xs" /><Button variant="outline" size="sm"><Copy class="size-3.5" />{i18n.t('account.copyLink')}</Button></div></Card.CardContent></Card.Card></div>
27
+ <MemberList members={filtered} />
28
+ </ContentPageShell>
@@ -1,206 +1,29 @@
1
1
  <script lang="ts">
2
2
  import { useTranslation } from '@svadmin/core/i18n';
3
- import * as Card from '../ui/card/index.js';
4
- import { Badge } from '../ui/badge/index.js';
3
+ import { Loader2 } from '@lucide/svelte';
5
4
  import { Button } from '../ui/button/index.js';
6
5
  import { Switch } from '../ui/switch/index.js';
7
- import {
8
- Briefcase, Calendar, FileText, Image, FileArchive, Link2, Loader2,
9
- Star, Award, Medal, Crown,
10
- } from '@lucide/svelte';
11
- import type { Component } from 'svelte';
12
-
6
+ import * as Card from '../ui/card/index.js';
7
+ import ContentPageShell from '../content/ContentPageShell.svelte';
8
+ import ContentPageHeader from '../content/ContentPageHeader.svelte';
9
+ import DescriptionList from '../content/DescriptionList.svelte';
10
+ import FileList from '../content/FileList.svelte';
11
+ import IntegrationCard from '../content/IntegrationCard.svelte';
12
+ import type { IntegrationSummary } from '../content/IntegrationCard.svelte';
13
+ import { referenceDemoData } from '../../reference-data.js';
13
14
  const i18n = useTranslation();
14
-
15
15
  let availableToHire = $state(true);
16
16
  let saving = $state(false);
17
-
18
- async function handleSave() {
19
- saving = true;
20
- await new Promise((r) => setTimeout(r, 800));
21
- saving = false;
22
- }
23
-
24
- const infoRows = [
25
- { label: 'profile.name', value: 'Alex Chen' },
26
- { label: 'account.phone', value: '+86 138 **** 9527' },
27
- { label: 'account.dateOfBirth', value: '1995-04-18' },
28
- { label: 'account.address', value: 'Shanghai, CN' },
29
- ];
30
-
31
- const badges: { name: string; Icon: Component; tone: string }[] = [
32
- { name: 'Top Contributor', Icon: Medal, tone: 'bg-amber-500/10 text-amber-600' },
33
- { name: 'Early Adopter', Icon: Star, tone: 'bg-blue-500/10 text-blue-600' },
34
- { name: 'Community Mentor', Icon: Award, tone: 'bg-violet-500/10 text-violet-600' },
35
- { name: 'Launch Partner', Icon: Crown, tone: 'bg-emerald-500/10 text-emerald-600' },
36
- ];
37
-
38
- interface Connection {
39
- id: string;
40
- name: string;
41
- detail: string;
42
- connected: boolean;
43
- }
44
- let connections = $state<Connection[]>([
45
- { id: 'repo', name: 'Source Control', detail: 'alexchen', connected: true },
46
- { id: 'chat', name: 'ChatOps', detail: '@alex.chen', connected: true },
47
- { id: 'drive', name: 'Cloud Drive', detail: 'alex@example.com', connected: false },
48
- ]);
49
-
50
- const files: { name: string; size: string; Icon: Component; tone: string }[] = [
51
- { name: 'portfolio-2026.pdf', size: '2.1 MB', Icon: FileText, tone: 'bg-red-500/10 text-red-600' },
52
- { name: 'avatar-pack.zip', size: '8.4 MB', Icon: FileArchive, tone: 'bg-amber-500/10 text-amber-600' },
53
- { name: 'cover-photo.png', size: '1.2 MB', Icon: Image, tone: 'bg-pink-500/10 text-pink-600' },
54
- ];
55
-
56
- const calendars = [
57
- { name: 'Work Calendar', detail: 'alex@nebulalabs.io', primary: true },
58
- { name: 'Personal Calendar', detail: 'alex@example.com', primary: false },
59
- ];
60
-
61
- function toggleConnection(id: string, connected: boolean) {
62
- connections = connections.map((c) => (c.id === id ? { ...c, connected } : c));
63
- }
17
+ async function save() { saving = true; await new Promise((resolve) => setTimeout(resolve, 400)); saving = false; }
18
+ let integrations = $state<IntegrationSummary[]>(referenceDemoData.integrations);
19
+ const files = [{ id: '1', name: 'portfolio-2026.pdf', size: '2.1 MB', type: 'PDF' }, { id: '2', name: 'avatar-pack.zip', size: '8.4 MB', type: 'Archive' }, { id: '3', name: 'cover-photo.png', size: '1.2 MB', type: 'Image' }];
20
+ function toggle(id: string) { integrations = integrations.map((item) => item.id === id ? { ...item, connected: !item.connected } : item); }
64
21
  </script>
65
22
 
66
- <div class="space-y-6" data-svadmin-content-page="account">
67
- <div class="flex items-start justify-between gap-4">
68
- <div>
69
- <h2 class="text-xl font-semibold text-foreground">{i18n.t('account.userProfile')}</h2>
70
- <p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.userProfileDescription')}</p>
71
- </div>
72
- <Button onclick={handleSave} disabled={saving} size="sm">
73
- {#if saving}<Loader2 class="h-4 w-4 animate-spin mr-2" />{/if}
74
- {i18n.t('common.save')}
75
- </Button>
76
- </div>
77
-
78
- <div class="grid gap-4 lg:grid-cols-3">
79
- <div class="space-y-4 lg:col-span-2">
80
- <!-- Personal Info -->
81
- <Card.Card class="border-border/60">
82
- <Card.CardHeader class="pb-3">
83
- <Card.CardTitle class="text-base">{i18n.t('account.personalInfo')}</Card.CardTitle>
84
- </Card.CardHeader>
85
- <Card.CardContent class="p-0">
86
- <div class="divide-y">
87
- {#each infoRows as row (row.label)}
88
- <div class="flex items-center justify-between px-6 py-3 text-sm">
89
- <span class="text-muted-foreground">{i18n.t(row.label)}</span>
90
- <span class="font-medium text-foreground">{row.value}</span>
91
- </div>
92
- {/each}
93
- </div>
94
- </Card.CardContent>
95
- </Card.Card>
96
-
97
- <!-- Work -->
98
- <Card.Card class="border-border/60">
99
- <Card.CardHeader class="pb-3">
100
- <Card.CardTitle class="flex items-center gap-2 text-base">
101
- <Briefcase class="h-4 w-4 text-muted-foreground" />{i18n.t('account.work')}
102
- </Card.CardTitle>
103
- </Card.CardHeader>
104
- <Card.CardContent class="space-y-4">
105
- <div class="flex items-center justify-between gap-4">
106
- <div class="space-y-1">
107
- <p class="text-sm font-medium text-foreground">{i18n.t('account.availableToHire')}</p>
108
- <p class="text-xs text-muted-foreground">{i18n.t('account.availableNow')}</p>
109
- </div>
110
- <Switch bind:checked={availableToHire} />
111
- </div>
112
- <div class="flex flex-wrap gap-1.5">
113
- {#each ['TypeScript', 'Svelte', 'Node.js', 'Design Systems'] as skill (skill)}
114
- <Badge variant="secondary" class="text-xs">{skill}</Badge>
115
- {/each}
116
- </div>
117
- </Card.CardContent>
118
- </Card.Card>
119
-
120
- <!-- Connections -->
121
- <Card.Card class="border-border/60">
122
- <Card.CardHeader class="pb-3">
123
- <Card.CardTitle class="flex items-center gap-2 text-base">
124
- <Link2 class="h-4 w-4 text-muted-foreground" />{i18n.t('account.connections')}
125
- </Card.CardTitle>
126
- </Card.CardHeader>
127
- <Card.CardContent class="p-0">
128
- <div class="divide-y">
129
- {#each connections as conn (conn.id)}
130
- <div class="flex items-center justify-between gap-4 px-6 py-3">
131
- <div class="min-w-0">
132
- <p class="text-sm font-medium text-foreground">{conn.name}</p>
133
- <p class="truncate text-xs text-muted-foreground">{conn.detail}</p>
134
- </div>
135
- <Switch checked={conn.connected} onCheckedChange={(checked) => toggleConnection(conn.id, checked)} />
136
- </div>
137
- {/each}
138
- </div>
139
- </Card.CardContent>
140
- </Card.Card>
141
- </div>
142
-
143
- <div class="space-y-4">
144
- <!-- Badges -->
145
- <Card.Card class="border-border/60">
146
- <Card.CardHeader class="pb-3">
147
- <Card.CardTitle class="text-base">{i18n.t('account.badges')}</Card.CardTitle>
148
- </Card.CardHeader>
149
- <Card.CardContent>
150
- <div class="grid grid-cols-2 gap-2">
151
- {#each badges as badge (badge.name)}
152
- <div class="flex items-center gap-2 rounded-lg border border-border/60 p-2.5">
153
- <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg {badge.tone}">
154
- <badge.Icon class="h-4 w-4" />
155
- </div>
156
- <span class="text-xs font-medium text-foreground">{badge.name}</span>
157
- </div>
158
- {/each}
159
- </div>
160
- </Card.CardContent>
161
- </Card.Card>
162
-
163
- <!-- Calendar Accounts -->
164
- <Card.Card class="border-border/60">
165
- <Card.CardHeader class="pb-3">
166
- <Card.CardTitle class="flex items-center gap-2 text-base">
167
- <Calendar class="h-4 w-4 text-muted-foreground" />{i18n.t('account.calendarAccounts')}
168
- </Card.CardTitle>
169
- </Card.CardHeader>
170
- <Card.CardContent class="space-y-3">
171
- {#each calendars as cal (cal.name)}
172
- <div class="flex items-center justify-between gap-2">
173
- <div class="min-w-0">
174
- <p class="truncate text-sm font-medium text-foreground">{cal.name}</p>
175
- <p class="truncate text-xs text-muted-foreground">{cal.detail}</p>
176
- </div>
177
- {#if cal.primary}
178
- <Badge variant="secondary" class="shrink-0 text-[10px]">{i18n.t('account.active')}</Badge>
179
- {/if}
180
- </div>
181
- {/each}
182
- </Card.CardContent>
183
- </Card.Card>
184
-
185
- <!-- My Files -->
186
- <Card.Card class="border-border/60">
187
- <Card.CardHeader class="pb-3">
188
- <Card.CardTitle class="text-base">{i18n.t('account.myFiles')}</Card.CardTitle>
189
- </Card.CardHeader>
190
- <Card.CardContent class="space-y-2.5">
191
- {#each files as file (file.name)}
192
- <div class="flex items-center gap-3">
193
- <div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg {file.tone}">
194
- <file.Icon class="h-4 w-4" />
195
- </div>
196
- <div class="min-w-0 flex-1">
197
- <p class="truncate text-sm font-medium text-foreground">{file.name}</p>
198
- <p class="text-xs text-muted-foreground">{file.size}</p>
199
- </div>
200
- </div>
201
- {/each}
202
- </Card.CardContent>
203
- </Card.Card>
204
- </div>
23
+ <ContentPageShell pageId="account-user-profile" width="wide">
24
+ <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>
205
28
  </div>
206
- </div>
29
+ </ContentPageShell>
@@ -1,4 +1,3 @@
1
- import type { Component } from 'svelte';
2
- declare const UserProfilePage: Component<Record<string, never>, {}, "">;
1
+ declare const UserProfilePage: import("svelte").Component<Record<string, never>, {}, "">;
3
2
  type UserProfilePage = ReturnType<typeof UserProfilePage>;
4
3
  export default UserProfilePage;
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ import { Copy, KeyRound, Trash2 } from '@lucide/svelte';
3
+ 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();
7
+ </script>
8
+ <div class={'divide-y divide-border overflow-hidden rounded-lg border border-border bg-card ' + className}>
9
+ {#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>
11
+ {/each}
12
+ {#if keys.length === 0}<div class="p-6 text-center text-sm text-muted-foreground">No API keys</div>{/if}
13
+ </div>
@@ -0,0 +1,16 @@
1
+ export interface ApiKeySummary {
2
+ id: string;
3
+ name: string;
4
+ prefix: string;
5
+ createdAt?: string;
6
+ lastUsedAt?: string;
7
+ }
8
+ interface Props {
9
+ keys?: ApiKeySummary[];
10
+ oncopy?: (key: ApiKeySummary) => void;
11
+ onrevoke?: (key: ApiKeySummary) => void;
12
+ class?: string;
13
+ }
14
+ declare const ApiKeyList: import("svelte").Component<Props, {}, "">;
15
+ type ApiKeyList = ReturnType<typeof ApiKeyList>;
16
+ export default ApiKeyList;
@@ -0,0 +1,31 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ interface Props {
4
+ title: string;
5
+ eyebrow?: string;
6
+ description?: string;
7
+ breadcrumbs?: string[];
8
+ actions?: Snippet;
9
+ class?: string;
10
+ }
11
+ let { title, eyebrow, description, breadcrumbs = [], actions, class: className = '' }: Props = $props();
12
+ </script>
13
+
14
+ <header class={'space-y-3 ' + className} data-svadmin-page-header>
15
+ {#if breadcrumbs.length > 0}
16
+ <nav aria-label="Breadcrumb" class="flex items-center gap-2 text-xs text-muted-foreground">
17
+ {#each breadcrumbs as crumb, index (crumb)}
18
+ {#if index > 0}<span aria-hidden="true">/</span>{/if}
19
+ <span class={index === breadcrumbs.length - 1 ? 'text-foreground' : ''}>{crumb}</span>
20
+ {/each}
21
+ </nav>
22
+ {/if}
23
+ <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
24
+ <div class="min-w-0 space-y-1">
25
+ {#if eyebrow}<p class="text-xs font-medium text-muted-foreground">{eyebrow}</p>{/if}
26
+ <h1 class="text-xl font-semibold leading-tight tracking-normal text-foreground">{title}</h1>
27
+ {#if description}<p class="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>{/if}
28
+ </div>
29
+ {#if actions}<div class="flex shrink-0 flex-wrap items-center gap-2">{@render actions()}</div>{/if}
30
+ </div>
31
+ </header>
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ title: string;
4
+ eyebrow?: string;
5
+ description?: string;
6
+ breadcrumbs?: string[];
7
+ actions?: Snippet;
8
+ class?: string;
9
+ }
10
+ declare const ContentPageHeader: import("svelte").Component<Props, {}, "">;
11
+ type ContentPageHeader = ReturnType<typeof ContentPageHeader>;
12
+ export default ContentPageHeader;
@@ -0,0 +1,39 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import ContentPageHeader from './ContentPageHeader.svelte';
4
+
5
+ interface Props {
6
+ title?: string;
7
+ eyebrow?: string;
8
+ description?: string;
9
+ actions?: Snippet;
10
+ children: Snippet;
11
+ pageId?: string;
12
+ width?: 'narrow' | 'default' | 'wide';
13
+ class?: string;
14
+ }
15
+
16
+ let {
17
+ title,
18
+ eyebrow,
19
+ description,
20
+ actions,
21
+ children,
22
+ pageId = 'content',
23
+ width = 'default',
24
+ class: className = '',
25
+ }: Props = $props();
26
+
27
+ const widthClass = $derived(
28
+ width === 'narrow'
29
+ ? 'max-w-3xl'
30
+ : width === 'wide'
31
+ ? 'max-w-[92rem]'
32
+ : 'max-w-[74rem]',
33
+ );
34
+ </script>
35
+
36
+ <div data-svadmin-content-page={pageId} class={'mx-auto w-full ' + widthClass + ' space-y-6 ' + className}>
37
+ {#if title}<ContentPageHeader {title} {eyebrow} {description} {actions} />{/if}
38
+ {@render children()}
39
+ </div>
@@ -0,0 +1,14 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ title?: string;
4
+ eyebrow?: string;
5
+ description?: string;
6
+ actions?: Snippet;
7
+ children: Snippet;
8
+ pageId?: string;
9
+ width?: 'narrow' | 'default' | 'wide';
10
+ class?: string;
11
+ }
12
+ declare const ContentPageShell: import("svelte").Component<Props, {}, "">;
13
+ type ContentPageShell = ReturnType<typeof ContentPageShell>;
14
+ export default ContentPageShell;
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { AlertTriangle, Inbox, LockKeyhole, RefreshCw } from '@lucide/svelte';
4
+ import { Button } from '../ui/button/index.js';
5
+ import * as Alert from '../ui/alert/index.js';
6
+ import { Skeleton } from '../ui/skeleton/index.js';
7
+ export type DataStateKind = 'loading' | 'empty' | 'error' | 'forbidden';
8
+ interface Props {
9
+ state: DataStateKind;
10
+ title?: string;
11
+ description?: string;
12
+ retry?: () => void;
13
+ action?: Snippet;
14
+ class?: string;
15
+ }
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;
23
+ const resolvedTitle = $derived(title ?? defaults[state][0]);
24
+ const resolvedDescription = $derived(description ?? defaults[state][1]);
25
+ </script>
26
+
27
+ {#if state === 'loading'}
28
+ <div class={'space-y-3 rounded-lg border border-border bg-card p-5 ' + className} aria-busy="true">
29
+ <Skeleton class="h-5 w-40" /><Skeleton class="h-4 w-full max-w-md" /><Skeleton class="h-20 w-full" />
30
+ </div>
31
+ {:else if state === 'error' || state === 'forbidden'}
32
+ <Alert.Root variant={state === 'error' ? 'destructive' : 'warning'} class={className}>
33
+ {#if state === 'error'}<AlertTriangle class="size-4" />{:else}<LockKeyhole class="size-4" />{/if}
34
+ <Alert.Title>{resolvedTitle}</Alert.Title>
35
+ <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}
37
+ {#if action}<div class="mt-3">{@render action()}</div>{/if}
38
+ </Alert.Root>
39
+ {:else}
40
+ <div class={'flex min-h-40 flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card p-6 text-center ' + className}>
41
+ <Inbox class="size-8 text-muted-foreground" /><h3 class="mt-3 text-sm font-semibold text-foreground">{resolvedTitle}</h3><p class="mt-1 max-w-sm text-sm text-muted-foreground">{resolvedDescription}</p>
42
+ {#if action}<div class="mt-4">{@render action()}</div>{/if}
43
+ </div>
44
+ {/if}