@runwisp/ui 0.4.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 (108) hide show
  1. package/LICENSE +215 -0
  2. package/README.md +20 -0
  3. package/package.json +128 -0
  4. package/src/app.d.ts +8 -0
  5. package/src/app.html +12 -0
  6. package/src/astro/CTABand.astro +32 -0
  7. package/src/astro/Hero.astro +58 -0
  8. package/src/astro/SiteFooter.astro +47 -0
  9. package/src/astro/SiteHeader.astro +46 -0
  10. package/src/lib/actions/portal.ts +16 -0
  11. package/src/lib/assets/runwisp-logo.svg +16 -0
  12. package/src/lib/components/Accordion.svelte +20 -0
  13. package/src/lib/components/AccordionItem.svelte +74 -0
  14. package/src/lib/components/Alert.svelte +107 -0
  15. package/src/lib/components/AlertDialog.svelte +63 -0
  16. package/src/lib/components/Avatar.svelte +88 -0
  17. package/src/lib/components/Badge.svelte +131 -0
  18. package/src/lib/components/Breadcrumb.svelte +58 -0
  19. package/src/lib/components/Button.svelte +69 -0
  20. package/src/lib/components/Card.svelte +70 -0
  21. package/src/lib/components/Checkbox.svelte +90 -0
  22. package/src/lib/components/CodeBlock.svelte +66 -0
  23. package/src/lib/components/CronInput.svelte +130 -0
  24. package/src/lib/components/DataGrid.svelte +368 -0
  25. package/src/lib/components/Drawer.svelte +156 -0
  26. package/src/lib/components/Dropdown.svelte +195 -0
  27. package/src/lib/components/DurationInput.svelte +170 -0
  28. package/src/lib/components/EmptyState.svelte +46 -0
  29. package/src/lib/components/ErrorState.svelte +73 -0
  30. package/src/lib/components/FeatureCard.svelte +35 -0
  31. package/src/lib/components/FeatureGrid.svelte +37 -0
  32. package/src/lib/components/FormField.svelte +53 -0
  33. package/src/lib/components/Heading.svelte +46 -0
  34. package/src/lib/components/Input.svelte +114 -0
  35. package/src/lib/components/Kbd.svelte +31 -0
  36. package/src/lib/components/Link.svelte +56 -0
  37. package/src/lib/components/LinkButton.svelte +55 -0
  38. package/src/lib/components/LogConsole.svelte +424 -0
  39. package/src/lib/components/Logo.svelte +34 -0
  40. package/src/lib/components/Modal.svelte +138 -0
  41. package/src/lib/components/PageContainer.svelte +38 -0
  42. package/src/lib/components/Pagination.svelte +96 -0
  43. package/src/lib/components/Popover.svelte +130 -0
  44. package/src/lib/components/Progress.svelte +68 -0
  45. package/src/lib/components/Prose.svelte +43 -0
  46. package/src/lib/components/Radio.svelte +82 -0
  47. package/src/lib/components/RadioGroup.svelte +53 -0
  48. package/src/lib/components/Select.svelte +454 -0
  49. package/src/lib/components/Separator.svelte +30 -0
  50. package/src/lib/components/Skeleton.svelte +75 -0
  51. package/src/lib/components/Sparkline.svelte +58 -0
  52. package/src/lib/components/Spinner.svelte +44 -0
  53. package/src/lib/components/StatusIndicator.svelte +203 -0
  54. package/src/lib/components/Table.svelte +19 -0
  55. package/src/lib/components/TableBody.svelte +17 -0
  56. package/src/lib/components/TableCell.svelte +34 -0
  57. package/src/lib/components/TableHead.svelte +19 -0
  58. package/src/lib/components/TableRow.svelte +22 -0
  59. package/src/lib/components/Tabs.svelte +148 -0
  60. package/src/lib/components/Text.svelte +57 -0
  61. package/src/lib/components/Textarea.svelte +81 -0
  62. package/src/lib/components/ThemeToggle.svelte +47 -0
  63. package/src/lib/components/TimezoneSelect.svelte +127 -0
  64. package/src/lib/components/ToastContainer.svelte +84 -0
  65. package/src/lib/components/Toggle.svelte +84 -0
  66. package/src/lib/components/Tooltip.svelte +56 -0
  67. package/src/lib/components/button-styles.ts +51 -0
  68. package/src/lib/components/dashboard/RunDetailPanel.svelte +252 -0
  69. package/src/lib/components/dashboard/RunsList.svelte +331 -0
  70. package/src/lib/components/dashboard/run-helpers.ts +12 -0
  71. package/src/lib/components/dashboard/status-config.ts +141 -0
  72. package/src/lib/components/dashboard/types.ts +35 -0
  73. package/src/lib/index.ts +122 -0
  74. package/src/lib/log-console/LogCache.svelte.ts +93 -0
  75. package/src/lib/log-console/LogFetcher.svelte.ts +179 -0
  76. package/src/lib/log-console/types.ts +24 -0
  77. package/src/lib/theme-tokens.css +235 -0
  78. package/src/lib/theme.css +276 -0
  79. package/src/lib/utils/error.ts +18 -0
  80. package/src/lib/utils/format.ts +76 -0
  81. package/src/lib/utils/id.ts +6 -0
  82. package/src/lib/utils/toast.svelte.ts +87 -0
  83. package/src/lib/utils/toast.ts +60 -0
  84. package/src/routes/+layout.svelte +10 -0
  85. package/src/routes/+page.svelte +5 -0
  86. package/src/routes/layout.css +3 -0
  87. package/src/stories/Alert.stories.svelte +64 -0
  88. package/src/stories/Avatar.stories.svelte +76 -0
  89. package/src/stories/Badge.stories.svelte +76 -0
  90. package/src/stories/Breadcrumb.stories.svelte +64 -0
  91. package/src/stories/Button.stories.svelte +101 -0
  92. package/src/stories/Card.stories.svelte +106 -0
  93. package/src/stories/Checkbox.stories.svelte +77 -0
  94. package/src/stories/DataGrid.stories.svelte +130 -0
  95. package/src/stories/Dropdown.stories.svelte +81 -0
  96. package/src/stories/EmptyState.stories.svelte +74 -0
  97. package/src/stories/Input.stories.svelte +88 -0
  98. package/src/stories/Logo.stories.svelte +82 -0
  99. package/src/stories/Modal.stories.svelte +101 -0
  100. package/src/stories/Progress.stories.svelte +65 -0
  101. package/src/stories/Select.stories.svelte +158 -0
  102. package/src/stories/Tabs.stories.svelte +103 -0
  103. package/src/stories/Textarea.stories.svelte +45 -0
  104. package/src/stories/Toggle.stories.svelte +74 -0
  105. package/src/stories/Tooltip.stories.svelte +77 -0
  106. package/src/stories/Welcome.mdx +241 -0
  107. package/src/stories/assets/runwisp_c.svg +16 -0
  108. package/src/svelte-shims.d.ts +18 -0
@@ -0,0 +1,156 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import type { Snippet } from "svelte";
6
+ import { X } from "@lucide/svelte";
7
+ import { portal } from "../actions/portal.js";
8
+ import { fly } from "svelte/transition";
9
+
10
+ type Side = "left" | "right" | "top" | "bottom";
11
+ type DrawerSize = "sm" | "md" | "lg" | "xl";
12
+
13
+ interface Props {
14
+ open?: boolean;
15
+ side?: Side;
16
+ size?: DrawerSize;
17
+ title?: string;
18
+ closable?: boolean;
19
+ onClose?: () => void;
20
+ header?: Snippet;
21
+ footer?: Snippet;
22
+ children?: Snippet;
23
+ class?: string;
24
+ }
25
+
26
+ let {
27
+ open = $bindable(false),
28
+ side = "right",
29
+ size = "md",
30
+ title,
31
+ closable = true,
32
+ onClose,
33
+ header,
34
+ footer,
35
+ children,
36
+ class: className = "",
37
+ }: Props = $props();
38
+
39
+ const sizeValues: Record<DrawerSize, string> = {
40
+ sm: "320px",
41
+ md: "400px",
42
+ lg: "560px",
43
+ xl: "720px",
44
+ };
45
+
46
+ const isHorizontal = $derived(side === "left" || side === "right");
47
+
48
+ const panelStyle = $derived(
49
+ isHorizontal
50
+ ? `width: ${sizeValues[size]}; max-width: 100vw;`
51
+ : `height: ${sizeValues[size]}; max-height: 100vh;`,
52
+ );
53
+
54
+ const positionClasses: Record<Side, string> = {
55
+ left: "inset-y-0 left-0",
56
+ right: "inset-y-0 right-0",
57
+ top: "inset-x-0 top-0",
58
+ bottom: "inset-x-0 bottom-0",
59
+ };
60
+
61
+ const flyParams: Record<Side, { x?: number; y?: number }> = {
62
+ left: { x: -320 },
63
+ right: { x: 320 },
64
+ top: { y: -320 },
65
+ bottom: { y: 320 },
66
+ };
67
+
68
+ function handleClose() {
69
+ open = false;
70
+ onClose?.();
71
+ }
72
+
73
+ function handleKeydown(e: KeyboardEvent) {
74
+ if (e.key === "Escape" && closable) {
75
+ handleClose();
76
+ }
77
+ }
78
+ </script>
79
+
80
+ <svelte:window onkeydown={handleKeydown} />
81
+
82
+ {#if open}
83
+ <div use:portal class="fixed inset-0 z-50">
84
+ {#if closable}
85
+ <button
86
+ type="button"
87
+ class="absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"
88
+ aria-label="Close drawer"
89
+ tabindex="-1"
90
+ onclick={handleClose}
91
+ transition:fly={{ duration: 150 }}
92
+ ></button>
93
+ {:else}
94
+ <div
95
+ class="absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"
96
+ transition:fly={{ duration: 150 }}
97
+ ></div>
98
+ {/if}
99
+
100
+ <div
101
+ class="
102
+ absolute z-10 flex flex-col
103
+ bg-surface-overlay shadow-xl
104
+ {positionClasses[side]}
105
+ {className}
106
+ "
107
+ style={panelStyle}
108
+ role="dialog"
109
+ aria-modal="true"
110
+ aria-labelledby={title ? "drawer-title" : undefined}
111
+ transition:fly={{ ...flyParams[side], duration: 250 }}
112
+ >
113
+ <!-- Header -->
114
+ {#if header}
115
+ <div class="border-b border-outline-faint px-6 py-4">
116
+ {@render header()}
117
+ </div>
118
+ {:else if title || closable}
119
+ <div
120
+ class="flex items-start justify-between gap-4 border-b border-outline-faint px-6 py-4"
121
+ >
122
+ <div>
123
+ {#if title}
124
+ <h2 id="drawer-title" class="text-lg font-semibold text-on-surface">
125
+ {title}
126
+ </h2>
127
+ {/if}
128
+ </div>
129
+ {#if closable}
130
+ <button
131
+ onclick={handleClose}
132
+ class="-m-2 shrink-0 rounded-lg p-2 text-on-surface-faint transition-colors hover:bg-surface-sunken hover:text-on-surface-muted"
133
+ aria-label="Close"
134
+ >
135
+ <X size={20} />
136
+ </button>
137
+ {/if}
138
+ </div>
139
+ {/if}
140
+
141
+ <!-- Body -->
142
+ {#if children}
143
+ <div class="flex-1 overflow-y-auto px-6 py-4">
144
+ {@render children()}
145
+ </div>
146
+ {/if}
147
+
148
+ <!-- Footer -->
149
+ {#if footer}
150
+ <div class="border-t border-outline-faint bg-surface-sunken/50 px-6 py-4">
151
+ {@render footer()}
152
+ </div>
153
+ {/if}
154
+ </div>
155
+ </div>
156
+ {/if}
@@ -0,0 +1,195 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import type { Snippet } from "svelte";
6
+ import type { Component } from "svelte";
7
+ import { tick, onDestroy } from "svelte";
8
+ import { EllipsisVertical } from "@lucide/svelte";
9
+ import { scale } from "svelte/transition";
10
+ import { quintOut } from "svelte/easing";
11
+ import { computePosition, autoUpdate, flip, shift, offset } from "@floating-ui/dom";
12
+
13
+ interface MenuItem {
14
+ label?: string;
15
+ icon?: Component;
16
+ href?: string;
17
+ onClick?: () => void;
18
+ danger?: boolean;
19
+ disabled?: boolean;
20
+ divider?: boolean;
21
+ title?: string;
22
+ }
23
+
24
+ interface Props {
25
+ items?: MenuItem[];
26
+ trigger?: Snippet;
27
+ children?: Snippet;
28
+ align?: "left" | "right";
29
+ class?: string;
30
+ }
31
+
32
+ let { items = [], trigger, children, align = "right", class: className = "" }: Props = $props();
33
+
34
+ let open = $state(false);
35
+ let triggerEl = $state<HTMLElement | null>(null);
36
+ let menuEl = $state<HTMLElement | null>(null);
37
+ let cleanupFloating = $state<(() => void) | null>(null);
38
+ let transformOrigin = $state("top right");
39
+
40
+ import { portal } from "../actions/portal.js";
41
+
42
+ async function setupFloating() {
43
+ if (!triggerEl || !menuEl) return;
44
+
45
+ cleanupFloating = autoUpdate(triggerEl, menuEl, () => {
46
+ if (!triggerEl || !menuEl) return;
47
+
48
+ const placement = align === "right" ? "bottom-end" : "bottom-start";
49
+
50
+ void computePosition(triggerEl, menuEl, {
51
+ placement,
52
+ middleware: [
53
+ offset(4), // MENU_OFFSET
54
+ flip(),
55
+ shift({ padding: 8 }), // VIEWPORT_PADDING
56
+ ],
57
+ }).then(({ x, y, placement }) => {
58
+ if (menuEl) {
59
+ Object.assign(menuEl.style, {
60
+ left: `${x}px`,
61
+ top: `${y}px`,
62
+ position: "absolute",
63
+ });
64
+
65
+ // Simple origin logic based on verify placement
66
+ const isTop = placement.startsWith("top");
67
+ transformOrigin = `${isTop ? "bottom" : "top"} ${align}`;
68
+ }
69
+ });
70
+ });
71
+ }
72
+
73
+ function handleItemClick(item: MenuItem) {
74
+ if (item.divider) return;
75
+ if (item.disabled) return;
76
+ item.onClick?.();
77
+ toggle(false);
78
+ }
79
+
80
+ function toggle(value: boolean) {
81
+ open = value;
82
+ if (open) {
83
+ void tick().then(() => {
84
+ void setupFloating();
85
+ });
86
+ } else {
87
+ cleanupFloating?.();
88
+ cleanupFloating = null;
89
+ }
90
+ }
91
+
92
+ function handleOutsideClick(e: MouseEvent) {
93
+ const target = e.target as HTMLElement;
94
+ if (triggerEl?.contains(target)) return;
95
+ if (menuEl?.contains(target)) return;
96
+ if (open) toggle(false);
97
+ }
98
+
99
+ function handleKeyDown(e: KeyboardEvent) {
100
+ if (e.key === "Escape" && open) {
101
+ toggle(false);
102
+ }
103
+ }
104
+
105
+ onDestroy(() => {
106
+ cleanupFloating?.();
107
+ });
108
+ </script>
109
+
110
+ <svelte:window onclick={handleOutsideClick} onkeydown={handleKeyDown} />
111
+
112
+ <div class="dropdown-container relative inline-block {className}">
113
+ <button
114
+ onclick={(e) => {
115
+ e.stopPropagation();
116
+ toggle(!open);
117
+ }}
118
+ bind:this={triggerEl}
119
+ aria-label="Options"
120
+ class="rounded-lg p-2 text-on-surface-muted transition-colors hover:bg-surface-sunken hover:text-on-surface focus:ring-2 focus:ring-outline focus:outline-none active:bg-surface-sunken"
121
+ >
122
+ {#if trigger}
123
+ {@render trigger()}
124
+ {:else}
125
+ <EllipsisVertical size={18} />
126
+ {/if}
127
+ </button>
128
+
129
+ {#if open}
130
+ <div
131
+ use:portal
132
+ bind:this={menuEl}
133
+ role="menu"
134
+ transition:scale={{ duration: 150, start: 0.95, easing: quintOut }}
135
+ class="
136
+ z-[9999]
137
+ min-w-48 overflow-y-auto
138
+ rounded-xl
139
+ border border-outline bg-surface-overlay py-1 shadow-xl shadow-on-surface/5
140
+ "
141
+ style="transform-origin: {transformOrigin};"
142
+ >
143
+ {#if children}
144
+ {@render children()}
145
+ {:else}
146
+ {#each items as item, idx (idx)}
147
+ {#if item.divider}
148
+ <hr class="my-1 border-outline-faint" />
149
+ {:else if item.href}
150
+ <a
151
+ href={item.href}
152
+ title={item.title}
153
+ role="menuitem"
154
+ class="
155
+ mx-1 flex items-center gap-3 rounded-lg px-3 py-2 text-sm
156
+ {item.danger
157
+ ? 'text-danger-soft-text hover:bg-danger-soft'
158
+ : 'text-on-surface-muted hover:bg-surface-sunken'}
159
+ {item.disabled ? 'cursor-not-allowed opacity-50' : ''}
160
+ transition-colors
161
+ "
162
+ >
163
+ {#if item.icon}
164
+ {@const Icon = item.icon}
165
+ <Icon size={16} class="opacity-70" />
166
+ {/if}
167
+ {item.label}
168
+ </a>
169
+ {:else}
170
+ <button
171
+ onclick={() => handleItemClick(item)}
172
+ disabled={item.disabled}
173
+ title={item.title}
174
+ role="menuitem"
175
+ class="
176
+ mx-1 box-border flex w-full max-w-[calc(100%-8px)] items-center gap-3 rounded-lg px-3 py-2 text-left text-sm
177
+ {item.danger
178
+ ? 'text-danger-soft-text hover:bg-danger-soft'
179
+ : 'text-on-surface-muted hover:bg-surface-sunken'}
180
+ transition-colors disabled:cursor-not-allowed
181
+ disabled:opacity-50
182
+ "
183
+ >
184
+ {#if item.icon}
185
+ {@const Icon = item.icon}
186
+ <Icon size={16} class="opacity-70" />
187
+ {/if}
188
+ {item.label}
189
+ </button>
190
+ {/if}
191
+ {/each}
192
+ {/if}
193
+ </div>
194
+ {/if}
195
+ </div>
@@ -0,0 +1,170 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import { CircleAlert, Clock } from "@lucide/svelte";
6
+ import ms, { type StringValue } from "ms";
7
+
8
+ type InputSize = "sm" | "md" | "lg";
9
+
10
+ interface Props {
11
+ id?: string;
12
+ value?: number | null;
13
+ label?: string;
14
+ hint?: string;
15
+ error?: string;
16
+ disabled?: boolean;
17
+ class?: string;
18
+ placeholder?: string;
19
+ size?: InputSize;
20
+ }
21
+
22
+ let {
23
+ id,
24
+ value = $bindable(),
25
+ label,
26
+ hint,
27
+ error = $bindable(),
28
+ disabled = false,
29
+ class: className = "",
30
+ placeholder = "e.g. 5m 30s",
31
+ size = "md",
32
+ ...restProps
33
+ }: Props = $props();
34
+
35
+ const generatedId = `duration-input-${Math.random().toString(36).slice(2, 10)}`;
36
+ const inputId = $derived(id ?? generatedId);
37
+
38
+ let textValue = $state("");
39
+ let parsedHuman = $state("");
40
+ let isFocused = $state(false);
41
+
42
+ const sizeClasses: Record<InputSize, string> = {
43
+ sm: "text-sm px-3 py-1.5",
44
+ md: "text-sm px-3.5 py-2",
45
+ lg: "text-base px-4 py-2.5",
46
+ };
47
+
48
+ const inputClasses = `
49
+ w-full rounded-lg border
50
+ bg-surface-raised text-on-surface placeholder:text-on-surface-faint
51
+ focus:outline-none focus:ring-4 focus:ring-ring/10 focus:border-ring
52
+ disabled:bg-surface-sunken disabled:text-on-surface-muted disabled:cursor-not-allowed
53
+ transition-all duration-normal ease-in-out
54
+ `;
55
+
56
+ const normalBorder = "border-outline hover:border-outline-hover shadow-sm";
57
+ const errorBorder = "border-danger-400 focus:border-danger-500 focus:ring-danger-500/20";
58
+
59
+ function formatMs(msValue: number): string {
60
+ if (typeof msValue !== "number" || isNaN(msValue)) return "";
61
+ try {
62
+ return ms(msValue);
63
+ } catch {
64
+ return "";
65
+ }
66
+ }
67
+
68
+ function parseToMs(str: string): number | null {
69
+ if (!str || !str.trim()) return null;
70
+ try {
71
+ const result = ms(str as StringValue);
72
+ return typeof result === "number" ? result : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function updateFromValue() {
79
+ if (!isFocused) {
80
+ textValue = value != null ? formatMs(value) : "";
81
+ }
82
+ }
83
+
84
+ $effect(() => {
85
+ updateFromValue();
86
+ });
87
+
88
+ function handleInput(e: Event) {
89
+ const val = (e.target as HTMLInputElement).value;
90
+ textValue = val;
91
+
92
+ const msValue = parseToMs(val);
93
+ value = msValue;
94
+
95
+ if (typeof msValue === "number") {
96
+ parsedHuman = ms(msValue, { long: true });
97
+ if (error) error = undefined;
98
+ } else {
99
+ parsedHuman = "";
100
+ }
101
+ }
102
+
103
+ function handleFocus() {
104
+ isFocused = true;
105
+ }
106
+
107
+ function handleBlur() {
108
+ isFocused = false;
109
+ textValue = value != null ? formatMs(value) : "";
110
+ parsedHuman = "";
111
+ }
112
+ </script>
113
+
114
+ <div class="space-y-1.5 {className}">
115
+ {#if label}
116
+ <label for={inputId} class="block text-sm font-medium text-on-surface-muted">
117
+ {label}
118
+ </label>
119
+ {/if}
120
+
121
+ <div class="group relative">
122
+ <div
123
+ class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-on-surface-faint transition-colors group-focus-within:text-primary"
124
+ >
125
+ <Clock size={16} />
126
+ </div>
127
+
128
+ <input
129
+ id={inputId}
130
+ type="text"
131
+ class="
132
+ {inputClasses}
133
+ {sizeClasses[size]}
134
+ {error ? errorBorder : normalBorder}
135
+ pl-10
136
+ {error ? 'pr-10' : ''}
137
+ "
138
+ {disabled}
139
+ value={textValue}
140
+ {placeholder}
141
+ oninput={handleInput}
142
+ onfocus={handleFocus}
143
+ onblur={handleBlur}
144
+ aria-invalid={error ? "true" : undefined}
145
+ {...restProps}
146
+ />
147
+
148
+ {#if error}
149
+ <div
150
+ class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-danger-500"
151
+ >
152
+ <CircleAlert size={18} />
153
+ </div>
154
+ {/if}
155
+ </div>
156
+
157
+ {#if parsedHuman && isFocused}
158
+ <p
159
+ class="animate-in slide-in-from-top-1 fade-in text-xs font-medium text-primary duration-200"
160
+ >
161
+ {parsedHuman}
162
+ </p>
163
+ {:else if error}
164
+ <p class="animate-in slide-in-from-top-1 fade-in text-sm text-danger-600 duration-200">
165
+ {error}
166
+ </p>
167
+ {:else if hint}
168
+ <p class="text-sm text-on-surface-muted">{hint}</p>
169
+ {/if}
170
+ </div>
@@ -0,0 +1,46 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import type { Snippet } from "svelte";
6
+ import type { Component } from "svelte";
7
+ import { Inbox } from "@lucide/svelte";
8
+
9
+ interface Props {
10
+ title?: string;
11
+ description?: string;
12
+ icon?: Component;
13
+ iconSize?: number;
14
+ actions?: Snippet;
15
+ class?: string;
16
+ }
17
+
18
+ let {
19
+ title = "No data",
20
+ description,
21
+ icon = Inbox,
22
+ iconSize = 48,
23
+ actions,
24
+ class: className = "",
25
+ }: Props = $props();
26
+
27
+ const Icon = $derived(icon);
28
+ </script>
29
+
30
+ <div class="flex flex-col items-center justify-center px-4 py-12 text-center {className}">
31
+ <div class="mb-4 rounded-full bg-surface-sunken p-4">
32
+ <Icon size={iconSize} class="text-on-surface-faint" />
33
+ </div>
34
+
35
+ <h3 class="mb-1 text-lg font-semibold text-on-surface">{title}</h3>
36
+
37
+ {#if description}
38
+ <p class="mb-6 max-w-sm text-sm text-on-surface-muted">{description}</p>
39
+ {/if}
40
+
41
+ {#if actions}
42
+ <div class="flex items-center gap-3">
43
+ {@render actions()}
44
+ </div>
45
+ {/if}
46
+ </div>
@@ -0,0 +1,73 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import type { Snippet, Component } from "svelte";
6
+ import { TriangleAlert, RefreshCw, WifiOff } from "@lucide/svelte";
7
+ import Button from "./Button.svelte";
8
+
9
+ interface Props {
10
+ /** Error message to display */
11
+ message: string;
12
+ /** Title override */
13
+ title?: string;
14
+ /** Icon override */
15
+ icon?: Component;
16
+ /** Callback when the retry button is clicked */
17
+ onRetry?: () => void;
18
+ /** Whether a retry is in progress */
19
+ retrying?: boolean;
20
+ /** Visual variant */
21
+ variant?: "default" | "disconnected";
22
+ /** Extra actions slot */
23
+ actions?: Snippet;
24
+ class?: string;
25
+ }
26
+
27
+ let {
28
+ message,
29
+ title,
30
+ icon,
31
+ onRetry,
32
+ retrying = false,
33
+ variant = "default",
34
+ actions,
35
+ class: className = "",
36
+ }: Props = $props();
37
+
38
+ const defaultTitle = $derived(
39
+ title ?? (variant === "disconnected" ? "Connection Lost" : "Something went wrong"),
40
+ );
41
+
42
+ const IconComponent = $derived(icon ?? (variant === "disconnected" ? WifiOff : TriangleAlert));
43
+
44
+ const iconBg = $derived(variant === "disconnected" ? "bg-danger-soft" : "bg-warning-soft");
45
+ const iconColor = $derived(
46
+ variant === "disconnected" ? "text-danger-surface" : "text-warning-surface",
47
+ );
48
+ </script>
49
+
50
+ <div
51
+ class="flex flex-col items-center justify-center gap-4 rounded-xl border border-outline bg-surface-raised px-6 py-12 text-center {className}"
52
+ role="alert"
53
+ >
54
+ <div class="rounded-full {iconBg} p-3">
55
+ <IconComponent class="h-8 w-8 {iconColor}" />
56
+ </div>
57
+
58
+ <div class="space-y-1">
59
+ <h3 class="text-lg font-semibold text-on-surface">{defaultTitle}</h3>
60
+ <p class="max-w-sm text-sm text-on-surface-muted">{message}</p>
61
+ </div>
62
+
63
+ {#if onRetry}
64
+ <Button variant="secondary" onclick={onRetry} loading={retrying}>
65
+ {#snippet icon()}<RefreshCw size={16} />{/snippet}
66
+ Retry
67
+ </Button>
68
+ {/if}
69
+
70
+ {#if actions}
71
+ {@render actions()}
72
+ {/if}
73
+ </div>
@@ -0,0 +1,35 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import type { Component, Snippet } from "svelte";
6
+
7
+ interface Props {
8
+ title: string;
9
+ icon?: Component;
10
+ children?: Snippet;
11
+ class?: string;
12
+ }
13
+
14
+ let { title, icon: Icon, children, class: className = "" }: Props = $props();
15
+ </script>
16
+
17
+ <article
18
+ class="overflow-hidden rounded-xl border border-outline bg-surface-raised p-6 shadow-sm transition-colors hover:border-outline-hover {className}"
19
+ >
20
+ <div class="flex flex-col gap-3">
21
+ {#if Icon}
22
+ <span
23
+ class="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-primary-soft text-primary-soft-text"
24
+ >
25
+ <Icon size={20} />
26
+ </span>
27
+ {/if}
28
+ <h3 class="text-lg font-semibold text-on-surface">{title}</h3>
29
+ {#if children}
30
+ <div class="text-sm leading-relaxed text-on-surface-muted">
31
+ {@render children()}
32
+ </div>
33
+ {/if}
34
+ </div>
35
+ </article>