@svadmin/ui 0.0.2 → 0.0.6

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 (29) hide show
  1. package/package.json +2 -2
  2. package/src/components/AdminApp.svelte +35 -15
  3. package/src/components/Authenticated.svelte +26 -0
  4. package/src/components/AutoForm.svelte +57 -5
  5. package/src/components/CanAccess.svelte +21 -0
  6. package/src/components/ConfigErrorScreen.svelte +224 -0
  7. package/src/components/DevTools.svelte +280 -0
  8. package/src/components/DrawerForm.svelte +26 -0
  9. package/src/components/ForgotPasswordPage.svelte +203 -0
  10. package/src/components/InferencerPanel.svelte +166 -0
  11. package/src/components/LoginPage.svelte +236 -0
  12. package/src/components/ModalForm.svelte +34 -0
  13. package/src/components/RegisterPage.svelte +241 -0
  14. package/src/components/StatsCard.svelte +23 -2
  15. package/src/components/UndoableNotification.svelte +132 -0
  16. package/src/components/UpdatePasswordPage.svelte +217 -0
  17. package/src/components/buttons/CloneButton.svelte +30 -0
  18. package/src/components/buttons/CreateButton.svelte +29 -0
  19. package/src/components/buttons/DeleteButton.svelte +60 -0
  20. package/src/components/buttons/EditButton.svelte +30 -0
  21. package/src/components/buttons/ExportButton.svelte +24 -0
  22. package/src/components/buttons/ImportButton.svelte +28 -0
  23. package/src/components/buttons/ListButton.svelte +23 -0
  24. package/src/components/buttons/RefreshButton.svelte +30 -0
  25. package/src/components/buttons/SaveButton.svelte +27 -0
  26. package/src/components/buttons/ShowButton.svelte +24 -0
  27. package/src/components/buttons/index.ts +10 -0
  28. package/src/index.ts +18 -0
  29. package/src/router-state.svelte.ts +26 -5
@@ -0,0 +1,280 @@
1
+ <script lang="ts">
2
+ import { getResources } from '@svadmin/core';
3
+ import { getTheme, getColorTheme } from '@svadmin/core';
4
+ import { getLocale } from '@svadmin/core/i18n';
5
+ import { currentPath } from '@svadmin/core/router';
6
+ import { Button } from './ui/button/index.js';
7
+ import { X, Bug, ChevronDown, ChevronUp, Wand2 } from 'lucide-svelte';
8
+ import InferencerPanel from './InferencerPanel.svelte';
9
+
10
+ let visible = $state(false);
11
+ let collapsed = $state(false);
12
+ let activeTab = $state<'state' | 'inferencer'>('state');
13
+
14
+ function toggle() {
15
+ visible = !visible;
16
+ }
17
+
18
+ // Keyboard shortcut: Ctrl+Shift+D
19
+ if (typeof window !== 'undefined') {
20
+ window.addEventListener('keydown', (e) => {
21
+ if (e.ctrlKey && e.shiftKey && e.key === 'D') {
22
+ e.preventDefault();
23
+ toggle();
24
+ }
25
+ });
26
+ }
27
+
28
+ const resources = $derived((() => { try { return getResources(); } catch { return []; } })());
29
+ const path = $derived(currentPath());
30
+ const theme = $derived(getTheme());
31
+ const colorTheme = $derived(getColorTheme());
32
+ const locale = $derived(getLocale());
33
+ </script>
34
+
35
+ {#if import.meta.env.DEV}
36
+ {#if visible}
37
+ <div class="devtools-panel" class:collapsed>
38
+ <div class="devtools-header">
39
+ <div class="devtools-title">
40
+ <Bug class="h-4 w-4" />
41
+ <span>svadmin DevTools</span>
42
+ </div>
43
+ <div class="devtools-header-actions">
44
+ <button onclick={() => collapsed = !collapsed} class="devtools-btn">
45
+ {#if collapsed}
46
+ <ChevronUp class="h-3.5 w-3.5" />
47
+ {:else}
48
+ <ChevronDown class="h-3.5 w-3.5" />
49
+ {/if}
50
+ </button>
51
+ <button onclick={toggle} class="devtools-btn">
52
+ <X class="h-3.5 w-3.5" />
53
+ </button>
54
+ </div>
55
+ </div>
56
+
57
+ {#if !collapsed}
58
+ <div class="devtools-tabs">
59
+ <button class="devtools-tab" class:active={activeTab === 'state'} onclick={() => activeTab = 'state'}>
60
+ State
61
+ </button>
62
+ <button class="devtools-tab" class:active={activeTab === 'inferencer'} onclick={() => activeTab = 'inferencer'}>
63
+ <Wand2 class="h-3 w-3" /> Inferencer
64
+ </button>
65
+ </div>
66
+
67
+ <div class="devtools-body">
68
+ {#if activeTab === 'state'}
69
+ <div class="devtools-section">
70
+ <h4>Router</h4>
71
+ <div class="devtools-row">
72
+ <span class="devtools-label">Path</span>
73
+ <code class="devtools-value">{path}</code>
74
+ </div>
75
+ </div>
76
+
77
+ <div class="devtools-section">
78
+ <h4>Theme</h4>
79
+ <div class="devtools-row">
80
+ <span class="devtools-label">Mode</span>
81
+ <code class="devtools-value">{theme}</code>
82
+ </div>
83
+ <div class="devtools-row">
84
+ <span class="devtools-label">Color</span>
85
+ <code class="devtools-value">{colorTheme}</code>
86
+ </div>
87
+ </div>
88
+
89
+ <div class="devtools-section">
90
+ <h4>i18n</h4>
91
+ <div class="devtools-row">
92
+ <span class="devtools-label">Locale</span>
93
+ <code class="devtools-value">{locale}</code>
94
+ </div>
95
+ </div>
96
+
97
+ <div class="devtools-section">
98
+ <h4>Resources ({resources.length})</h4>
99
+ {#each resources as r}
100
+ <div class="devtools-row">
101
+ <span class="devtools-label">{r.name}</span>
102
+ <code class="devtools-value">{r.fields.length} fields</code>
103
+ </div>
104
+ {/each}
105
+ </div>
106
+ {:else}
107
+ <InferencerPanel />
108
+ {/if}
109
+ </div>
110
+ {/if}
111
+ </div>
112
+ {:else}
113
+ <button class="devtools-fab" onclick={toggle} title="DevTools (Ctrl+Shift+D)">
114
+ <Bug class="h-4 w-4" />
115
+ </button>
116
+ {/if}
117
+ {/if}
118
+
119
+ <style>
120
+ .devtools-panel {
121
+ position: fixed;
122
+ bottom: 0;
123
+ right: 1rem;
124
+ z-index: 9999;
125
+ width: 420px;
126
+ max-width: 95vw;
127
+ background: hsl(var(--card));
128
+ border: 1px solid hsl(var(--border));
129
+ border-bottom: none;
130
+ border-radius: 0.75rem 0.75rem 0 0;
131
+ box-shadow: 0 -4px 24px hsl(0 0% 0% / 0.12);
132
+ font-size: 0.8125rem;
133
+ overflow: hidden;
134
+ }
135
+
136
+ .devtools-panel.collapsed {
137
+ width: auto;
138
+ min-width: 200px;
139
+ }
140
+
141
+ .devtools-header {
142
+ display: flex;
143
+ align-items: center;
144
+ justify-content: space-between;
145
+ padding: 0.5rem 0.75rem;
146
+ background: hsl(var(--muted));
147
+ border-bottom: 1px solid hsl(var(--border));
148
+ }
149
+
150
+ .devtools-title {
151
+ display: flex;
152
+ align-items: center;
153
+ gap: 0.375rem;
154
+ font-weight: 600;
155
+ font-size: 0.75rem;
156
+ text-transform: uppercase;
157
+ letter-spacing: 0.05em;
158
+ color: hsl(var(--foreground));
159
+ }
160
+
161
+ .devtools-header-actions {
162
+ display: flex;
163
+ gap: 0.25rem;
164
+ }
165
+
166
+ .devtools-btn {
167
+ background: none;
168
+ border: none;
169
+ cursor: pointer;
170
+ padding: 0.25rem;
171
+ color: hsl(var(--muted-foreground));
172
+ border-radius: 0.25rem;
173
+ }
174
+ .devtools-btn:hover {
175
+ background: hsl(var(--accent));
176
+ color: hsl(var(--foreground));
177
+ }
178
+
179
+ .devtools-body {
180
+ max-height: 400px;
181
+ overflow-y: auto;
182
+ padding: 0.5rem;
183
+ }
184
+
185
+ .devtools-section {
186
+ padding: 0.375rem 0;
187
+ }
188
+
189
+ .devtools-section h4 {
190
+ font-size: 0.6875rem;
191
+ font-weight: 700;
192
+ text-transform: uppercase;
193
+ letter-spacing: 0.08em;
194
+ color: hsl(var(--muted-foreground));
195
+ margin: 0 0 0.25rem;
196
+ padding: 0 0.25rem;
197
+ }
198
+
199
+ .devtools-row {
200
+ display: flex;
201
+ align-items: center;
202
+ justify-content: space-between;
203
+ padding: 0.1875rem 0.25rem;
204
+ border-radius: 0.25rem;
205
+ }
206
+ .devtools-row:hover {
207
+ background: hsl(var(--muted) / 0.5);
208
+ }
209
+
210
+ .devtools-label {
211
+ color: hsl(var(--foreground));
212
+ font-size: 0.75rem;
213
+ }
214
+
215
+ .devtools-value {
216
+ font-size: 0.6875rem;
217
+ color: hsl(var(--primary));
218
+ background: hsl(var(--primary) / 0.1);
219
+ padding: 0.125rem 0.375rem;
220
+ border-radius: 0.25rem;
221
+ font-family: monospace;
222
+ }
223
+
224
+ .devtools-fab {
225
+ position: fixed;
226
+ bottom: 1rem;
227
+ right: 1rem;
228
+ z-index: 9999;
229
+ width: 36px;
230
+ height: 36px;
231
+ border-radius: 50%;
232
+ background: hsl(var(--primary));
233
+ color: hsl(var(--primary-foreground));
234
+ border: none;
235
+ cursor: pointer;
236
+ display: flex;
237
+ align-items: center;
238
+ justify-content: center;
239
+ box-shadow: 0 2px 8px hsl(var(--primary) / 0.3);
240
+ transition: all 0.2s;
241
+ opacity: 0.6;
242
+ }
243
+ .devtools-fab:hover {
244
+ opacity: 1;
245
+ transform: scale(1.1);
246
+ }
247
+ .devtools-tabs {
248
+ display: flex;
249
+ border-bottom: 1px solid hsl(var(--border));
250
+ }
251
+
252
+ .devtools-tab {
253
+ flex: 1;
254
+ display: flex;
255
+ align-items: center;
256
+ justify-content: center;
257
+ gap: 0.25rem;
258
+ padding: 0.375rem 0.5rem;
259
+ font-size: 0.6875rem;
260
+ font-weight: 600;
261
+ text-transform: uppercase;
262
+ letter-spacing: 0.05em;
263
+ background: none;
264
+ border: none;
265
+ cursor: pointer;
266
+ color: hsl(var(--muted-foreground));
267
+ border-bottom: 2px solid transparent;
268
+ transition: all 0.15s;
269
+ }
270
+
271
+ .devtools-tab:hover {
272
+ color: hsl(var(--foreground));
273
+ background: hsl(var(--muted) / 0.3);
274
+ }
275
+
276
+ .devtools-tab.active {
277
+ color: hsl(var(--primary));
278
+ border-bottom-color: hsl(var(--primary));
279
+ }
280
+ </style>
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import { getResource } from '@svadmin/core';
3
+ import { Sheet } from './ui/sheet/index.js';
4
+ import AutoForm from './AutoForm.svelte';
5
+
6
+ let { resourceName, mode = 'create', id, open = $bindable(false), side = 'right' } = $props<{
7
+ resourceName: string;
8
+ mode?: 'create' | 'edit';
9
+ id?: string | number;
10
+ open: boolean;
11
+ side?: 'left' | 'right';
12
+ }>();
13
+
14
+ const resource = $derived(getResource(resourceName));
15
+ </script>
16
+
17
+ {#if open}
18
+ <Sheet bind:open {side}>
19
+ <div class="p-6 space-y-4">
20
+ <h2 class="text-lg font-semibold text-foreground">
21
+ {mode === 'create' ? `Create ${resource.label}` : `Edit ${resource.label}`}
22
+ </h2>
23
+ <AutoForm {resourceName} {mode} {id} />
24
+ </div>
25
+ </Sheet>
26
+ {/if}
@@ -0,0 +1,203 @@
1
+ <script lang="ts">
2
+ import { useForgotPassword } from '@svadmin/core';
3
+ import { t } from '@svadmin/core/i18n';
4
+ import { navigate } from '@svadmin/core/router';
5
+ import { Button } from './ui/button/index.js';
6
+ import { Input } from './ui/input/index.js';
7
+ import * as Card from './ui/card/index.js';
8
+ import { KeyRound, Mail, ArrowLeft, CheckCircle } from 'lucide-svelte';
9
+
10
+ let { title = 'Admin' } = $props<{
11
+ title?: string;
12
+ }>();
13
+
14
+ const forgot = useForgotPassword();
15
+
16
+ let email = $state('');
17
+ let error = $state('');
18
+ let sent = $state(false);
19
+
20
+ async function handleSubmit(e: SubmitEvent) {
21
+ e.preventDefault();
22
+ error = '';
23
+
24
+ if (!email) { error = t('auth.emailRequired'); return; }
25
+
26
+ const result = await forgot.mutate({ email });
27
+ if (result.success) {
28
+ sent = true;
29
+ } else {
30
+ error = result.error?.message ?? t('common.operationFailed');
31
+ }
32
+ }
33
+ </script>
34
+
35
+ <div class="forgot-page">
36
+ <div class="forgot-container">
37
+ <Card.Card class="login-card">
38
+ <Card.CardHeader class="login-header">
39
+ <div class="forgot-icon">
40
+ {#if sent}
41
+ <CheckCircle class="h-6 w-6" />
42
+ {:else}
43
+ <KeyRound class="h-6 w-6" />
44
+ {/if}
45
+ </div>
46
+ <Card.CardTitle class="text-2xl font-bold">
47
+ {sent ? t('auth.resetLinkSent') : t('auth.forgotPassword')}
48
+ </Card.CardTitle>
49
+ {#if !sent}
50
+ <p class="text-sm text-muted-foreground">{t('auth.forgotPasswordDescription')}</p>
51
+ {/if}
52
+ </Card.CardHeader>
53
+ <Card.CardContent>
54
+ {#if sent}
55
+ <div class="success-state">
56
+ <p class="text-sm text-muted-foreground text-center mb-4">
57
+ {t('auth.resetLinkSent')}
58
+ </p>
59
+ <Button variant="outline" class="w-full" onclick={() => navigate('/login')}>
60
+ <ArrowLeft class="h-4 w-4 mr-2" />
61
+ {t('auth.backToLogin')}
62
+ </Button>
63
+ </div>
64
+ {:else}
65
+ <form onsubmit={handleSubmit} class="space-y-4">
66
+ {#if error}
67
+ <div class="error-alert">
68
+ <p>{error}</p>
69
+ </div>
70
+ {/if}
71
+
72
+ <div class="space-y-2">
73
+ <label for="forgot-email" class="text-sm font-medium text-foreground">{t('auth.email')}</label>
74
+ <div class="input-with-icon">
75
+ <Mail class="input-icon h-4 w-4" />
76
+ <Input
77
+ id="forgot-email"
78
+ type="email"
79
+ placeholder="name@example.com"
80
+ bind:value={email}
81
+ class="pl-9"
82
+ autocomplete="email"
83
+ />
84
+ </div>
85
+ </div>
86
+
87
+ <Button type="submit" class="w-full" disabled={forgot.isLoading}>
88
+ {#if forgot.isLoading}
89
+ <span class="loading-spinner"></span>
90
+ {/if}
91
+ {t('auth.sendResetLink')}
92
+ </Button>
93
+ </form>
94
+
95
+ <div class="auth-link">
96
+ <button
97
+ type="button"
98
+ class="text-sm text-primary hover:underline font-medium inline-flex items-center gap-1"
99
+ onclick={() => navigate('/login')}
100
+ >
101
+ <ArrowLeft class="h-3 w-3" />
102
+ {t('auth.backToLogin')}
103
+ </button>
104
+ </div>
105
+ {/if}
106
+ </Card.CardContent>
107
+ </Card.Card>
108
+
109
+ <p class="text-xs text-muted-foreground mt-4 text-center opacity-60">
110
+ Powered by {title}
111
+ </p>
112
+ </div>
113
+ </div>
114
+
115
+ <style>
116
+ .forgot-page {
117
+ min-height: 100vh;
118
+ display: flex;
119
+ align-items: center;
120
+ justify-content: center;
121
+ background: linear-gradient(135deg, hsl(var(--primary) / 0.08) 0%, hsl(var(--background)) 50%, hsl(var(--primary) / 0.04) 100%);
122
+ padding: 1rem;
123
+ }
124
+
125
+ .forgot-container {
126
+ width: 100%;
127
+ max-width: 420px;
128
+ }
129
+
130
+ .forgot-icon {
131
+ display: inline-flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ width: 48px;
135
+ height: 48px;
136
+ border-radius: 12px;
137
+ background: hsl(var(--primary) / 0.1);
138
+ color: hsl(var(--primary));
139
+ margin: 0 auto 0.75rem;
140
+ }
141
+
142
+ :global(.login-card) {
143
+ backdrop-filter: blur(20px);
144
+ border: 1px solid hsl(var(--border) / 0.5);
145
+ box-shadow: 0 8px 32px hsl(var(--primary) / 0.08), 0 2px 8px hsl(0 0% 0% / 0.06);
146
+ }
147
+
148
+ :global(.login-header) {
149
+ text-align: center;
150
+ padding-bottom: 0.5rem;
151
+ }
152
+
153
+ .input-with-icon {
154
+ position: relative;
155
+ }
156
+
157
+ :global(.input-icon) {
158
+ position: absolute;
159
+ left: 0.75rem;
160
+ top: 50%;
161
+ transform: translateY(-50%);
162
+ color: hsl(var(--muted-foreground));
163
+ pointer-events: none;
164
+ z-index: 1;
165
+ }
166
+
167
+ .error-alert {
168
+ padding: 0.75rem;
169
+ border-radius: 0.5rem;
170
+ background: hsl(var(--destructive) / 0.1);
171
+ border: 1px solid hsl(var(--destructive) / 0.3);
172
+ color: hsl(var(--destructive));
173
+ font-size: 0.875rem;
174
+ }
175
+
176
+ .success-state {
177
+ padding: 0.5rem 0;
178
+ }
179
+
180
+ .loading-spinner {
181
+ display: inline-block;
182
+ width: 16px;
183
+ height: 16px;
184
+ border: 2px solid transparent;
185
+ border-top-color: currentColor;
186
+ border-radius: 50%;
187
+ animation: spin 0.6s linear infinite;
188
+ margin-right: 0.5rem;
189
+ }
190
+
191
+ @keyframes spin {
192
+ to { transform: rotate(360deg); }
193
+ }
194
+
195
+ .auth-link {
196
+ display: flex;
197
+ align-items: center;
198
+ justify-content: center;
199
+ margin-top: 1.25rem;
200
+ padding-top: 1.25rem;
201
+ border-top: 1px solid hsl(var(--border));
202
+ }
203
+ </style>
@@ -0,0 +1,166 @@
1
+ <script lang="ts">
2
+ import { getDataProvider, getResources, inferResource } from '@svadmin/core';
3
+ import type { InferResult, ResourceDefinition } from '@svadmin/core';
4
+ import { Button } from './ui/button/index.js';
5
+ import { Wand2, Copy, Check, RefreshCw, Loader2 } from 'lucide-svelte';
6
+
7
+ const dataProvider = getDataProvider();
8
+ const resources = getResources();
9
+
10
+ let selectedResource = $state('');
11
+ let inferResult = $state<InferResult | null>(null);
12
+ let loading = $state(false);
13
+ let error = $state<string | null>(null);
14
+ let copied = $state(false);
15
+ let customEndpoint = $state('');
16
+
17
+ async function runInference() {
18
+ const resourceName = customEndpoint.trim() || selectedResource;
19
+ if (!resourceName) return;
20
+
21
+ loading = true;
22
+ error = null;
23
+ inferResult = null;
24
+
25
+ try {
26
+ const response = await dataProvider.getList({
27
+ resource: resourceName,
28
+ pagination: { current: 1, pageSize: 25 },
29
+ });
30
+
31
+ if (!response.data || response.data.length === 0) {
32
+ error = `No data returned from "${resourceName}". The API must return at least one record to infer fields.`;
33
+ return;
34
+ }
35
+
36
+ inferResult = inferResource(
37
+ resourceName,
38
+ response.data as Record<string, unknown>[],
39
+ );
40
+ } catch (e: any) {
41
+ error = e?.message ?? 'Failed to fetch data for inference.';
42
+ } finally {
43
+ loading = false;
44
+ }
45
+ }
46
+
47
+ function copyCode() {
48
+ if (!inferResult) return;
49
+ navigator.clipboard.writeText(inferResult.code);
50
+ copied = true;
51
+ setTimeout(() => { copied = false; }, 2000);
52
+ }
53
+
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',
70
+ };
71
+ </script>
72
+
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
106
+ {/if}
107
+ </Button>
108
+
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>
144
+
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>
158
+ </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
+
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>