@svadmin/ui 0.56.0 → 0.58.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.
Files changed (52) hide show
  1. package/dist/app.css +28 -0
  2. package/dist/app.theme.css +28 -0
  3. package/dist/components/AICommandBar.svelte +33 -10
  4. package/dist/components/AdminApp.svelte +16 -5
  5. package/dist/components/AutoForm.svelte +4 -4
  6. package/dist/components/AutoTable.svelte +866 -204
  7. package/dist/components/AutoTable.svelte.d.ts +1 -1
  8. package/dist/components/CommandPalette.svelte +39 -17
  9. package/dist/components/ConfirmDialog.svelte +4 -3
  10. package/dist/components/ConfirmDialog.svelte.d.ts +1 -0
  11. package/dist/components/CopilotPanel.svelte +3 -3
  12. package/dist/components/DraggableHeader.svelte +15 -36
  13. package/dist/components/DraggableHeader.svelte.d.ts +13 -4
  14. package/dist/components/EditPage.svelte +4 -4
  15. package/dist/components/EmptyState.svelte +1 -1
  16. package/dist/components/ErrorComponent.svelte +1 -1
  17. package/dist/components/FieldRenderer.svelte +2 -1
  18. package/dist/components/ListPage.svelte +4 -12
  19. package/dist/components/ListPage.svelte.d.ts +1 -1
  20. package/dist/components/RecordDetailDrawer.svelte +121 -0
  21. package/dist/components/RecordDetailDrawer.svelte.d.ts +9 -0
  22. package/dist/components/ResourceAccessGuard.svelte +68 -0
  23. package/dist/components/ResourceAccessGuard.svelte.d.ts +12 -0
  24. package/dist/components/ResourceOperationsPage.svelte +8 -3
  25. package/dist/components/ShowPage.svelte +5 -6
  26. package/dist/components/TaskQueueDrawer.svelte +1 -1
  27. package/dist/components/TenantSwitcher.svelte +1 -0
  28. package/dist/components/Toast.svelte +35 -1
  29. package/dist/components/UndoableNotification.svelte +12 -6
  30. package/dist/components/UndoableNotification.svelte.d.ts +2 -0
  31. package/dist/components/account/MembersStarterPage.svelte +1 -1
  32. package/dist/components/buttons/CloneButton.svelte +9 -2
  33. package/dist/components/buttons/CreateButton.svelte +9 -2
  34. package/dist/components/buttons/DeleteButton.svelte +9 -2
  35. package/dist/components/buttons/EditButton.svelte +9 -2
  36. package/dist/components/buttons/ListButton.svelte +1 -1
  37. package/dist/components/buttons/ShowButton.svelte +9 -2
  38. package/dist/components/content/DataState.svelte +1 -1
  39. package/dist/components/content/FilterToolbar.svelte +6 -2
  40. package/dist/components/content/MediaThumbnail.svelte +109 -0
  41. package/dist/components/content/MediaThumbnail.svelte.d.ts +22 -0
  42. package/dist/components/content/ProjectCard.svelte +2 -1
  43. package/dist/components/content/SystemErrorState.svelte +1 -1
  44. package/dist/components/content/index.d.ts +2 -0
  45. package/dist/components/content/index.js +1 -0
  46. package/dist/components/fields/ImageField.svelte +19 -4
  47. package/dist/components/fields/ImageField.svelte.d.ts +4 -0
  48. package/dist/components/saved-list-views.d.ts +35 -0
  49. package/dist/components/saved-list-views.js +168 -0
  50. package/dist/index.d.ts +4 -0
  51. package/dist/index.js +3 -0
  52. package/package.json +2 -2
@@ -1,12 +1,11 @@
1
1
  <script lang="ts">
2
- import { captureAdminContext, useShow, getResource } from '@svadmin/core';
2
+ import { useNavigation, useShow, getResource } from '@svadmin/core';
3
3
  import { useTranslation } from '@svadmin/core/i18n';
4
4
  import type { Snippet } from 'svelte';
5
5
  import * as Card from './ui/card/index.js';
6
6
  import { Skeleton } from './ui/skeleton/index.js';
7
7
  import PageHeader from './PageHeader.svelte';
8
8
  import { getDisplayComponent } from './fieldComponentMap.js';
9
- import ListButton from './buttons/ListButton.svelte';
10
9
  import EditButton from './buttons/EditButton.svelte';
11
10
  import DeleteButton from './buttons/DeleteButton.svelte';
12
11
  import CloneButton from './buttons/CloneButton.svelte';
@@ -38,7 +37,7 @@
38
37
  class: className = '',
39
38
  }: Props = $props();
40
39
 
41
- const adminContext = captureAdminContext();
40
+ const navigation = useNavigation();
42
41
  const isCompact = $derived(density === 'compact');
43
42
 
44
43
  const resource = $derived(getResource(resourceName));
@@ -60,10 +59,10 @@
60
59
  <PageHeader
61
60
  title="{resource.label} {i18n.t('common.detail')} #{id}"
62
61
  {density}
63
- onBack={() => adminContext.navigate(`/${resourceName}`)}
62
+ onBack={() => navigation.list(resourceName)}
63
+ backLabel={i18n.t('common.backToList')}
64
64
  >
65
65
  {#snippet actions()}
66
- <ListButton resource={resourceName} hideText />
67
66
  {#if resource.canEdit !== false}
68
67
  <EditButton resource={resourceName} recordItemId={id} hideText />
69
68
  {/if}
@@ -71,7 +70,7 @@
71
70
  <CloneButton resource={resourceName} recordItemId={id} hideText />
72
71
  {/if}
73
72
  {#if resource.canDelete !== false}
74
- <DeleteButton resource={resourceName} recordItemId={id} hideText onSuccess={() => adminContext.navigate(`/${resourceName}`)} />
73
+ <DeleteButton resource={resourceName} recordItemId={id} hideText onSuccess={() => navigation.list(resourceName)} />
75
74
  {/if}
76
75
  <RefreshButton resource={resourceName} hideText />
77
76
  {#if headerActions}
@@ -362,7 +362,7 @@
362
362
  }}
363
363
  />
364
364
  {:else}
365
- <div class="flex h-full min-h-[280px] items-center justify-center rounded-lg border border-dashed border-border/60 bg-background/70 text-center text-sm text-muted-foreground">
365
+ <div class="flex h-full items-center justify-center rounded-lg border border-dashed border-border/60 bg-background/70 p-8 text-center text-sm text-muted-foreground">
366
366
  {i18n.t('task.selectHint')}
367
367
  </div>
368
368
  {/if}
@@ -31,6 +31,7 @@
31
31
  <DropdownMenu.Trigger>
32
32
  {#snippet child({ props }: { props: Record<string, unknown> })}
33
33
  <button
34
+ type="button"
34
35
  {...props}
35
36
  class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/50
36
37
  {collapsed ? 'justify-center' : ''}"
@@ -1,8 +1,17 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte';
3
3
  import { Toaster, toast as sonner, type ToasterProps } from 'svelte-sonner';
4
- import { getResolvedTheme, getToastQueue, consumeToastQueue, getPromiseQueue, consumePromiseQueue } from '@svadmin/core';
4
+ import {
5
+ consumePromiseQueue,
6
+ consumeToastQueue,
7
+ getPromiseQueue,
8
+ getResolvedTheme,
9
+ getToastQueue,
10
+ getToasts,
11
+ removeToast,
12
+ } from '@svadmin/core';
5
13
  import { registerToastHost, type ToastHostRegistration } from './toast-host.svelte.js';
14
+ import UndoableNotification from './UndoableNotification.svelte';
6
15
 
7
16
  let host = $state<ToastHostRegistration | null>(null);
8
17
  const isActiveHost = $derived(host?.isActive() ?? false);
@@ -56,8 +65,33 @@
56
65
  },
57
66
  }
58
67
  } satisfies ToasterProps);
68
+
69
+ function undo(id: number, callback?: () => void): void {
70
+ removeToast(id);
71
+ callback?.();
72
+ }
73
+
74
+ function commit(id: number, callback?: () => void): void {
75
+ removeToast(id);
76
+ callback?.();
77
+ }
59
78
  </script>
60
79
 
61
80
  {#if isActiveHost}
62
81
  <Toaster {...toasterProps} />
82
+ <div
83
+ class="pointer-events-none fixed bottom-4 left-4 right-4 z-[100] flex flex-col-reverse gap-2 sm:bottom-6 sm:left-1/2 sm:right-auto sm:w-[min(32rem,calc(100vw-3rem))] sm:-translate-x-1/2"
84
+ data-svadmin-undo-stack
85
+ >
86
+ {#each getToasts().slice(-3) as item (item.id)}
87
+ <UndoableNotification
88
+ message={item.message}
89
+ duration={item.duration}
90
+ embedded
91
+ managedExternally
92
+ onUndo={() => undo(item.id, item.onUndo)}
93
+ onTimeout={() => commit(item.id, item.onTimeout)}
94
+ />
95
+ {/each}
96
+ </div>
63
97
  {/if}
@@ -8,9 +8,11 @@
8
8
 
9
9
  const i18n = useTranslation();
10
10
 
11
- let { message, duration = 5000, onUndo, onTimeout } = $props<{
11
+ let { message, duration = 5000, embedded = false, managedExternally = false, onUndo, onTimeout } = $props<{
12
12
  message: string;
13
13
  duration?: number;
14
+ embedded?: boolean;
15
+ managedExternally?: boolean;
14
16
  onUndo: () => void;
15
17
  onTimeout: () => void;
16
18
  }>();
@@ -27,7 +29,7 @@
27
29
  if (remaining <= 0) {
28
30
  remaining = 0;
29
31
  clearInterval(interval);
30
- if (!dismissed) {
32
+ if (!dismissed && !managedExternally) {
31
33
  dismissed = true;
32
34
  onTimeout();
33
35
  }
@@ -51,17 +53,21 @@
51
53
 
52
54
  {#if !dismissed}
53
55
  <div
54
- class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] min-w-80 max-w-lg rounded-lg border bg-card shadow-lg overflow-hidden"
56
+ class={embedded
57
+ ? 'pointer-events-auto relative w-full overflow-hidden rounded-lg border bg-card shadow-lg'
58
+ : 'fixed bottom-4 left-4 right-4 z-[100] overflow-hidden rounded-lg border bg-card shadow-lg sm:bottom-6 sm:left-1/2 sm:right-auto sm:w-[min(32rem,calc(100vw-3rem))] sm:-translate-x-1/2'}
59
+ role="status"
60
+ aria-live="polite"
55
61
  transition:slide={{ duration: 250 }}
56
62
  >
57
- <div class="flex items-center justify-between px-4 py-3 gap-3">
58
- <p class="text-sm text-foreground">{message}</p>
63
+ <div class="flex items-center justify-between gap-3 px-4 py-3">
64
+ <p class="min-w-0 break-words text-sm text-foreground">{message}</p>
59
65
  <div class="flex items-center gap-1 flex-shrink-0">
60
66
  <Button variant="ghost" size="sm" onclick={handleUndo} class="font-semibold text-primary">
61
67
  <Undo2 class="h-3.5 w-3.5 mr-1" />
62
68
  {i18n.t('common.undo')}
63
69
  </Button>
64
- <Button variant="ghost" size="icon" class="h-7 w-7" onclick={handleDismiss}>
70
+ <Button variant="ghost" size="icon" class="h-7 w-7" onclick={handleDismiss} aria-label={i18n.t('common.close')}>
65
71
  <X class="h-3.5 w-3.5" />
66
72
  </Button>
67
73
  </div>
@@ -1,6 +1,8 @@
1
1
  type $$ComponentProps = {
2
2
  message: string;
3
3
  duration?: number;
4
+ embedded?: boolean;
5
+ managedExternally?: boolean;
4
6
  onUndo: () => void;
5
7
  onTimeout: () => void;
6
8
  };
@@ -25,7 +25,7 @@
25
25
  <ContentPageShell pageId="account-members-starter" width="wide">
26
26
  <ContentPageHeader title={isZh ? '开始组建团队' : 'Start building your team'} description={isZh ? '这是零成员工作区的起始页。邀请第一位成员,或通过 CSV 批量导入。' : 'This is the starting state for a workspace with no members. Invite the first teammate or import a CSV roster.'} />
27
27
 
28
- <section class="grid min-h-[28rem] overflow-hidden rounded-lg border border-border bg-card lg:grid-cols-[minmax(0,1fr)_21rem]">
28
+ <section class="grid overflow-hidden rounded-lg border border-border bg-card lg:grid-cols-[minmax(0,1fr)_21rem]">
29
29
  <div class="flex flex-col items-center justify-center px-6 py-12 text-center">
30
30
  <span class="flex size-14 items-center justify-center rounded-lg bg-primary/10 text-primary"><Users class="size-6" /></span>
31
31
  <Badge variant="outline" class="mt-5">0 {isZh ? '名成员' : 'members'}</Badge>
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { useNavigation, useCan, useTranslation } from '@svadmin/core';
3
+ import { getResource, useNavigation, useCan, useTranslation } from '@svadmin/core';
4
4
  import { Button } from '../ui/button/index.js';
5
5
  import { Copy } from '@lucide/svelte';
6
6
  import type { ButtonAccessControl } from './access-control';
@@ -27,6 +27,13 @@
27
27
  }>();
28
28
 
29
29
  const nav = useNavigation();
30
+ const resourceDefinition = $derived.by(() => {
31
+ try {
32
+ return getResource(resource);
33
+ } catch {
34
+ return null;
35
+ }
36
+ });
30
37
  const can = useCan(() => ({
31
38
  resource,
32
39
  action: 'create',
@@ -34,8 +41,8 @@
34
41
  meta: accessControl?.meta,
35
42
  queryOptions: { enabled: accessControl?.enabled ?? true }
36
43
  }));
37
- const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
38
44
  const displayText = $derived(label ?? i18n.t('common.clone'));
45
+ const hidden = $derived(resourceDefinition?.canCreate === false || (accessControl?.hideIfUnauthorized && !can.allowed));
39
46
  </script>
40
47
 
41
48
  {#if !hidden}
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { useNavigation, useCan, useTranslation } from '@svadmin/core';
3
+ import { getResource, useNavigation, useCan, useTranslation } from '@svadmin/core';
4
4
  import { Button } from '../ui/button/index.js';
5
5
  import { Plus } from '@lucide/svelte';
6
6
  import type { ButtonAccessControl } from './access-control';
@@ -24,6 +24,13 @@
24
24
  }>();
25
25
 
26
26
  const nav = useNavigation();
27
+ const resourceDefinition = $derived.by(() => {
28
+ try {
29
+ return getResource(resource);
30
+ } catch {
31
+ return null;
32
+ }
33
+ });
27
34
  const can = useCan(() => ({
28
35
  resource,
29
36
  action: 'create',
@@ -31,8 +38,8 @@
31
38
  meta: accessControl?.meta,
32
39
  queryOptions: { enabled: accessControl?.enabled ?? true }
33
40
  }));
34
- const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
35
41
  const displayText = $derived(label ?? i18n.t('common.create'));
42
+ const hidden = $derived(resourceDefinition?.canCreate === false || (accessControl?.hideIfUnauthorized && !can.allowed));
36
43
  </script>
37
44
 
38
45
  {#if !hidden}
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { useDelete, useCan, useTranslation } from '@svadmin/core';
3
+ import { getResource, useDelete, useCan, useTranslation } from '@svadmin/core';
4
4
  import { Button } from '../ui/button/index.js';
5
5
  import { Trash2 } from '@lucide/svelte';
6
6
  import type { ButtonAccessControl } from './access-control';
@@ -31,6 +31,13 @@
31
31
  }>();
32
32
 
33
33
  const deleteMut = useDelete({ get resource() { return resource; }, get mutationMode() { return undoable ? 'undoable' as const : 'pessimistic' as const; } });
34
+ const resourceDefinition = $derived.by(() => {
35
+ try {
36
+ return getResource(resource);
37
+ } catch {
38
+ return null;
39
+ }
40
+ });
34
41
  const can = useCan(() => ({
35
42
  resource,
36
43
  action: 'delete',
@@ -38,8 +45,8 @@
38
45
  meta: accessControl?.meta,
39
46
  queryOptions: { enabled: accessControl?.enabled ?? true }
40
47
  }));
41
- const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
42
48
  const displayText = $derived(label ?? i18n.t('common.delete'));
49
+ const hidden = $derived(resourceDefinition?.canDelete === false || (accessControl?.hideIfUnauthorized && !can.allowed));
43
50
  let confirming = $state(false);
44
51
 
45
52
  async function handleDelete() {
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { useNavigation, useCan, useTranslation } from '@svadmin/core';
3
+ import { getResource, useNavigation, useCan, useTranslation } from '@svadmin/core';
4
4
  import { Button } from '../ui/button/index.js';
5
5
  import { Pencil } from '@lucide/svelte';
6
6
  import type { ButtonAccessControl } from './access-control';
@@ -27,6 +27,13 @@
27
27
  }>();
28
28
 
29
29
  const nav = useNavigation();
30
+ const resourceDefinition = $derived.by(() => {
31
+ try {
32
+ return getResource(resource);
33
+ } catch {
34
+ return null;
35
+ }
36
+ });
30
37
  const can = useCan(() => ({
31
38
  resource,
32
39
  action: 'edit',
@@ -34,8 +41,8 @@
34
41
  meta: accessControl?.meta,
35
42
  queryOptions: { enabled: accessControl?.enabled ?? true }
36
43
  }));
37
- const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
38
44
  const displayText = $derived(label ?? i18n.t('common.edit'));
45
+ const hidden = $derived(resourceDefinition?.canEdit === false || (accessControl?.hideIfUnauthorized && !can.allowed));
39
46
  </script>
40
47
 
41
48
  {#if !hidden}
@@ -19,7 +19,7 @@
19
19
  const res = $derived((() => {
20
20
  try { return getResource(resourceName); } catch { return null; }
21
21
  })());
22
- const displayLabel = $derived(label ?? res?.label ?? i18n.t('common.list') ?? resourceName);
22
+ const displayLabel = $derived(label ?? (hideText ? i18n.t('common.backToList') : (res?.label ?? i18n.t('common.list') ?? resourceName)));
23
23
 
24
24
  function navigateToList() {
25
25
  const navigate = () => nav.list(resourceName);
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { useNavigation, useCan, useTranslation } from '@svadmin/core';
3
+ import { getResource, useNavigation, useCan, useTranslation } from '@svadmin/core';
4
4
  import { Button } from '../ui/button/index.js';
5
5
  import { Eye } from '@lucide/svelte';
6
6
  import type { ButtonAccessControl } from './access-control';
@@ -29,6 +29,13 @@
29
29
  }>();
30
30
 
31
31
  const nav = useNavigation();
32
+ const resourceDefinition = $derived.by(() => {
33
+ try {
34
+ return getResource(resource);
35
+ } catch {
36
+ return null;
37
+ }
38
+ });
32
39
  const can = useCan(() => ({
33
40
  resource,
34
41
  action: 'show',
@@ -36,7 +43,7 @@
36
43
  meta: accessControl?.meta,
37
44
  queryOptions: { enabled: accessControl?.enabled ?? true }
38
45
  }));
39
- const hidden = $derived(accessControl?.hideIfUnauthorized && !can.allowed);
46
+ const hidden = $derived(resourceDefinition?.canShow === false || (accessControl?.hideIfUnauthorized && !can.allowed));
40
47
  const displayText = $derived(label ?? i18n.t('common.detail'));
41
48
 
42
49
  function navigateToRecord() {
@@ -53,7 +53,7 @@
53
53
  {#if action}<div class="mt-3">{@render action()}</div>{/if}
54
54
  </Alert.Root>
55
55
  {:else}
56
- <div class={'flex min-h-40 flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card p-6 text-center ' + className}>
56
+ <div class={'flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card px-6 py-8 text-center ' + className}>
57
57
  <Inbox class="size-8 text-muted-foreground" /><h3 class="mt-3 text-sm font-semibold text-foreground">{resolvedTitle}</h3><p class="mt-1 max-w-sm text-sm text-muted-foreground">{resolvedDescription}</p>
58
58
  {#if action}<div class="mt-4">{@render action()}</div>{/if}
59
59
  </div>
@@ -6,6 +6,8 @@
6
6
  import { Badge } from '../ui/badge/index.js';
7
7
  import { cn } from '../../utils.js';
8
8
 
9
+ const uid = $props.id();
10
+
9
11
  interface Props {
10
12
  query?: string;
11
13
  placeholder?: string;
@@ -37,9 +39,10 @@
37
39
  }: Props = $props();
38
40
 
39
41
  const isCompact = $derived(density === 'compact');
42
+ const advancedPanelId = `svadmin-filter-toolbar-advanced-${uid}`;
40
43
  </script>
41
44
 
42
- <div class={cn('space-y-2', className)}>
45
+ <div class={cn('space-y-2', className)} data-svadmin-filter-toolbar data-advanced-open={advanced && advancedOpen ? 'true' : 'false'}>
43
46
  <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
44
47
  {#if showSearch}
45
48
  <div class="relative min-w-0 flex-1">
@@ -76,6 +79,7 @@
76
79
  class={cn('gap-1.5 shrink-0', isCompact ? 'h-8 text-xs px-2.5' : '')}
77
80
  onclick={() => advancedOpen = !advancedOpen}
78
81
  aria-expanded={advancedOpen}
82
+ aria-controls={advancedPanelId}
79
83
  >
80
84
  <SlidersHorizontal class="size-3.5" />
81
85
  <span>{advancedLabel}</span>
@@ -98,7 +102,7 @@
98
102
  {/if}
99
103
  </div>
100
104
  {#if advanced && advancedOpen}
101
- <div class="rounded-lg border border-border/60 bg-muted/20 p-3">
105
+ <div id={advancedPanelId} class="svadmin-filter-toolbar-advanced rounded-lg border border-border/60 bg-muted/20 p-3" aria-hidden="false">
102
106
  {@render advanced()}
103
107
  </div>
104
108
  {/if}
@@ -0,0 +1,109 @@
1
+ <script lang="ts">
2
+ import { Eye, FileText, ImageOff, Loader2 } from '@lucide/svelte';
3
+ import * as Dialog from '../ui/dialog/index.js';
4
+ import { cn } from '../../utils.js';
5
+
6
+ export type MediaThumbnailSize = 'sm' | 'md' | 'lg' | 'full';
7
+ export type MediaThumbnailFit = 'contain' | 'cover';
8
+
9
+ interface Props {
10
+ src?: string | null;
11
+ alt?: string;
12
+ title?: string;
13
+ fileName?: string;
14
+ mimeType?: string;
15
+ size?: MediaThumbnailSize;
16
+ fit?: MediaThumbnailFit;
17
+ aspectRatio?: string;
18
+ showOverlay?: boolean;
19
+ overlayText?: string;
20
+ emptyLabel?: string;
21
+ loadingLabel?: string;
22
+ errorLabel?: string;
23
+ onopen?: () => void;
24
+ class?: string;
25
+ }
26
+
27
+ let {
28
+ src = null,
29
+ alt = '',
30
+ title = '',
31
+ fileName = '',
32
+ mimeType = '',
33
+ size = 'md',
34
+ fit = 'contain',
35
+ aspectRatio,
36
+ showOverlay = true,
37
+ overlayText = 'View',
38
+ emptyLabel = 'No media',
39
+ loadingLabel = 'Loading media',
40
+ errorLabel = 'Image unavailable',
41
+ onopen,
42
+ class: className = '',
43
+ }: Props = $props();
44
+
45
+ const isImage = $derived(Boolean(
46
+ (mimeType && mimeType.startsWith('image/'))
47
+ || (src && /\.(png|jpe?g|webp|gif|svg|bmp|avif)(\?.*)?$/i.test(src))
48
+ || (fileName && /\.(png|jpe?g|webp|gif|svg|bmp|avif)$/i.test(fileName))
49
+ || (src && !mimeType && !fileName && !/\.(pdf|docx?|xlsx?|zip|tar|gz|txt|csv|json)$/i.test(src))
50
+ ));
51
+ const sizeClasses: Record<MediaThumbnailSize, string> = {
52
+ sm: 'h-16 min-w-16 max-w-24',
53
+ md: 'min-h-20 max-h-56 w-full',
54
+ lg: 'min-h-32 max-h-80 w-full',
55
+ full: 'h-full w-full',
56
+ };
57
+ const fitClasses: Record<MediaThumbnailFit, string> = {
58
+ contain: 'object-contain',
59
+ cover: 'object-cover',
60
+ };
61
+
62
+ let imageState = $state<'loading' | 'loaded' | 'error'>('loading');
63
+ let previewOpen = $state(false);
64
+
65
+ $effect(() => {
66
+ if (src && isImage) imageState = 'loading';
67
+ previewOpen = false;
68
+ });
69
+
70
+ function openMedia() {
71
+ if (onopen) onopen();
72
+ else previewOpen = true;
73
+ }
74
+
75
+ function openDocument() {
76
+ if (onopen) onopen();
77
+ else if (src) window.open(src, '_blank', 'noopener,noreferrer');
78
+ }
79
+ </script>
80
+
81
+ {#if isImage && src}
82
+ <div class={cn('relative flex items-center justify-center overflow-hidden rounded-lg border border-border bg-muted/30 transition-colors', sizeClasses[size], className)} style:aspect-ratio={aspectRatio} data-slot="media-thumbnail" data-size={size} data-media-state={imageState} aria-busy={imageState === 'loading' || undefined}>
83
+ <img {src} alt={alt || title || fileName || 'Thumbnail'} class={cn('block max-h-full max-w-full transition-transform duration-200', showOverlay && 'group-hover:scale-[1.02]', fitClasses[fit], imageState !== 'loaded' && 'invisible')} loading="lazy" onload={() => imageState = 'loaded'} onerror={() => imageState = 'error'} />
84
+
85
+ {#if imageState === 'loading'}
86
+ <div class="absolute inset-0 flex items-center justify-center bg-muted/40" role="status" aria-label={loadingLabel}><Loader2 class="size-4 animate-spin text-muted-foreground" aria-hidden="true" /></div>
87
+ {:else if imageState === 'error'}
88
+ <div class="absolute inset-0 flex flex-col items-center justify-center gap-1 p-3 text-center text-xs text-muted-foreground" role="img" aria-label={errorLabel} data-slot="media-thumbnail-error"><ImageOff class="size-5" aria-hidden="true" /><span class="max-w-[12rem] truncate text-[11px]">{errorLabel}</span></div>
89
+ {:else if showOverlay}
90
+ <Dialog.Root bind:open={previewOpen}>
91
+ <button type="button" class="group absolute inset-0 cursor-zoom-in outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" onclick={openMedia} aria-label={alt ? `Preview ${alt}` : overlayText}>
92
+ <span class="pointer-events-none absolute inset-0 flex items-center justify-center gap-1.5 bg-background/60 text-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" aria-hidden="true"><Eye class="size-4" /><span class="text-xs font-semibold">{overlayText}</span></span>
93
+ </button>
94
+ {#if !onopen}
95
+ <Dialog.Content class="max-w-[min(92vw,72rem)] border-none bg-transparent p-0 shadow-none">
96
+ <Dialog.Header class="sr-only"><Dialog.Title>{alt || title || fileName || 'Media preview'}</Dialog.Title><Dialog.Description>{alt || title || fileName || 'Expanded media preview'}</Dialog.Description></Dialog.Header>
97
+ <img {src} alt={alt || title || fileName || 'Media preview'} class="max-h-[82vh] w-full rounded-md object-contain" />
98
+ </Dialog.Content>
99
+ {/if}
100
+ </Dialog.Root>
101
+ {/if}
102
+ </div>
103
+ {:else if src || fileName}
104
+ <button type="button" class={cn('inline-flex items-center gap-2 rounded-lg border border-border bg-muted/20 px-3 py-2 text-left text-xs text-foreground transition-colors hover:bg-muted/40', className)} onclick={openDocument} data-slot="media-thumbnail-document">
105
+ <FileText class="size-4 shrink-0 text-muted-foreground" /><span class="truncate font-medium" title={fileName || alt || title}>{fileName || alt || title || 'Document'}</span>
106
+ </button>
107
+ {:else}
108
+ <span class={cn('inline-flex items-center gap-1 text-xs text-muted-foreground', className)} data-media-state="empty"><ImageOff class="size-3.5" aria-hidden="true" /><span>{emptyLabel}</span></span>
109
+ {/if}
@@ -0,0 +1,22 @@
1
+ export type MediaThumbnailSize = 'sm' | 'md' | 'lg' | 'full';
2
+ export type MediaThumbnailFit = 'contain' | 'cover';
3
+ interface Props {
4
+ src?: string | null;
5
+ alt?: string;
6
+ title?: string;
7
+ fileName?: string;
8
+ mimeType?: string;
9
+ size?: MediaThumbnailSize;
10
+ fit?: MediaThumbnailFit;
11
+ aspectRatio?: string;
12
+ showOverlay?: boolean;
13
+ overlayText?: string;
14
+ emptyLabel?: string;
15
+ loadingLabel?: string;
16
+ errorLabel?: string;
17
+ onopen?: () => void;
18
+ class?: string;
19
+ }
20
+ declare const MediaThumbnail: import("svelte").Component<Props, {}, "">;
21
+ type MediaThumbnail = ReturnType<typeof MediaThumbnail>;
22
+ export default MediaThumbnail;
@@ -5,6 +5,7 @@
5
5
  import { Badge } from '../ui/badge/index.js';
6
6
  import Progress from '../ui/progress/progress.svelte';
7
7
  import StatusBadge from './StatusBadge.svelte';
8
+ import MediaThumbnail from './MediaThumbnail.svelte';
8
9
  export type ProjectStatus = 'active' | 'completed' | 'on-hold' | 'draft';
9
10
  export interface ProjectSummary {
10
11
  id: string;
@@ -22,7 +23,7 @@
22
23
  const progress = $derived(project.progress ?? (project.status === 'completed' ? 100 : project.status === 'active' ? 72 : 0));
23
24
  </script>
24
25
  <Card.Card data-interactive={onclick ? 'true' : undefined} class={'h-full ' + className}>
25
- {#if project.image}<img src={project.image} alt={project.name} class="h-32 w-full object-cover" />{/if}
26
+ {#if project.image}<div class="h-32 w-full"><MediaThumbnail src={project.image} alt={project.name} size="full" fit="cover" showOverlay={false} /></div>{/if}
26
27
  <Card.CardContent class="flex h-full flex-col gap-4 p-4">
27
28
  <div class="flex items-start justify-between gap-3">
28
29
  <div class="flex min-w-0 items-center gap-3"><span class="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted/50 text-muted-foreground"><FolderKanban class="size-4" /></span><h3 class="truncate text-sm font-semibold text-foreground">{project.name}</h3></div>
@@ -13,6 +13,6 @@
13
13
  const resolvedTitle = $derived(title ?? (isNotFound ? i18n.t('common.pageNotFound') : i18n.t('common.error')));
14
14
  const resolvedDescription = $derived(description ?? (isNotFound ? i18n.t('error.pageNotFoundDescription') : i18n.t('error.internalServerErrorDescription')));
15
15
  </script>
16
- <div class="flex min-h-[24rem] items-center justify-center p-4" data-svadmin-system-error>
16
+ <div class="flex items-center justify-center p-4" data-svadmin-system-error>
17
17
  <Card.Card class="w-full max-w-md"><Card.CardContent class="space-y-5 p-8 text-center"><div class="mx-auto flex size-14 items-center justify-center rounded-lg bg-muted text-muted-foreground">{#if isNotFound}<FileQuestion class="size-7" />{:else}<AlertTriangle class="size-7 text-destructive" />{/if}</div><div class="space-y-2"><p class="text-xs font-medium text-muted-foreground">{status}</p><h2 class="text-xl font-semibold text-foreground">{resolvedTitle}</h2><p class="text-sm leading-6 text-muted-foreground">{resolvedDescription}</p></div>{#if action}<div>{@render action()}</div>{/if}<div class="flex flex-wrap justify-center gap-2"><Button variant="outline" onclick={() => adminContext.back()}><ArrowLeft class="size-4" />{i18n.t('common.back')}</Button><Button onclick={() => adminContext.navigate('/')}><Home class="size-4" />{i18n.t('common.returnHome')}</Button></div></Card.CardContent></Card.Card>
18
18
  </div>
@@ -46,3 +46,5 @@ export { default as FeedbackNotice } from './FeedbackNotice.svelte';
46
46
  export type { FeedbackNoticeTone, FeedbackNoticePriority } from './FeedbackNotice.svelte';
47
47
  export { default as AuditTimeline } from './AuditTimeline.svelte';
48
48
  export type { TimelineItem } from './AuditTimeline.svelte';
49
+ export { default as MediaThumbnail } from './MediaThumbnail.svelte';
50
+ export type { MediaThumbnailSize, MediaThumbnailFit } from './MediaThumbnail.svelte';
@@ -31,3 +31,4 @@ export { default as TwoFactorStepper } from './TwoFactorStepper.svelte';
31
31
  export { default as SystemErrorState } from './SystemErrorState.svelte';
32
32
  export { default as FeedbackNotice } from './FeedbackNotice.svelte';
33
33
  export { default as AuditTimeline } from './AuditTimeline.svelte';
34
+ export { default as MediaThumbnail } from './MediaThumbnail.svelte';
@@ -1,9 +1,24 @@
1
1
  <script lang="ts">
2
- interface Props { value: string | null | undefined; alt?: string; width?: number; height?: number }
3
- let { value, alt = '', width = 80, height = 80 }: Props = $props();
2
+ import MediaThumbnail from '../content/MediaThumbnail.svelte';
3
+
4
+ interface Props {
5
+ value: string | null | undefined;
6
+ alt?: string;
7
+ title?: string;
8
+ width?: number;
9
+ height?: number;
10
+ previewable?: boolean;
11
+ emptyLabel?: string;
12
+ class?: string;
13
+ }
14
+
15
+ let { value, alt = '', title, width = 80, height = 80, previewable = true, emptyLabel = 'No image', class: className = '' }: Props = $props();
4
16
  </script>
17
+
5
18
  {#if value}
6
- <img src={value} {alt} {width} {height} class="rounded-lg object-cover border border-border" />
19
+ <div style={`width: ${width}px; height: ${height}px;`} class="inline-block shrink-0">
20
+ <MediaThumbnail src={value} {alt} {title} size="full" fit="cover" showOverlay={previewable} class={className} />
21
+ </div>
7
22
  {:else}
8
- <span class="inline-flex items-center justify-center w-20 h-20 bg-muted rounded-lg text-muted-foreground">—</span>
23
+ <MediaThumbnail src={null} {emptyLabel} class={className} />
9
24
  {/if}
@@ -1,8 +1,12 @@
1
1
  interface Props {
2
2
  value: string | null | undefined;
3
3
  alt?: string;
4
+ title?: string;
4
5
  width?: number;
5
6
  height?: number;
7
+ previewable?: boolean;
8
+ emptyLabel?: string;
9
+ class?: string;
6
10
  }
7
11
  declare const ImageField: import("svelte").Component<Props, {}, "">;
8
12
  type ImageField = ReturnType<typeof ImageField>;