@svadmin/lite 0.25.7 → 0.26.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 (33) hide show
  1. package/README.md +11 -5
  2. package/dist/components/LiteAuditLog.svelte +102 -36
  3. package/dist/components/LiteAuditLog.svelte.d.ts +6 -3
  4. package/dist/components/LiteConcurrentConflictModal.svelte +110 -0
  5. package/dist/components/LiteConcurrentConflictModal.svelte.d.ts +21 -0
  6. package/dist/components/LiteCronExpressionBuilder.svelte +78 -0
  7. package/dist/components/LiteCronExpressionBuilder.svelte.d.ts +17 -0
  8. package/dist/components/LiteMetricGrid.svelte +143 -0
  9. package/dist/components/LiteMetricGrid.svelte.d.ts +31 -0
  10. package/dist/components/LiteMicroAppFrame.svelte +57 -0
  11. package/dist/components/LiteMicroAppFrame.svelte.d.ts +15 -0
  12. package/dist/components/LiteOrgChart.svelte +78 -0
  13. package/dist/components/LiteOrgChart.svelte.d.ts +17 -0
  14. package/dist/components/LitePresenceAvatarGroup.svelte +118 -15
  15. package/dist/components/LitePresenceAvatarGroup.svelte.d.ts +8 -3
  16. package/dist/components/LiteSafeDeleteConfirm.svelte +86 -0
  17. package/dist/components/LiteSafeDeleteConfirm.svelte.d.ts +21 -0
  18. package/dist/components/LiteSignaturePad.svelte +3 -1
  19. package/dist/components/LiteSignaturePad.svelte.d.ts +1 -0
  20. package/dist/components/LiteTaskCenter.svelte +133 -0
  21. package/dist/components/LiteTaskCenter.svelte.d.ts +24 -0
  22. package/dist/components/LiteTour.svelte +4 -0
  23. package/dist/components/LiteTour.svelte.d.ts +4 -0
  24. package/dist/components/LiteTrendIndicator.svelte +119 -0
  25. package/dist/components/LiteTrendIndicator.svelte.d.ts +19 -0
  26. package/dist/components/LiteVirtualTreeSelect.svelte +92 -0
  27. package/dist/components/LiteVirtualTreeSelect.svelte.d.ts +20 -0
  28. package/dist/components/widgets/LiteGaugeChart.svelte +50 -3
  29. package/dist/components/widgets/LiteGaugeChart.svelte.d.ts +12 -0
  30. package/dist/index.d.ts +14 -0
  31. package/dist/index.js +10 -0
  32. package/dist/rich-content.js +1 -1
  33. package/package.json +1 -1
@@ -0,0 +1,31 @@
1
+ import type { Snippet } from 'svelte';
2
+ type MetricColumns = 1 | 2 | 3 | 4 | 5 | 6 | 'auto';
3
+ type MetricGap = 'sm' | 'md' | 'lg';
4
+ interface LiteMetricGridItem {
5
+ id?: string;
6
+ label: string;
7
+ value: string | number;
8
+ description?: string;
9
+ trend?: 'up' | 'down' | 'neutral';
10
+ trendValue?: string;
11
+ icon?: string;
12
+ href?: string;
13
+ }
14
+ interface Props {
15
+ items?: LiteMetricGridItem[];
16
+ cols?: MetricColumns;
17
+ gap?: MetricGap;
18
+ title?: string;
19
+ description?: string;
20
+ loading?: boolean;
21
+ skeletonCount?: number;
22
+ empty?: boolean;
23
+ emptyMessage?: string;
24
+ class?: string;
25
+ actions?: Snippet;
26
+ children?: Snippet;
27
+ emptyContent?: Snippet;
28
+ }
29
+ declare const LiteMetricGrid: import("svelte").Component<Props, {}, "">;
30
+ type LiteMetricGrid = ReturnType<typeof LiteMetricGrid>;
31
+ export default LiteMetricGrid;
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ import type { HTMLIframeAttributes } from 'svelte/elements';
3
+
4
+ interface Props {
5
+ src: string;
6
+ title?: string;
7
+ height?: string | number;
8
+ sandbox?: string;
9
+ allow?: string;
10
+ referrerpolicy?: HTMLIframeAttributes['referrerpolicy'];
11
+ fallbackHref?: string;
12
+ fallbackLabel?: string;
13
+ class?: string;
14
+ }
15
+
16
+ let {
17
+ src,
18
+ title = 'Embedded application',
19
+ height = '650px',
20
+ sandbox = 'allow-scripts allow-forms allow-same-origin',
21
+ allow,
22
+ referrerpolicy = 'strict-origin-when-cross-origin',
23
+ fallbackHref,
24
+ fallbackLabel = 'Open in a new tab',
25
+ class: className = '',
26
+ }: Props = $props();
27
+
28
+ const frameHeight = $derived(typeof height === 'number' ? `${height}px` : height);
29
+ </script>
30
+
31
+ <section class="lite-micro-app-frame lite-card {className}" aria-label={title}>
32
+ <header>
33
+ <h2>{title}</h2>
34
+ {#if fallbackHref}<a href={fallbackHref} target="_blank" rel="noopener noreferrer">{fallbackLabel}</a>{/if}
35
+ </header>
36
+
37
+ <iframe
38
+ {src}
39
+ {title}
40
+ {sandbox}
41
+ {allow}
42
+ {referrerpolicy}
43
+ style={`height:${frameHeight};`}
44
+ loading="lazy"
45
+ ></iframe>
46
+ <p class="lite-micro-app-help">Embedded messaging, theme sync and automatic height are client-side enhancements.</p>
47
+ </section>
48
+
49
+ <style>
50
+ .lite-micro-app-frame { padding: 12px; }
51
+ .lite-micro-app-frame header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
52
+ .lite-micro-app-frame header a { margin-left: 12px; }
53
+ .lite-micro-app-frame h2 { margin: 0; color: #1d2129; font-size: 15px; }
54
+ .lite-micro-app-frame header a { color: #165dff; font-size: 12px; }
55
+ .lite-micro-app-frame iframe { display: block; width: 100%; border: 1px solid #e5e6eb; border-radius: 4px; background: #fff; }
56
+ .lite-micro-app-help { margin: 6px 0 0; color: #86909c; font-size: 11px; }
57
+ </style>
@@ -0,0 +1,15 @@
1
+ import type { HTMLIframeAttributes } from 'svelte/elements';
2
+ interface Props {
3
+ src: string;
4
+ title?: string;
5
+ height?: string | number;
6
+ sandbox?: string;
7
+ allow?: string;
8
+ referrerpolicy?: HTMLIframeAttributes['referrerpolicy'];
9
+ fallbackHref?: string;
10
+ fallbackLabel?: string;
11
+ class?: string;
12
+ }
13
+ declare const LiteMicroAppFrame: import("svelte").Component<Props, {}, "">;
14
+ type LiteMicroAppFrame = ReturnType<typeof LiteMicroAppFrame>;
15
+ export default LiteMicroAppFrame;
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ export interface LiteOrgNode {
3
+ id: string;
4
+ name: string;
5
+ title?: string;
6
+ department?: string;
7
+ href?: string;
8
+ children?: LiteOrgNode[];
9
+ }
10
+
11
+ interface Props {
12
+ data?: LiteOrgNode | LiteOrgNode[];
13
+ title?: string;
14
+ emptyText?: string;
15
+ class?: string;
16
+ }
17
+
18
+ let {
19
+ data = [],
20
+ title = 'Organization chart',
21
+ emptyText = 'No organization data',
22
+ class: className = '',
23
+ }: Props = $props();
24
+
25
+ interface FlatNode {
26
+ node: LiteOrgNode;
27
+ depth: number;
28
+ }
29
+
30
+ function flatten(nodes: LiteOrgNode[], depth = 0, out: FlatNode[] = []): FlatNode[] {
31
+ for (const node of nodes) {
32
+ out.push({ node, depth });
33
+ if (node.children?.length) flatten(node.children, depth + 1, out);
34
+ }
35
+ return out;
36
+ }
37
+
38
+ const rows = $derived(flatten(Array.isArray(data) ? data : [data]));
39
+ </script>
40
+
41
+ <section class="lite-org-chart lite-card {className}" aria-label={title}>
42
+ <h2>{title}</h2>
43
+ {#if rows.length === 0}
44
+ <p class="lite-org-empty">{emptyText}</p>
45
+ {:else}
46
+ <table class="lite-table">
47
+ <thead>
48
+ <tr>
49
+ <th>Name</th>
50
+ <th>Title</th>
51
+ <th>Department</th>
52
+ </tr>
53
+ </thead>
54
+ <tbody>
55
+ {#each rows as row (row.node.id)}
56
+ <tr data-depth={row.depth}>
57
+ <td>
58
+ <span class="lite-org-indent" style={`padding-left:${row.depth * 16}px`}>
59
+ {#if row.depth > 0}<span class="lite-org-branch" aria-hidden="true">└</span>{/if}
60
+ {#if row.node.href}<a href={row.node.href}>{row.node.name}</a>{:else}{row.node.name}{/if}
61
+ </span>
62
+ </td>
63
+ <td>{row.node.title ?? '—'}</td>
64
+ <td>{row.node.department ?? '—'}</td>
65
+ </tr>
66
+ {/each}
67
+ </tbody>
68
+ </table>
69
+ {/if}
70
+ </section>
71
+
72
+ <style>
73
+ .lite-org-chart { padding: 16px; }
74
+ .lite-org-chart h2 { margin: 0 0 12px; color: #1d2129; font-size: 16px; }
75
+ .lite-org-empty { margin: 0; padding: 20px; color: #86909c; text-align: center; }
76
+ .lite-org-indent { display: inline-block; }
77
+ .lite-org-branch { display: inline-block; margin-right: 4px; color: #86909c; }
78
+ </style>
@@ -0,0 +1,17 @@
1
+ export interface LiteOrgNode {
2
+ id: string;
3
+ name: string;
4
+ title?: string;
5
+ department?: string;
6
+ href?: string;
7
+ children?: LiteOrgNode[];
8
+ }
9
+ interface Props {
10
+ data?: LiteOrgNode | LiteOrgNode[];
11
+ title?: string;
12
+ emptyText?: string;
13
+ class?: string;
14
+ }
15
+ declare const LiteOrgChart: import("svelte").Component<Props, {}, "">;
16
+ type LiteOrgChart = ReturnType<typeof LiteOrgChart>;
17
+ export default LiteOrgChart;
@@ -1,55 +1,158 @@
1
1
  <script lang="ts">
2
- interface LitePresenceUser {
2
+ export interface LitePresenceUser {
3
3
  id: string;
4
4
  name: string;
5
- status?: 'online' | 'idle' | 'editing';
5
+ avatar?: string;
6
+ role?: string;
7
+ status?: 'online' | 'viewing' | 'idle' | 'editing';
6
8
  }
7
9
 
8
10
  interface Props {
9
11
  users?: LitePresenceUser[];
10
12
  label?: string;
11
- /** Caller class merged with the built-in class list. */
13
+ maxVisible?: number;
14
+ maxCount?: number;
15
+ size?: 'sm' | 'md' | 'lg';
16
+ showStatusDot?: boolean;
12
17
  class?: string;
13
18
  }
14
19
 
15
20
  let {
16
21
  users = [],
17
22
  label = 'Viewing now',
18
- class: className = ''
23
+ maxVisible,
24
+ maxCount,
25
+ size = 'md',
26
+ showStatusDot = true,
27
+ class: className = '',
19
28
  }: Props = $props();
29
+
30
+ const limit = $derived(Math.max(0, maxVisible ?? maxCount ?? 4));
31
+ const visible = $derived(users.slice(0, limit));
32
+ const hidden = $derived(users.slice(limit));
33
+
34
+ function initials(name: string): string {
35
+ return name
36
+ .trim()
37
+ .split(/\s+/)
38
+ .map((part) => part[0] ?? '')
39
+ .join('')
40
+ .slice(0, 2)
41
+ .toUpperCase() || 'U';
42
+ }
43
+
44
+ function statusLabel(status: LitePresenceUser['status']): string {
45
+ return status ?? 'online';
46
+ }
20
47
  </script>
21
48
 
22
- <div class="lite-presence-container {className}">
49
+ <div class="lite-presence-container lite-presence-{size} {className}">
23
50
  <span class="lite-presence-label">{label}:</span>
24
- {#each users as user (user.id)}
25
- <span class="lite-presence-badge" title="{user.name} ({user.status ?? 'online'})">
26
- 👤 {user.name}
51
+
52
+ {#each visible as user (user.id)}
53
+ <span
54
+ class="lite-presence-badge"
55
+ title={`${user.name}${user.role ? ` (${user.role})` : ''} · ${statusLabel(user.status)}`}
56
+ data-presence-user={user.id}
57
+ data-status={statusLabel(user.status)}
58
+ >
59
+ {#if user.avatar}
60
+ <img src={user.avatar} alt="" class="lite-presence-avatar" />
61
+ {:else}
62
+ <span class="lite-presence-initials" aria-hidden="true">{initials(user.name)}</span>
63
+ {/if}
64
+ <span class="lite-presence-name">{user.name}</span>
65
+ {#if showStatusDot && user.status}
66
+ <span class="lite-presence-dot" aria-hidden="true"></span>
67
+ {/if}
27
68
  </span>
28
69
  {/each}
70
+
71
+ {#if hidden.length > 0}
72
+ <span
73
+ class="lite-presence-overflow"
74
+ title={hidden.map((user) => user.name).join(', ')}
75
+ aria-label={`+${hidden.length} more users`}
76
+ >
77
+ +{hidden.length}
78
+ </span>
79
+ {/if}
80
+
29
81
  {#if users.length === 0}
30
- <span style="color: #86909C; font-size: 11px;">No active viewers</span>
82
+ <span class="lite-presence-empty">No active viewers</span>
31
83
  {/if}
32
84
  </div>
33
85
 
34
86
  <style>
35
87
  .lite-presence-container {
36
88
  display: inline-flex;
89
+ flex-wrap: wrap;
37
90
  align-items: center;
38
91
  font-size: 12px;
39
92
  margin-bottom: 8px;
40
93
  }
41
94
  .lite-presence-label {
42
- color: #4E5969;
43
- margin-right: 6px;
95
+ color: #4e5969;
96
+ margin-right: 2px;
44
97
  font-weight: 500;
45
98
  }
46
99
  .lite-presence-badge {
100
+ position: relative;
101
+ display: inline-flex;
102
+ align-items: center;
103
+ padding: 2px 6px 2px 3px;
104
+ background: #f2f3f5;
105
+ border: 1px solid #e5e6eb;
106
+ border-radius: 999px;
107
+ margin-right: 2px;
108
+ font-size: 11px;
109
+ line-height: 1.4;
110
+ }
111
+ .lite-presence-avatar,
112
+ .lite-presence-initials {
47
113
  display: inline-block;
48
- padding: 2px 6px;
49
- background: #F2F3F5;
50
- border: 1px solid #E5E6EB;
51
- border-radius: 4px;
114
+ width: 20px;
115
+ height: 20px;
116
+ flex: 0 0 20px;
52
117
  margin-right: 4px;
118
+ border-radius: 999px;
119
+ text-align: center;
120
+ line-height: 20px;
121
+ object-fit: cover;
122
+ background: #e8f3ff;
123
+ color: #165dff;
124
+ font-size: 9px;
125
+ font-weight: 700;
126
+ }
127
+ .lite-presence-name { max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
128
+ .lite-presence-dot {
129
+ margin-left: 4px;
130
+ width: 6px;
131
+ height: 6px;
132
+ border-radius: 999px;
133
+ background: #00b42a;
134
+ box-shadow: 0 0 0 1px #fff;
135
+ }
136
+ .lite-presence-badge[data-status='idle'] .lite-presence-dot { background: #86909c; }
137
+ .lite-presence-badge[data-status='editing'] .lite-presence-dot { background: #ff7d00; }
138
+ .lite-presence-badge[data-status='viewing'] .lite-presence-dot { background: #00b42a; }
139
+ .lite-presence-overflow {
140
+ display: inline-block;
141
+ min-width: 24px;
142
+ line-height: 24px;
143
+ text-align: center;
144
+ height: 24px;
145
+ padding: 0 5px;
146
+ border: 1px solid #e5e6eb;
147
+ border-radius: 999px;
148
+ background: #fff;
149
+ color: #4e5969;
53
150
  font-size: 11px;
151
+ font-weight: 600;
54
152
  }
153
+ .lite-presence-empty { color: #86909c; font-size: 11px; }
154
+ .lite-presence-sm .lite-presence-avatar,
155
+ .lite-presence-sm .lite-presence-initials { width: 16px; height: 16px; flex-basis: 16px; font-size: 8px; }
156
+ .lite-presence-lg .lite-presence-avatar,
157
+ .lite-presence-lg .lite-presence-initials { width: 26px; height: 26px; flex-basis: 26px; font-size: 10px; }
55
158
  </style>
@@ -1,12 +1,17 @@
1
- interface LitePresenceUser {
1
+ export interface LitePresenceUser {
2
2
  id: string;
3
3
  name: string;
4
- status?: 'online' | 'idle' | 'editing';
4
+ avatar?: string;
5
+ role?: string;
6
+ status?: 'online' | 'viewing' | 'idle' | 'editing';
5
7
  }
6
8
  interface Props {
7
9
  users?: LitePresenceUser[];
8
10
  label?: string;
9
- /** Caller class merged with the built-in class list. */
11
+ maxVisible?: number;
12
+ maxCount?: number;
13
+ size?: 'sm' | 'md' | 'lg';
14
+ showStatusDot?: boolean;
10
15
  class?: string;
11
16
  }
12
17
  declare const LitePresenceAvatarGroup: import("svelte").Component<Props, {}, "">;
@@ -0,0 +1,86 @@
1
+ <script lang="ts">
2
+ export interface LiteDeleteImpact {
3
+ label: string;
4
+ count?: number;
5
+ description?: string;
6
+ }
7
+
8
+ interface Props {
9
+ title?: string;
10
+ description?: string;
11
+ impactItems?: LiteDeleteImpact[];
12
+ action: string;
13
+ method?: 'POST' | 'post';
14
+ confirmLabel?: string;
15
+ cancelHref?: string;
16
+ cancelLabel?: string;
17
+ confirmationText?: string;
18
+ name?: string;
19
+ class?: string;
20
+ }
21
+
22
+ let {
23
+ title = 'Delete this record?',
24
+ description = 'This action cannot be undone. Review the impact before continuing.',
25
+ impactItems = [],
26
+ action,
27
+ method = 'POST',
28
+ confirmLabel = 'Delete permanently',
29
+ cancelHref,
30
+ cancelLabel = 'Cancel',
31
+ confirmationText,
32
+ name = 'confirm',
33
+ class: className = '',
34
+ }: Props = $props();
35
+ </script>
36
+
37
+ <section class="lite-safe-delete lite-card {className}" aria-label={title}>
38
+ <header>
39
+ <h2>{title}</h2>
40
+ <p>{description}</p>
41
+ </header>
42
+
43
+ {#if impactItems.length > 0}
44
+ <ul class="lite-safe-delete-impact">
45
+ {#each impactItems as item (item.label)}
46
+ <li>
47
+ <strong>{item.label}</strong>
48
+ {#if typeof item.count === 'number'}<span class="lite-safe-delete-count">{item.count}</span>{/if}
49
+ {#if item.description}<span class="lite-safe-delete-desc">{item.description}</span>{/if}
50
+ </li>
51
+ {/each}
52
+ </ul>
53
+ {/if}
54
+
55
+ <form method={method} action={action}>
56
+ {#if confirmationText}
57
+ <label for="lite-delete-confirmation">
58
+ Type <strong>{confirmationText}</strong> to confirm
59
+ </label>
60
+ <input id="lite-delete-confirmation" name={name} autocomplete="off" required />
61
+ {/if}
62
+ <div class="lite-safe-delete-actions">
63
+ <button type="submit" class="lite-safe-delete-danger">{confirmLabel}</button>
64
+ {#if cancelHref}<a href={cancelHref}>{cancelLabel}</a>{/if}
65
+ </div>
66
+ </form>
67
+ </section>
68
+
69
+ <style>
70
+ .lite-safe-delete { padding: 16px; border-color: #ffd8d2; }
71
+ .lite-safe-delete header h2 { margin: 0; color: #1d2129; font-size: 16px; }
72
+ .lite-safe-delete header p { margin: 6px 0 12px; color: #4e5969; font-size: 12px; line-height: 1.5; }
73
+ .lite-safe-delete-impact { margin: 0 0 12px; padding: 0; list-style: none; }
74
+ .lite-safe-delete-impact li { display: flex; align-items: baseline; padding: 7px 9px; border: 1px solid #ffe4e0; border-radius: 4px; background: #fff7f6; color: #4e5969; font-size: 12px; }
75
+ .lite-safe-delete-impact li > * + * { margin-left: 6px; }
76
+ .lite-safe-delete-impact li + li { margin-top: 6px; }
77
+ .lite-safe-delete-impact strong { color: #1d2129; }
78
+ .lite-safe-delete-count { padding: 1px 5px; border-radius: 999px; background: #f53f3f; color: #fff; font-size: 11px; }
79
+ .lite-safe-delete-desc { color: #86909c; }
80
+ .lite-safe-delete label { display: block; margin-bottom: 5px; color: #4e5969; font-size: 12px; }
81
+ .lite-safe-delete input { width: 100%; box-sizing: border-box; padding: 7px 9px; border: 1px solid #e5e6eb; border-radius: 4px; font: inherit; font-size: 13px; }
82
+ .lite-safe-delete-actions { display: flex; align-items: center; margin-top: 12px; }
83
+ .lite-safe-delete-actions > * + * { margin-left: 12px; }
84
+ .lite-safe-delete-danger { padding: 7px 12px; border: 0; border-radius: 4px; background: #f53f3f; color: #fff; font-size: 12px; cursor: pointer; }
85
+ .lite-safe-delete-actions a { color: #4e5969; font-size: 12px; }
86
+ </style>
@@ -0,0 +1,21 @@
1
+ export interface LiteDeleteImpact {
2
+ label: string;
3
+ count?: number;
4
+ description?: string;
5
+ }
6
+ interface Props {
7
+ title?: string;
8
+ description?: string;
9
+ impactItems?: LiteDeleteImpact[];
10
+ action: string;
11
+ method?: 'POST' | 'post';
12
+ confirmLabel?: string;
13
+ cancelHref?: string;
14
+ cancelLabel?: string;
15
+ confirmationText?: string;
16
+ name?: string;
17
+ class?: string;
18
+ }
19
+ declare const LiteSafeDeleteConfirm: import("svelte").Component<Props, {}, "">;
20
+ type LiteSafeDeleteConfirm = ReturnType<typeof LiteSafeDeleteConfirm>;
21
+ export default LiteSafeDeleteConfirm;
@@ -4,6 +4,7 @@
4
4
  interface Props {
5
5
  name?: string;
6
6
  value?: string;
7
+ alt?: string;
7
8
  label?: string;
8
9
  formAction?: string;
9
10
  disabled?: boolean;
@@ -13,6 +14,7 @@
13
14
  let {
14
15
  name = 'signature',
15
16
  value = '',
17
+ alt = 'Signature preview',
16
18
  label = 'Electronic Signature',
17
19
  formAction = '',
18
20
  disabled = false,
@@ -28,7 +30,7 @@
28
30
 
29
31
  {#if value}
30
32
  <div class="sv-lite-sig-preview">
31
- <img src={value} alt="Signature Preview" class="sv-lite-sig-img" />
33
+ <img src={value} alt={alt} class="sv-lite-sig-img" />
32
34
  </div>
33
35
  {:else}
34
36
  <div class="sv-lite-sig-empty">
@@ -1,6 +1,7 @@
1
1
  interface Props {
2
2
  name?: string;
3
3
  value?: string;
4
+ alt?: string;
4
5
  label?: string;
5
6
  formAction?: string;
6
7
  disabled?: boolean;
@@ -0,0 +1,133 @@
1
+ <script lang="ts">
2
+ export type LiteTaskStatus = 'running' | 'pending' | 'completed' | 'failed' | 'cancelled';
3
+
4
+ export interface LiteBackgroundTask {
5
+ id: string;
6
+ title: string;
7
+ status: LiteTaskStatus;
8
+ progress?: number;
9
+ processed?: number;
10
+ total?: number;
11
+ error?: string;
12
+ downloadHref?: string;
13
+ cancelAction?: string;
14
+ retryAction?: string;
15
+ }
16
+
17
+ interface Props {
18
+ tasks?: LiteBackgroundTask[];
19
+ title?: string;
20
+ emptyText?: string;
21
+ clearAction?: string;
22
+ clearLabel?: string;
23
+ class?: string;
24
+ }
25
+
26
+ let {
27
+ tasks = [],
28
+ title = 'Background tasks',
29
+ emptyText = 'No background tasks',
30
+ clearAction,
31
+ clearLabel = 'Clear completed',
32
+ class: className = '',
33
+ }: Props = $props();
34
+
35
+ const completedCount = $derived(tasks.filter((task) => task.status === 'completed').length);
36
+
37
+ function statusLabel(status: LiteTaskStatus): string {
38
+ switch (status) {
39
+ case 'running': return 'Running';
40
+ case 'pending': return 'Pending';
41
+ case 'completed': return 'Completed';
42
+ case 'failed': return 'Failed';
43
+ case 'cancelled': return 'Cancelled';
44
+ }
45
+ }
46
+
47
+ function boundedProgress(task: LiteBackgroundTask): number {
48
+ if (typeof task.progress === 'number') return Math.max(0, Math.min(100, task.progress));
49
+ if (task.total && task.total > 0 && typeof task.processed === 'number') {
50
+ return Math.max(0, Math.min(100, Math.round((task.processed / task.total) * 100)));
51
+ }
52
+ return task.status === 'completed' ? 100 : 0;
53
+ }
54
+ </script>
55
+
56
+ <section class="lite-task-center lite-card {className}" aria-label={title}>
57
+ <header>
58
+ <div>
59
+ <h2>{title}</h2>
60
+ <p>{tasks.length} task{tasks.length === 1 ? '' : 's'}{completedCount ? ` · ${completedCount} completed` : ''}</p>
61
+ </div>
62
+ {#if clearAction && completedCount > 0}
63
+ <form method="POST" action={clearAction}>
64
+ <button type="submit" class="lite-task-secondary">{clearLabel}</button>
65
+ </form>
66
+ {/if}
67
+ </header>
68
+
69
+ {#if tasks.length === 0}
70
+ <p class="lite-task-empty" role="status">{emptyText}</p>
71
+ {:else}
72
+ <table class="lite-table">
73
+ <thead>
74
+ <tr>
75
+ <th>Task</th>
76
+ <th>Status</th>
77
+ <th>Progress</th>
78
+ <th>Actions</th>
79
+ </tr>
80
+ </thead>
81
+ <tbody>
82
+ {#each tasks as task (task.id)}
83
+ <tr data-task-id={task.id} data-status={task.status}>
84
+ <td>
85
+ <strong>{task.title}</strong>
86
+ {#if task.error}<span class="lite-task-error">{task.error}</span>{/if}
87
+ </td>
88
+ <td><span class="lite-task-status" data-status={task.status}>{statusLabel(task.status)}</span></td>
89
+ <td>
90
+ <progress max="100" value={boundedProgress(task)} aria-label={`${task.title} progress`}></progress>
91
+ <span class="lite-task-progress-text">
92
+ {boundedProgress(task)}%{typeof task.processed === 'number' && typeof task.total === 'number'
93
+ ? ` · ${task.processed} / ${task.total}`
94
+ : ''}
95
+ </span>
96
+ </td>
97
+ <td class="lite-task-actions">
98
+ {#if task.downloadHref}<a href={task.downloadHref}>Download</a>{/if}
99
+ {#if task.retryAction}
100
+ <form method="POST" action={task.retryAction}><button type="submit" class="lite-task-link">Retry</button></form>
101
+ {/if}
102
+ {#if task.cancelAction && (task.status === 'running' || task.status === 'pending')}
103
+ <form method="POST" action={task.cancelAction}><button type="submit" class="lite-task-link">Cancel</button></form>
104
+ {/if}
105
+ </td>
106
+ </tr>
107
+ {/each}
108
+ </tbody>
109
+ </table>
110
+ {/if}
111
+ </section>
112
+
113
+ <style>
114
+ .lite-task-center { padding: 16px; }
115
+ .lite-task-center header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 12px; }
116
+ .lite-task-center header > form { margin-left: 12px; }
117
+ .lite-task-center header h2 { margin: 0; color: #1d2129; font-size: 16px; }
118
+ .lite-task-center header p { margin: 4px 0 0; color: #4e5969; font-size: 12px; }
119
+ .lite-task-empty { margin: 0; padding: 20px; color: #86909c; text-align: center; }
120
+ .lite-task-error { display: block; margin-top: 3px; color: #f53f3f; font-size: 11px; }
121
+ .lite-task-status { display: inline-block; padding: 2px 6px; border-radius: 999px; background: #f2f3f5; color: #4e5969; font-size: 11px; }
122
+ .lite-task-status[data-status='running'] { background: #e8f3ff; color: #165dff; }
123
+ .lite-task-status[data-status='pending'] { background: #fff7e8; color: #d25f00; }
124
+ .lite-task-status[data-status='completed'] { background: #e8ffea; color: #00b42a; }
125
+ .lite-task-status[data-status='failed'] { background: #ffece8; color: #f53f3f; }
126
+ .lite-task-status[data-status='cancelled'] { background: #f2f3f5; color: #86909c; }
127
+ .lite-task-center progress { width: 100%; height: 6px; }
128
+ .lite-task-progress-text { display: block; margin-top: 3px; color: #86909c; font-size: 11px; }
129
+ .lite-task-actions { white-space: nowrap; }
130
+ .lite-task-actions form { display: inline; }
131
+ .lite-task-link { padding: 0; border: 0; background: none; color: #165dff; font: inherit; font-size: 12px; cursor: pointer; }
132
+ .lite-task-secondary { padding: 5px 9px; border: 1px solid #e5e6eb; border-radius: 4px; background: #fff; color: #4e5969; font-size: 11px; cursor: pointer; }
133
+ </style>