@kahitsan/ksui 0.3.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/LICENSE +21 -0
- package/README.md +45 -0
- package/host-ui.d.ts +145 -0
- package/package.json +44 -0
- package/src/components/AccountAvatar.tsx +169 -0
- package/src/components/AddAttachmentTile.tsx +96 -0
- package/src/components/CameraCapture.tsx +144 -0
- package/src/components/ClientPicker.tsx +358 -0
- package/src/components/ExistingAttachmentTile.tsx +94 -0
- package/src/components/MarkdownNotes.tsx +466 -0
- package/src/components/MentionTextarea.tsx +490 -0
- package/src/components/PaymentAccountPicker.tsx +312 -0
- package/src/components/VoucherPicker.tsx +369 -0
- package/src/index.ts +62 -0
- package/src/lib/account-icons.ts +106 -0
- package/src/lib/account-logo-url.ts +12 -0
- package/src/lib/accounts-index.tsx +105 -0
- package/src/lib/attachments.ts +20 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// Source: KahitSan/kserp src/components/ClientPicker.tsx (vendored into the plugin remote).
|
|
2
|
+
//
|
|
3
|
+
// Cross-plugin picker: fetches the SIBLING clients plugin's public API at
|
|
4
|
+
// /api/clients. Degrades gracefully — when the clients plugin isn't deployed
|
|
5
|
+
// the fetch 404s/fails, the popup shows an inline "couldn't load" notice, and
|
|
6
|
+
// the rest of the transaction modal still works (the sale just has no
|
|
7
|
+
// billed-to client). highlightMatch comes from the host UI kit.
|
|
8
|
+
|
|
9
|
+
import { Portal } from "solid-js/web";
|
|
10
|
+
import { createEffect, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js";
|
|
11
|
+
import UserRound from "lucide-solid/icons/user-round";
|
|
12
|
+
import UserPlus from "lucide-solid/icons/user-plus";
|
|
13
|
+
import Search from "lucide-solid/icons/search";
|
|
14
|
+
import X from "lucide-solid/icons/x";
|
|
15
|
+
import Loader2 from "lucide-solid/icons/loader-2";
|
|
16
|
+
import { highlightMatch } from "@kserp/host-ui";
|
|
17
|
+
|
|
18
|
+
export interface ClientOption {
|
|
19
|
+
id: number;
|
|
20
|
+
name_raw: string;
|
|
21
|
+
email?: string | null;
|
|
22
|
+
phone?: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ClientPickerProps {
|
|
26
|
+
selected: ClientOption | null;
|
|
27
|
+
onChange: (next: ClientOption | null) => void;
|
|
28
|
+
onCreate?: (created: ClientOption) => void;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
defaultOpen?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const POPUP_MAX_HEIGHT = 360;
|
|
34
|
+
const POPUP_MIN_WIDTH = 320;
|
|
35
|
+
const SEARCH_DEBOUNCE_MS = 200;
|
|
36
|
+
|
|
37
|
+
export default function ClientPicker(props: ClientPickerProps): JSX.Element {
|
|
38
|
+
const [open, setOpen] = createSignal(false);
|
|
39
|
+
onMount(() => {
|
|
40
|
+
if (props.defaultOpen) queueMicrotask(() => setOpen(true));
|
|
41
|
+
});
|
|
42
|
+
const [query, setQuery] = createSignal("");
|
|
43
|
+
const [debouncedQuery, setDebouncedQuery] = createSignal("");
|
|
44
|
+
const [results, setResults] = createSignal<ClientOption[]>([]);
|
|
45
|
+
const [loading, setLoading] = createSignal(false);
|
|
46
|
+
const [creating, setCreating] = createSignal(false);
|
|
47
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
48
|
+
const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
|
|
49
|
+
|
|
50
|
+
let triggerRef: HTMLButtonElement | undefined;
|
|
51
|
+
let popupRef: HTMLDivElement | undefined;
|
|
52
|
+
let inputRef: HTMLInputElement | undefined;
|
|
53
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
54
|
+
let activeFetchToken = 0;
|
|
55
|
+
|
|
56
|
+
createEffect(() => {
|
|
57
|
+
const q = query();
|
|
58
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
59
|
+
debounceTimer = setTimeout(() => setDebouncedQuery(q), SEARCH_DEBOUNCE_MS);
|
|
60
|
+
onCleanup(() => {
|
|
61
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
createEffect(() => {
|
|
66
|
+
if (!open()) return;
|
|
67
|
+
const q = debouncedQuery().trim();
|
|
68
|
+
const token = ++activeFetchToken;
|
|
69
|
+
setLoading(true);
|
|
70
|
+
setError(null);
|
|
71
|
+
const params = new URLSearchParams({ status: "active", limit: "10" });
|
|
72
|
+
if (q) params.set("search", q);
|
|
73
|
+
fetch(`/api/clients?${params.toString()}`, { credentials: "include" })
|
|
74
|
+
.then((r) => {
|
|
75
|
+
if (!r.ok)
|
|
76
|
+
throw new Error(
|
|
77
|
+
r.status === 403
|
|
78
|
+
? "Permission denied"
|
|
79
|
+
: r.status === 404
|
|
80
|
+
? "Clients module isn't available"
|
|
81
|
+
: "Failed to load",
|
|
82
|
+
);
|
|
83
|
+
return r.json();
|
|
84
|
+
})
|
|
85
|
+
.then((json) => {
|
|
86
|
+
if (token !== activeFetchToken) return;
|
|
87
|
+
setResults((json.data || []) as ClientOption[]);
|
|
88
|
+
})
|
|
89
|
+
.catch((e) => {
|
|
90
|
+
if (token !== activeFetchToken) return;
|
|
91
|
+
setError(e instanceof Error ? e.message : "Failed to load");
|
|
92
|
+
setResults([]);
|
|
93
|
+
})
|
|
94
|
+
.finally(() => {
|
|
95
|
+
if (token !== activeFetchToken) return;
|
|
96
|
+
setLoading(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const updatePosition = () => {
|
|
101
|
+
if (!triggerRef) return;
|
|
102
|
+
const rect = triggerRef.getBoundingClientRect();
|
|
103
|
+
const vpHeight = window.innerHeight;
|
|
104
|
+
const vpWidth = window.innerWidth;
|
|
105
|
+
const width = Math.max(POPUP_MIN_WIDTH, rect.width);
|
|
106
|
+
const spaceBelow = vpHeight - rect.bottom;
|
|
107
|
+
const spaceAbove = rect.top;
|
|
108
|
+
const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
|
|
109
|
+
const top = flipUp ? Math.max(8, rect.top - POPUP_MAX_HEIGHT - 4) : rect.bottom + 4;
|
|
110
|
+
const maxHeight = Math.max(
|
|
111
|
+
200,
|
|
112
|
+
Math.min(POPUP_MAX_HEIGHT, flipUp ? spaceAbove - 12 : spaceBelow - 12),
|
|
113
|
+
);
|
|
114
|
+
const left = Math.min(Math.max(8, rect.left), vpWidth - width - 8);
|
|
115
|
+
setPopupStyle({
|
|
116
|
+
position: "fixed",
|
|
117
|
+
top: `${top}px`,
|
|
118
|
+
left: `${left}px`,
|
|
119
|
+
width: `${width}px`,
|
|
120
|
+
"max-height": `${maxHeight}px`,
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
createEffect(() => {
|
|
125
|
+
if (!open()) return;
|
|
126
|
+
updatePosition();
|
|
127
|
+
queueMicrotask(() => inputRef?.focus());
|
|
128
|
+
|
|
129
|
+
const onDocClick = (e: MouseEvent) => {
|
|
130
|
+
const t = e.target as Node;
|
|
131
|
+
if (triggerRef?.contains(t)) return;
|
|
132
|
+
if (popupRef?.contains(t)) return;
|
|
133
|
+
close();
|
|
134
|
+
};
|
|
135
|
+
const onEsc = (e: KeyboardEvent) => {
|
|
136
|
+
if (e.key === "Escape") {
|
|
137
|
+
e.stopPropagation();
|
|
138
|
+
close();
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const onReflow = () => updatePosition();
|
|
142
|
+
|
|
143
|
+
document.addEventListener("mousedown", onDocClick);
|
|
144
|
+
document.addEventListener("keydown", onEsc, true);
|
|
145
|
+
window.addEventListener("resize", onReflow);
|
|
146
|
+
window.addEventListener("scroll", onReflow, true);
|
|
147
|
+
onCleanup(() => {
|
|
148
|
+
document.removeEventListener("mousedown", onDocClick);
|
|
149
|
+
document.removeEventListener("keydown", onEsc, true);
|
|
150
|
+
window.removeEventListener("resize", onReflow);
|
|
151
|
+
window.removeEventListener("scroll", onReflow, true);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const close = () => {
|
|
156
|
+
setOpen(false);
|
|
157
|
+
setQuery("");
|
|
158
|
+
setDebouncedQuery("");
|
|
159
|
+
setResults([]);
|
|
160
|
+
setError(null);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const trimmedQuery = () => query().trim();
|
|
164
|
+
|
|
165
|
+
const hasExactMatch = () => {
|
|
166
|
+
const q = trimmedQuery().toLowerCase();
|
|
167
|
+
if (!q) return true;
|
|
168
|
+
return results().some((r) => r.name_raw.trim().toLowerCase() === q);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const showCreateOption = () => trimmedQuery().length > 0 && !hasExactMatch() && !loading();
|
|
172
|
+
|
|
173
|
+
const select = (c: ClientOption) => {
|
|
174
|
+
props.onChange(c);
|
|
175
|
+
close();
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const createAndSelect = async () => {
|
|
179
|
+
const name = trimmedQuery();
|
|
180
|
+
if (!name || creating()) return;
|
|
181
|
+
setCreating(true);
|
|
182
|
+
setError(null);
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch("/api/clients", {
|
|
185
|
+
method: "POST",
|
|
186
|
+
credentials: "include",
|
|
187
|
+
headers: { "Content-Type": "application/json" },
|
|
188
|
+
body: JSON.stringify({ name_raw: name }),
|
|
189
|
+
});
|
|
190
|
+
if (!res.ok) {
|
|
191
|
+
const body = await res.json().catch(() => ({ error: "Failed to create client" }));
|
|
192
|
+
throw new Error(body.error || "Failed to create client");
|
|
193
|
+
}
|
|
194
|
+
const created = (await res.json()) as ClientOption;
|
|
195
|
+
props.onChange(created);
|
|
196
|
+
props.onCreate?.(created);
|
|
197
|
+
close();
|
|
198
|
+
} catch (e) {
|
|
199
|
+
setError(e instanceof Error ? e.message : "Failed to create client");
|
|
200
|
+
} finally {
|
|
201
|
+
setCreating(false);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const clear = (e: MouseEvent) => {
|
|
206
|
+
e.stopPropagation();
|
|
207
|
+
props.onChange(null);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<>
|
|
212
|
+
<button
|
|
213
|
+
ref={triggerRef}
|
|
214
|
+
type="button"
|
|
215
|
+
data-testid="client-picker-trigger"
|
|
216
|
+
disabled={props.disabled}
|
|
217
|
+
onClick={() => !props.disabled && setOpen((o) => !o)}
|
|
218
|
+
class={`w-full mt-1.5 flex items-center gap-2 px-3 py-2.5 rounded-lg border transition-colors text-sm text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-60 ${
|
|
219
|
+
props.selected
|
|
220
|
+
? "bg-zinc-800/30 border-zinc-700/50 hover:border-amber-500/40 hover:bg-amber-500/5"
|
|
221
|
+
: "bg-red-500/5 border-red-500/40 hover:bg-red-500/10 hover:border-red-500/60"
|
|
222
|
+
}`}
|
|
223
|
+
aria-haspopup="listbox"
|
|
224
|
+
aria-expanded={open()}
|
|
225
|
+
>
|
|
226
|
+
<UserRound size={16} class={`shrink-0 ${props.selected ? "text-zinc-400" : "text-red-300"}`} />
|
|
227
|
+
<Show
|
|
228
|
+
when={props.selected}
|
|
229
|
+
fallback={<span class="text-red-300/90 italic">Walk-in (tap to pick a client)</span>}
|
|
230
|
+
>
|
|
231
|
+
<span class="flex-1 min-w-0">
|
|
232
|
+
<span class="block truncate text-zinc-100 font-medium">{props.selected!.name_raw}</span>
|
|
233
|
+
<Show when={props.selected!.email || props.selected!.phone}>
|
|
234
|
+
<span class="block truncate text-[11px] text-zinc-500">
|
|
235
|
+
{props.selected!.email || props.selected!.phone}
|
|
236
|
+
</span>
|
|
237
|
+
</Show>
|
|
238
|
+
</span>
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
data-testid="client-picker-clear"
|
|
242
|
+
onClick={clear}
|
|
243
|
+
class="shrink-0 p-1 rounded text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
|
|
244
|
+
title="Reset to walk-in"
|
|
245
|
+
aria-label="Reset to walk-in"
|
|
246
|
+
>
|
|
247
|
+
<X size={14} />
|
|
248
|
+
</button>
|
|
249
|
+
</Show>
|
|
250
|
+
</button>
|
|
251
|
+
|
|
252
|
+
<Show when={open()}>
|
|
253
|
+
<Portal>
|
|
254
|
+
<div
|
|
255
|
+
ref={popupRef}
|
|
256
|
+
data-testid="client-picker-popup"
|
|
257
|
+
class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
|
|
258
|
+
style={popupStyle()}
|
|
259
|
+
>
|
|
260
|
+
<div class="px-2 py-2 border-b border-zinc-800 flex items-center gap-2">
|
|
261
|
+
<Search size={14} class="text-zinc-500 shrink-0 ml-1" />
|
|
262
|
+
<input
|
|
263
|
+
ref={inputRef}
|
|
264
|
+
type="text"
|
|
265
|
+
data-testid="client-picker-input"
|
|
266
|
+
role="combobox"
|
|
267
|
+
aria-expanded={open()}
|
|
268
|
+
aria-controls="client-picker-listbox"
|
|
269
|
+
aria-autocomplete="list"
|
|
270
|
+
aria-label="Search clients"
|
|
271
|
+
value={query()}
|
|
272
|
+
onInput={(e) => setQuery(e.currentTarget.value)}
|
|
273
|
+
placeholder="Search clients by name, email, phone…"
|
|
274
|
+
class="w-full px-1 py-1 text-sm bg-transparent text-zinc-200 placeholder:text-zinc-600 focus:outline-none"
|
|
275
|
+
/>
|
|
276
|
+
<Show when={loading()}>
|
|
277
|
+
<Loader2 size={14} class="animate-spin text-zinc-500 mr-1 shrink-0" />
|
|
278
|
+
</Show>
|
|
279
|
+
</div>
|
|
280
|
+
<div class="flex-1 overflow-y-auto">
|
|
281
|
+
<Show when={error()}>
|
|
282
|
+
<div role="status" class="px-3 py-2 text-xs text-red-400">
|
|
283
|
+
{error()}
|
|
284
|
+
</div>
|
|
285
|
+
</Show>
|
|
286
|
+
<Show when={!loading() && results().length === 0 && !showCreateOption() && !error()}>
|
|
287
|
+
<div role="status" class="px-3 py-4 text-xs text-zinc-500 text-center">
|
|
288
|
+
{trimmedQuery() ? "No matches" : "Type to search clients…"}
|
|
289
|
+
</div>
|
|
290
|
+
</Show>
|
|
291
|
+
<Show when={results().length > 0}>
|
|
292
|
+
<ul
|
|
293
|
+
id="client-picker-listbox"
|
|
294
|
+
data-testid="client-picker-listbox"
|
|
295
|
+
role="listbox"
|
|
296
|
+
aria-label="Client search results"
|
|
297
|
+
class="m-0 p-0 list-none"
|
|
298
|
+
>
|
|
299
|
+
<For each={results()}>
|
|
300
|
+
{(c) => (
|
|
301
|
+
<li role="option" aria-selected={props.selected?.id === c.id}>
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
data-testid={`client-picker-result-${c.id}`}
|
|
305
|
+
onClick={() => select(c)}
|
|
306
|
+
class="w-full text-left px-3 py-2 hover:bg-amber-500/10 transition-colors flex items-start gap-2 cursor-pointer"
|
|
307
|
+
>
|
|
308
|
+
<UserRound size={14} class="text-zinc-500 shrink-0 mt-0.5" />
|
|
309
|
+
<span class="flex-1 min-w-0">
|
|
310
|
+
<span class="block text-sm text-zinc-100 truncate">
|
|
311
|
+
{highlightMatch(c.name_raw, debouncedQuery().trim())}
|
|
312
|
+
</span>
|
|
313
|
+
<Show when={c.email || c.phone}>
|
|
314
|
+
<span class="block text-[11px] text-zinc-500 truncate">
|
|
315
|
+
{highlightMatch(
|
|
316
|
+
[c.email, c.phone].filter(Boolean).join(" · "),
|
|
317
|
+
debouncedQuery().trim(),
|
|
318
|
+
)}
|
|
319
|
+
</span>
|
|
320
|
+
</Show>
|
|
321
|
+
</span>
|
|
322
|
+
<Show when={props.selected?.id === c.id}>
|
|
323
|
+
<span class="text-amber-400 text-xs shrink-0 mt-0.5">✓</span>
|
|
324
|
+
</Show>
|
|
325
|
+
</button>
|
|
326
|
+
</li>
|
|
327
|
+
)}
|
|
328
|
+
</For>
|
|
329
|
+
</ul>
|
|
330
|
+
</Show>
|
|
331
|
+
<Show when={showCreateOption()}>
|
|
332
|
+
<div class="border-t border-zinc-800">
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
data-testid="client-picker-create"
|
|
336
|
+
onClick={createAndSelect}
|
|
337
|
+
disabled={creating()}
|
|
338
|
+
class="w-full text-left px-3 py-2.5 hover:bg-emerald-500/10 transition-colors flex items-center gap-2 cursor-pointer disabled:cursor-wait disabled:opacity-60"
|
|
339
|
+
>
|
|
340
|
+
<Show
|
|
341
|
+
when={!creating()}
|
|
342
|
+
fallback={<Loader2 size={14} class="animate-spin text-emerald-400 shrink-0" />}
|
|
343
|
+
>
|
|
344
|
+
<UserPlus size={14} class="text-emerald-400 shrink-0" />
|
|
345
|
+
</Show>
|
|
346
|
+
<span class="text-sm text-emerald-300">
|
|
347
|
+
Create "<span class="font-medium">{trimmedQuery()}</span>"
|
|
348
|
+
</span>
|
|
349
|
+
</button>
|
|
350
|
+
</div>
|
|
351
|
+
</Show>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</Portal>
|
|
355
|
+
</Show>
|
|
356
|
+
</>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Renders one already-uploaded attachment as a 24×24 tile: an image preview or
|
|
2
|
+
// a paperclip/file fallback linking to the s3_link public URL, an "Unavailable"
|
|
3
|
+
// placeholder when the link can't be resolved (see lib/attachments.ts), and an
|
|
4
|
+
// optional remove button. confirm comes from the host UI kit. The third of the
|
|
5
|
+
// attachment widget set alongside AddAttachmentTile + CameraCapture.
|
|
6
|
+
|
|
7
|
+
import { Show, type Component } from "solid-js";
|
|
8
|
+
import Paperclip from "lucide-solid/icons/paperclip";
|
|
9
|
+
import X from "lucide-solid/icons/x";
|
|
10
|
+
import TriangleAlert from "lucide-solid/icons/triangle-alert";
|
|
11
|
+
import { confirm } from "@kserp/host-ui";
|
|
12
|
+
import { attachmentUrl, isResolvableAttachment } from "../lib/attachments";
|
|
13
|
+
|
|
14
|
+
export interface ExistingAttachment {
|
|
15
|
+
id: number;
|
|
16
|
+
file_name: string;
|
|
17
|
+
mime_type: string;
|
|
18
|
+
s3_link: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
attachment: ExistingAttachment;
|
|
23
|
+
testId: string;
|
|
24
|
+
onDelete?: (attachmentId: number) => Promise<void> | void;
|
|
25
|
+
fallbackIcon?: Component<{ size?: number }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function ExistingAttachmentTile(props: Props) {
|
|
29
|
+
const url = () => attachmentUrl(props.attachment.s3_link);
|
|
30
|
+
const FallbackIcon = () => {
|
|
31
|
+
const Icon = props.fallbackIcon ?? Paperclip;
|
|
32
|
+
return <Icon size={20} />;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div class="relative group shrink-0" data-testid={props.testId}>
|
|
37
|
+
<Show
|
|
38
|
+
when={isResolvableAttachment(props.attachment.s3_link)}
|
|
39
|
+
fallback={
|
|
40
|
+
<div
|
|
41
|
+
class="flex w-24 h-24 flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-zinc-700 bg-zinc-900/40 px-2 text-center text-zinc-500"
|
|
42
|
+
title={`${props.attachment.file_name} — file is no longer available`}
|
|
43
|
+
>
|
|
44
|
+
<TriangleAlert size={18} class="text-amber-500/70" />
|
|
45
|
+
<span class="truncate max-w-full text-[10px]">{props.attachment.file_name}</span>
|
|
46
|
+
<span class="text-[9px] uppercase tracking-wider">Unavailable</span>
|
|
47
|
+
</div>
|
|
48
|
+
}
|
|
49
|
+
>
|
|
50
|
+
<Show
|
|
51
|
+
when={props.attachment.mime_type.startsWith("image/")}
|
|
52
|
+
fallback={
|
|
53
|
+
<a
|
|
54
|
+
href={url()}
|
|
55
|
+
target="_blank"
|
|
56
|
+
rel="noopener"
|
|
57
|
+
class="flex w-24 h-24 flex-col items-center justify-center gap-1 rounded-lg border border-zinc-700 bg-zinc-800/50 px-2 text-xs text-zinc-300 hover:border-amber-500/30"
|
|
58
|
+
>
|
|
59
|
+
<FallbackIcon />
|
|
60
|
+
<span class="truncate max-w-full text-[10px]">{props.attachment.file_name}</span>
|
|
61
|
+
</a>
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
<a
|
|
65
|
+
href={url()}
|
|
66
|
+
target="_blank"
|
|
67
|
+
rel="noopener"
|
|
68
|
+
class="block rounded-lg border border-zinc-700 overflow-hidden hover:border-amber-500/30"
|
|
69
|
+
>
|
|
70
|
+
<img src={url()} alt={props.attachment.file_name} class="w-24 h-24 object-cover" />
|
|
71
|
+
</a>
|
|
72
|
+
</Show>
|
|
73
|
+
</Show>
|
|
74
|
+
<Show when={props.onDelete}>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
aria-label={`Remove ${props.attachment.file_name}`}
|
|
78
|
+
onClick={async () => {
|
|
79
|
+
const ok = await confirm({
|
|
80
|
+
title: "Remove attachment?",
|
|
81
|
+
message: `Remove attachment "${props.attachment.file_name}"?`,
|
|
82
|
+
confirmLabel: "Remove",
|
|
83
|
+
danger: true,
|
|
84
|
+
});
|
|
85
|
+
if (ok) await props.onDelete!(props.attachment.id);
|
|
86
|
+
}}
|
|
87
|
+
class="absolute -top-2 -right-2 flex w-7 h-7 items-center justify-center rounded-full bg-red-600/90 border border-red-400/60 text-white cursor-pointer hover:bg-red-500 active:bg-red-700 shadow-lg"
|
|
88
|
+
>
|
|
89
|
+
<X size={12} />
|
|
90
|
+
</button>
|
|
91
|
+
</Show>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|