@joewinke/jatui 0.1.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 (62) hide show
  1. package/package.json +46 -0
  2. package/src/lib/components/AudioWaveform.svelte +694 -0
  3. package/src/lib/components/AvailabilityModal.svelte +173 -0
  4. package/src/lib/components/Badge.svelte +38 -0
  5. package/src/lib/components/BookingForm.svelte +276 -0
  6. package/src/lib/components/Button.svelte +72 -0
  7. package/src/lib/components/CalendarPicker.svelte +284 -0
  8. package/src/lib/components/Card.svelte +67 -0
  9. package/src/lib/components/CharacterCounter.svelte +82 -0
  10. package/src/lib/components/ChipInput.svelte +596 -0
  11. package/src/lib/components/ColorSelector.svelte +163 -0
  12. package/src/lib/components/ConfirmModal.svelte +75 -0
  13. package/src/lib/components/CountdownTimer.svelte +94 -0
  14. package/src/lib/components/DateRangePicker.svelte +192 -0
  15. package/src/lib/components/Drawer.svelte +110 -0
  16. package/src/lib/components/FilterDropdown.svelte +202 -0
  17. package/src/lib/components/ImageUpload.svelte +97 -0
  18. package/src/lib/components/InlineEdit.svelte +283 -0
  19. package/src/lib/components/LazyImage.svelte +122 -0
  20. package/src/lib/components/LoadingSpinner.svelte +102 -0
  21. package/src/lib/components/Modal.svelte +208 -0
  22. package/src/lib/components/PhoneInput.svelte +92 -0
  23. package/src/lib/components/ResizableDivider.svelte +305 -0
  24. package/src/lib/components/ResizablePanel.svelte +302 -0
  25. package/src/lib/components/SearchDropdown.svelte +341 -0
  26. package/src/lib/components/SelectInput.svelte +215 -0
  27. package/src/lib/components/SignaturePad.svelte +171 -0
  28. package/src/lib/components/SortDropdown.svelte +148 -0
  29. package/src/lib/components/Sparkline.svelte +107 -0
  30. package/src/lib/components/SpeechForm.svelte +114 -0
  31. package/src/lib/components/StatusBadge.svelte +155 -0
  32. package/src/lib/components/TextArea.svelte +143 -0
  33. package/src/lib/components/TextInput.svelte +108 -0
  34. package/src/lib/components/ThemeSelector.svelte +195 -0
  35. package/src/lib/components/TimeSlotPicker.svelte +162 -0
  36. package/src/lib/components/VoicePlayer.svelte +420 -0
  37. package/src/lib/components/messaging/Avatar.svelte +81 -0
  38. package/src/lib/components/messaging/ChannelInfoModal.svelte +163 -0
  39. package/src/lib/components/messaging/ChannelList.svelte +107 -0
  40. package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +69 -0
  41. package/src/lib/components/messaging/ChannelMembersModal.svelte +182 -0
  42. package/src/lib/components/messaging/CreateChannelModal.svelte +190 -0
  43. package/src/lib/components/messaging/DirectMessageList.svelte +145 -0
  44. package/src/lib/components/messaging/EmojiSelector.svelte +260 -0
  45. package/src/lib/components/messaging/MentionAutocomplete.svelte +193 -0
  46. package/src/lib/components/messaging/MessageAttachment.svelte +270 -0
  47. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +243 -0
  48. package/src/lib/components/messaging/MessageInput.svelte +451 -0
  49. package/src/lib/components/messaging/MessageItem.svelte +338 -0
  50. package/src/lib/components/messaging/MessageThread.svelte +306 -0
  51. package/src/lib/components/messaging/NotificationSettingsModal.svelte +234 -0
  52. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +118 -0
  53. package/src/lib/components/messaging/StartDMModal.svelte +100 -0
  54. package/src/lib/components/messaging/ThreadPanel.svelte +153 -0
  55. package/src/lib/index.ts +185 -0
  56. package/src/lib/types/booking.ts +143 -0
  57. package/src/lib/types/messaging.ts +459 -0
  58. package/src/lib/utils/currency.ts +20 -0
  59. package/src/lib/utils/daisyuiColors.ts +243 -0
  60. package/src/lib/utils/dateFormatters.ts +153 -0
  61. package/src/lib/utils/mentionParser.ts +188 -0
  62. package/src/lib/utils/phoneFormat.ts +74 -0
@@ -0,0 +1,202 @@
1
+ <script lang="ts">
2
+ /**
3
+ * FilterDropdown Component
4
+ * Reusable multi-select filter component for filter bars
5
+ *
6
+ * Supports two modes:
7
+ * - dropdown: Click button to show options in dropdown menu (default)
8
+ * - inline: Options displayed inline without dropdown wrapper
9
+ *
10
+ * Supports two styles:
11
+ * - badge: Clickable badge buttons (default, good for compact filters)
12
+ * - checkbox: Checkbox list in menu format (good for longer lists)
13
+ */
14
+
15
+ interface FilterOption {
16
+ value: string;
17
+ label: string;
18
+ count: number;
19
+ }
20
+
21
+ interface Props {
22
+ /** Label shown on dropdown trigger button */
23
+ label: string;
24
+ /** Available options to select from */
25
+ options: FilterOption[];
26
+ /** Currently selected values */
27
+ selected: Set<string>;
28
+ /** Callback when option is toggled */
29
+ onToggle: (value: string) => void;
30
+ /** Function to get badge color class for a value */
31
+ colorFn?: (value: string, isSelected: boolean) => string;
32
+ /** Text to show when all options are selected */
33
+ allSelectedText?: string;
34
+ /** If true, empty selection means "all" (vs "none") */
35
+ emptyMeansAll?: boolean;
36
+ /** Display mode: 'dropdown' (default) or 'inline' */
37
+ mode?: 'dropdown' | 'inline';
38
+ /** Selection style: 'badge' (default) or 'checkbox' */
39
+ style?: 'badge' | 'checkbox';
40
+ /** Additional CSS class for the container */
41
+ class?: string;
42
+ /** Dropdown menu width class (e.g., 'w-44', 'w-52') */
43
+ menuWidth?: string;
44
+ /** Max height for scrollable content (e.g., 'max-h-60') */
45
+ maxHeight?: string;
46
+ /** Show hover effect on dropdown trigger */
47
+ hoverTrigger?: boolean;
48
+ }
49
+
50
+ let {
51
+ label,
52
+ options,
53
+ selected,
54
+ onToggle,
55
+ colorFn = () => 'badge-primary',
56
+ allSelectedText = 'all',
57
+ emptyMeansAll = false,
58
+ mode = 'dropdown',
59
+ style = 'badge',
60
+ class: className = '',
61
+ menuWidth = 'min-w-48',
62
+ maxHeight = '',
63
+ hoverTrigger = true
64
+ }: Props = $props();
65
+
66
+ // Compute display text for the trigger button
67
+ const displayText = $derived.by(() => {
68
+ if (emptyMeansAll && selected.size === 0) {
69
+ return allSelectedText;
70
+ }
71
+ if (selected.size === options.length && options.length > 0) {
72
+ return allSelectedText;
73
+ }
74
+ return String(selected.size);
75
+ });
76
+
77
+ // Compute badge class for trigger (show primary when filters active)
78
+ const triggerBadgeClass = $derived.by(() => {
79
+ if (emptyMeansAll) {
80
+ return selected.size > 0 ? 'badge-primary' : 'badge-ghost';
81
+ }
82
+ return 'badge-primary';
83
+ });
84
+
85
+ // Handle option toggle
86
+ function handleToggle(value: string) {
87
+ onToggle(value);
88
+ }
89
+
90
+ // Handle keyboard navigation
91
+ function handleKeydown(e: KeyboardEvent, value: string) {
92
+ if (e.key === 'Enter' || e.key === ' ') {
93
+ e.preventDefault();
94
+ handleToggle(value);
95
+ }
96
+ }
97
+ </script>
98
+
99
+ {#if mode === 'inline'}
100
+ <!-- Inline Mode: No dropdown wrapper, just badge/checkbox grid -->
101
+ <div class="flex flex-wrap gap-1.5 {className}">
102
+ {#each options as opt, index}
103
+ {#if style === 'checkbox'}
104
+ <label class="label cursor-pointer justify-start gap-2">
105
+ <input
106
+ type="checkbox"
107
+ class="checkbox checkbox-sm"
108
+ checked={selected.has(opt.value)}
109
+ onchange={() => handleToggle(opt.value)}
110
+ />
111
+ <span>{opt.label}</span>
112
+ <span class="text-xs opacity-60">({opt.count})</span>
113
+ </label>
114
+ {:else}
115
+ <button
116
+ class="badge badge-sm transition-all duration-200 cursor-pointer {selected.has(opt.value)
117
+ ? colorFn(opt.value, true) + ' shadow-md'
118
+ : 'badge-ghost hover:badge-primary/20 hover:shadow-sm hover:scale-105'}"
119
+ onclick={() => handleToggle(opt.value)}
120
+ onkeydown={(e) => handleKeydown(e, opt.value)}
121
+ >
122
+ {opt.label}
123
+ <span class="ml-1 opacity-70">({opt.count})</span>
124
+ </button>
125
+ {/if}
126
+ {/each}
127
+ </div>
128
+ {:else}
129
+ <!-- Dropdown Mode: Click to show options -->
130
+ <div class="dropdown {hoverTrigger ? 'dropdown-hover' : ''} {className}">
131
+ <!-- Trigger Button -->
132
+ <div
133
+ tabindex="0"
134
+ role="button"
135
+ class="px-2.5 py-1 rounded cursor-pointer transition-all flex items-center gap-1.5 font-mono text-xs tracking-wider bg-base-200 border border-base-300 text-base-content/60"
136
+ >
137
+ <span class="uppercase">{label}</span>
138
+ <span
139
+ class="px-1.5 py-0.5 rounded text-xs font-mono {selected.size > 0 && !(emptyMeansAll && selected.size === 0) ? 'bg-primary/20 text-primary' : 'bg-base-300 text-base-content opacity-60'}"
140
+ >
141
+ {displayText}
142
+ </span>
143
+ <svg
144
+ xmlns="http://www.w3.org/2000/svg"
145
+ fill="none"
146
+ viewBox="0 0 24 24"
147
+ stroke-width="1.5"
148
+ stroke="currentColor"
149
+ class="w-3.5 h-3.5 text-base-content/50"
150
+ >
151
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
152
+ </svg>
153
+ </div>
154
+
155
+ <!-- Dropdown Content -->
156
+ {#if style === 'checkbox'}
157
+ <!-- Checkbox style: Menu with checkboxes -->
158
+ <ul
159
+ tabindex="0"
160
+ class="dropdown-content z-40 menu p-2 shadow rounded-box {menuWidth} {maxHeight ? maxHeight + ' overflow-y-auto' : ''} bg-base-200 border border-base-300"
161
+ >
162
+ {#each options as opt, index}
163
+ <li>
164
+ <label
165
+ class="label cursor-pointer justify-start gap-2 rounded text-base-content/70"
166
+ >
167
+ <input
168
+ type="checkbox"
169
+ class="checkbox checkbox-sm border-base-content/40"
170
+ checked={selected.has(opt.value)}
171
+ onchange={() => handleToggle(opt.value)}
172
+ />
173
+ <span class="truncate font-mono text-xs">{opt.label}</span>
174
+ <span class="text-xs font-mono text-base-content/50">({opt.count})</span>
175
+ </label>
176
+ </li>
177
+ {/each}
178
+ </ul>
179
+ {:else}
180
+ <!-- Badge style: Grid of clickable badges -->
181
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
182
+ <div
183
+ tabindex="0"
184
+ role="menu"
185
+ class="dropdown-content rounded-box shadow-lg p-2 z-40 {menuWidth} mt-1 {maxHeight ? maxHeight + ' overflow-y-auto' : ''} bg-base-200 border border-base-300"
186
+ >
187
+ <div class="flex flex-wrap gap-1.5">
188
+ {#each options as opt, index}
189
+ <button
190
+ class="px-2 py-0.5 rounded font-mono text-xs transition-all cursor-pointer border {selected.has(opt.value) ? 'bg-primary/20 border-primary/40 text-primary' : 'bg-base-300 border-base-content/20 text-base-content opacity-70 hover:opacity-100'}"
191
+ onclick={() => handleToggle(opt.value)}
192
+ onkeydown={(e) => handleKeydown(e, opt.value)}
193
+ >
194
+ {opt.label}
195
+ <span class="ml-1 text-base-content/50">({opt.count})</span>
196
+ </button>
197
+ {/each}
198
+ </div>
199
+ </div>
200
+ {/if}
201
+ </div>
202
+ {/if}
@@ -0,0 +1,97 @@
1
+ <script lang="ts">
2
+ let {
3
+ imageUrl = "",
4
+ disabled = false,
5
+ label = "Upload Image",
6
+ maxSize = 10 * 1024 * 1024,
7
+ onUpload = undefined,
8
+ } = $props<{
9
+ imageUrl?: string
10
+ disabled?: boolean
11
+ label?: string
12
+ maxSize?: number
13
+ onUpload?: (url: string) => void
14
+ }>()
15
+
16
+ let error = $state("")
17
+
18
+ async function handleFileInput(event: Event) {
19
+ error = ""
20
+ const input = event.target as HTMLInputElement
21
+ if (!input.files?.length) return
22
+
23
+ const file = input.files[0]
24
+
25
+ if (file.size > maxSize) {
26
+ error = `File too large! Maximum size is ${formatFileSize(maxSize)}.`
27
+ return
28
+ }
29
+
30
+ const url = URL.createObjectURL(file)
31
+ imageUrl = url
32
+
33
+ if (onUpload) {
34
+ onUpload(url)
35
+ }
36
+ }
37
+
38
+ function clearImage() {
39
+ if (imageUrl.startsWith("blob:")) {
40
+ URL.revokeObjectURL(imageUrl)
41
+ }
42
+ imageUrl = ""
43
+ error = ""
44
+ }
45
+
46
+ function formatFileSize(bytes: number): string {
47
+ if (bytes < 1024) return `${bytes} bytes`
48
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
49
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
50
+ }
51
+
52
+ $effect(() => {
53
+ return () => {
54
+ if (imageUrl.startsWith("blob:")) {
55
+ URL.revokeObjectURL(imageUrl)
56
+ }
57
+ }
58
+ })
59
+ </script>
60
+
61
+ {#if imageUrl}
62
+ <div class="relative w-full h-40 bg-base-200 rounded-md overflow-hidden">
63
+ <img src={imageUrl} alt="Uploaded" class="w-full h-full object-contain" />
64
+ <button
65
+ class="btn btn-sm btn-circle absolute top-2 right-2 bg-base-100/80"
66
+ onclick={clearImage}
67
+ {disabled}
68
+ >
69
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
70
+ </button>
71
+ </div>
72
+ {:else}
73
+ <label
74
+ class="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-md cursor-pointer bg-base-200 hover:bg-base-300"
75
+ >
76
+ <div class="flex flex-col items-center justify-center pt-5 pb-6">
77
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mb-2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>
78
+ <p class="mb-2 text-sm">
79
+ <span class="font-semibold">Click to upload</span> or drag and drop
80
+ </p>
81
+ <p class="text-xs opacity-70">
82
+ PNG, JPG or JPEG (max {formatFileSize(maxSize)})
83
+ </p>
84
+ </div>
85
+ <input
86
+ type="file"
87
+ class="hidden"
88
+ accept="image/*"
89
+ {disabled}
90
+ onchange={handleFileInput}
91
+ />
92
+ </label>
93
+ {/if}
94
+
95
+ {#if error}
96
+ <p class="text-error text-sm mt-1">{error}</p>
97
+ {/if}
@@ -0,0 +1,283 @@
1
+ <script lang="ts">
2
+ /**
3
+ * InlineEdit Component
4
+ *
5
+ * A reusable inline editing component for text and textarea inputs.
6
+ * Shows display value when not editing, converts to input on click.
7
+ *
8
+ * Features:
9
+ * - Auto-saves on blur or Enter (Enter only for text type)
10
+ * - Cancels on Escape, reverting to original value
11
+ * - Hover state to indicate editability
12
+ * - Auto-focus when editing starts
13
+ * - Disabled state during saves
14
+ * - Optional formula-aware display: segments with type/display/tooltip
15
+ */
16
+
17
+ /** A display segment for formula-aware rendering */
18
+ export interface DisplaySegment {
19
+ type: 'text' | 'formula';
20
+ display: string;
21
+ tooltip?: string;
22
+ }
23
+
24
+ interface Props {
25
+ /** Current value */
26
+ value: string;
27
+ /** Callback when value is saved */
28
+ onSave: (newValue: string) => Promise<void> | void;
29
+ /** Input type: 'text' or 'textarea' */
30
+ type?: 'text' | 'textarea';
31
+ /** Placeholder text when empty */
32
+ placeholder?: string;
33
+ /** Disable editing */
34
+ disabled?: boolean;
35
+ /** Additional CSS classes for the container */
36
+ class?: string;
37
+ /** Number of rows for textarea (default: 3) */
38
+ rows?: number;
39
+ /** Show save/cancel buttons instead of auto-save on blur */
40
+ showButtons?: boolean;
41
+ /** Truncate long text with ellipsis (shows full text on hover) */
42
+ truncate?: boolean;
43
+ /** Optional display segments for formula-aware rendering */
44
+ displaySegments?: DisplaySegment[];
45
+ }
46
+
47
+ let {
48
+ value,
49
+ onSave,
50
+ type = 'text',
51
+ placeholder = 'Click to edit...',
52
+ disabled = false,
53
+ class: className = '',
54
+ rows = 3,
55
+ showButtons = false,
56
+ truncate = false,
57
+ displaySegments = []
58
+ }: Props = $props();
59
+
60
+ // --- Formula rendering ---
61
+ let hasFormulas = $derived(displaySegments.length > 0);
62
+
63
+ // Internal state
64
+ let isEditing = $state(false);
65
+ let editValue = $state(value);
66
+ let isSaving = $state(false);
67
+ let inputElement = $state<HTMLInputElement | HTMLTextAreaElement | null>(null);
68
+
69
+ // Sync editValue when value prop changes (only when not editing)
70
+ $effect(() => {
71
+ if (!isEditing) {
72
+ editValue = value;
73
+ }
74
+ });
75
+
76
+ // Start editing
77
+ function startEditing() {
78
+ if (disabled || isSaving) return;
79
+ editValue = value;
80
+ isEditing = true;
81
+ }
82
+
83
+ // Cancel editing
84
+ function cancelEditing() {
85
+ editValue = value;
86
+ isEditing = false;
87
+ }
88
+
89
+ // Save the edited value
90
+ async function saveValue() {
91
+ // Don't save if value hasn't changed
92
+ if (editValue === value) {
93
+ isEditing = false;
94
+ return;
95
+ }
96
+
97
+ isSaving = true;
98
+ try {
99
+ await onSave(editValue);
100
+ isEditing = false;
101
+ } catch (err) {
102
+ console.error('Failed to save:', err);
103
+ // Keep editing mode open on error
104
+ } finally {
105
+ isSaving = false;
106
+ }
107
+ }
108
+
109
+ // Handle keydown events
110
+ function handleKeyDown(event: KeyboardEvent) {
111
+ if (event.key === 'Escape') {
112
+ event.preventDefault();
113
+ cancelEditing();
114
+ } else if (event.key === 'Enter') {
115
+ if (type === 'text' || event.metaKey || event.ctrlKey) {
116
+ event.preventDefault();
117
+ saveValue();
118
+ }
119
+ }
120
+ }
121
+
122
+ // Handle blur (auto-save when not using buttons)
123
+ function handleBlur() {
124
+ if (!showButtons) {
125
+ saveValue();
126
+ }
127
+ }
128
+
129
+ // Auto-focus action
130
+ function autofocus(node: HTMLElement) {
131
+ requestAnimationFrame(() => {
132
+ node.focus();
133
+ if (node instanceof HTMLInputElement) {
134
+ node.select();
135
+ }
136
+ });
137
+ }
138
+
139
+ // Auto-resize textarea to fit content
140
+ function autoresize(node: HTMLTextAreaElement) {
141
+ function resize() {
142
+ node.style.height = 'auto';
143
+ node.style.height = node.scrollHeight + 'px';
144
+ }
145
+
146
+ requestAnimationFrame(resize);
147
+
148
+ node.addEventListener('input', resize);
149
+
150
+ return {
151
+ destroy() {
152
+ node.removeEventListener('input', resize);
153
+ }
154
+ };
155
+ }
156
+ </script>
157
+
158
+ {#if isEditing}
159
+ <!-- Edit mode -->
160
+ <div class="inline-edit-container w-full {className}">
161
+ {#if type === 'textarea'}
162
+ <textarea
163
+ bind:this={inputElement}
164
+ bind:value={editValue}
165
+ {placeholder}
166
+ {rows}
167
+ class="textarea textarea-bordered w-full text-sm resize-none overflow-hidden"
168
+ style="min-height: {rows * 1.5}rem;"
169
+ disabled={isSaving}
170
+ onkeydown={handleKeyDown}
171
+ onblur={handleBlur}
172
+ use:autofocus
173
+ use:autoresize
174
+ ></textarea>
175
+ {:else}
176
+ <input
177
+ bind:this={inputElement}
178
+ bind:value={editValue}
179
+ type="text"
180
+ {placeholder}
181
+ class="input input-bordered input-sm w-full"
182
+ disabled={isSaving}
183
+ onkeydown={handleKeyDown}
184
+ onblur={handleBlur}
185
+ use:autofocus
186
+ />
187
+ {/if}
188
+
189
+ {#if showButtons}
190
+ <div class="flex gap-1 mt-1">
191
+ <button
192
+ class="btn btn-xs btn-success"
193
+ onclick={saveValue}
194
+ disabled={isSaving}
195
+ >
196
+ {#if isSaving}
197
+ <span class="loading loading-spinner loading-xs"></span>
198
+ {:else}
199
+ Save
200
+ {/if}
201
+ </button>
202
+ <button
203
+ class="btn btn-xs btn-ghost"
204
+ onclick={cancelEditing}
205
+ disabled={isSaving}
206
+ >
207
+ Cancel
208
+ </button>
209
+ </div>
210
+ {:else if isSaving}
211
+ <div class="absolute right-2 top-1/2 -translate-y-1/2">
212
+ <span class="loading loading-spinner loading-xs"></span>
213
+ </div>
214
+ {/if}
215
+ </div>
216
+ {:else}
217
+ <!-- Display mode -->
218
+ <button
219
+ class="inline-edit-display text-left w-full rounded px-2 py-1 transition-colors {disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-base-200'} {truncate ? 'inline-edit-truncate' : ''} {className}"
220
+ onclick={startEditing}
221
+ disabled={disabled}
222
+ type="button"
223
+ title={truncate && value ? value : undefined}
224
+ >
225
+ {#if value}
226
+ {#if hasFormulas}
227
+ <span class={type === 'textarea' ? 'whitespace-pre-wrap' : (truncate ? 'truncate-text' : '')}>
228
+ {#each displaySegments as seg}
229
+ {#if seg.type === 'formula'}
230
+ <span class="inline-fx-result" title={seg.tooltip}>{seg.display}</span>
231
+ {:else}
232
+ {seg.display}
233
+ {/if}
234
+ {/each}
235
+ </span>
236
+ {:else if type === 'textarea'}
237
+ <span class="whitespace-pre-wrap">{value}</span>
238
+ {:else}
239
+ <span class={truncate ? 'truncate-text' : ''}>{value}</span>
240
+ {/if}
241
+ {:else}
242
+ <span class="text-base-content/50 italic">{placeholder}</span>
243
+ {/if}
244
+ </button>
245
+ {/if}
246
+
247
+ <style>
248
+ .inline-edit-container {
249
+ position: relative;
250
+ }
251
+
252
+ .inline-edit-display {
253
+ min-height: 2rem;
254
+ display: flex;
255
+ align-items: center;
256
+ }
257
+
258
+ /* Truncation styles */
259
+ .inline-edit-truncate {
260
+ max-width: 100%;
261
+ overflow: hidden;
262
+ }
263
+
264
+ .truncate-text {
265
+ display: block;
266
+ white-space: nowrap;
267
+ overflow: hidden;
268
+ text-overflow: ellipsis;
269
+ max-width: 100%;
270
+ }
271
+
272
+ .inline-fx-result {
273
+ display: inline;
274
+ padding: 0 4px;
275
+ border-radius: 3px;
276
+ background: oklch(0.25 0.06 80 / 0.4);
277
+ border: 1px solid oklch(0.35 0.08 80 / 0.3);
278
+ color: oklch(0.82 0.10 80);
279
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
280
+ font-size: 0.85em;
281
+ cursor: help;
282
+ }
283
+ </style>
@@ -0,0 +1,122 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from "svelte"
3
+
4
+ let {
5
+ src = "",
6
+ alt = "",
7
+ className = "",
8
+ aspectRatio = null,
9
+ fit = "cover",
10
+ preload = false,
11
+ priority = false,
12
+ width = null,
13
+ height = null,
14
+ } = $props<{
15
+ src: string
16
+ alt: string
17
+ className?: string
18
+ aspectRatio?: string | null
19
+ fit?: "cover" | "contain" | "fill"
20
+ preload?: boolean
21
+ priority?: boolean
22
+ width?: number | null
23
+ height?: number | null
24
+ }>()
25
+
26
+ let loaded = $state(false)
27
+ let error = $state(false)
28
+ let shouldLoad = $state(preload || priority)
29
+ let containerRef: HTMLDivElement
30
+
31
+ function startLoadingImage() {
32
+ if (loaded || !src) return
33
+ shouldLoad = true
34
+ }
35
+
36
+ onMount(() => {
37
+ if (priority || preload) {
38
+ startLoadingImage()
39
+ return
40
+ }
41
+
42
+ if (containerRef) {
43
+ const observer = new IntersectionObserver(
44
+ (entries) => {
45
+ for (const entry of entries) {
46
+ if (entry.isIntersecting) {
47
+ startLoadingImage()
48
+ observer.unobserve(entry.target)
49
+ }
50
+ }
51
+ },
52
+ { root: null, rootMargin: "200px", threshold: 0.01 }
53
+ )
54
+ observer.observe(containerRef)
55
+ return () => observer.disconnect()
56
+ }
57
+ })
58
+
59
+ $effect(() => {
60
+ if (src) {
61
+ loaded = false
62
+ error = false
63
+ }
64
+ })
65
+ </script>
66
+
67
+ <div
68
+ bind:this={containerRef}
69
+ class="relative overflow-hidden {className}"
70
+ style={aspectRatio ? `aspect-ratio: ${aspectRatio};` : ""}
71
+ data-src={src}
72
+ >
73
+ {#if !loaded && shouldLoad}
74
+ <div class="skeleton absolute inset-0 w-full h-full"></div>
75
+ {/if}
76
+
77
+ {#if shouldLoad}
78
+ <img
79
+ {src}
80
+ {alt}
81
+ {width}
82
+ {height}
83
+ class="w-full h-full object-{fit} transition-opacity duration-300 {loaded ? 'opacity-100' : 'opacity-0'}"
84
+ onload={(e) => {
85
+ loaded = true
86
+ if (!width || !height) {
87
+ const img = e.target as HTMLImageElement
88
+ width = img.naturalWidth
89
+ height = img.naturalHeight
90
+ }
91
+ }}
92
+ onerror={() => (error = true)}
93
+ loading={priority ? "eager" : "lazy"}
94
+ decoding="async"
95
+ />
96
+ {/if}
97
+
98
+ {#if !shouldLoad}
99
+ <div
100
+ class="w-full h-full bg-base-200"
101
+ style={width && height ? `aspect-ratio: ${width / height};` : ""}
102
+ ></div>
103
+ {/if}
104
+
105
+ {#if error && shouldLoad}
106
+ <div class="absolute inset-0 flex items-center justify-center bg-base-200">
107
+ <div class="flex flex-col items-center text-base-content/70">
108
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mb-2">
109
+ <rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect>
110
+ <circle cx="9" cy="9" r="2"></circle>
111
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>
112
+ </svg>
113
+ <span class="text-xs">Failed to load image</span>
114
+ </div>
115
+ </div>
116
+ {/if}
117
+ </div>
118
+
119
+ <style>
120
+ img { will-change: opacity; }
121
+ .skeleton { transform: translateZ(0); will-change: opacity; }
122
+ </style>