@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.
- package/package.json +9 -4
- package/src/actions.ts +110 -0
- package/src/components/AdminApp.svelte +7 -4
- package/src/components/Authenticated.svelte +1 -7
- package/src/components/AutoForm.svelte +158 -69
- package/src/components/AutoSaveIndicator.svelte +19 -14
- package/src/components/AutoTable.svelte +465 -243
- package/src/components/Breadcrumbs.svelte +20 -17
- package/src/components/CanAccess.svelte +6 -4
- package/src/components/ComboboxField.svelte +122 -0
- package/src/components/CommandPalette.svelte +93 -0
- package/src/components/ConfigErrorScreen.svelte +53 -151
- package/src/components/ConfirmDialog.svelte +25 -17
- package/src/components/CreatePage.svelte +3 -2
- package/src/components/DevTools.svelte +99 -230
- package/src/components/DrawerForm.svelte +19 -10
- package/src/components/EditPage.svelte +25 -6
- package/src/components/EmptyState.svelte +5 -1
- package/src/components/ErrorBoundary.svelte +38 -40
- package/src/components/ErrorComponent.svelte +18 -17
- package/src/components/FieldRenderer.svelte +240 -75
- package/src/components/ForgotPasswordPage.svelte +20 -111
- package/src/components/Header.svelte +28 -8
- package/src/components/InferencerPanel.svelte +136 -110
- package/src/components/InfiniteList.svelte +93 -0
- package/src/components/Layout.svelte +85 -22
- package/src/components/ListPage.svelte +25 -1
- package/src/components/LiveIndicator.svelte +30 -87
- package/src/components/LoginPage.svelte +81 -155
- package/src/components/ModalForm.svelte +6 -2
- package/src/components/PageSkeleton.svelte +2 -1
- package/src/components/PasswordInput.svelte +95 -0
- package/src/components/ProfilePage.svelte +166 -0
- package/src/components/RegisterPage.svelte +35 -164
- package/src/components/ShowPage.svelte +32 -33
- package/src/components/Sidebar.svelte +185 -73
- package/src/components/StatsCard.svelte +12 -3
- package/src/components/StepsForm.svelte +139 -0
- package/src/components/Toast.svelte +15 -54
- package/src/components/TooltipButton.svelte +52 -0
- package/src/components/UndoableNotification.svelte +28 -96
- package/src/components/UpdatePasswordPage.svelte +32 -160
- package/src/components/buttons/RefreshButton.svelte +7 -2
- package/src/components/fieldComponentMap.ts +79 -0
- package/src/components/fields/BooleanField.svelte +9 -9
- package/src/components/fields/EmailField.svelte +2 -8
- package/src/components/fields/FileField.svelte +4 -9
- package/src/components/fields/ImageField.svelte +2 -7
- package/src/components/fields/JsonField.svelte +72 -0
- package/src/components/fields/MarkdownField.svelte +2 -12
- package/src/components/fields/MultiSelectField.svelte +20 -0
- package/src/components/fields/RelationField.svelte +24 -0
- package/src/components/fields/RichTextField.svelte +35 -0
- package/src/components/fields/SelectField.svelte +14 -0
- package/src/components/fields/TagField.svelte +6 -11
- package/src/components/fields/TextField.svelte +2 -7
- package/src/components/fields/UrlField.svelte +4 -10
- package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
- package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
- package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
- package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
- package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
- package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
- package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
- package/src/components/ui/alert-dialog/index.ts +20 -0
- package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
- package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
- package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
- package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
- package/src/components/ui/breadcrumb/index.ts +7 -0
- package/src/components/ui/collapsible/index.ts +7 -0
- package/src/components/ui/command/command-dialog.svelte +31 -0
- package/src/components/ui/command/index.ts +2 -0
- package/src/components/ui/context-menu/index.ts +11 -0
- package/src/components/ui/drawer/drawer-content.svelte +21 -0
- package/src/components/ui/drawer/drawer-description.svelte +11 -0
- package/src/components/ui/drawer/drawer-footer.svelte +11 -0
- package/src/components/ui/drawer/drawer-header.svelte +11 -0
- package/src/components/ui/drawer/drawer-title.svelte +11 -0
- package/src/components/ui/drawer/drawer.svelte +18 -0
- package/src/components/ui/drawer/index.ts +6 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
- package/src/components/ui/dropdown-menu/index.ts +6 -0
- package/src/components/ui/label/index.ts +1 -0
- package/src/components/ui/label/label.svelte +18 -0
- package/src/components/ui/pagination/index.ts +7 -0
- package/src/components/ui/pagination/pagination-content.svelte +11 -0
- package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
- package/src/components/ui/pagination/pagination-item.svelte +11 -0
- package/src/components/ui/pagination/pagination-link.svelte +26 -0
- package/src/components/ui/pagination/pagination-next.svelte +22 -0
- package/src/components/ui/pagination/pagination-previous.svelte +22 -0
- package/src/components/ui/pagination/pagination.svelte +11 -0
- package/src/components/ui/popover/index.ts +12 -0
- package/src/components/ui/popover/popover-content.svelte +25 -0
- package/src/components/ui/progress/index.ts +1 -0
- package/src/components/ui/progress/progress.svelte +22 -0
- package/src/components/ui/scroll-area/index.ts +1 -0
- package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
- package/src/components/ui/sheet/index.ts +15 -1
- package/src/components/ui/sheet/sheet-content.svelte +28 -0
- package/src/components/ui/sheet/sheet-description.svelte +22 -0
- package/src/components/ui/sheet/sheet-footer.svelte +22 -0
- package/src/components/ui/sheet/sheet-header.svelte +22 -0
- package/src/components/ui/sheet/sheet-title.svelte +22 -0
- package/src/index.ts +31 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { Button } from './ui/button/index.js';
|
|
3
|
+
import { Progress } from './ui/progress/index.js';
|
|
4
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
|
+
import { slide } from 'svelte/transition';
|
|
3
6
|
import { X, Undo2 } from 'lucide-svelte';
|
|
4
7
|
|
|
5
8
|
let { message, duration = 5000, onUndo, onTimeout } = $props<{
|
|
@@ -12,121 +15,50 @@
|
|
|
12
15
|
let remaining = $state(duration);
|
|
13
16
|
let dismissed = $state(false);
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
dismissed
|
|
21
|
-
|
|
18
|
+
$effect(() => {
|
|
19
|
+
const interval = setInterval(() => {
|
|
20
|
+
remaining -= 100;
|
|
21
|
+
if (remaining <= 0) {
|
|
22
|
+
clearInterval(interval);
|
|
23
|
+
if (!dismissed) {
|
|
24
|
+
dismissed = true;
|
|
25
|
+
onTimeout();
|
|
26
|
+
}
|
|
22
27
|
}
|
|
23
|
-
}
|
|
24
|
-
|
|
28
|
+
}, 100);
|
|
29
|
+
return () => clearInterval(interval);
|
|
30
|
+
});
|
|
25
31
|
|
|
26
32
|
function handleUndo() {
|
|
27
|
-
clearInterval(interval);
|
|
28
33
|
dismissed = true;
|
|
29
34
|
onUndo();
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
function handleDismiss() {
|
|
33
|
-
clearInterval(interval);
|
|
34
38
|
dismissed = true;
|
|
35
39
|
onTimeout();
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
const
|
|
42
|
+
const progressValue = $derived(remaining / duration * 100);
|
|
39
43
|
</script>
|
|
40
44
|
|
|
41
45
|
{#if !dismissed}
|
|
42
|
-
<div
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
<div
|
|
47
|
+
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] min-w-80 max-w-lg rounded-xl border bg-card shadow-2xl overflow-hidden"
|
|
48
|
+
transition:slide={{ duration: 250 }}
|
|
49
|
+
>
|
|
50
|
+
<div class="flex items-center justify-between px-4 py-3 gap-3">
|
|
51
|
+
<p class="text-sm text-foreground">{message}</p>
|
|
52
|
+
<div class="flex items-center gap-1 flex-shrink-0">
|
|
53
|
+
<Button variant="ghost" size="sm" onclick={handleUndo} class="font-semibold text-primary">
|
|
47
54
|
<Undo2 class="h-3.5 w-3.5 mr-1" />
|
|
48
|
-
|
|
55
|
+
{t('common.undo')}
|
|
49
56
|
</Button>
|
|
50
|
-
<
|
|
57
|
+
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={handleDismiss}>
|
|
51
58
|
<X class="h-3.5 w-3.5" />
|
|
52
|
-
</
|
|
59
|
+
</Button>
|
|
53
60
|
</div>
|
|
54
61
|
</div>
|
|
55
|
-
<
|
|
56
|
-
<div class="undoable-progress-bar" style="width: {progress}%"></div>
|
|
57
|
-
</div>
|
|
62
|
+
<Progress value={progressValue} class="h-1 rounded-none" />
|
|
58
63
|
</div>
|
|
59
64
|
{/if}
|
|
60
|
-
|
|
61
|
-
<style>
|
|
62
|
-
.undoable-notification {
|
|
63
|
-
position: fixed;
|
|
64
|
-
bottom: 1.5rem;
|
|
65
|
-
left: 50%;
|
|
66
|
-
transform: translateX(-50%);
|
|
67
|
-
z-index: 100;
|
|
68
|
-
min-width: 320px;
|
|
69
|
-
max-width: 480px;
|
|
70
|
-
background: hsl(var(--card));
|
|
71
|
-
border: 1px solid hsl(var(--border));
|
|
72
|
-
border-radius: 0.75rem;
|
|
73
|
-
box-shadow: 0 8px 32px hsl(0 0% 0% / 0.15);
|
|
74
|
-
overflow: hidden;
|
|
75
|
-
animation: slideUp 0.3s ease;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
.undoable-content {
|
|
79
|
-
display: flex;
|
|
80
|
-
align-items: center;
|
|
81
|
-
justify-content: space-between;
|
|
82
|
-
padding: 0.75rem 1rem;
|
|
83
|
-
gap: 0.75rem;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
.undoable-message {
|
|
87
|
-
font-size: 0.875rem;
|
|
88
|
-
color: hsl(var(--foreground));
|
|
89
|
-
margin: 0;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
.undoable-actions {
|
|
93
|
-
display: flex;
|
|
94
|
-
align-items: center;
|
|
95
|
-
gap: 0.25rem;
|
|
96
|
-
flex-shrink: 0;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
:global(.undoable-undo-btn) {
|
|
100
|
-
font-weight: 600;
|
|
101
|
-
color: hsl(var(--primary)) !important;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
.undoable-close {
|
|
105
|
-
background: none;
|
|
106
|
-
border: none;
|
|
107
|
-
cursor: pointer;
|
|
108
|
-
padding: 0.25rem;
|
|
109
|
-
color: hsl(var(--muted-foreground));
|
|
110
|
-
border-radius: 0.25rem;
|
|
111
|
-
}
|
|
112
|
-
.undoable-close:hover {
|
|
113
|
-
color: hsl(var(--foreground));
|
|
114
|
-
background: hsl(var(--muted));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
.undoable-progress {
|
|
118
|
-
height: 3px;
|
|
119
|
-
background: hsl(var(--muted));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
.undoable-progress-bar {
|
|
123
|
-
height: 100%;
|
|
124
|
-
background: hsl(var(--primary));
|
|
125
|
-
transition: width 100ms linear;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
@keyframes slideUp {
|
|
129
|
-
from { transform: translateX(-50%) translateY(100%); opacity: 0; }
|
|
130
|
-
to { transform: translateX(-50%) translateY(0); opacity: 1; }
|
|
131
|
-
}
|
|
132
|
-
</style>
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
import { t } from '@svadmin/core/i18n';
|
|
4
4
|
import { navigate } from '@svadmin/core/router';
|
|
5
5
|
import { Button } from './ui/button/index.js';
|
|
6
|
-
import { Input } from './ui/input/index.js';
|
|
7
6
|
import * as Card from './ui/card/index.js';
|
|
8
|
-
import
|
|
7
|
+
import * as Alert from './ui/alert/index.js';
|
|
8
|
+
import PasswordInput from './PasswordInput.svelte';
|
|
9
|
+
import { ShieldCheck, Loader2, AlertCircle } from 'lucide-svelte';
|
|
9
10
|
|
|
10
11
|
let { title = 'Admin' } = $props<{
|
|
11
12
|
title?: string;
|
|
@@ -15,7 +16,6 @@
|
|
|
15
16
|
|
|
16
17
|
let password = $state('');
|
|
17
18
|
let confirmPassword = $state('');
|
|
18
|
-
let showPassword = $state(false);
|
|
19
19
|
let error = $state('');
|
|
20
20
|
|
|
21
21
|
async function handleSubmit(e: SubmitEvent) {
|
|
@@ -32,79 +32,51 @@
|
|
|
32
32
|
}
|
|
33
33
|
</script>
|
|
34
34
|
|
|
35
|
-
<div class="
|
|
36
|
-
<div class="
|
|
37
|
-
<Card.Card class="
|
|
38
|
-
<Card.CardHeader class="
|
|
39
|
-
<div class="
|
|
35
|
+
<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">
|
|
36
|
+
<div class="w-full max-w-[420px]">
|
|
37
|
+
<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)]">
|
|
38
|
+
<Card.CardHeader class="text-center pb-2">
|
|
39
|
+
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 text-primary mx-auto mb-3">
|
|
40
40
|
<ShieldCheck class="h-6 w-6" />
|
|
41
41
|
</div>
|
|
42
42
|
<Card.CardTitle class="text-2xl font-bold">{t('auth.resetPassword')}</Card.CardTitle>
|
|
43
|
-
<p class="text-sm text-muted-foreground">
|
|
43
|
+
<p class="text-sm text-muted-foreground">{t('auth.resetPasswordDescription')}</p>
|
|
44
44
|
</Card.CardHeader>
|
|
45
45
|
<Card.CardContent>
|
|
46
46
|
<form onsubmit={handleSubmit} class="space-y-4">
|
|
47
47
|
{#if error}
|
|
48
|
-
<
|
|
49
|
-
<
|
|
50
|
-
|
|
48
|
+
<Alert.Root variant="destructive">
|
|
49
|
+
<AlertCircle class="h-4 w-4" />
|
|
50
|
+
<Alert.Description>{error}</Alert.Description>
|
|
51
|
+
</Alert.Root>
|
|
51
52
|
{/if}
|
|
52
53
|
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class="password-toggle"
|
|
68
|
-
onclick={() => showPassword = !showPassword}
|
|
69
|
-
tabindex={-1}
|
|
70
|
-
>
|
|
71
|
-
{#if showPassword}
|
|
72
|
-
<EyeOff class="h-4 w-4" />
|
|
73
|
-
{:else}
|
|
74
|
-
<Eye class="h-4 w-4" />
|
|
75
|
-
{/if}
|
|
76
|
-
</button>
|
|
77
|
-
</div>
|
|
78
|
-
</div>
|
|
79
|
-
|
|
80
|
-
<div class="space-y-2">
|
|
81
|
-
<label for="confirm-password" class="text-sm font-medium text-foreground">{t('auth.confirmPassword')}</label>
|
|
82
|
-
<div class="input-with-icon">
|
|
83
|
-
<Lock class="input-icon h-4 w-4" />
|
|
84
|
-
<Input
|
|
85
|
-
id="confirm-password"
|
|
86
|
-
type={showPassword ? 'text' : 'password'}
|
|
87
|
-
placeholder="••••••••"
|
|
88
|
-
bind:value={confirmPassword}
|
|
89
|
-
class="pl-9"
|
|
90
|
-
autocomplete="new-password"
|
|
91
|
-
/>
|
|
92
|
-
</div>
|
|
93
|
-
</div>
|
|
54
|
+
<PasswordInput
|
|
55
|
+
id="new-password"
|
|
56
|
+
label={t('auth.password')}
|
|
57
|
+
bind:value={password}
|
|
58
|
+
autocomplete="new-password"
|
|
59
|
+
showStrength
|
|
60
|
+
/>
|
|
61
|
+
|
|
62
|
+
<PasswordInput
|
|
63
|
+
id="confirm-password"
|
|
64
|
+
label={t('auth.confirmPassword')}
|
|
65
|
+
bind:value={confirmPassword}
|
|
66
|
+
autocomplete="new-password"
|
|
67
|
+
/>
|
|
94
68
|
|
|
95
69
|
<Button type="submit" class="w-full" disabled={updatePw.isLoading}>
|
|
96
70
|
{#if updatePw.isLoading}
|
|
97
|
-
<
|
|
71
|
+
<Loader2 class="h-4 w-4 animate-spin mr-2" />
|
|
98
72
|
{/if}
|
|
99
73
|
{t('auth.resetPassword')}
|
|
100
74
|
</Button>
|
|
101
75
|
|
|
102
|
-
<div class="
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
onclick={() => navigate('/login')}
|
|
107
|
-
>{t('auth.backToLogin')}</button>
|
|
76
|
+
<div class="flex items-center justify-center mt-2">
|
|
77
|
+
<Button variant="link" class="text-sm h-auto p-0 font-medium" onclick={() => navigate('/login')}>
|
|
78
|
+
{t('auth.backToLogin')}
|
|
79
|
+
</Button>
|
|
108
80
|
</div>
|
|
109
81
|
</form>
|
|
110
82
|
</Card.CardContent>
|
|
@@ -115,103 +87,3 @@
|
|
|
115
87
|
</p>
|
|
116
88
|
</div>
|
|
117
89
|
</div>
|
|
118
|
-
|
|
119
|
-
<style>
|
|
120
|
-
.auth-page {
|
|
121
|
-
min-height: 100vh;
|
|
122
|
-
display: flex;
|
|
123
|
-
align-items: center;
|
|
124
|
-
justify-content: center;
|
|
125
|
-
background: linear-gradient(135deg, hsl(var(--primary) / 0.08) 0%, hsl(var(--background)) 50%, hsl(var(--primary) / 0.04) 100%);
|
|
126
|
-
padding: 1rem;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
.auth-container {
|
|
130
|
-
width: 100%;
|
|
131
|
-
max-width: 420px;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
:global(.auth-card) {
|
|
135
|
-
backdrop-filter: blur(20px);
|
|
136
|
-
border: 1px solid hsl(var(--border) / 0.5);
|
|
137
|
-
box-shadow: 0 8px 32px hsl(var(--primary) / 0.08), 0 2px 8px hsl(0 0% 0% / 0.06);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
:global(.auth-header) {
|
|
141
|
-
text-align: center;
|
|
142
|
-
padding-bottom: 0.5rem;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
.auth-icon {
|
|
146
|
-
display: inline-flex;
|
|
147
|
-
align-items: center;
|
|
148
|
-
justify-content: center;
|
|
149
|
-
width: 48px;
|
|
150
|
-
height: 48px;
|
|
151
|
-
border-radius: 12px;
|
|
152
|
-
background: hsl(var(--primary) / 0.1);
|
|
153
|
-
color: hsl(var(--primary));
|
|
154
|
-
margin: 0 auto 0.75rem;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
.input-with-icon {
|
|
158
|
-
position: relative;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
:global(.input-icon) {
|
|
162
|
-
position: absolute;
|
|
163
|
-
left: 0.75rem;
|
|
164
|
-
top: 50%;
|
|
165
|
-
transform: translateY(-50%);
|
|
166
|
-
color: hsl(var(--muted-foreground));
|
|
167
|
-
pointer-events: none;
|
|
168
|
-
z-index: 1;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
.password-toggle {
|
|
172
|
-
position: absolute;
|
|
173
|
-
right: 0.75rem;
|
|
174
|
-
top: 50%;
|
|
175
|
-
transform: translateY(-50%);
|
|
176
|
-
color: hsl(var(--muted-foreground));
|
|
177
|
-
background: none;
|
|
178
|
-
border: none;
|
|
179
|
-
cursor: pointer;
|
|
180
|
-
padding: 2px;
|
|
181
|
-
z-index: 1;
|
|
182
|
-
}
|
|
183
|
-
.password-toggle:hover {
|
|
184
|
-
color: hsl(var(--foreground));
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
.error-alert {
|
|
188
|
-
padding: 0.75rem;
|
|
189
|
-
border-radius: 0.5rem;
|
|
190
|
-
background: hsl(var(--destructive) / 0.1);
|
|
191
|
-
border: 1px solid hsl(var(--destructive) / 0.3);
|
|
192
|
-
color: hsl(var(--destructive));
|
|
193
|
-
font-size: 0.875rem;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
.loading-spinner {
|
|
197
|
-
display: inline-block;
|
|
198
|
-
width: 16px;
|
|
199
|
-
height: 16px;
|
|
200
|
-
border: 2px solid transparent;
|
|
201
|
-
border-top-color: currentColor;
|
|
202
|
-
border-radius: 50%;
|
|
203
|
-
animation: spin 0.6s linear infinite;
|
|
204
|
-
margin-right: 0.5rem;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
@keyframes spin {
|
|
208
|
-
to { transform: rotate(360deg); }
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
.auth-link {
|
|
212
|
-
display: flex;
|
|
213
|
-
align-items: center;
|
|
214
|
-
justify-content: center;
|
|
215
|
-
margin-top: 0.5rem;
|
|
216
|
-
}
|
|
217
|
-
</style>
|
|
@@ -11,11 +11,17 @@
|
|
|
11
11
|
|
|
12
12
|
const invalidate = useInvalidate();
|
|
13
13
|
let spinning = $state(false);
|
|
14
|
+
let spinTimer: ReturnType<typeof setTimeout> | undefined;
|
|
15
|
+
|
|
16
|
+
$effect(() => {
|
|
17
|
+
return () => { if (spinTimer) clearTimeout(spinTimer); };
|
|
18
|
+
});
|
|
14
19
|
|
|
15
20
|
function refresh() {
|
|
16
21
|
spinning = true;
|
|
17
22
|
invalidate({ resource, invalidates: ['list', 'many'] });
|
|
18
|
-
|
|
23
|
+
if (spinTimer) clearTimeout(spinTimer);
|
|
24
|
+
spinTimer = setTimeout(() => { spinning = false; }, 600);
|
|
19
25
|
}
|
|
20
26
|
</script>
|
|
21
27
|
|
|
@@ -28,4 +34,3 @@
|
|
|
28
34
|
<RefreshCw class="h-4 w-4 {spinning ? 'animate-spin' : ''}" />
|
|
29
35
|
{#if !hideText}<span class="ml-1">{t('common.retry')}</span>{/if}
|
|
30
36
|
</Button>
|
|
31
|
-
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Shared field type → display component mapping
|
|
2
|
+
// Used by both ShowPage and FieldRenderer to avoid duplicate if/else chains.
|
|
3
|
+
// Components are lazily resolved by field type at runtime.
|
|
4
|
+
|
|
5
|
+
import type { Component } from 'svelte';
|
|
6
|
+
|
|
7
|
+
export interface FieldComponentMap {
|
|
8
|
+
[fieldType: string]: Component<Record<string, unknown>>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Default display component map (read-only field display)
|
|
12
|
+
// Dynamically imports components only when first used
|
|
13
|
+
const displayCache = new Map<string, Component<Record<string, unknown>>>();
|
|
14
|
+
|
|
15
|
+
// Pre-import all field display components (tree-shaken by bundler)
|
|
16
|
+
import BooleanField from './fields/BooleanField.svelte';
|
|
17
|
+
import ImageField from './fields/ImageField.svelte';
|
|
18
|
+
import TagField from './fields/TagField.svelte';
|
|
19
|
+
import DateField from './fields/DateField.svelte';
|
|
20
|
+
import EmailField from './fields/EmailField.svelte';
|
|
21
|
+
import UrlField from './fields/UrlField.svelte';
|
|
22
|
+
import SelectField from './fields/SelectField.svelte';
|
|
23
|
+
import MultiSelectField from './fields/MultiSelectField.svelte';
|
|
24
|
+
import RelationField from './fields/RelationField.svelte';
|
|
25
|
+
import JsonField from './fields/JsonField.svelte';
|
|
26
|
+
import RichTextField from './fields/RichTextField.svelte';
|
|
27
|
+
import NumberField from './fields/NumberField.svelte';
|
|
28
|
+
import FileField from './fields/FileField.svelte';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Built-in display components for show/detail pages.
|
|
32
|
+
* Keys match FieldDefinition.type values.
|
|
33
|
+
*/
|
|
34
|
+
export const builtinDisplayComponents: FieldComponentMap = {
|
|
35
|
+
boolean: BooleanField,
|
|
36
|
+
image: ImageField,
|
|
37
|
+
images: ImageField,
|
|
38
|
+
tags: TagField,
|
|
39
|
+
date: DateField,
|
|
40
|
+
email: EmailField,
|
|
41
|
+
url: UrlField,
|
|
42
|
+
select: SelectField,
|
|
43
|
+
multiselect: MultiSelectField,
|
|
44
|
+
relation: RelationField,
|
|
45
|
+
json: JsonField,
|
|
46
|
+
richtext: RichTextField,
|
|
47
|
+
textarea: RichTextField,
|
|
48
|
+
number: NumberField,
|
|
49
|
+
file: FileField,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ─── Custom component registry ─────────────────────────────────
|
|
53
|
+
// Users can register custom field display components at runtime
|
|
54
|
+
|
|
55
|
+
const customDisplayComponents = new Map<string, Component<Record<string, unknown>>>();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register a custom field display component for a given field type.
|
|
59
|
+
* The component will be used by ShowPage and any other display contexts.
|
|
60
|
+
*/
|
|
61
|
+
export function registerDisplayComponent(fieldType: string, component: Component<Record<string, unknown>>): void {
|
|
62
|
+
customDisplayComponents.set(fieldType, component);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get the display component for a field type.
|
|
67
|
+
* Custom components take precedence over built-in ones.
|
|
68
|
+
* Returns undefined if no component is registered for the type.
|
|
69
|
+
*/
|
|
70
|
+
export function getDisplayComponent(fieldType: string): Component<Record<string, unknown>> | undefined {
|
|
71
|
+
return customDisplayComponents.get(fieldType) ?? builtinDisplayComponents[fieldType];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if a field type has a display component (custom or built-in).
|
|
76
|
+
*/
|
|
77
|
+
export function hasDisplayComponent(fieldType: string): boolean {
|
|
78
|
+
return customDisplayComponents.has(fieldType) || fieldType in builtinDisplayComponents;
|
|
79
|
+
}
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
interface Props { value: boolean | null | undefined; trueIcon?: string; falseIcon?: string }
|
|
3
3
|
let { value, trueIcon = '✓', falseIcon = '✗' }: Props = $props();
|
|
4
4
|
</script>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
{#if value}
|
|
6
|
+
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full text-sm bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400">
|
|
7
|
+
{trueIcon}
|
|
8
|
+
</span>
|
|
9
|
+
{:else}
|
|
10
|
+
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full text-sm bg-red-50 text-red-600 dark:bg-red-500/20 dark:text-red-400">
|
|
11
|
+
{falseIcon}
|
|
12
|
+
</span>
|
|
13
|
+
{/if}
|
|
@@ -3,13 +3,7 @@
|
|
|
3
3
|
let { value }: Props = $props();
|
|
4
4
|
</script>
|
|
5
5
|
{#if value}
|
|
6
|
-
<a href="mailto:{value}" class="
|
|
6
|
+
<a href="mailto:{value}" class="text-primary hover:underline no-underline">{value}</a>
|
|
7
7
|
{:else}
|
|
8
|
-
<span class="
|
|
8
|
+
<span class="text-muted-foreground">—</span>
|
|
9
9
|
{/if}
|
|
10
|
-
|
|
11
|
-
<style>
|
|
12
|
-
.field-email { color: var(--color-primary, #3b82f6); text-decoration: none; }
|
|
13
|
-
.field-email:hover { text-decoration: underline; }
|
|
14
|
-
.field-empty { color: var(--color-text-muted, #9ca3af); }
|
|
15
|
-
</style>
|
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { Download } from 'lucide-svelte';
|
|
2
3
|
interface Props { value: string | null | undefined; title?: string }
|
|
3
4
|
let { value, title }: Props = $props();
|
|
4
5
|
const fileName = $derived(value ? value.split('/').pop() ?? value : null);
|
|
5
6
|
</script>
|
|
6
7
|
{#if value}
|
|
7
|
-
<a href={value} download class="
|
|
8
|
-
<
|
|
8
|
+
<a href={value} download class="text-primary hover:underline no-underline inline-flex items-center gap-1" title={title ?? fileName ?? ''}>
|
|
9
|
+
<Download class="h-3.5 w-3.5 opacity-60" />
|
|
9
10
|
{fileName}
|
|
10
11
|
</a>
|
|
11
12
|
{:else}
|
|
12
|
-
<span class="
|
|
13
|
+
<span class="text-muted-foreground">—</span>
|
|
13
14
|
{/if}
|
|
14
|
-
|
|
15
|
-
<style>
|
|
16
|
-
.field-file { color: var(--color-primary, #3b82f6); text-decoration: none; display: inline-flex; align-items: center; gap: 4px; }
|
|
17
|
-
.field-file:hover { text-decoration: underline; }
|
|
18
|
-
.field-empty { color: var(--color-text-muted, #9ca3af); }
|
|
19
|
-
</style>
|
|
@@ -3,12 +3,7 @@
|
|
|
3
3
|
let { value, alt = '', width = 80, height = 80 }: Props = $props();
|
|
4
4
|
</script>
|
|
5
5
|
{#if value}
|
|
6
|
-
<img src={value} {alt} {width} {height} class="
|
|
6
|
+
<img src={value} {alt} {width} {height} class="rounded-lg object-cover border border-border" />
|
|
7
7
|
{:else}
|
|
8
|
-
<span class="
|
|
8
|
+
<span class="inline-flex items-center justify-center w-20 h-20 bg-muted rounded-lg text-muted-foreground">—</span>
|
|
9
9
|
{/if}
|
|
10
|
-
|
|
11
|
-
<style>
|
|
12
|
-
.field-image { border-radius: 8px; object-fit: cover; border: 1px solid var(--color-border, #e5e7eb); }
|
|
13
|
-
.field-empty { color: var(--color-text-muted, #9ca3af); display: inline-flex; align-items: center; justify-content: center; width: 80px; height: 80px; background: var(--color-surface, #f3f4f6); border-radius: 8px; }
|
|
14
|
-
</style>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '@svadmin/core/i18n';
|
|
3
|
+
import { Button } from '../ui/button/index.js';
|
|
4
|
+
import { Copy, Check, ChevronDown, ChevronRight } from 'lucide-svelte';
|
|
5
|
+
|
|
6
|
+
let { value } = $props<{
|
|
7
|
+
value: unknown;
|
|
8
|
+
}>();
|
|
9
|
+
|
|
10
|
+
let expanded = $state(false);
|
|
11
|
+
let copied = $state(false);
|
|
12
|
+
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
|
13
|
+
|
|
14
|
+
$effect(() => {
|
|
15
|
+
return () => { if (copyTimer) clearTimeout(copyTimer); };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const formatted = $derived(
|
|
19
|
+
value != null ? JSON.stringify(value, null, 2) : '—'
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const preview = $derived(
|
|
23
|
+
value != null ? JSON.stringify(value).slice(0, 80) + (JSON.stringify(value).length > 80 ? '...' : '') : '—'
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
function copyJson() {
|
|
27
|
+
navigator.clipboard.writeText(formatted);
|
|
28
|
+
copied = true;
|
|
29
|
+
if (copyTimer) clearTimeout(copyTimer);
|
|
30
|
+
copyTimer = setTimeout(() => { copied = false; }, 2000);
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
{#if value == null}
|
|
35
|
+
<span class="text-muted-foreground">—</span>
|
|
36
|
+
{:else}
|
|
37
|
+
<div class="space-y-1">
|
|
38
|
+
<div class="flex items-center gap-2">
|
|
39
|
+
<Button
|
|
40
|
+
variant="ghost"
|
|
41
|
+
size="sm"
|
|
42
|
+
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground h-auto py-0.5 px-1"
|
|
43
|
+
onclick={() => { expanded = !expanded; }}
|
|
44
|
+
>
|
|
45
|
+
{#if expanded}
|
|
46
|
+
<ChevronDown class="h-3 w-3" />
|
|
47
|
+
{:else}
|
|
48
|
+
<ChevronRight class="h-3 w-3" />
|
|
49
|
+
{/if}
|
|
50
|
+
{expanded ? t('common.collapse') : t('common.expand')}
|
|
51
|
+
</Button>
|
|
52
|
+
<Button
|
|
53
|
+
variant="ghost"
|
|
54
|
+
size="sm"
|
|
55
|
+
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground h-auto py-0.5 px-1"
|
|
56
|
+
onclick={copyJson}
|
|
57
|
+
aria-label={t('common.copy')}
|
|
58
|
+
>
|
|
59
|
+
{#if copied}
|
|
60
|
+
<Check class="h-3 w-3 text-emerald-500" />
|
|
61
|
+
{:else}
|
|
62
|
+
<Copy class="h-3 w-3" />
|
|
63
|
+
{/if}
|
|
64
|
+
</Button>
|
|
65
|
+
</div>
|
|
66
|
+
{#if expanded}
|
|
67
|
+
<pre class="rounded-lg bg-muted p-3 text-xs overflow-auto max-h-64 font-mono">{formatted}</pre>
|
|
68
|
+
{:else}
|
|
69
|
+
<code class="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">{preview}</code>
|
|
70
|
+
{/if}
|
|
71
|
+
</div>
|
|
72
|
+
{/if}
|
|
@@ -3,17 +3,7 @@
|
|
|
3
3
|
let { value }: Props = $props();
|
|
4
4
|
</script>
|
|
5
5
|
{#if value}
|
|
6
|
-
<div class="
|
|
6
|
+
<div class="prose prose-sm dark:prose-invert max-w-none leading-relaxed [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-[0.875em] [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_a]:text-primary">{@html value}</div>
|
|
7
7
|
{:else}
|
|
8
|
-
<span class="
|
|
8
|
+
<span class="text-muted-foreground">—</span>
|
|
9
9
|
{/if}
|
|
10
|
-
|
|
11
|
-
<style>
|
|
12
|
-
.field-markdown { line-height: 1.6; }
|
|
13
|
-
.field-markdown :global(h1), .field-markdown :global(h2), .field-markdown :global(h3) { margin-top: 0.5em; margin-bottom: 0.25em; }
|
|
14
|
-
.field-markdown :global(p) { margin: 0.5em 0; }
|
|
15
|
-
.field-markdown :global(code) { background: var(--color-surface, #f3f4f6); padding: 2px 6px; border-radius: 4px; font-size: 0.875em; }
|
|
16
|
-
.field-markdown :global(pre) { background: var(--color-surface, #f3f4f6); padding: 12px; border-radius: 8px; overflow-x: auto; }
|
|
17
|
-
.field-markdown :global(a) { color: var(--color-primary, #3b82f6); }
|
|
18
|
-
.field-empty { color: var(--color-text-muted, #9ca3af); }
|
|
19
|
-
</style>
|