@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.
- package/package.json +46 -0
- package/src/lib/components/AudioWaveform.svelte +694 -0
- package/src/lib/components/AvailabilityModal.svelte +173 -0
- package/src/lib/components/Badge.svelte +38 -0
- package/src/lib/components/BookingForm.svelte +276 -0
- package/src/lib/components/Button.svelte +72 -0
- package/src/lib/components/CalendarPicker.svelte +284 -0
- package/src/lib/components/Card.svelte +67 -0
- package/src/lib/components/CharacterCounter.svelte +82 -0
- package/src/lib/components/ChipInput.svelte +596 -0
- package/src/lib/components/ColorSelector.svelte +163 -0
- package/src/lib/components/ConfirmModal.svelte +75 -0
- package/src/lib/components/CountdownTimer.svelte +94 -0
- package/src/lib/components/DateRangePicker.svelte +192 -0
- package/src/lib/components/Drawer.svelte +110 -0
- package/src/lib/components/FilterDropdown.svelte +202 -0
- package/src/lib/components/ImageUpload.svelte +97 -0
- package/src/lib/components/InlineEdit.svelte +283 -0
- package/src/lib/components/LazyImage.svelte +122 -0
- package/src/lib/components/LoadingSpinner.svelte +102 -0
- package/src/lib/components/Modal.svelte +208 -0
- package/src/lib/components/PhoneInput.svelte +92 -0
- package/src/lib/components/ResizableDivider.svelte +305 -0
- package/src/lib/components/ResizablePanel.svelte +302 -0
- package/src/lib/components/SearchDropdown.svelte +341 -0
- package/src/lib/components/SelectInput.svelte +215 -0
- package/src/lib/components/SignaturePad.svelte +171 -0
- package/src/lib/components/SortDropdown.svelte +148 -0
- package/src/lib/components/Sparkline.svelte +107 -0
- package/src/lib/components/SpeechForm.svelte +114 -0
- package/src/lib/components/StatusBadge.svelte +155 -0
- package/src/lib/components/TextArea.svelte +143 -0
- package/src/lib/components/TextInput.svelte +108 -0
- package/src/lib/components/ThemeSelector.svelte +195 -0
- package/src/lib/components/TimeSlotPicker.svelte +162 -0
- package/src/lib/components/VoicePlayer.svelte +420 -0
- package/src/lib/components/messaging/Avatar.svelte +81 -0
- package/src/lib/components/messaging/ChannelInfoModal.svelte +163 -0
- package/src/lib/components/messaging/ChannelList.svelte +107 -0
- package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +69 -0
- package/src/lib/components/messaging/ChannelMembersModal.svelte +182 -0
- package/src/lib/components/messaging/CreateChannelModal.svelte +190 -0
- package/src/lib/components/messaging/DirectMessageList.svelte +145 -0
- package/src/lib/components/messaging/EmojiSelector.svelte +260 -0
- package/src/lib/components/messaging/MentionAutocomplete.svelte +193 -0
- package/src/lib/components/messaging/MessageAttachment.svelte +270 -0
- package/src/lib/components/messaging/MessageAttachmentUpload.svelte +243 -0
- package/src/lib/components/messaging/MessageInput.svelte +451 -0
- package/src/lib/components/messaging/MessageItem.svelte +338 -0
- package/src/lib/components/messaging/MessageThread.svelte +306 -0
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +234 -0
- package/src/lib/components/messaging/QuotedMessageDisplay.svelte +118 -0
- package/src/lib/components/messaging/StartDMModal.svelte +100 -0
- package/src/lib/components/messaging/ThreadPanel.svelte +153 -0
- package/src/lib/index.ts +185 -0
- package/src/lib/types/booking.ts +143 -0
- package/src/lib/types/messaging.ts +459 -0
- package/src/lib/utils/currency.ts +20 -0
- package/src/lib/utils/daisyuiColors.ts +243 -0
- package/src/lib/utils/dateFormatters.ts +153 -0
- package/src/lib/utils/mentionParser.ts +188 -0
- 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>
|