@svadmin/ui 0.0.3 → 0.3.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.
- package/package.json +3 -2
- package/src/components/AdminApp.svelte +31 -21
- package/src/components/Authenticated.svelte +57 -0
- package/src/components/AutoForm.svelte +5 -5
- package/src/components/AutoSaveIndicator.svelte +30 -0
- package/src/components/AutoTable.svelte +81 -10
- package/src/components/Breadcrumbs.svelte +2 -0
- package/src/components/CatchAllNavigate.svelte +21 -0
- package/src/components/ConfigErrorScreen.svelte +224 -0
- package/src/components/ConfirmDialog.svelte +1 -1
- package/src/components/CreatePage.svelte +42 -0
- package/src/components/DevTools.svelte +83 -34
- package/src/components/EditPage.svelte +52 -0
- package/src/components/EmptyState.svelte +17 -31
- package/src/components/ErrorComponent.svelte +35 -0
- package/src/components/ForgotPasswordPage.svelte +12 -22
- package/src/components/Header.svelte +28 -59
- package/src/components/InferencerPanel.svelte +166 -0
- package/src/components/ListPage.svelte +47 -0
- package/src/components/LiveIndicator.svelte +115 -0
- package/src/components/LoginPage.svelte +13 -22
- package/src/components/NavigateToResource.svelte +20 -0
- package/src/components/PageSkeleton.svelte +70 -0
- package/src/components/RegisterPage.svelte +12 -23
- package/src/components/ShowPage.svelte +3 -3
- package/src/components/Sidebar.svelte +29 -10
- package/src/components/StatsCard.svelte +23 -2
- package/src/components/ThemedTitle.svelte +32 -0
- package/src/components/Toast.svelte +21 -11
- package/src/components/UpdatePasswordPage.svelte +217 -0
- package/src/components/buttons/CloneButton.svelte +30 -0
- package/src/components/buttons/CreateButton.svelte +29 -0
- package/src/components/buttons/DeleteButton.svelte +59 -0
- package/src/components/buttons/EditButton.svelte +30 -0
- package/src/components/buttons/ExportButton.svelte +24 -0
- package/src/components/buttons/ImportButton.svelte +50 -0
- package/src/components/buttons/ListButton.svelte +23 -0
- package/src/components/buttons/RefreshButton.svelte +31 -0
- package/src/components/buttons/SaveButton.svelte +27 -0
- package/src/components/buttons/ShowButton.svelte +24 -0
- package/src/components/buttons/index.ts +10 -0
- package/src/components/fields/BooleanField.svelte +13 -0
- package/src/components/fields/DateField.svelte +6 -0
- package/src/components/fields/EmailField.svelte +15 -0
- package/src/components/fields/FileField.svelte +19 -0
- package/src/components/fields/ImageField.svelte +14 -0
- package/src/components/fields/MarkdownField.svelte +19 -0
- package/src/components/fields/NumberField.svelte +6 -0
- package/src/components/fields/TagField.svelte +17 -0
- package/src/components/fields/TextField.svelte +16 -0
- package/src/components/fields/UrlField.svelte +20 -0
- package/src/components/ui/avatar/avatar.svelte +2 -1
- package/src/index.ts +32 -0
- package/src/router-state.svelte.ts +33 -7
|
@@ -1,66 +1,35 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import { t } from '@svadmin/core/i18n';
|
|
6
|
-
import { Button } from './ui/button/index.js';
|
|
7
|
-
import { Sun, Moon, Bell, Search } from 'lucide-svelte';
|
|
8
|
-
|
|
9
|
-
interface Props {
|
|
10
|
-
identity?: Identity | null;
|
|
11
|
-
title?: string;
|
|
12
|
-
showSearch?: boolean;
|
|
13
|
-
actions?: Snippet;
|
|
14
|
-
class?: string;
|
|
15
|
-
}
|
|
2
|
+
import { Moon, Sun } from 'lucide-svelte';
|
|
3
|
+
import { Button } from './ui/button';
|
|
4
|
+
import { getTheme, toggleTheme } from '@svadmin/core';
|
|
16
5
|
|
|
17
6
|
let {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
7
|
+
showThemeToggle = true,
|
|
8
|
+
children,
|
|
9
|
+
} = $props<{
|
|
10
|
+
showThemeToggle?: boolean;
|
|
11
|
+
children?: import('svelte').Snippet;
|
|
12
|
+
}>();
|
|
24
13
|
</script>
|
|
25
14
|
|
|
26
|
-
<header class="sticky top-0 z-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
{#if showSearch}
|
|
34
|
-
<div class="relative hidden md:block">
|
|
35
|
-
<Search class="absolute left-2.5 top-2 h-4 w-4 text-muted-foreground" />
|
|
36
|
-
<input
|
|
37
|
-
type="search"
|
|
38
|
-
placeholder={t('common.search')}
|
|
39
|
-
class="h-8 w-64 rounded-lg border border-input bg-background pl-8 pr-3 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
|
|
40
|
-
/>
|
|
41
|
-
</div>
|
|
42
|
-
{/if}
|
|
43
|
-
|
|
44
|
-
{#if actions}
|
|
45
|
-
{@render actions()}
|
|
46
|
-
{/if}
|
|
47
|
-
|
|
48
|
-
<!-- Theme toggle -->
|
|
49
|
-
<Button variant="ghost" size="icon-sm" onclick={toggleTheme} title={t('common.toggleTheme')}>
|
|
50
|
-
{#if getResolvedTheme() === 'dark'}
|
|
51
|
-
<Sun class="h-4 w-4" />
|
|
52
|
-
{:else}
|
|
53
|
-
<Moon class="h-4 w-4" />
|
|
15
|
+
<header class="sticky top-0 z-30 flex h-16 w-full shrink-0 items-center justify-between border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:px-6">
|
|
16
|
+
<div class="flex items-center gap-4">
|
|
17
|
+
<!-- Mobile sidebar toggle can go here -->
|
|
18
|
+
</div>
|
|
19
|
+
<div class="ml-auto flex items-center gap-4">
|
|
20
|
+
{#if children}
|
|
21
|
+
{@render children()}
|
|
54
22
|
{/if}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
23
|
+
|
|
24
|
+
{#if showThemeToggle}
|
|
25
|
+
<Button variant="ghost" size="icon" onclick={() => toggleTheme()} class="rounded-full">
|
|
26
|
+
{#if getTheme() === 'dark'}
|
|
27
|
+
<Moon class="h-5 w-5 transition-all" />
|
|
28
|
+
{:else}
|
|
29
|
+
<Sun class="h-5 w-5 transition-all" />
|
|
30
|
+
{/if}
|
|
31
|
+
<span class="sr-only">Toggle theme</span>
|
|
32
|
+
</Button>
|
|
33
|
+
{/if}
|
|
34
|
+
</div>
|
|
66
35
|
</header>
|
|
@@ -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>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getResource } from '@svadmin/core';
|
|
3
|
+
import { navigate } from '@svadmin/core/router';
|
|
4
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
|
+
import type { Snippet } from 'svelte';
|
|
6
|
+
import PageHeader from './PageHeader.svelte';
|
|
7
|
+
import AutoTable from './AutoTable.svelte';
|
|
8
|
+
import { Button } from './ui/button/index.js';
|
|
9
|
+
import { Plus } from 'lucide-svelte';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
resourceName: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
canCreate?: boolean;
|
|
15
|
+
headerActions?: Snippet;
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
resourceName,
|
|
21
|
+
title,
|
|
22
|
+
canCreate,
|
|
23
|
+
headerActions,
|
|
24
|
+
class: className = '',
|
|
25
|
+
}: Props = $props();
|
|
26
|
+
|
|
27
|
+
const resource = getResource(resourceName);
|
|
28
|
+
const pageTitle = title ?? resource.label;
|
|
29
|
+
const showCreate = canCreate ?? resource.canCreate !== false;
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<div class="space-y-6 {className}">
|
|
33
|
+
<PageHeader title={pageTitle}>
|
|
34
|
+
{#snippet actions()}
|
|
35
|
+
{#if showCreate}
|
|
36
|
+
<Button onclick={() => navigate(`/${resourceName}/create`)}>
|
|
37
|
+
<Plus class="h-4 w-4" data-icon="inline-start" /> {t('common.create')}
|
|
38
|
+
</Button>
|
|
39
|
+
{/if}
|
|
40
|
+
{#if headerActions}
|
|
41
|
+
{@render headerActions()}
|
|
42
|
+
{/if}
|
|
43
|
+
{/snippet}
|
|
44
|
+
</PageHeader>
|
|
45
|
+
|
|
46
|
+
<AutoTable {resourceName} />
|
|
47
|
+
</div>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Wifi, WifiOff, Loader2 } from 'lucide-svelte';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
status = 'disconnected',
|
|
6
|
+
resource = '',
|
|
7
|
+
lastEvent = null,
|
|
8
|
+
showDetails = false,
|
|
9
|
+
} = $props<{
|
|
10
|
+
status: 'connecting' | 'connected' | 'disconnected';
|
|
11
|
+
resource?: string;
|
|
12
|
+
lastEvent?: { type: string; timestamp?: number } | null;
|
|
13
|
+
showDetails?: boolean;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
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 },
|
|
20
|
+
} as const;
|
|
21
|
+
type StatusKey = 'connected' | 'connecting' | 'disconnected';
|
|
22
|
+
const statusConfig = $derived(statusConfigs[status as StatusKey]);
|
|
23
|
+
|
|
24
|
+
const timeSinceEvent = $derived(
|
|
25
|
+
lastEvent?.timestamp
|
|
26
|
+
? Math.floor((Date.now() - lastEvent.timestamp) / 1000)
|
|
27
|
+
: null
|
|
28
|
+
);
|
|
29
|
+
</script>
|
|
30
|
+
|
|
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>
|
|
50
|
+
{/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
|
+
|
|
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>
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import { useLogin, getAuthProvider } from '@svadmin/core';
|
|
3
3
|
import { t } from '@svadmin/core/i18n';
|
|
4
4
|
import { navigate } from '@svadmin/core/router';
|
|
5
|
-
import { toast } from '@svadmin/core/toast';
|
|
6
5
|
import { Button } from './ui/button/index.js';
|
|
7
6
|
import { Input } from './ui/input/index.js';
|
|
8
7
|
import * as Card from './ui/card/index.js';
|
|
9
8
|
import { LogIn, Mail, Lock, Eye, EyeOff } from 'lucide-svelte';
|
|
10
9
|
|
|
11
|
-
let {
|
|
12
|
-
authProvider: AuthProvider;
|
|
10
|
+
let { title = 'Admin', onSuccess } = $props<{
|
|
13
11
|
title?: string;
|
|
14
12
|
onSuccess?: () => void;
|
|
15
13
|
}>();
|
|
16
14
|
|
|
15
|
+
const login = useLogin();
|
|
16
|
+
const authProvider = getAuthProvider();
|
|
17
|
+
|
|
17
18
|
let email = $state('');
|
|
18
19
|
let password = $state('');
|
|
19
|
-
let loading = $state(false);
|
|
20
20
|
let showPassword = $state(false);
|
|
21
21
|
let error = $state('');
|
|
22
22
|
|
|
@@ -27,20 +27,11 @@
|
|
|
27
27
|
if (!email) { error = t('auth.emailRequired'); return; }
|
|
28
28
|
if (!password) { error = t('auth.passwordRequired'); return; }
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (result.redirectTo) navigate(result.redirectTo);
|
|
36
|
-
} else {
|
|
37
|
-
error = result.error?.message ?? t('common.loginFailed');
|
|
38
|
-
}
|
|
39
|
-
} catch (err) {
|
|
40
|
-
error = err instanceof Error ? err.message : t('common.loginFailed');
|
|
41
|
-
toast.error(error);
|
|
42
|
-
} finally {
|
|
43
|
-
loading = false;
|
|
30
|
+
const result = await login.mutate({ email, password });
|
|
31
|
+
if (result.success) {
|
|
32
|
+
onSuccess?.();
|
|
33
|
+
} else {
|
|
34
|
+
error = result.error?.message ?? t('common.loginFailed');
|
|
44
35
|
}
|
|
45
36
|
}
|
|
46
37
|
</script>
|
|
@@ -114,8 +105,8 @@
|
|
|
114
105
|
</div>
|
|
115
106
|
</div>
|
|
116
107
|
|
|
117
|
-
<Button type="submit" class="w-full" disabled={
|
|
118
|
-
{#if
|
|
108
|
+
<Button type="submit" class="w-full" disabled={login.isLoading}>
|
|
109
|
+
{#if login.isLoading}
|
|
119
110
|
<span class="loading-spinner"></span>
|
|
120
111
|
{/if}
|
|
121
112
|
{t('auth.loginButton')}
|
|
@@ -183,7 +174,7 @@
|
|
|
183
174
|
position: relative;
|
|
184
175
|
}
|
|
185
176
|
|
|
186
|
-
.input-icon {
|
|
177
|
+
:global(.input-icon) {
|
|
187
178
|
position: absolute;
|
|
188
179
|
left: 0.75rem;
|
|
189
180
|
top: 50%;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useMenu } from '@svadmin/core';
|
|
3
|
+
import { navigate } from '@svadmin/core/router';
|
|
4
|
+
|
|
5
|
+
const { menuItems } = useMenu();
|
|
6
|
+
|
|
7
|
+
$effect(() => {
|
|
8
|
+
// Automatically navigate to the first defined resource
|
|
9
|
+
if (menuItems.length > 0) {
|
|
10
|
+
navigate(menuItems[0].route);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<div class="flex h-screen w-screen items-center justify-center p-4 text-muted-foreground">
|
|
16
|
+
<div class="text-center">
|
|
17
|
+
<div class="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"></div>
|
|
18
|
+
<p>Navigating to default resource...</p>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Skeleton } from './ui/skeleton';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
type = 'list',
|
|
6
|
+
rows = 5,
|
|
7
|
+
} = $props<{
|
|
8
|
+
type?: 'list' | 'show' | 'form';
|
|
9
|
+
rows?: number;
|
|
10
|
+
}>();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<div class="w-full space-y-6">
|
|
14
|
+
<!-- Header Skeleton -->
|
|
15
|
+
<div class="flex items-center justify-between">
|
|
16
|
+
<div class="space-y-2">
|
|
17
|
+
<Skeleton class="h-8 w-[200px]" />
|
|
18
|
+
<Skeleton class="h-4 w-[300px]" />
|
|
19
|
+
</div>
|
|
20
|
+
{#if type === 'list'}
|
|
21
|
+
<Skeleton class="h-10 w-[120px]" />
|
|
22
|
+
{/if}
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="rounded-md border p-4 space-y-4">
|
|
26
|
+
{#if type === 'list'}
|
|
27
|
+
<!-- Table Header -->
|
|
28
|
+
<div class="flex gap-4 border-b pb-4">
|
|
29
|
+
{#each Array(4) as _}
|
|
30
|
+
<Skeleton class="h-6 flex-1" />
|
|
31
|
+
{/each}
|
|
32
|
+
</div>
|
|
33
|
+
<!-- Table Rows -->
|
|
34
|
+
<div class="space-y-4">
|
|
35
|
+
{#each Array(rows) as _}
|
|
36
|
+
<div class="flex gap-4">
|
|
37
|
+
<Skeleton class="h-6 flex-1" />
|
|
38
|
+
<Skeleton class="h-6 flex-1" />
|
|
39
|
+
<Skeleton class="h-6 flex-1" />
|
|
40
|
+
<Skeleton class="h-6 flex-1" />
|
|
41
|
+
</div>
|
|
42
|
+
{/each}
|
|
43
|
+
</div>
|
|
44
|
+
{:else if type === 'show'}
|
|
45
|
+
<!-- Key-Value pairs -->
|
|
46
|
+
<div class="space-y-6">
|
|
47
|
+
{#each Array(rows) as _}
|
|
48
|
+
<div class="space-y-2">
|
|
49
|
+
<Skeleton class="h-4 w-[100px]" />
|
|
50
|
+
<Skeleton class="h-6 w-[250px]" />
|
|
51
|
+
</div>
|
|
52
|
+
{/each}
|
|
53
|
+
</div>
|
|
54
|
+
{:else if type === 'form'}
|
|
55
|
+
<!-- Form Inputs -->
|
|
56
|
+
<div class="space-y-6">
|
|
57
|
+
{#each Array(rows) as _}
|
|
58
|
+
<div class="space-y-2">
|
|
59
|
+
<Skeleton class="h-4 w-[100px]" />
|
|
60
|
+
<Skeleton class="h-10 w-full max-w-md" />
|
|
61
|
+
</div>
|
|
62
|
+
{/each}
|
|
63
|
+
<div class="pt-4 flex gap-2">
|
|
64
|
+
<Skeleton class="h-10 w-[100px]" />
|
|
65
|
+
<Skeleton class="h-10 w-[100px]" />
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
{/if}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import { useRegister } from '@svadmin/core';
|
|
3
3
|
import { t } from '@svadmin/core/i18n';
|
|
4
4
|
import { navigate } from '@svadmin/core/router';
|
|
5
|
-
import { toast } from '@svadmin/core/toast';
|
|
6
5
|
import { Button } from './ui/button/index.js';
|
|
7
6
|
import { Input } from './ui/input/index.js';
|
|
8
7
|
import * as Card from './ui/card/index.js';
|
|
9
8
|
import { UserPlus, Mail, Lock, Eye, EyeOff } from 'lucide-svelte';
|
|
10
9
|
|
|
11
|
-
let {
|
|
12
|
-
authProvider: AuthProvider;
|
|
10
|
+
let { title = 'Admin', onSuccess } = $props<{
|
|
13
11
|
title?: string;
|
|
14
12
|
onSuccess?: () => void;
|
|
15
13
|
}>();
|
|
16
14
|
|
|
15
|
+
const register = useRegister();
|
|
16
|
+
|
|
17
17
|
let email = $state('');
|
|
18
18
|
let password = $state('');
|
|
19
19
|
let confirmPassword = $state('');
|
|
20
|
-
let loading = $state(false);
|
|
21
20
|
let showPassword = $state(false);
|
|
22
21
|
let error = $state('');
|
|
23
22
|
|
|
@@ -29,21 +28,11 @@
|
|
|
29
28
|
if (!password) { error = t('auth.passwordRequired'); return; }
|
|
30
29
|
if (password !== confirmPassword) { error = t('auth.passwordMismatch'); return; }
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
onSuccess?.();
|
|
38
|
-
navigate(result.redirectTo ?? '/login');
|
|
39
|
-
} else {
|
|
40
|
-
error = result.error?.message ?? t('common.operationFailed');
|
|
41
|
-
}
|
|
42
|
-
} catch (err) {
|
|
43
|
-
error = err instanceof Error ? err.message : t('common.operationFailed');
|
|
44
|
-
toast.error(error);
|
|
45
|
-
} finally {
|
|
46
|
-
loading = false;
|
|
31
|
+
const result = await register.mutate({ email, password });
|
|
32
|
+
if (result.success) {
|
|
33
|
+
onSuccess?.();
|
|
34
|
+
} else {
|
|
35
|
+
error = result.error?.message ?? t('common.operationFailed');
|
|
47
36
|
}
|
|
48
37
|
}
|
|
49
38
|
</script>
|
|
@@ -123,8 +112,8 @@
|
|
|
123
112
|
</div>
|
|
124
113
|
</div>
|
|
125
114
|
|
|
126
|
-
<Button type="submit" class="w-full" disabled={
|
|
127
|
-
{#if
|
|
115
|
+
<Button type="submit" class="w-full" disabled={register.isLoading}>
|
|
116
|
+
{#if register.isLoading}
|
|
128
117
|
<span class="loading-spinner"></span>
|
|
129
118
|
{/if}
|
|
130
119
|
{t('auth.registerButton')}
|
|
@@ -190,7 +179,7 @@
|
|
|
190
179
|
position: relative;
|
|
191
180
|
}
|
|
192
181
|
|
|
193
|
-
.input-icon {
|
|
182
|
+
:global(.input-icon) {
|
|
194
183
|
position: absolute;
|
|
195
184
|
left: 0.75rem;
|
|
196
185
|
top: 50%;
|
|
@@ -43,15 +43,15 @@
|
|
|
43
43
|
{/if}
|
|
44
44
|
</div>
|
|
45
45
|
|
|
46
|
-
{#if query.isLoading}
|
|
46
|
+
{#if query.query.isLoading}
|
|
47
47
|
<div class="flex h-64 items-center justify-center">
|
|
48
48
|
<Loader2 class="h-6 w-6 animate-spin text-primary" />
|
|
49
49
|
</div>
|
|
50
|
-
{:else if query.data}
|
|
50
|
+
{:else if query.query.data?.data}
|
|
51
51
|
<Card.Root>
|
|
52
52
|
<Card.Content class="divide-y divide-border p-0">
|
|
53
53
|
{#each showFields as field}
|
|
54
|
-
{@const value = (query.data as Record<string, unknown>)[field.key]}
|
|
54
|
+
{@const value = (query.query.data!.data as Record<string, unknown>)[field.key]}
|
|
55
55
|
<div class="flex px-6 py-4">
|
|
56
56
|
<div class="w-1/3 text-sm font-medium text-muted-foreground">{field.label}</div>
|
|
57
57
|
<div class="w-2/3 text-sm text-foreground">
|