@svadmin/ui 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/package.json +7 -3
  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/ComboboxField.svelte +122 -0
  10. package/src/components/CommandPalette.svelte +93 -0
  11. package/src/components/ConfigErrorScreen.svelte +53 -151
  12. package/src/components/ConfirmDialog.svelte +25 -17
  13. package/src/components/CreatePage.svelte +3 -2
  14. package/src/components/DevTools.svelte +99 -230
  15. package/src/components/DrawerForm.svelte +19 -10
  16. package/src/components/EditPage.svelte +25 -6
  17. package/src/components/EmptyState.svelte +5 -1
  18. package/src/components/ErrorBoundary.svelte +38 -40
  19. package/src/components/ErrorComponent.svelte +18 -17
  20. package/src/components/FieldRenderer.svelte +240 -75
  21. package/src/components/ForgotPasswordPage.svelte +20 -111
  22. package/src/components/Header.svelte +28 -8
  23. package/src/components/InferencerPanel.svelte +136 -110
  24. package/src/components/InfiniteList.svelte +93 -0
  25. package/src/components/Layout.svelte +85 -22
  26. package/src/components/ListPage.svelte +25 -1
  27. package/src/components/LiveIndicator.svelte +30 -87
  28. package/src/components/LoginPage.svelte +33 -154
  29. package/src/components/ModalForm.svelte +6 -2
  30. package/src/components/PageSkeleton.svelte +2 -1
  31. package/src/components/PasswordInput.svelte +95 -0
  32. package/src/components/RegisterPage.svelte +35 -164
  33. package/src/components/ShowPage.svelte +32 -33
  34. package/src/components/Sidebar.svelte +185 -73
  35. package/src/components/StatsCard.svelte +12 -3
  36. package/src/components/StepsForm.svelte +139 -0
  37. package/src/components/Toast.svelte +15 -54
  38. package/src/components/TooltipButton.svelte +52 -0
  39. package/src/components/UndoableNotification.svelte +28 -96
  40. package/src/components/UpdatePasswordPage.svelte +32 -160
  41. package/src/components/buttons/RefreshButton.svelte +7 -2
  42. package/src/components/fieldComponentMap.ts +79 -0
  43. package/src/components/fields/BooleanField.svelte +9 -9
  44. package/src/components/fields/EmailField.svelte +2 -8
  45. package/src/components/fields/FileField.svelte +4 -9
  46. package/src/components/fields/ImageField.svelte +2 -7
  47. package/src/components/fields/JsonField.svelte +72 -0
  48. package/src/components/fields/MarkdownField.svelte +2 -12
  49. package/src/components/fields/MultiSelectField.svelte +20 -0
  50. package/src/components/fields/RelationField.svelte +24 -0
  51. package/src/components/fields/RichTextField.svelte +35 -0
  52. package/src/components/fields/SelectField.svelte +14 -0
  53. package/src/components/fields/TagField.svelte +6 -11
  54. package/src/components/fields/TextField.svelte +2 -7
  55. package/src/components/fields/UrlField.svelte +4 -10
  56. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
  57. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
  58. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
  59. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
  60. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
  61. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
  62. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
  63. package/src/components/ui/alert-dialog/index.ts +20 -0
  64. package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
  65. package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
  66. package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
  67. package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
  68. package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
  69. package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
  70. package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
  71. package/src/components/ui/breadcrumb/index.ts +7 -0
  72. package/src/components/ui/collapsible/index.ts +7 -0
  73. package/src/components/ui/command/command-dialog.svelte +31 -0
  74. package/src/components/ui/command/index.ts +2 -0
  75. package/src/components/ui/context-menu/index.ts +11 -0
  76. package/src/components/ui/drawer/drawer-content.svelte +21 -0
  77. package/src/components/ui/drawer/drawer-description.svelte +11 -0
  78. package/src/components/ui/drawer/drawer-footer.svelte +11 -0
  79. package/src/components/ui/drawer/drawer-header.svelte +11 -0
  80. package/src/components/ui/drawer/drawer-title.svelte +11 -0
  81. package/src/components/ui/drawer/drawer.svelte +18 -0
  82. package/src/components/ui/drawer/index.ts +6 -0
  83. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
  84. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
  85. package/src/components/ui/dropdown-menu/index.ts +6 -0
  86. package/src/components/ui/label/index.ts +1 -0
  87. package/src/components/ui/label/label.svelte +18 -0
  88. package/src/components/ui/pagination/index.ts +7 -0
  89. package/src/components/ui/pagination/pagination-content.svelte +11 -0
  90. package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
  91. package/src/components/ui/pagination/pagination-item.svelte +11 -0
  92. package/src/components/ui/pagination/pagination-link.svelte +26 -0
  93. package/src/components/ui/pagination/pagination-next.svelte +22 -0
  94. package/src/components/ui/pagination/pagination-previous.svelte +22 -0
  95. package/src/components/ui/pagination/pagination.svelte +11 -0
  96. package/src/components/ui/popover/index.ts +12 -0
  97. package/src/components/ui/popover/popover-content.svelte +25 -0
  98. package/src/components/ui/progress/index.ts +1 -0
  99. package/src/components/ui/progress/progress.svelte +22 -0
  100. package/src/components/ui/scroll-area/index.ts +1 -0
  101. package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
  102. package/src/components/ui/sheet/index.ts +15 -1
  103. package/src/components/ui/sheet/sheet-content.svelte +28 -0
  104. package/src/components/ui/sheet/sheet-description.svelte +22 -0
  105. package/src/components/ui/sheet/sheet-footer.svelte +22 -0
  106. package/src/components/ui/sheet/sheet-header.svelte +22 -0
  107. package/src/components/ui/sheet/sheet-title.svelte +22 -0
  108. package/src/index.ts +30 -1
@@ -1,8 +1,16 @@
1
1
  <script lang="ts">
2
2
  import { getDataProvider, getResources, inferResource } from '@svadmin/core';
3
- import type { InferResult, ResourceDefinition } from '@svadmin/core';
3
+ import type { InferResult } from '@svadmin/core';
4
4
  import { Button } from './ui/button/index.js';
5
- import { Wand2, Copy, Check, RefreshCw, Loader2 } from 'lucide-svelte';
5
+ import { Input } from './ui/input/index.js';
6
+ import { Badge } from './ui/badge/index.js';
7
+ import * as Card from './ui/card/index.js';
8
+ import * as Table from './ui/table/index.js';
9
+ import * as Alert from './ui/alert/index.js';
10
+ import { Skeleton } from './ui/skeleton/index.js';
11
+ import { ScrollArea } from './ui/scroll-area/index.js';
12
+ import { Select } from './ui/select/index.js';
13
+ import { Wand2, Copy, Check, RefreshCw, Loader2, AlertCircle } from 'lucide-svelte';
6
14
 
7
15
  const dataProvider = getDataProvider();
8
16
  const resources = getResources();
@@ -13,6 +21,11 @@
13
21
  let error = $state<string | null>(null);
14
22
  let copied = $state(false);
15
23
  let customEndpoint = $state('');
24
+ let copyTimer: ReturnType<typeof setTimeout> | undefined;
25
+
26
+ $effect(() => {
27
+ return () => { if (copyTimer) clearTimeout(copyTimer); };
28
+ });
16
29
 
17
30
  async function runInference() {
18
31
  const resourceName = customEndpoint.trim() || selectedResource;
@@ -37,8 +50,8 @@
37
50
  resourceName,
38
51
  response.data as Record<string, unknown>[],
39
52
  );
40
- } catch (e: any) {
41
- error = e?.message ?? 'Failed to fetch data for inference.';
53
+ } catch (e: unknown) {
54
+ error = e instanceof Error ? e.message : 'Failed to fetch data for inference.';
42
55
  } finally {
43
56
  loading = false;
44
57
  }
@@ -48,119 +61,132 @@
48
61
  if (!inferResult) return;
49
62
  navigator.clipboard.writeText(inferResult.code);
50
63
  copied = true;
51
- setTimeout(() => { copied = false; }, 2000);
64
+ if (copyTimer) clearTimeout(copyTimer);
65
+ copyTimer = setTimeout(() => { copied = false; }, 2000);
52
66
  }
53
67
 
54
- const typeColors: Record<string, string> = {
55
- text: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
56
- number: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
57
- boolean: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
58
- date: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
59
- email: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200',
60
- url: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200',
61
- image: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200',
62
- images: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200',
63
- textarea: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
64
- json: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
65
- tags: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200',
66
- select: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
67
- relation: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
68
- color: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-900 dark:text-fuchsia-200',
69
- phone: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
68
+ const typeVariants: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
69
+ text: 'secondary',
70
+ number: 'default',
71
+ boolean: 'outline',
72
+ date: 'secondary',
73
+ email: 'default',
74
+ url: 'outline',
75
+ image: 'secondary',
76
+ images: 'secondary',
77
+ textarea: 'outline',
78
+ json: 'secondary',
79
+ tags: 'default',
80
+ select: 'outline',
81
+ relation: 'destructive',
82
+ color: 'secondary',
83
+ phone: 'default',
70
84
  };
71
85
  </script>
72
86
 
73
- <div class="inferencer-panel space-y-4">
74
- <div class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
75
- <Wand2 class="h-4 w-4" />
76
- Resource Inferencer
77
- </div>
78
-
79
- <!-- Resource selector -->
80
- <div class="flex gap-2">
81
- <select
82
- class="flex-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
83
- bind:value={selectedResource}
84
- >
85
- <option value="">— Select a resource —</option>
86
- {#each resources as res}
87
- <option value={res.name}>{res.label} ({res.name})</option>
88
- {/each}
89
- </select>
90
- <span class="flex items-center text-xs text-gray-400">or</span>
91
- <input
92
- type="text"
93
- class="w-36 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
94
- placeholder="custom endpoint"
95
- bind:value={customEndpoint}
96
- />
97
- </div>
98
-
99
- <Button size="sm" onclick={runInference} disabled={loading || (!selectedResource && !customEndpoint.trim())}>
100
- {#if loading}
101
- <Loader2 class="mr-1 h-3 w-3 animate-spin" />
102
- Inferring...
103
- {:else}
104
- <RefreshCw class="mr-1 h-3 w-3" />
105
- Infer Fields
87
+ <Card.Root>
88
+ <Card.CardHeader class="pb-3">
89
+ <Card.CardTitle class="flex items-center gap-2 text-base">
90
+ <Wand2 class="h-4 w-4" />
91
+ Resource Inferencer
92
+ </Card.CardTitle>
93
+ </Card.CardHeader>
94
+ <Card.CardContent class="space-y-4">
95
+ <!-- Resource selector -->
96
+ <div class="flex gap-2">
97
+ <Select
98
+ class="flex-1"
99
+ bind:value={selectedResource}
100
+ placeholder="— Select a resource —"
101
+ >
102
+ {#each resources as res}
103
+ <option value={res.name}>{res.label} ({res.name})</option>
104
+ {/each}
105
+ </Select>
106
+ <span class="flex items-center text-xs text-muted-foreground">or</span>
107
+ <Input
108
+ type="text"
109
+ class="w-36"
110
+ placeholder="custom endpoint"
111
+ bind:value={customEndpoint}
112
+ />
113
+ </div>
114
+
115
+ <Button size="sm" onclick={runInference} disabled={loading || (!selectedResource && !customEndpoint.trim())}>
116
+ {#if loading}
117
+ <Loader2 class="h-3 w-3 animate-spin" data-icon="inline-start" />
118
+ Inferring...
119
+ {:else}
120
+ <RefreshCw class="h-3 w-3" data-icon="inline-start" />
121
+ Infer Fields
122
+ {/if}
123
+ </Button>
124
+
125
+ {#if error}
126
+ <Alert.Root variant="destructive">
127
+ <AlertCircle class="h-4 w-4" />
128
+ <Alert.Description>{error}</Alert.Description>
129
+ </Alert.Root>
106
130
  {/if}
107
- </Button>
108
131
 
109
- {#if error}
110
- <div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-300">
111
- {error}
112
- </div>
113
- {/if}
114
-
115
- {#if inferResult}
116
- <!-- Field table -->
117
- <div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
118
- <table class="w-full text-left text-sm">
119
- <thead class="bg-gray-50 dark:bg-gray-800">
120
- <tr>
121
- <th class="px-3 py-2 font-medium">Field</th>
122
- <th class="px-3 py-2 font-medium">Type</th>
123
- <th class="px-3 py-2 font-medium">List</th>
124
- <th class="px-3 py-2 font-medium">Form</th>
125
- </tr>
126
- </thead>
127
- <tbody>
128
- {#each inferResult.fields as field}
129
- <tr class="border-t border-gray-100 dark:border-gray-700/50">
130
- <td class="px-3 py-1.5 font-mono text-xs">{field.key}</td>
131
- <td class="px-3 py-1.5">
132
- <span class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {typeColors[field.type] ?? 'bg-gray-100 text-gray-600'}">
133
- {field.type}
134
- {#if field.resource}→ {field.resource}{/if}
135
- </span>
136
- </td>
137
- <td class="px-3 py-1.5 text-center">{field.showInList ? '✓' : '—'}</td>
138
- <td class="px-3 py-1.5 text-center">{field.showInForm ? '✓' : '—'}</td>
139
- </tr>
140
- {/each}
141
- </tbody>
142
- </table>
143
- </div>
132
+ {#if loading}
133
+ <div class="space-y-2">
134
+ {#each Array(4) as _}
135
+ <Skeleton class="h-8 w-full" />
136
+ {/each}
137
+ </div>
138
+ {/if}
144
139
 
145
- <!-- Generated code -->
146
- <div class="relative">
147
- <div class="flex items-center justify-between rounded-t-lg bg-gray-800 px-3 py-1.5 text-xs text-gray-400">
148
- <span>Generated ResourceDefinition</span>
149
- <button class="flex items-center gap-1 hover:text-white transition-colors" onclick={copyCode}>
150
- {#if copied}
151
- <Check class="h-3 w-3 text-green-400" />
152
- <span class="text-green-400">Copied!</span>
153
- {:else}
154
- <Copy class="h-3 w-3" />
155
- Copy
156
- {/if}
157
- </button>
140
+ {#if inferResult}
141
+ <!-- Field table -->
142
+ <ScrollArea class="max-h-80">
143
+ <Table.Root>
144
+ <Table.Header>
145
+ <Table.Row class="bg-muted/50">
146
+ <Table.Head>Field</Table.Head>
147
+ <Table.Head>Type</Table.Head>
148
+ <Table.Head class="text-center">List</Table.Head>
149
+ <Table.Head class="text-center">Form</Table.Head>
150
+ </Table.Row>
151
+ </Table.Header>
152
+ <Table.Body>
153
+ {#each inferResult.fields as field}
154
+ <Table.Row>
155
+ <Table.Cell class="font-mono text-xs">{field.key}</Table.Cell>
156
+ <Table.Cell>
157
+ <Badge variant={typeVariants[field.type] ?? 'secondary'}>
158
+ {field.type}
159
+ {#if field.resource}→ {field.resource}{/if}
160
+ </Badge>
161
+ </Table.Cell>
162
+ <Table.Cell class="text-center">{field.showInList ? '✓' : '—'}</Table.Cell>
163
+ <Table.Cell class="text-center">{field.showInForm ? '✓' : '—'}</Table.Cell>
164
+ </Table.Row>
165
+ {/each}
166
+ </Table.Body>
167
+ </Table.Root>
168
+ </ScrollArea>
169
+
170
+ <!-- Generated code -->
171
+ <div class="rounded-lg border overflow-hidden">
172
+ <div class="flex items-center justify-between bg-muted px-3 py-1.5 text-xs text-muted-foreground">
173
+ <span>Generated ResourceDefinition</span>
174
+ <Button variant="ghost" size="sm" class="h-6 text-xs gap-1" onclick={copyCode}>
175
+ {#if copied}
176
+ <Check class="h-3 w-3 text-emerald-500" />
177
+ <span class="text-emerald-500">Copied!</span>
178
+ {:else}
179
+ <Copy class="h-3 w-3" />
180
+ Copy
181
+ {/if}
182
+ </Button>
183
+ </div>
184
+ <pre class="max-h-64 overflow-auto bg-muted/30 p-3 text-xs text-foreground font-mono">{inferResult.code}</pre>
158
185
  </div>
159
- <pre class="max-h-64 overflow-auto rounded-b-lg bg-gray-900 p-3 text-xs text-green-300 font-mono">{inferResult.code}</pre>
160
- </div>
161
186
 
162
- <p class="text-xs text-gray-400">
163
- Inferred {inferResult.fields.length} fields from sample data. Copy the code above into your <code>resources.ts</code> file.
164
- </p>
165
- {/if}
166
- </div>
187
+ <p class="text-xs text-muted-foreground">
188
+ Inferred {inferResult.fields.length} fields from sample data. Copy the code above into your <code class="bg-muted px-1 rounded text-foreground">resources.ts</code> file.
189
+ </p>
190
+ {/if}
191
+ </Card.CardContent>
192
+ </Card.Root>
@@ -0,0 +1,93 @@
1
+ <script lang="ts">
2
+ import { fade, fly } from 'svelte/transition';
3
+ import { useInfiniteList } from '@svadmin/core';
4
+ import type { BaseRecord, Sort, Filter } from '@svadmin/core';
5
+ import type { Snippet } from 'svelte';
6
+ import { t } from '@svadmin/core/i18n';
7
+ import { Skeleton } from './ui/skeleton/index.js';
8
+ import { Button } from './ui/button/index.js';
9
+ import { Loader2 } from 'lucide-svelte';
10
+ import { intersect } from '../actions.js';
11
+
12
+ interface Props<T extends BaseRecord = BaseRecord> {
13
+ resource: string;
14
+ pageSize?: number;
15
+ sorters?: Sort[];
16
+ filters?: Filter[];
17
+ /** Render each item */
18
+ children: Snippet<[{ item: T; index: number }]>;
19
+ /** Custom empty state */
20
+ empty?: Snippet;
21
+ /** Custom loading skeleton */
22
+ loadingSkeleton?: Snippet;
23
+ }
24
+
25
+ let {
26
+ resource,
27
+ pageSize = 20,
28
+ sorters,
29
+ filters,
30
+ children,
31
+ empty,
32
+ loadingSkeleton,
33
+ }: Props = $props();
34
+
35
+ const { query } = useInfiniteList({
36
+ resource,
37
+ pagination: { pageSize },
38
+ sorters,
39
+ filters,
40
+ });
41
+
42
+ const allItems = $derived(
43
+ (query.data as { pages?: { data: BaseRecord[] }[] })?.pages?.flatMap(p => p.data) ?? []
44
+ );
45
+
46
+ const hasNextPage = $derived(query.hasNextPage ?? false);
47
+ const isFetchingNextPage = $derived(query.isFetchingNextPage ?? false);
48
+
49
+ function loadMore() {
50
+ if (hasNextPage && !isFetchingNextPage) {
51
+ query.fetchNextPage();
52
+ }
53
+ }
54
+ </script>
55
+
56
+ <div class="space-y-2">
57
+ {#if query.isLoading}
58
+ {#if loadingSkeleton}
59
+ {@render loadingSkeleton()}
60
+ {:else}
61
+ <div class="space-y-3">
62
+ {#each Array(5) as _}
63
+ <Skeleton class="h-16 w-full rounded-lg" />
64
+ {/each}
65
+ </div>
66
+ {/if}
67
+ {:else if allItems.length === 0}
68
+ {#if empty}
69
+ {@render empty()}
70
+ {:else}
71
+ <p class="text-center text-sm text-muted-foreground py-8">{t('empty.title')}</p>
72
+ {/if}
73
+ {:else}
74
+ {#each allItems as item, index}
75
+ <div in:fade={{ duration: 200, delay: index < 20 ? index * 30 : 0 }}>
76
+ {@render children({ item, index })}
77
+ </div>
78
+ {/each}
79
+
80
+ {#if isFetchingNextPage}
81
+ <div class="flex justify-center py-4">
82
+ <Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
83
+ </div>
84
+ {/if}
85
+
86
+ {#if hasNextPage}
87
+ <!-- Sentinel for infinite scroll -->
88
+ <div use:intersect={loadMore} class="h-1"></div>
89
+ {:else}
90
+ <p class="text-center text-xs text-muted-foreground py-2">{t('empty.noMore')}</p>
91
+ {/if}
92
+ {/if}
93
+ </div>
@@ -1,13 +1,23 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { onMount } from 'svelte';
3
+ import { fade, fly } from 'svelte/transition';
4
4
  import Sidebar from './Sidebar.svelte';
5
5
  import Toast from './Toast.svelte';
6
- import Breadcrumbs from './Breadcrumbs.svelte';
6
+ import Header from './Header.svelte';
7
+ import CommandPalette from './CommandPalette.svelte';
8
+ import TooltipButton from './TooltipButton.svelte';
9
+ import { t } from '@svadmin/core/i18n';
7
10
  import { getAuthProvider } from '@svadmin/core';
8
11
  import type { Identity } from '@svadmin/core';
9
12
  import { navigate } from '@svadmin/core/router';
10
- import { Loader2 } from 'lucide-svelte';
13
+ import { currentPath } from '@svadmin/core/router';
14
+ import { Skeleton } from './ui/skeleton/index.js';
15
+ import { Button } from './ui/button/index.js';
16
+ import * as Sheet from './ui/sheet/index.js';
17
+ import { Menu } from 'lucide-svelte';
18
+
19
+ let commandOpen = $state(false);
20
+ let mobileMenuOpen = $state(false);
11
21
 
12
22
  let { children, title = 'Admin' }: { children: Snippet; title?: string } = $props();
13
23
 
@@ -21,18 +31,23 @@
21
31
  let loading = $state(true);
22
32
  let identity = $state<Identity | null>(null);
23
33
 
24
- onMount(async () => {
34
+ $effect(() => {
25
35
  if (!auth) {
26
36
  loading = false;
27
37
  return;
28
38
  }
29
- const { authenticated, redirectTo } = await auth.check();
30
- if (!authenticated) {
31
- navigate(redirectTo ?? '/login');
32
- return;
33
- }
34
- identity = await auth.getIdentity();
35
- loading = false;
39
+ (async () => {
40
+ try {
41
+ const { authenticated, redirectTo } = await auth.check();
42
+ if (!authenticated) {
43
+ navigate(redirectTo ?? '/login');
44
+ return;
45
+ }
46
+ identity = await auth.getIdentity();
47
+ } finally {
48
+ loading = false;
49
+ }
50
+ })();
36
51
  });
37
52
 
38
53
  async function handleLogout() {
@@ -46,21 +61,69 @@
46
61
  let collapsed = $state(false);
47
62
  </script>
48
63
 
64
+ <div role="application" onkeydown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; } }}>
49
65
  {#if loading}
50
- <div class="flex h-screen items-center justify-center">
51
- <Loader2 class="h-8 w-8 animate-spin text-primary" />
66
+ <div class="flex h-screen" in:fade={{ duration: 150 }}>
67
+ <div class="hidden md:block w-64 border-r bg-sidebar/80 p-4 space-y-4">
68
+ <Skeleton class="h-8 w-32" />
69
+ <div class="space-y-2 mt-6">
70
+ {#each Array(5) as _}
71
+ <Skeleton class="h-9 w-full rounded-lg" />
72
+ {/each}
73
+ </div>
74
+ </div>
75
+ <div class="flex-1 p-8 space-y-6">
76
+ <Skeleton class="h-8 w-48" />
77
+ <div class="space-y-3">
78
+ {#each Array(4) as _}
79
+ <Skeleton class="h-12 w-full" />
80
+ {/each}
81
+ </div>
82
+ </div>
52
83
  </div>
53
84
  {:else}
54
- <div class="flex h-screen bg-gradient-to-br from-background via-background to-muted/30">
55
- <Sidebar {collapsed} {identity} {title} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
56
- <main
57
- class="flex-1 overflow-y-auto p-8 transition-all duration-300"
58
- class:ml-64={!collapsed}
59
- class:ml-16={collapsed}
85
+ <div class="flex h-screen bg-gradient-to-br from-background via-background to-muted/30" in:fade={{ duration: 200, delay: 50 }}>
86
+ <!-- Desktop sidebar -->
87
+ <div class="hidden md:block">
88
+ <Sidebar {collapsed} {identity} {title} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
89
+ </div>
90
+
91
+ <!-- Mobile sidebar via Sheet -->
92
+ <Sheet.Root bind:open={mobileMenuOpen} side="left">
93
+ <div class="md:hidden">
94
+ <Sidebar collapsed={false} {identity} {title} onToggle={() => { mobileMenuOpen = false; }} onLogout={handleLogout} />
95
+ </div>
96
+ </Sheet.Root>
97
+
98
+ <div
99
+ class="flex-1 flex flex-col overflow-hidden transition-all duration-300"
100
+ class:md:ml-64={!collapsed}
101
+ class:md:ml-16={collapsed}
60
102
  >
61
- <Breadcrumbs />
62
- {@render children()}
63
- </main>
103
+ <!-- Header with mobile hamburger -->
104
+ <Header
105
+ showSearch={true}
106
+ onSearchClick={() => { commandOpen = true; }}
107
+ >
108
+ {#snippet children()}
109
+ <!-- Mobile hamburger -->
110
+ <TooltipButton tooltip={t('common.menu')} class="md:hidden" onclick={() => { mobileMenuOpen = true; }}>
111
+ <Menu class="h-5 w-5" />
112
+ </TooltipButton>
113
+ {/snippet}
114
+ </Header>
115
+
116
+ <!-- Content area -->
117
+ <main class="flex-1 overflow-y-auto p-6 md:p-8">
118
+ {#key currentPath()}
119
+ <div in:fly={{ x: 20, duration: 150 }} out:fade={{ duration: 80 }}>
120
+ {@render children()}
121
+ </div>
122
+ {/key}
123
+ </main>
124
+ </div>
64
125
  </div>
126
+ <CommandPalette bind:open={commandOpen} />
65
127
  <Toast />
66
128
  {/if}
129
+ </div>
@@ -3,6 +3,7 @@
3
3
  import { navigate } from '@svadmin/core/router';
4
4
  import { t } from '@svadmin/core/i18n';
5
5
  import type { Snippet } from 'svelte';
6
+ import type { FieldDefinition } from '@svadmin/core';
6
7
  import PageHeader from './PageHeader.svelte';
7
8
  import AutoTable from './AutoTable.svelte';
8
9
  import { Button } from './ui/button/index.js';
@@ -12,7 +13,18 @@
12
13
  resourceName: string;
13
14
  title?: string;
14
15
  canCreate?: boolean;
16
+ /** Passthrough: enable row selection checkboxes */
17
+ selectable?: boolean;
18
+ /** Passthrough: custom header actions */
15
19
  headerActions?: Snippet;
20
+ /** Passthrough: custom cell renderer per field */
21
+ cellRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; record: Record<string, unknown> }]>;
22
+ /** Passthrough: custom row actions */
23
+ rowActions?: Snippet<[{ record: Record<string, unknown>; id: string | number }]>;
24
+ /** Passthrough: custom empty state */
25
+ emptyState?: Snippet;
26
+ /** Passthrough: expandable row content */
27
+ expandedRowRender?: Snippet<[{ record: Record<string, unknown> }]>;
16
28
  class?: string;
17
29
  }
18
30
 
@@ -20,7 +32,12 @@
20
32
  resourceName,
21
33
  title,
22
34
  canCreate,
35
+ selectable,
23
36
  headerActions,
37
+ cellRenderer,
38
+ rowActions,
39
+ emptyState,
40
+ expandedRowRender,
24
41
  class: className = '',
25
42
  }: Props = $props();
26
43
 
@@ -43,5 +60,12 @@
43
60
  {/snippet}
44
61
  </PageHeader>
45
62
 
46
- <AutoTable {resourceName} />
63
+ <AutoTable
64
+ {resourceName}
65
+ {selectable}
66
+ {cellRenderer}
67
+ {rowActions}
68
+ {emptyState}
69
+ {expandedRowRender}
70
+ />
47
71
  </div>