@svadmin/ui 0.3.0 → 0.5.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 (110) hide show
  1. package/package.json +9 -4
  2. package/src/actions.ts +110 -0
  3. package/src/components/AdminApp.svelte +7 -4
  4. package/src/components/Authenticated.svelte +1 -7
  5. package/src/components/AutoForm.svelte +158 -69
  6. package/src/components/AutoSaveIndicator.svelte +19 -14
  7. package/src/components/AutoTable.svelte +465 -243
  8. package/src/components/Breadcrumbs.svelte +20 -17
  9. package/src/components/CanAccess.svelte +6 -4
  10. package/src/components/ComboboxField.svelte +122 -0
  11. package/src/components/CommandPalette.svelte +93 -0
  12. package/src/components/ConfigErrorScreen.svelte +53 -151
  13. package/src/components/ConfirmDialog.svelte +25 -17
  14. package/src/components/CreatePage.svelte +3 -2
  15. package/src/components/DevTools.svelte +99 -230
  16. package/src/components/DrawerForm.svelte +19 -10
  17. package/src/components/EditPage.svelte +25 -6
  18. package/src/components/EmptyState.svelte +5 -1
  19. package/src/components/ErrorBoundary.svelte +38 -40
  20. package/src/components/ErrorComponent.svelte +18 -17
  21. package/src/components/FieldRenderer.svelte +240 -75
  22. package/src/components/ForgotPasswordPage.svelte +20 -111
  23. package/src/components/Header.svelte +28 -8
  24. package/src/components/InferencerPanel.svelte +136 -110
  25. package/src/components/InfiniteList.svelte +93 -0
  26. package/src/components/Layout.svelte +85 -22
  27. package/src/components/ListPage.svelte +25 -1
  28. package/src/components/LiveIndicator.svelte +30 -87
  29. package/src/components/LoginPage.svelte +81 -155
  30. package/src/components/ModalForm.svelte +6 -2
  31. package/src/components/PageSkeleton.svelte +2 -1
  32. package/src/components/PasswordInput.svelte +95 -0
  33. package/src/components/ProfilePage.svelte +166 -0
  34. package/src/components/RegisterPage.svelte +35 -164
  35. package/src/components/ShowPage.svelte +32 -33
  36. package/src/components/Sidebar.svelte +185 -73
  37. package/src/components/StatsCard.svelte +12 -3
  38. package/src/components/StepsForm.svelte +139 -0
  39. package/src/components/Toast.svelte +15 -54
  40. package/src/components/TooltipButton.svelte +52 -0
  41. package/src/components/UndoableNotification.svelte +28 -96
  42. package/src/components/UpdatePasswordPage.svelte +32 -160
  43. package/src/components/buttons/RefreshButton.svelte +7 -2
  44. package/src/components/fieldComponentMap.ts +79 -0
  45. package/src/components/fields/BooleanField.svelte +9 -9
  46. package/src/components/fields/EmailField.svelte +2 -8
  47. package/src/components/fields/FileField.svelte +4 -9
  48. package/src/components/fields/ImageField.svelte +2 -7
  49. package/src/components/fields/JsonField.svelte +72 -0
  50. package/src/components/fields/MarkdownField.svelte +2 -12
  51. package/src/components/fields/MultiSelectField.svelte +20 -0
  52. package/src/components/fields/RelationField.svelte +24 -0
  53. package/src/components/fields/RichTextField.svelte +35 -0
  54. package/src/components/fields/SelectField.svelte +14 -0
  55. package/src/components/fields/TagField.svelte +6 -11
  56. package/src/components/fields/TextField.svelte +2 -7
  57. package/src/components/fields/UrlField.svelte +4 -10
  58. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
  59. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
  60. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
  61. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
  62. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
  63. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
  64. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
  65. package/src/components/ui/alert-dialog/index.ts +20 -0
  66. package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
  67. package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
  68. package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
  69. package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
  70. package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
  71. package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
  72. package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
  73. package/src/components/ui/breadcrumb/index.ts +7 -0
  74. package/src/components/ui/collapsible/index.ts +7 -0
  75. package/src/components/ui/command/command-dialog.svelte +31 -0
  76. package/src/components/ui/command/index.ts +2 -0
  77. package/src/components/ui/context-menu/index.ts +11 -0
  78. package/src/components/ui/drawer/drawer-content.svelte +21 -0
  79. package/src/components/ui/drawer/drawer-description.svelte +11 -0
  80. package/src/components/ui/drawer/drawer-footer.svelte +11 -0
  81. package/src/components/ui/drawer/drawer-header.svelte +11 -0
  82. package/src/components/ui/drawer/drawer-title.svelte +11 -0
  83. package/src/components/ui/drawer/drawer.svelte +18 -0
  84. package/src/components/ui/drawer/index.ts +6 -0
  85. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
  86. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
  87. package/src/components/ui/dropdown-menu/index.ts +6 -0
  88. package/src/components/ui/label/index.ts +1 -0
  89. package/src/components/ui/label/label.svelte +18 -0
  90. package/src/components/ui/pagination/index.ts +7 -0
  91. package/src/components/ui/pagination/pagination-content.svelte +11 -0
  92. package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
  93. package/src/components/ui/pagination/pagination-item.svelte +11 -0
  94. package/src/components/ui/pagination/pagination-link.svelte +26 -0
  95. package/src/components/ui/pagination/pagination-next.svelte +22 -0
  96. package/src/components/ui/pagination/pagination-previous.svelte +22 -0
  97. package/src/components/ui/pagination/pagination.svelte +11 -0
  98. package/src/components/ui/popover/index.ts +12 -0
  99. package/src/components/ui/popover/popover-content.svelte +25 -0
  100. package/src/components/ui/progress/index.ts +1 -0
  101. package/src/components/ui/progress/progress.svelte +22 -0
  102. package/src/components/ui/scroll-area/index.ts +1 -0
  103. package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
  104. package/src/components/ui/sheet/index.ts +15 -1
  105. package/src/components/ui/sheet/sheet-content.svelte +28 -0
  106. package/src/components/ui/sheet/sheet-description.svelte +22 -0
  107. package/src/components/ui/sheet/sheet-footer.svelte +22 -0
  108. package/src/components/ui/sheet/sheet-header.svelte +22 -0
  109. package/src/components/ui/sheet/sheet-title.svelte +22 -0
  110. package/src/index.ts +31 -1
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
+ import { fade } from 'svelte/transition';
2
3
  import { Wifi, WifiOff, Loader2 } from 'lucide-svelte';
4
+ import { Badge } from './ui/badge/index.js';
3
5
 
4
6
  let {
5
7
  status = 'disconnected',
@@ -14,12 +16,12 @@
14
16
  }>();
15
17
 
16
18
  const statusConfigs = {
17
- connected: { color: 'var(--live-connected, #22c55e)', label: 'Live', icon: Wifi },
18
- connecting: { color: 'var(--live-connecting, #f59e0b)', label: 'Connecting...', icon: Loader2 as typeof Wifi },
19
- disconnected: { color: 'var(--live-disconnected, #ef4444)', label: 'Offline', icon: WifiOff },
19
+ connected: { variant: 'default' as const, dotClass: 'bg-emerald-500', label: 'Live', icon: Wifi },
20
+ connecting: { variant: 'secondary' as const, dotClass: 'bg-amber-500', label: 'Connecting...', icon: Loader2 as typeof Wifi },
21
+ disconnected: { variant: 'destructive' as const, dotClass: 'bg-red-500', label: 'Offline', icon: WifiOff },
20
22
  } as const;
21
23
  type StatusKey = 'connected' | 'connecting' | 'disconnected';
22
- const statusConfig = $derived(statusConfigs[status as StatusKey]);
24
+ const cfg = $derived(statusConfigs[status as StatusKey]);
23
25
 
24
26
  const timeSinceEvent = $derived(
25
27
  lastEvent?.timestamp
@@ -28,88 +30,29 @@
28
30
  );
29
31
  </script>
30
32
 
31
- <div class="live-indicator" title={statusConfig.label}>
32
- <span class="live-dot" style="--dot-color: {statusConfig.color}">
33
- {#if status === 'connected'}
34
- <span class="live-pulse"></span>
35
- {/if}
36
- </span>
37
- <span class="live-label" style="color: {statusConfig.color}">
38
- {statusConfig.label}
39
- </span>
40
-
41
- {#if showDetails && resource}
42
- <span class="live-resource">{resource}</span>
43
- {/if}
44
-
45
- {#if showDetails && lastEvent}
46
- <span class="live-event">
47
- {lastEvent.type}
48
- {#if timeSinceEvent !== null && timeSinceEvent < 60}
49
- <span class="live-time">{timeSinceEvent}s ago</span>
33
+ {#key status}
34
+ <div transition:fade={{ duration: 200 }}>
35
+ <Badge variant={cfg.variant} class="gap-1.5 select-none" title={cfg.label}>
36
+ <span class="relative flex h-2 w-2">
37
+ <span class="{cfg.dotClass} rounded-full h-2 w-2"></span>
38
+ {#if status === 'connected'}
39
+ <span class="absolute inset-0 rounded-full {cfg.dotClass} opacity-40 animate-ping"></span>
40
+ {/if}
41
+ </span>
42
+ <span class="text-[0.6875rem] font-semibold uppercase tracking-wide">{cfg.label}</span>
43
+
44
+ {#if showDetails && resource}
45
+ <span class="text-[0.625rem] font-mono opacity-70">{resource}</span>
50
46
  {/if}
51
- </span>
52
- {/if}
53
- </div>
54
-
55
- <style>
56
- .live-indicator {
57
- display: inline-flex;
58
- align-items: center;
59
- gap: 0.375rem;
60
- font-size: 0.75rem;
61
- font-weight: 500;
62
- padding: 0.25rem 0.5rem;
63
- border-radius: 9999px;
64
- background: hsl(var(--muted) / 0.3);
65
- user-select: none;
66
- }
67
-
68
- .live-dot {
69
- position: relative;
70
- width: 8px;
71
- height: 8px;
72
- border-radius: 50%;
73
- background: var(--dot-color);
74
- flex-shrink: 0;
75
- }
76
-
77
- .live-pulse {
78
- position: absolute;
79
- inset: -3px;
80
- border-radius: 50%;
81
- background: var(--dot-color);
82
- opacity: 0.4;
83
- animation: live-pulse 2s ease-in-out infinite;
84
- }
85
47
 
86
- @keyframes live-pulse {
87
- 0%, 100% { transform: scale(1); opacity: 0.4; }
88
- 50% { transform: scale(1.8); opacity: 0; }
89
- }
90
-
91
- .live-label {
92
- font-size: 0.6875rem;
93
- font-weight: 600;
94
- text-transform: uppercase;
95
- letter-spacing: 0.05em;
96
- }
97
-
98
- .live-resource {
99
- font-size: 0.625rem;
100
- color: hsl(var(--muted-foreground));
101
- font-family: monospace;
102
- }
103
-
104
- .live-event {
105
- font-size: 0.625rem;
106
- color: hsl(var(--muted-foreground));
107
- display: flex;
108
- align-items: center;
109
- gap: 0.25rem;
110
- }
111
-
112
- .live-time {
113
- opacity: 0.6;
114
- }
115
- </style>
48
+ {#if showDetails && lastEvent}
49
+ <span class="text-[0.625rem] opacity-70">
50
+ {lastEvent.type}
51
+ {#if timeSinceEvent !== null && timeSinceEvent < 60}
52
+ <span class="opacity-60">{timeSinceEvent}s ago</span>
53
+ {/if}
54
+ </span>
55
+ {/if}
56
+ </Badge>
57
+ </div>
58
+ {/key}
@@ -4,12 +4,38 @@
4
4
  import { navigate } from '@svadmin/core/router';
5
5
  import { Button } from './ui/button/index.js';
6
6
  import { Input } from './ui/input/index.js';
7
+ import { Label } from './ui/label/index.js';
7
8
  import * as Card from './ui/card/index.js';
8
- import { LogIn, Mail, Lock, Eye, EyeOff } from 'lucide-svelte';
9
+ import * as Alert from './ui/alert/index.js';
10
+ import PasswordInput from './PasswordInput.svelte';
11
+ import { Separator } from './ui/separator/index.js';
12
+ import { LogIn, Mail, Loader2, AlertCircle } from 'lucide-svelte';
13
+
14
+ /**
15
+ * Social provider config for the login page.
16
+ * Each entry renders a button that calls `onClick()` when clicked.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * const socialProviders = [
21
+ * { name: 'Google', icon: '🔵', onClick: () => googleAuth.login({}) },
22
+ * { name: 'GitHub', icon: '🐙', onClick: () => githubAuth.login({}) },
23
+ * ];
24
+ * ```
25
+ */
26
+ interface SocialProvider {
27
+ /** Display name (e.g., 'Google') */
28
+ name: string;
29
+ /** Icon character, emoji, or SVG string */
30
+ icon?: string;
31
+ /** Handler to initiate social login */
32
+ onClick: () => void | Promise<void>;
33
+ }
9
34
 
10
- let { title = 'Admin', onSuccess } = $props<{
35
+ let { title = 'Admin', onSuccess, socialProviders = [] } = $props<{
11
36
  title?: string;
12
37
  onSuccess?: () => void;
38
+ socialProviders?: SocialProvider[];
13
39
  }>();
14
40
 
15
41
  const login = useLogin();
@@ -17,7 +43,6 @@
17
43
 
18
44
  let email = $state('');
19
45
  let password = $state('');
20
- let showPassword = $state(false);
21
46
  let error = $state('');
22
47
 
23
48
  async function handleSubmit(e: SubmitEvent) {
@@ -36,11 +61,11 @@
36
61
  }
37
62
  </script>
38
63
 
39
- <div class="login-page">
40
- <div class="login-container">
41
- <Card.Card class="login-card">
42
- <Card.CardHeader class="login-header">
43
- <div class="login-icon">
64
+ <div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/[0.08] via-background to-primary/[0.04] p-4">
65
+ <div class="w-full max-w-[420px]">
66
+ <Card.Card class="backdrop-blur-xl border-border/50 shadow-[0_8px_32px_hsl(var(--primary)/0.08),0_2px_8px_hsl(0_0%_0%/0.06)]">
67
+ <Card.CardHeader class="text-center pb-2">
68
+ <div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 text-primary mx-auto mb-3">
44
69
  <LogIn class="h-6 w-6" />
45
70
  </div>
46
71
  <Card.CardTitle class="text-2xl font-bold">{t('auth.welcomeBack')}</Card.CardTitle>
@@ -49,15 +74,16 @@
49
74
  <Card.CardContent>
50
75
  <form onsubmit={handleSubmit} class="space-y-4">
51
76
  {#if error}
52
- <div class="error-alert">
53
- <p>{error}</p>
54
- </div>
77
+ <Alert.Root variant="destructive">
78
+ <AlertCircle class="h-4 w-4" />
79
+ <Alert.Description>{error}</Alert.Description>
80
+ </Alert.Root>
55
81
  {/if}
56
82
 
57
83
  <div class="space-y-2">
58
- <label for="login-email" class="text-sm font-medium text-foreground">{t('auth.email')}</label>
59
- <div class="input-with-icon">
60
- <Mail class="input-icon h-4 w-4" />
84
+ <Label for="login-email">{t('auth.email')}</Label>
85
+ <div class="relative">
86
+ <Mail class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none z-[1]" />
61
87
  <Input
62
88
  id="login-email"
63
89
  type="email"
@@ -69,58 +95,61 @@
69
95
  </div>
70
96
  </div>
71
97
 
72
- <div class="space-y-2">
73
- <div class="flex items-center justify-between">
74
- <label for="login-password" class="text-sm font-medium text-foreground">{t('auth.password')}</label>
98
+ <div>
99
+ <div class="flex items-center justify-between mb-2">
100
+ <Label for="login-password">{t('auth.password')}</Label>
75
101
  {#if authProvider.forgotPassword}
76
- <button
77
- type="button"
78
- class="text-xs text-primary hover:underline"
79
- onclick={() => navigate('/forgot-password')}
80
- >{t('auth.forgotPasswordLink')}</button>
102
+ <Button variant="link" class="text-xs h-auto p-0" onclick={() => navigate('/forgot-password')}>
103
+ {t('auth.forgotPasswordLink')}
104
+ </Button>
81
105
  {/if}
82
106
  </div>
83
- <div class="input-with-icon">
84
- <Lock class="input-icon h-4 w-4" />
85
- <Input
86
- id="login-password"
87
- type={showPassword ? 'text' : 'password'}
88
- placeholder="••••••••"
89
- bind:value={password}
90
- class="pl-9 pr-9"
91
- autocomplete="current-password"
92
- />
93
- <button
94
- type="button"
95
- class="password-toggle"
96
- onclick={() => showPassword = !showPassword}
97
- tabindex={-1}
98
- >
99
- {#if showPassword}
100
- <EyeOff class="h-4 w-4" />
101
- {:else}
102
- <Eye class="h-4 w-4" />
103
- {/if}
104
- </button>
105
- </div>
107
+ <PasswordInput
108
+ id="login-password"
109
+ label=""
110
+ bind:value={password}
111
+ autocomplete="current-password"
112
+ />
106
113
  </div>
107
114
 
108
115
  <Button type="submit" class="w-full" disabled={login.isLoading}>
109
116
  {#if login.isLoading}
110
- <span class="loading-spinner"></span>
117
+ <Loader2 class="h-4 w-4 animate-spin mr-2" />
111
118
  {/if}
112
119
  {t('auth.loginButton')}
113
120
  </Button>
114
121
  </form>
115
122
 
123
+ {#if socialProviders.length > 0}
124
+ <div class="relative my-5">
125
+ <Separator />
126
+ <span class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-3 text-xs text-muted-foreground">
127
+ {t('auth.orContinueWith')}
128
+ </span>
129
+ </div>
130
+
131
+ <div class="grid gap-2" class:grid-cols-2={socialProviders.length >= 2}>
132
+ {#each socialProviders as provider (provider.name)}
133
+ <Button
134
+ variant="outline"
135
+ class="w-full"
136
+ onclick={provider.onClick}
137
+ >
138
+ {#if provider.icon}
139
+ <span class="mr-2">{provider.icon}</span>
140
+ {/if}
141
+ {provider.name}
142
+ </Button>
143
+ {/each}
144
+ </div>
145
+ {/if}
146
+
116
147
  {#if authProvider.register}
117
- <div class="auth-link">
148
+ <div class="flex items-center justify-center gap-1 mt-5 pt-5 border-t">
118
149
  <span class="text-sm text-muted-foreground">{t('auth.noAccount')}</span>
119
- <button
120
- type="button"
121
- class="text-sm text-primary hover:underline font-medium"
122
- onclick={() => navigate('/register')}
123
- >{t('auth.register')}</button>
150
+ <Button variant="link" class="text-sm h-auto p-0 font-medium" onclick={() => navigate('/register')}>
151
+ {t('auth.register')}
152
+ </Button>
124
153
  </div>
125
154
  {/if}
126
155
  </Card.CardContent>
@@ -131,106 +160,3 @@
131
160
  </p>
132
161
  </div>
133
162
  </div>
134
-
135
- <style>
136
- .login-page {
137
- min-height: 100vh;
138
- display: flex;
139
- align-items: center;
140
- justify-content: center;
141
- background: linear-gradient(135deg, hsl(var(--primary) / 0.08) 0%, hsl(var(--background)) 50%, hsl(var(--primary) / 0.04) 100%);
142
- padding: 1rem;
143
- }
144
-
145
- .login-container {
146
- width: 100%;
147
- max-width: 420px;
148
- }
149
-
150
- :global(.login-card) {
151
- backdrop-filter: blur(20px);
152
- border: 1px solid hsl(var(--border) / 0.5);
153
- box-shadow: 0 8px 32px hsl(var(--primary) / 0.08), 0 2px 8px hsl(0 0% 0% / 0.06);
154
- }
155
-
156
- :global(.login-header) {
157
- text-align: center;
158
- padding-bottom: 0.5rem;
159
- }
160
-
161
- .login-icon {
162
- display: inline-flex;
163
- align-items: center;
164
- justify-content: center;
165
- width: 48px;
166
- height: 48px;
167
- border-radius: 12px;
168
- background: hsl(var(--primary) / 0.1);
169
- color: hsl(var(--primary));
170
- margin: 0 auto 0.75rem;
171
- }
172
-
173
- .input-with-icon {
174
- position: relative;
175
- }
176
-
177
- :global(.input-icon) {
178
- position: absolute;
179
- left: 0.75rem;
180
- top: 50%;
181
- transform: translateY(-50%);
182
- color: hsl(var(--muted-foreground));
183
- pointer-events: none;
184
- z-index: 1;
185
- }
186
-
187
- .password-toggle {
188
- position: absolute;
189
- right: 0.75rem;
190
- top: 50%;
191
- transform: translateY(-50%);
192
- color: hsl(var(--muted-foreground));
193
- background: none;
194
- border: none;
195
- cursor: pointer;
196
- padding: 2px;
197
- z-index: 1;
198
- }
199
- .password-toggle:hover {
200
- color: hsl(var(--foreground));
201
- }
202
-
203
- .error-alert {
204
- padding: 0.75rem;
205
- border-radius: 0.5rem;
206
- background: hsl(var(--destructive) / 0.1);
207
- border: 1px solid hsl(var(--destructive) / 0.3);
208
- color: hsl(var(--destructive));
209
- font-size: 0.875rem;
210
- }
211
-
212
- .loading-spinner {
213
- display: inline-block;
214
- width: 16px;
215
- height: 16px;
216
- border: 2px solid transparent;
217
- border-top-color: currentColor;
218
- border-radius: 50%;
219
- animation: spin 0.6s linear infinite;
220
- margin-right: 0.5rem;
221
- }
222
-
223
- @keyframes spin {
224
- to { transform: rotate(360deg); }
225
- }
226
-
227
- .auth-link {
228
- display: flex;
229
- align-items: center;
230
- justify-content: center;
231
- gap: 0.25rem;
232
- margin-top: 1.25rem;
233
- padding-top: 1.25rem;
234
- border-top: 1px solid hsl(var(--border));
235
- }
236
- </style>
@@ -2,6 +2,7 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { FieldDefinition } from '@svadmin/core';
4
4
  import { getResource, useForm } from '@svadmin/core';
5
+ import { t } from '@svadmin/core/i18n';
5
6
  import * as Dialog from './ui/dialog/index.js';
6
7
  import AutoForm from './AutoForm.svelte';
7
8
 
@@ -14,18 +15,21 @@
14
15
  }>();
15
16
 
16
17
  const resource = $derived(getResource(resourceName));
18
+ const fieldCount = $derived(resource.fields.filter(f => f.showInForm !== false).length);
19
+ const dialogWidth = $derived(fieldCount > 8 ? 'sm:max-w-3xl' : fieldCount > 5 ? 'sm:max-w-2xl' : 'sm:max-w-lg');
17
20
 
18
21
  function handleClose() {
19
22
  open = false;
23
+ onSuccess?.();
20
24
  }
21
25
  </script>
22
26
 
23
27
  {#if open}
24
28
  <Dialog.Dialog bind:open>
25
- <Dialog.DialogContent class="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
29
+ <Dialog.DialogContent class="{dialogWidth} max-h-[85vh] overflow-y-auto">
26
30
  <Dialog.DialogHeader>
27
31
  <Dialog.DialogTitle>
28
- {mode === 'create' ? `Create ${resource.label}` : `Edit ${resource.label}`}
32
+ {mode === 'create' ? `${t('common.create')}${resource.label}` : `${t('common.edit')}${resource.label}`}
29
33
  </Dialog.DialogTitle>
30
34
  </Dialog.DialogHeader>
31
35
  <AutoForm {resourceName} {mode} {id} />
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { fade } from 'svelte/transition';
2
3
  import { Skeleton } from './ui/skeleton';
3
4
 
4
5
  let {
@@ -10,7 +11,7 @@
10
11
  }>();
11
12
  </script>
12
13
 
13
- <div class="w-full space-y-6">
14
+ <div class="w-full space-y-6" out:fade={{ duration: 200 }}>
14
15
  <!-- Header Skeleton -->
15
16
  <div class="flex items-center justify-between">
16
17
  <div class="space-y-2">
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ import { Input } from './ui/input/index.js';
3
+ import { Button } from './ui/button/index.js';
4
+ import TooltipButton from './TooltipButton.svelte';
5
+ import { Label } from './ui/label/index.js';
6
+ import { Progress } from './ui/progress/index.js';
7
+ import { Lock, Eye, EyeOff } from 'lucide-svelte';
8
+
9
+ interface Props {
10
+ id: string;
11
+ label: string;
12
+ value: string;
13
+ placeholder?: string;
14
+ autocomplete?: string;
15
+ showStrength?: boolean;
16
+ class?: string;
17
+ }
18
+
19
+ let {
20
+ id,
21
+ label,
22
+ value = $bindable(''),
23
+ placeholder = '••••••••',
24
+ autocomplete = 'current-password',
25
+ showStrength = false,
26
+ class: className = '',
27
+ }: Props = $props();
28
+
29
+ let showPassword = $state(false);
30
+
31
+ // Password strength calculation
32
+ const strength = $derived.by(() => {
33
+ if (!showStrength || !value) return 0;
34
+ let score = 0;
35
+ if (value.length >= 8) score += 25;
36
+ if (value.length >= 12) score += 10;
37
+ if (/[a-z]/.test(value) && /[A-Z]/.test(value)) score += 25;
38
+ if (/\d/.test(value)) score += 20;
39
+ if (/[^a-zA-Z0-9]/.test(value)) score += 20;
40
+ return Math.min(score, 100);
41
+ });
42
+
43
+ const strengthColor = $derived(
44
+ strength < 30 ? 'bg-destructive' :
45
+ strength < 60 ? 'bg-amber-500' :
46
+ strength < 80 ? 'bg-blue-500' : 'bg-emerald-500'
47
+ );
48
+
49
+ const strengthLabel = $derived(
50
+ strength < 30 ? 'Weak' :
51
+ strength < 60 ? 'Fair' :
52
+ strength < 80 ? 'Good' : 'Strong'
53
+ );
54
+ </script>
55
+
56
+ <div class="space-y-2 {className}">
57
+ <Label for={id}>{label}</Label>
58
+ <div class="relative">
59
+ <Lock class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none z-[1]" />
60
+ <Input
61
+ {id}
62
+ type={showPassword ? 'text' : 'password'}
63
+ {placeholder}
64
+ bind:value
65
+ class="pl-9 pr-9"
66
+ {autocomplete}
67
+ />
68
+ <TooltipButton
69
+ tooltip={showPassword ? 'Hide password' : 'Show password'}
70
+ variant="ghost"
71
+ size="icon"
72
+ type="button"
73
+ class="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 z-[1]"
74
+ onclick={() => showPassword = !showPassword}
75
+ tabindex={-1}
76
+ >
77
+ {#if showPassword}
78
+ <EyeOff class="h-4 w-4" />
79
+ {:else}
80
+ <Eye class="h-4 w-4" />
81
+ {/if}
82
+ </TooltipButton>
83
+ </div>
84
+ {#if showStrength && value}
85
+ <div class="flex items-center gap-2">
86
+ <div class="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
87
+ <div
88
+ class="h-full rounded-full transition-all duration-300 {strengthColor}"
89
+ style="width: {strength}%"
90
+ ></div>
91
+ </div>
92
+ <span class="text-xs text-muted-foreground w-12 text-right">{strengthLabel}</span>
93
+ </div>
94
+ {/if}
95
+ </div>