@svadmin/ui 0.51.0 → 0.53.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/dist/components/BatchActionBar.svelte +119 -0
- package/dist/components/BatchActionBar.svelte.d.ts +15 -0
- package/dist/components/FilterDropdown.svelte +252 -0
- package/dist/components/FilterDropdown.svelte.d.ts +24 -0
- package/dist/components/content/AuditTimeline.svelte +86 -0
- package/dist/components/content/AuditTimeline.svelte.d.ts +20 -0
- package/dist/components/content/index.d.ts +2 -0
- package/dist/components/content/index.js +1 -0
- package/dist/components/fields/AvatarField.svelte +123 -0
- package/dist/components/fields/AvatarField.svelte.d.ts +16 -0
- package/dist/components/fields/BooleanField.svelte +90 -8
- package/dist/components/fields/BooleanField.svelte.d.ts +8 -0
- package/dist/components/fields/CodeField.svelte +96 -0
- package/dist/components/fields/CodeField.svelte.d.ts +13 -0
- package/dist/components/fields/CopyField.svelte +102 -0
- package/dist/components/fields/CopyField.svelte.d.ts +15 -0
- package/dist/components/fields/DateField.svelte +74 -4
- package/dist/components/fields/DateField.svelte.d.ts +4 -0
- package/dist/components/fields/DateRangeField.svelte +98 -0
- package/dist/components/fields/DateRangeField.svelte.d.ts +22 -0
- package/dist/components/fields/NumberField.svelte +72 -4
- package/dist/components/fields/NumberField.svelte.d.ts +9 -0
- package/dist/components/fields/PercentField.svelte +92 -0
- package/dist/components/fields/PercentField.svelte.d.ts +13 -0
- package/dist/components/fields/RatingField.svelte +76 -0
- package/dist/components/fields/RatingField.svelte.d.ts +12 -0
- package/dist/components/fields/TagField.svelte +90 -6
- package/dist/components/fields/TagField.svelte.d.ts +9 -0
- package/dist/components/fields/UrlField.svelte +43 -7
- package/dist/components/fields/UrlField.svelte.d.ts +5 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { CheckCircle2, X } from '@lucide/svelte';
|
|
4
|
+
import { Button } from './ui/button/index.js';
|
|
5
|
+
import { Badge } from './ui/badge/index.js';
|
|
6
|
+
import { cn } from '../utils.js';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
selectedCount?: number;
|
|
10
|
+
totalCount?: number;
|
|
11
|
+
selectedLabel?: string;
|
|
12
|
+
clearLabel?: string;
|
|
13
|
+
variant?: 'default' | 'floating' | 'subtle';
|
|
14
|
+
onclear?: () => void;
|
|
15
|
+
actions?: Snippet;
|
|
16
|
+
children?: Snippet;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
selectedCount = 0,
|
|
22
|
+
totalCount,
|
|
23
|
+
selectedLabel,
|
|
24
|
+
clearLabel = 'Clear',
|
|
25
|
+
variant = 'default',
|
|
26
|
+
onclear,
|
|
27
|
+
actions,
|
|
28
|
+
children,
|
|
29
|
+
class: className = '',
|
|
30
|
+
}: Props = $props();
|
|
31
|
+
|
|
32
|
+
const isVisible = $derived(selectedCount > 0);
|
|
33
|
+
const displayText = $derived.by(() => {
|
|
34
|
+
if (selectedLabel) {
|
|
35
|
+
return selectedLabel.replace('{count}', String(selectedCount));
|
|
36
|
+
}
|
|
37
|
+
return totalCount !== undefined
|
|
38
|
+
? `${selectedCount} of ${totalCount} selected`
|
|
39
|
+
: `${selectedCount} selected`;
|
|
40
|
+
});
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
{#if isVisible}
|
|
44
|
+
{#if variant === 'floating'}
|
|
45
|
+
<div
|
|
46
|
+
class={cn(
|
|
47
|
+
'fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 rounded-lg border border-border/80 bg-background/95 px-4 py-2.5 shadow-2xl backdrop-blur-md transition-all duration-200 animate-in fade-in-0 slide-in-from-bottom-4',
|
|
48
|
+
className
|
|
49
|
+
)}
|
|
50
|
+
role="region"
|
|
51
|
+
aria-label="Batch Actions"
|
|
52
|
+
>
|
|
53
|
+
<div class="flex items-center gap-2 pr-2 border-r border-border/60">
|
|
54
|
+
<CheckCircle2 class="h-4 w-4 text-primary" />
|
|
55
|
+
<span class="text-xs font-medium text-foreground">{displayText}</span>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{#if actions}
|
|
59
|
+
<div class="flex items-center gap-1.5">
|
|
60
|
+
{@render actions()}
|
|
61
|
+
</div>
|
|
62
|
+
{/if}
|
|
63
|
+
|
|
64
|
+
{#if children}
|
|
65
|
+
<div class="flex items-center gap-1.5">
|
|
66
|
+
{@render children()}
|
|
67
|
+
</div>
|
|
68
|
+
{/if}
|
|
69
|
+
|
|
70
|
+
{#if onclear}
|
|
71
|
+
<Button
|
|
72
|
+
variant="ghost"
|
|
73
|
+
size="sm"
|
|
74
|
+
class="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
75
|
+
onclick={onclear}
|
|
76
|
+
aria-label={clearLabel}
|
|
77
|
+
>
|
|
78
|
+
<X class="h-3.5 w-3.5 mr-1" />
|
|
79
|
+
{clearLabel}
|
|
80
|
+
</Button>
|
|
81
|
+
{/if}
|
|
82
|
+
</div>
|
|
83
|
+
{:else}
|
|
84
|
+
<div
|
|
85
|
+
class={cn(
|
|
86
|
+
'flex flex-wrap items-center justify-between gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs transition-all duration-150',
|
|
87
|
+
variant === 'subtle' ? 'border-border/60 bg-muted/40' : '',
|
|
88
|
+
className
|
|
89
|
+
)}
|
|
90
|
+
role="region"
|
|
91
|
+
aria-label="Batch Actions"
|
|
92
|
+
>
|
|
93
|
+
<div class="flex items-center gap-2">
|
|
94
|
+
<Badge variant="secondary" class="h-5 px-1.5 font-semibold text-[11px] bg-primary/15 text-primary border-primary/20">
|
|
95
|
+
{selectedCount}
|
|
96
|
+
</Badge>
|
|
97
|
+
<span class="font-medium text-foreground">{displayText}</span>
|
|
98
|
+
{#if onclear}
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
class="text-muted-foreground hover:text-foreground underline underline-offset-2 ml-1 text-xs cursor-pointer focus:outline-none"
|
|
102
|
+
onclick={onclear}
|
|
103
|
+
>
|
|
104
|
+
{clearLabel}
|
|
105
|
+
</button>
|
|
106
|
+
{/if}
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div class="flex items-center gap-2">
|
|
110
|
+
{#if actions}
|
|
111
|
+
{@render actions()}
|
|
112
|
+
{/if}
|
|
113
|
+
{#if children}
|
|
114
|
+
{@render children()}
|
|
115
|
+
{/if}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
{/if}
|
|
119
|
+
{/if}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
selectedCount?: number;
|
|
4
|
+
totalCount?: number;
|
|
5
|
+
selectedLabel?: string;
|
|
6
|
+
clearLabel?: string;
|
|
7
|
+
variant?: 'default' | 'floating' | 'subtle';
|
|
8
|
+
onclear?: () => void;
|
|
9
|
+
actions?: Snippet;
|
|
10
|
+
children?: Snippet;
|
|
11
|
+
class?: string;
|
|
12
|
+
}
|
|
13
|
+
declare const BatchActionBar: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type BatchActionBar = ReturnType<typeof BatchActionBar>;
|
|
15
|
+
export default BatchActionBar;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { Filter, X, Search } from '@lucide/svelte';
|
|
4
|
+
import { Button } from './ui/button/index.js';
|
|
5
|
+
import { Input } from './ui/input/index.js';
|
|
6
|
+
import { clickOutside } from '../actions.js';
|
|
7
|
+
import { cn } from '../utils.js';
|
|
8
|
+
|
|
9
|
+
interface OptionItem {
|
|
10
|
+
label: string;
|
|
11
|
+
value: string | number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
title?: string;
|
|
16
|
+
open?: boolean;
|
|
17
|
+
active?: boolean;
|
|
18
|
+
value?: unknown;
|
|
19
|
+
options?: OptionItem[];
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
applyLabel?: string;
|
|
22
|
+
resetLabel?: string;
|
|
23
|
+
align?: 'left' | 'right';
|
|
24
|
+
onapply?: (value: unknown) => void;
|
|
25
|
+
onreset?: () => void;
|
|
26
|
+
children?: Snippet;
|
|
27
|
+
trigger?: Snippet;
|
|
28
|
+
class?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let {
|
|
32
|
+
title = 'Filter',
|
|
33
|
+
open = $bindable(false),
|
|
34
|
+
active,
|
|
35
|
+
value = $bindable(undefined),
|
|
36
|
+
options,
|
|
37
|
+
placeholder = 'Search...',
|
|
38
|
+
applyLabel = 'Apply',
|
|
39
|
+
resetLabel = 'Reset',
|
|
40
|
+
align = 'right',
|
|
41
|
+
onapply,
|
|
42
|
+
onreset,
|
|
43
|
+
children,
|
|
44
|
+
trigger,
|
|
45
|
+
class: className = '',
|
|
46
|
+
}: Props = $props();
|
|
47
|
+
|
|
48
|
+
type OptionValue = OptionItem['value'];
|
|
49
|
+
|
|
50
|
+
function isOptionValue(candidate: unknown): candidate is OptionValue {
|
|
51
|
+
return typeof candidate === 'string' || typeof candidate === 'number';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getTextValue(candidate: unknown): string {
|
|
55
|
+
return typeof candidate === 'string' ? candidate : '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getSelectedValues(candidate: unknown): OptionValue[] {
|
|
59
|
+
if (Array.isArray(candidate)) return candidate.filter(isOptionValue);
|
|
60
|
+
return isOptionValue(candidate) && candidate !== '' ? [candidate] : [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let internalTextValue = $state(getTextValue(value));
|
|
64
|
+
let internalSelectedValues = $state<OptionValue[]>(getSelectedValues(value));
|
|
65
|
+
let optionSearch = $state('');
|
|
66
|
+
|
|
67
|
+
// Keep the editable draft aligned with controlled value changes.
|
|
68
|
+
$effect(() => {
|
|
69
|
+
internalTextValue = getTextValue(value);
|
|
70
|
+
internalSelectedValues = getSelectedValues(value);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const isFilterActive = $derived(
|
|
74
|
+
active !== undefined
|
|
75
|
+
? active
|
|
76
|
+
: Array.isArray(value)
|
|
77
|
+
? value.length > 0
|
|
78
|
+
: value != null && value !== ''
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const filteredOptions = $derived(
|
|
82
|
+
options
|
|
83
|
+
? optionSearch
|
|
84
|
+
? options.filter((opt) => opt.label.toLowerCase().includes(optionSearch.toLowerCase()))
|
|
85
|
+
: options
|
|
86
|
+
: []
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
function restoreDraft() {
|
|
90
|
+
internalTextValue = getTextValue(value);
|
|
91
|
+
internalSelectedValues = getSelectedValues(value);
|
|
92
|
+
optionSearch = '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function handleClose() {
|
|
96
|
+
restoreDraft();
|
|
97
|
+
open = false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handleClickOutside() {
|
|
101
|
+
if (open) handleClose();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function handleTriggerClick() {
|
|
105
|
+
if (open) {
|
|
106
|
+
handleClose();
|
|
107
|
+
} else {
|
|
108
|
+
open = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toggleOption(optValue: OptionValue) {
|
|
113
|
+
if (internalSelectedValues.includes(optValue)) {
|
|
114
|
+
internalSelectedValues = internalSelectedValues.filter((v) => v !== optValue);
|
|
115
|
+
} else {
|
|
116
|
+
internalSelectedValues = [...internalSelectedValues, optValue];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handleApply() {
|
|
121
|
+
if (options && options.length > 0) {
|
|
122
|
+
const nextValue = [...internalSelectedValues];
|
|
123
|
+
value = nextValue;
|
|
124
|
+
onapply?.(nextValue);
|
|
125
|
+
} else if (!children) {
|
|
126
|
+
value = internalTextValue;
|
|
127
|
+
onapply?.(internalTextValue);
|
|
128
|
+
} else {
|
|
129
|
+
onapply?.(value);
|
|
130
|
+
}
|
|
131
|
+
optionSearch = '';
|
|
132
|
+
open = false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function handleReset() {
|
|
136
|
+
internalTextValue = '';
|
|
137
|
+
internalSelectedValues = [];
|
|
138
|
+
optionSearch = '';
|
|
139
|
+
value = options && options.length > 0 ? [] : undefined;
|
|
140
|
+
onreset?.();
|
|
141
|
+
open = false;
|
|
142
|
+
}
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<div class={cn('relative inline-block text-left', className)} use:clickOutside={handleClickOutside}>
|
|
146
|
+
{#if trigger}
|
|
147
|
+
{@render trigger()}
|
|
148
|
+
{:else}
|
|
149
|
+
<Button
|
|
150
|
+
variant={isFilterActive ? 'secondary' : 'ghost'}
|
|
151
|
+
size="icon-sm"
|
|
152
|
+
class={cn(
|
|
153
|
+
'relative h-7 w-7 rounded-sm p-0 transition-colors',
|
|
154
|
+
isFilterActive ? 'text-primary bg-primary/10 border border-primary/20 hover:bg-primary/20' : 'text-muted-foreground hover:text-foreground'
|
|
155
|
+
)}
|
|
156
|
+
onclick={handleTriggerClick}
|
|
157
|
+
aria-label={title}
|
|
158
|
+
aria-expanded={open}
|
|
159
|
+
aria-haspopup="dialog"
|
|
160
|
+
title={title}
|
|
161
|
+
>
|
|
162
|
+
<Filter class="h-3.5 w-3.5" />
|
|
163
|
+
{#if isFilterActive}
|
|
164
|
+
<span class="absolute -top-0.5 -right-0.5 flex h-2 w-2">
|
|
165
|
+
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
|
166
|
+
<span class="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
|
167
|
+
</span>
|
|
168
|
+
{/if}
|
|
169
|
+
</Button>
|
|
170
|
+
{/if}
|
|
171
|
+
|
|
172
|
+
{#if open}
|
|
173
|
+
<div
|
|
174
|
+
class={cn(
|
|
175
|
+
'absolute z-50 mt-1.5 w-64 rounded-md border border-border bg-popover text-popover-foreground shadow-lg outline-none animate-in fade-in-0 zoom-in-95',
|
|
176
|
+
align === 'right' ? 'right-0 origin-top-right' : 'left-0 origin-top-left'
|
|
177
|
+
)}
|
|
178
|
+
role="dialog"
|
|
179
|
+
aria-label={title}
|
|
180
|
+
>
|
|
181
|
+
<div class="flex items-center justify-between border-b border-border/60 px-3 py-2">
|
|
182
|
+
<span class="text-xs font-semibold text-foreground">{title}</span>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
class="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
186
|
+
onclick={handleClose}
|
|
187
|
+
aria-label="Close"
|
|
188
|
+
>
|
|
189
|
+
<X class="h-3.5 w-3.5" />
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div class="p-3">
|
|
194
|
+
{#if children}
|
|
195
|
+
{@render children()}
|
|
196
|
+
{:else if options && options.length > 0}
|
|
197
|
+
{#if options.length > 5}
|
|
198
|
+
<div class="relative mb-2">
|
|
199
|
+
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
200
|
+
<Input
|
|
201
|
+
bind:value={optionSearch}
|
|
202
|
+
placeholder="Search options..."
|
|
203
|
+
class="h-7 pl-7 text-xs"
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
{/if}
|
|
207
|
+
<div class="max-h-44 overflow-y-auto space-y-1.5 pr-1">
|
|
208
|
+
{#each filteredOptions as opt (opt.value)}
|
|
209
|
+
<label class="flex items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-accent cursor-pointer">
|
|
210
|
+
<input
|
|
211
|
+
type="checkbox"
|
|
212
|
+
checked={internalSelectedValues.includes(opt.value)}
|
|
213
|
+
onchange={() => toggleOption(opt.value)}
|
|
214
|
+
class="h-3.5 w-3.5 rounded border-muted-foreground text-primary focus:ring-primary"
|
|
215
|
+
/>
|
|
216
|
+
<span class="flex-1 truncate">{opt.label}</span>
|
|
217
|
+
</label>
|
|
218
|
+
{:else}
|
|
219
|
+
<div class="py-2 text-center text-xs text-muted-foreground">No options match</div>
|
|
220
|
+
{/each}
|
|
221
|
+
</div>
|
|
222
|
+
{:else}
|
|
223
|
+
<Input
|
|
224
|
+
bind:value={internalTextValue}
|
|
225
|
+
{placeholder}
|
|
226
|
+
class="h-8 text-xs"
|
|
227
|
+
onkeydown={(e) => { if (e.key === 'Enter') handleApply(); }}
|
|
228
|
+
/>
|
|
229
|
+
{/if}
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<div class="flex items-center justify-between border-t border-border/60 bg-muted/30 px-3 py-2">
|
|
233
|
+
<Button
|
|
234
|
+
variant="ghost"
|
|
235
|
+
size="sm"
|
|
236
|
+
class="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
237
|
+
onclick={handleReset}
|
|
238
|
+
>
|
|
239
|
+
{resetLabel}
|
|
240
|
+
</Button>
|
|
241
|
+
<Button
|
|
242
|
+
variant="default"
|
|
243
|
+
size="sm"
|
|
244
|
+
class="h-7 px-3 text-xs"
|
|
245
|
+
onclick={handleApply}
|
|
246
|
+
>
|
|
247
|
+
{applyLabel}
|
|
248
|
+
</Button>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
{/if}
|
|
252
|
+
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface OptionItem {
|
|
3
|
+
label: string;
|
|
4
|
+
value: string | number;
|
|
5
|
+
}
|
|
6
|
+
interface Props {
|
|
7
|
+
title?: string;
|
|
8
|
+
open?: boolean;
|
|
9
|
+
active?: boolean;
|
|
10
|
+
value?: unknown;
|
|
11
|
+
options?: OptionItem[];
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
applyLabel?: string;
|
|
14
|
+
resetLabel?: string;
|
|
15
|
+
align?: 'left' | 'right';
|
|
16
|
+
onapply?: (value: unknown) => void;
|
|
17
|
+
onreset?: () => void;
|
|
18
|
+
children?: Snippet;
|
|
19
|
+
trigger?: Snippet;
|
|
20
|
+
class?: string;
|
|
21
|
+
}
|
|
22
|
+
declare const FilterDropdown: import("svelte").Component<Props, {}, "value" | "open">;
|
|
23
|
+
type FilterDropdown = ReturnType<typeof FilterDropdown>;
|
|
24
|
+
export default FilterDropdown;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { Status } from './StatusBadge.svelte';
|
|
3
|
+
|
|
4
|
+
export interface TimelineItem {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
timestamp?: string;
|
|
9
|
+
actor?: string;
|
|
10
|
+
status?: Status;
|
|
11
|
+
tag?: string;
|
|
12
|
+
meta?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<script lang="ts">
|
|
17
|
+
import { cn } from '../../utils.js';
|
|
18
|
+
import StatusBadge from './StatusBadge.svelte';
|
|
19
|
+
import DataState from './DataState.svelte';
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
items?: TimelineItem[];
|
|
23
|
+
emptyTitle?: string;
|
|
24
|
+
emptyDescription?: string;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
items = [],
|
|
30
|
+
emptyTitle = 'No timeline events',
|
|
31
|
+
emptyDescription = 'Activity events and status transitions will appear here.',
|
|
32
|
+
class: className = '',
|
|
33
|
+
}: Props = $props();
|
|
34
|
+
|
|
35
|
+
const dotClass: Record<Status, string> = {
|
|
36
|
+
success: 'bg-success border-success/30 text-success',
|
|
37
|
+
warning: 'bg-warning border-warning/30 text-warning-foreground',
|
|
38
|
+
danger: 'bg-destructive border-destructive/30 text-destructive',
|
|
39
|
+
info: 'bg-primary border-primary/30 text-primary',
|
|
40
|
+
neutral: 'bg-muted-foreground border-border text-muted-foreground',
|
|
41
|
+
};
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<div class={cn('space-y-4', className)}>
|
|
45
|
+
{#if items.length === 0}
|
|
46
|
+
<DataState state="empty" title={emptyTitle} description={emptyDescription} />
|
|
47
|
+
{:else}
|
|
48
|
+
<div class="relative pl-6 space-y-6 before:absolute before:left-2 before:top-2 before:bottom-2 before:w-0.5 before:bg-border">
|
|
49
|
+
{#each items as item (item.id)}
|
|
50
|
+
{@const statusTone = item.status ?? 'info'}
|
|
51
|
+
<div class="relative flex flex-col gap-1 text-sm group">
|
|
52
|
+
<span
|
|
53
|
+
class={cn(
|
|
54
|
+
'absolute -left-6 top-1 flex h-4 w-4 items-center justify-center rounded-full border-2 bg-background transition-transform group-hover:scale-110',
|
|
55
|
+
item.status ? dotClass[statusTone] : 'border-border bg-muted'
|
|
56
|
+
)}
|
|
57
|
+
aria-hidden="true"
|
|
58
|
+
></span>
|
|
59
|
+
|
|
60
|
+
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
61
|
+
<div class="flex items-center gap-2">
|
|
62
|
+
<span class="font-medium text-foreground">{item.title}</span>
|
|
63
|
+
{#if item.actor}
|
|
64
|
+
<span class="text-xs text-muted-foreground">by {item.actor}</span>
|
|
65
|
+
{/if}
|
|
66
|
+
{#if item.status}
|
|
67
|
+
<StatusBadge status={item.status} label={item.tag ?? item.status} class="text-[11px] py-0 h-4" />
|
|
68
|
+
{:else if item.tag}
|
|
69
|
+
<span class="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground bg-muted">
|
|
70
|
+
{item.tag}
|
|
71
|
+
</span>
|
|
72
|
+
{/if}
|
|
73
|
+
</div>
|
|
74
|
+
{#if item.timestamp}
|
|
75
|
+
<time class="text-xs tabular-nums text-muted-foreground font-mono">{item.timestamp}</time>
|
|
76
|
+
{/if}
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{#if item.description}
|
|
80
|
+
<p class="text-xs text-muted-foreground leading-relaxed whitespace-pre-wrap">{item.description}</p>
|
|
81
|
+
{/if}
|
|
82
|
+
</div>
|
|
83
|
+
{/each}
|
|
84
|
+
</div>
|
|
85
|
+
{/if}
|
|
86
|
+
</div>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Status } from './StatusBadge.svelte';
|
|
2
|
+
export interface TimelineItem {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
timestamp?: string;
|
|
7
|
+
actor?: string;
|
|
8
|
+
status?: Status;
|
|
9
|
+
tag?: string;
|
|
10
|
+
meta?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
interface Props {
|
|
13
|
+
items?: TimelineItem[];
|
|
14
|
+
emptyTitle?: string;
|
|
15
|
+
emptyDescription?: string;
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
declare const AuditTimeline: import("svelte").Component<Props, {}, "">;
|
|
19
|
+
type AuditTimeline = ReturnType<typeof AuditTimeline>;
|
|
20
|
+
export default AuditTimeline;
|
|
@@ -41,3 +41,5 @@ export { default as TwoFactorStepper } from './TwoFactorStepper.svelte';
|
|
|
41
41
|
export { default as SystemErrorState } from './SystemErrorState.svelte';
|
|
42
42
|
export { default as FeedbackNotice } from './FeedbackNotice.svelte';
|
|
43
43
|
export type { FeedbackNoticeTone, FeedbackNoticePriority } from './FeedbackNotice.svelte';
|
|
44
|
+
export { default as AuditTimeline } from './AuditTimeline.svelte';
|
|
45
|
+
export type { TimelineItem } from './AuditTimeline.svelte';
|
|
@@ -29,3 +29,4 @@ export { default as OtpInput } from './OtpInput.svelte';
|
|
|
29
29
|
export { default as TwoFactorStepper } from './TwoFactorStepper.svelte';
|
|
30
30
|
export { default as SystemErrorState } from './SystemErrorState.svelte';
|
|
31
31
|
export { default as FeedbackNotice } from './FeedbackNotice.svelte';
|
|
32
|
+
export { default as AuditTimeline } from './AuditTimeline.svelte';
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export type AvatarStatus = 'online' | 'offline' | 'busy' | 'away' | 'success' | 'warning' | 'error' | 'neutral';
|
|
3
|
+
export type AvatarSize = 'xs' | 'sm' | 'default' | 'lg';
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { cn } from '../../utils.js';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
src?: string | null | undefined;
|
|
11
|
+
name?: string | null | undefined;
|
|
12
|
+
subtitle?: string | null | undefined;
|
|
13
|
+
status?: AvatarStatus;
|
|
14
|
+
size?: AvatarSize;
|
|
15
|
+
shape?: 'circle' | 'square';
|
|
16
|
+
showName?: boolean;
|
|
17
|
+
nullLabel?: string;
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
src,
|
|
23
|
+
name,
|
|
24
|
+
subtitle,
|
|
25
|
+
status,
|
|
26
|
+
size = 'sm',
|
|
27
|
+
shape = 'circle',
|
|
28
|
+
showName = false,
|
|
29
|
+
nullLabel = '—',
|
|
30
|
+
class: className = '',
|
|
31
|
+
}: Props = $props();
|
|
32
|
+
|
|
33
|
+
let imgError = $state(false);
|
|
34
|
+
|
|
35
|
+
$effect(() => {
|
|
36
|
+
void src;
|
|
37
|
+
imgError = false;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const sizeClasses: Record<AvatarSize, { container: string; text: string; dot: string }> = {
|
|
41
|
+
xs: { container: 'h-5 w-5', text: 'text-[10px]', dot: 'h-1.5 w-1.5' },
|
|
42
|
+
sm: { container: 'h-7 w-7', text: 'text-xs', dot: 'h-2 w-2' },
|
|
43
|
+
default: { container: 'h-9 w-9', text: 'text-sm', dot: 'h-2.5 w-2.5' },
|
|
44
|
+
lg: { container: 'h-11 w-11', text: 'text-base', dot: 'h-3 w-3' },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const statusColors: Record<AvatarStatus, string> = {
|
|
48
|
+
online: 'bg-emerald-500',
|
|
49
|
+
success: 'bg-emerald-500',
|
|
50
|
+
busy: 'bg-rose-500',
|
|
51
|
+
error: 'bg-rose-500',
|
|
52
|
+
warning: 'bg-amber-500',
|
|
53
|
+
away: 'bg-amber-500',
|
|
54
|
+
offline: 'bg-slate-400',
|
|
55
|
+
neutral: 'bg-slate-400',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function getInitials(value?: string | null): string {
|
|
59
|
+
const trimmed = value?.trim();
|
|
60
|
+
if (!trimmed) return '?';
|
|
61
|
+
|
|
62
|
+
const parts = trimmed.split(/\s+/);
|
|
63
|
+
if (parts.length >= 2) {
|
|
64
|
+
return `${parts[0][0]}${parts.at(-1)?.[0] ?? ''}`.toUpperCase();
|
|
65
|
+
}
|
|
66
|
+
return trimmed.slice(0, 2).toUpperCase();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const initials = $derived(getInitials(name));
|
|
70
|
+
const hasContent = $derived(Boolean(src || name?.trim()));
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
{#if !hasContent}
|
|
74
|
+
<span class={cn('field-avatar text-muted-foreground text-sm', className)}>{nullLabel}</span>
|
|
75
|
+
{:else}
|
|
76
|
+
<div class={cn('field-avatar inline-flex items-center gap-2.5', className)}>
|
|
77
|
+
<div class="relative inline-block shrink-0">
|
|
78
|
+
<div
|
|
79
|
+
class={cn(
|
|
80
|
+
'flex items-center justify-center overflow-hidden bg-muted font-medium text-muted-foreground select-none',
|
|
81
|
+
shape === 'circle' ? 'rounded-full' : 'rounded-md',
|
|
82
|
+
sizeClasses[size].container,
|
|
83
|
+
sizeClasses[size].text
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
{#if src && !imgError}
|
|
87
|
+
<img
|
|
88
|
+
{src}
|
|
89
|
+
alt={name?.trim() || 'Avatar'}
|
|
90
|
+
class="h-full w-full object-cover"
|
|
91
|
+
onerror={() => { imgError = true; }}
|
|
92
|
+
/>
|
|
93
|
+
{:else}
|
|
94
|
+
<span>{initials}</span>
|
|
95
|
+
{/if}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{#if status}
|
|
99
|
+
<span
|
|
100
|
+
class={cn(
|
|
101
|
+
'absolute bottom-0 right-0 rounded-full ring-2 ring-background',
|
|
102
|
+
statusColors[status],
|
|
103
|
+
sizeClasses[size].dot
|
|
104
|
+
)}
|
|
105
|
+
role="img"
|
|
106
|
+
aria-label={`Status: ${status}`}
|
|
107
|
+
title={`Status: ${status}`}
|
|
108
|
+
></span>
|
|
109
|
+
{/if}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{#if showName && (name || subtitle)}
|
|
113
|
+
<div class="flex flex-col min-w-0 text-left">
|
|
114
|
+
{#if name}
|
|
115
|
+
<span class="truncate text-xs font-medium text-foreground leading-tight">{name}</span>
|
|
116
|
+
{/if}
|
|
117
|
+
{#if subtitle}
|
|
118
|
+
<span class="truncate text-[11px] text-muted-foreground leading-tight">{subtitle}</span>
|
|
119
|
+
{/if}
|
|
120
|
+
</div>
|
|
121
|
+
{/if}
|
|
122
|
+
</div>
|
|
123
|
+
{/if}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type AvatarStatus = 'online' | 'offline' | 'busy' | 'away' | 'success' | 'warning' | 'error' | 'neutral';
|
|
2
|
+
export type AvatarSize = 'xs' | 'sm' | 'default' | 'lg';
|
|
3
|
+
interface Props {
|
|
4
|
+
src?: string | null | undefined;
|
|
5
|
+
name?: string | null | undefined;
|
|
6
|
+
subtitle?: string | null | undefined;
|
|
7
|
+
status?: AvatarStatus;
|
|
8
|
+
size?: AvatarSize;
|
|
9
|
+
shape?: 'circle' | 'square';
|
|
10
|
+
showName?: boolean;
|
|
11
|
+
nullLabel?: string;
|
|
12
|
+
class?: string;
|
|
13
|
+
}
|
|
14
|
+
declare const AvatarField: import("svelte").Component<Props, {}, "">;
|
|
15
|
+
type AvatarField = ReturnType<typeof AvatarField>;
|
|
16
|
+
export default AvatarField;
|