@joewinke/jatui 0.1.11 → 0.1.20

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 (100) hide show
  1. package/README.md +123 -0
  2. package/package.json +2 -1
  3. package/src/lib/actions/railNav.ts +473 -0
  4. package/src/lib/components/AnnotationLayer.svelte +108 -0
  5. package/src/lib/components/AnnotationPanel.svelte +319 -0
  6. package/src/lib/components/AudioWaveform.svelte +9 -5
  7. package/src/lib/components/AvailabilityModal.svelte +7 -3
  8. package/src/lib/components/AvatarUpload.svelte +27 -4
  9. package/src/lib/components/BookingForm.svelte +11 -9
  10. package/src/lib/components/BurndownChart.svelte +778 -0
  11. package/src/lib/components/Button.svelte +10 -1
  12. package/src/lib/components/CalendarPicker.svelte +3 -3
  13. package/src/lib/components/Card.svelte +2 -2
  14. package/src/lib/components/ChipInput.svelte +8 -3
  15. package/src/lib/components/ColorSelector.svelte +17 -13
  16. package/src/lib/components/CommentThread.svelte +773 -0
  17. package/src/lib/components/ConfirmDialog.svelte +348 -0
  18. package/src/lib/components/ConfirmModal.svelte +78 -11
  19. package/src/lib/components/ContextMenu.svelte +59 -19
  20. package/src/lib/components/CountdownTimer.svelte +1 -1
  21. package/src/lib/components/DateRangePicker.svelte +6 -4
  22. package/src/lib/components/Drawer.svelte +36 -3
  23. package/src/lib/components/EntityPreviewCard.svelte +104 -0
  24. package/src/lib/components/FileDropzone.svelte +493 -0
  25. package/src/lib/components/FilePicker.svelte +83 -14
  26. package/src/lib/components/FileThumbnail.svelte +80 -0
  27. package/src/lib/components/FilterDropdown.svelte +11 -11
  28. package/src/lib/components/GPSTracker.svelte +202 -0
  29. package/src/lib/components/HunkDiffView.svelte +348 -0
  30. package/src/lib/components/ImageLightbox.svelte +274 -0
  31. package/src/lib/components/ImageUpload.svelte +58 -9
  32. package/src/lib/components/InlineEdit.svelte +6 -2
  33. package/src/lib/components/InputDialog.svelte +327 -0
  34. package/src/lib/components/KeyboardShortcutsOverlay.svelte +296 -0
  35. package/src/lib/components/LazyImage.svelte +1 -0
  36. package/src/lib/components/LinkShortener.svelte +1 -1
  37. package/src/lib/components/LoadingSpinner.svelte +6 -2
  38. package/src/lib/components/LocationMap.svelte +186 -0
  39. package/src/lib/components/MapView.svelte +341 -0
  40. package/src/lib/components/MarkupEditor.svelte +485 -0
  41. package/src/lib/components/MarkupOverlay.svelte +55 -0
  42. package/src/lib/components/MediaWorkbench.svelte +871 -0
  43. package/src/lib/components/MilestoneCard.svelte +1 -1
  44. package/src/lib/components/MilestoneTimeline.svelte +1 -1
  45. package/src/lib/components/Modal.svelte +39 -4
  46. package/src/lib/components/PDFViewer.svelte +105 -0
  47. package/src/lib/components/PdfThumbnail.svelte +3 -1
  48. package/src/lib/components/PhoneInput.svelte +1 -1
  49. package/src/lib/components/ResizablePanel.svelte +4 -4
  50. package/src/lib/components/SearchDropdown.svelte +26 -13
  51. package/src/lib/components/SelectInput.svelte +26 -4
  52. package/src/lib/components/SidebarUserFooter.svelte +1 -1
  53. package/src/lib/components/SignaturePad.svelte +8 -4
  54. package/src/lib/components/SmartImageEditor.svelte +720 -0
  55. package/src/lib/components/SortDropdown.svelte +9 -3
  56. package/src/lib/components/Sparkline.svelte +9 -0
  57. package/src/lib/components/StatusBadge.svelte +20 -18
  58. package/src/lib/components/TextArea.svelte +24 -5
  59. package/src/lib/components/TextInput.svelte +29 -6
  60. package/src/lib/components/ThemeSelector.svelte +15 -4
  61. package/src/lib/components/TimeSlotPicker.svelte +7 -7
  62. package/src/lib/components/UserAvatar.svelte +14 -1
  63. package/src/lib/components/VariablePicker.svelte +170 -0
  64. package/src/lib/components/VoicePlayer.svelte +4 -3
  65. package/src/lib/components/linked-columns/LinkedColumns.svelte +520 -0
  66. package/src/lib/components/markup.ts +287 -0
  67. package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
  68. package/src/lib/components/messaging/ChannelList.svelte +1 -1
  69. package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
  70. package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
  71. package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
  72. package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
  73. package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
  74. package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
  75. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
  76. package/src/lib/components/messaging/MessageInput.svelte +1 -1
  77. package/src/lib/components/messaging/MessageItem.svelte +6 -3
  78. package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
  79. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
  80. package/src/lib/components/messaging/StartDMModal.svelte +1 -1
  81. package/src/lib/components/pipeline/Pipeline.svelte +4 -4
  82. package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
  83. package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
  84. package/src/lib/components/replay/ChapterTimeline.svelte +326 -0
  85. package/src/lib/components/session-nav/transcriptModel.ts +352 -0
  86. package/src/lib/index.ts +138 -0
  87. package/src/lib/stores/confirmDialog.svelte.ts +48 -0
  88. package/src/lib/stores/inputDialog.svelte.ts +51 -0
  89. package/src/lib/styles/rail.css +63 -0
  90. package/src/lib/types/annotation.ts +38 -0
  91. package/src/lib/types/comments.ts +97 -0
  92. package/src/lib/types/entityPreview.ts +45 -0
  93. package/src/lib/types/filePicker.ts +2 -0
  94. package/src/lib/types/googleMaps.d.ts +51 -0
  95. package/src/lib/types/maps.ts +43 -0
  96. package/src/lib/types/smartImageEditor.ts +39 -0
  97. package/src/lib/types/templateVars.ts +36 -0
  98. package/src/lib/utils/dateFormatters.ts +12 -10
  99. package/src/lib/utils/googleMapsLoader.ts +84 -0
  100. package/src/lib/utils/taskUtils.ts +21 -7
@@ -0,0 +1,720 @@
1
+ <script lang="ts">
2
+ import type {
3
+ ImageProcessingSpec,
4
+ ImageVariantSpec,
5
+ VariantResult,
6
+ VariantState,
7
+ } from "../types/smartImageEditor"
8
+
9
+ let {
10
+ spec = undefined,
11
+ uploadEndpoint = "/api/admin/image/process",
12
+ value = $bindable(""),
13
+ onComplete = undefined,
14
+ onDelete = undefined,
15
+ onDirectUpload = undefined,
16
+ accept = "image/png,image/jpeg,image/webp,image/svg+xml",
17
+ maxSizeMb = 10,
18
+ previewUrls = undefined,
19
+ } = $props<{
20
+ spec?: ImageProcessingSpec
21
+ uploadEndpoint?: string
22
+ value?: string
23
+ onComplete?: (variants: VariantResult[]) => void
24
+ /** Called when the user confirms delete on a variant tile. The host
25
+ * decides what "delete" means (typically: remove from storage + clear
26
+ * the stored URL). Returning false (or throwing) leaves the tile in
27
+ * place; returning truthy clears the tile's variant state. */
28
+ onDelete?: (variantName: string) => Promise<boolean | void> | boolean | void
29
+ /** Called when the user drops/picks a file directly into an empty
30
+ * variant tile — bypasses the AI pipeline. The host uploads the file
31
+ * to its variant slot and returns the resulting VariantResult (or
32
+ * null on failure). When provided, idle tiles become drop-targets. */
33
+ onDirectUpload?: (variantName: string, file: File) => Promise<VariantResult | null>
34
+ accept?: string
35
+ maxSizeMb?: number
36
+ /** Pre-populate variant cells with existing stored URLs (keyed by variant name). */
37
+ previewUrls?: Record<string, string>
38
+ }>()
39
+
40
+ const isEditorMode = $derived(spec !== undefined)
41
+ const maxBytes = $derived(maxSizeMb * 1024 * 1024)
42
+
43
+ // ─── State ────────────────────────────────────────────────────────────────
44
+ let isDragging = $state(false)
45
+ let error = $state("")
46
+ let processing = $state(false)
47
+ let backgroundRemovalFailed = $state(false)
48
+
49
+ // Editor mode: per-variant state, keyed by variant name.
50
+ // Initialized from previewUrls so existing stored images render immediately.
51
+ // Intentionally captures the initial prop value — changes to previewUrls do not re-initialize.
52
+ function _initVariantStates(): Record<string, VariantState> {
53
+ if (!previewUrls) return {}
54
+ return Object.fromEntries(
55
+ Object.entries(previewUrls)
56
+ .filter(([, url]) => Boolean(url))
57
+ .map(([name, url]): [string, VariantState] => [
58
+ name,
59
+ { status: "done" as const, result: { name, url: String(url), width: 0, height: 0 } },
60
+ ])
61
+ )
62
+ }
63
+ let variantStates = $state<Record<string, VariantState>>(_initVariantStates())
64
+ let sourceFile = $state<File | null>(null) // retained for per-cell regenerate
65
+ let hasResults = $derived(
66
+ spec !== undefined &&
67
+ spec.variants.length > 0 &&
68
+ spec.variants.every((v: ImageVariantSpec) => variantStates[v.name]?.status === "done" || variantStates[v.name]?.status === "error"),
69
+ )
70
+
71
+ // ─── Simple mode: forward to value + onComplete ───────────────────────────
72
+ function formatSize(bytes: number) {
73
+ if (bytes < 1024) return `${bytes} B`
74
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
75
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
76
+ }
77
+
78
+ async function handleFile(file: File) {
79
+ error = ""
80
+ const allowedTypes = accept.split(",").map((s: string) => s.trim())
81
+ if (!allowedTypes.includes(file.type)) {
82
+ error = `Unsupported file type: ${file.type || "unknown"}. Accepted: PNG, JPG, WebP, SVG.`
83
+ return
84
+ }
85
+ if (file.size > maxBytes) {
86
+ error = `File too large (${formatSize(file.size)}). Maximum: ${maxSizeMb} MB.`
87
+ return
88
+ }
89
+
90
+ if (!isEditorMode) {
91
+ // Simple mode — just surface a blob URL
92
+ value = URL.createObjectURL(file)
93
+ onComplete?.([{ name: "image", url: value, width: 0, height: 0 }])
94
+ return
95
+ }
96
+
97
+ // Editor mode — POST to pipeline
98
+ sourceFile = file
99
+ await runPipeline(file, spec!.variants.map((v: ImageVariantSpec) => v.name))
100
+ }
101
+
102
+ async function runPipeline(file: File, variantNames: string[]) {
103
+ processing = true
104
+ backgroundRemovalFailed = false
105
+
106
+ // Initialize all requested variants to loading
107
+ const newStates: Record<string, VariantState> = {}
108
+ for (const name of variantNames) {
109
+ newStates[name] = { status: "loading" }
110
+ }
111
+ // Merge, keeping other variant states intact
112
+ variantStates = { ...variantStates, ...newStates }
113
+
114
+ const formData = new FormData()
115
+ formData.append("file", file)
116
+ formData.append(
117
+ "spec",
118
+ JSON.stringify({
119
+ removeBackground: spec!.removeBackground,
120
+ variants: spec!.variants.filter((v: ImageVariantSpec) => variantNames.includes(v.name)),
121
+ }),
122
+ )
123
+
124
+ try {
125
+ const res = await fetch(uploadEndpoint, { method: "POST", body: formData })
126
+ if (!res.ok) {
127
+ const body = await res.json().catch(() => ({ error: res.statusText }))
128
+ throw new Error(body.error ?? `HTTP ${res.status}`)
129
+ }
130
+ const body = await res.json()
131
+ if (!body.ok) throw new Error(body.error ?? "Processing failed")
132
+
133
+ if (body.backgroundRemovalFailed) backgroundRemovalFailed = true
134
+
135
+ const results: VariantResult[] = body.variants
136
+ const nextStates: Record<string, VariantState> = {}
137
+ for (const result of results) {
138
+ nextStates[result.name] = { status: "done", result }
139
+ }
140
+ // Mark any requested variant that didn't come back as error
141
+ for (const name of variantNames) {
142
+ if (!nextStates[name]) {
143
+ nextStates[name] = { status: "error", message: "No result returned" }
144
+ }
145
+ }
146
+ variantStates = { ...variantStates, ...nextStates }
147
+ onComplete?.(results)
148
+ } catch (err) {
149
+ const msg = err instanceof Error ? err.message : String(err)
150
+ const errStates: Record<string, VariantState> = {}
151
+ for (const name of variantNames) {
152
+ if (variantStates[name]?.status === "loading") {
153
+ errStates[name] = { status: "error", message: msg }
154
+ }
155
+ }
156
+ variantStates = { ...variantStates, ...errStates }
157
+ error = msg
158
+ } finally {
159
+ processing = false
160
+ }
161
+ }
162
+
163
+ async function regenerateVariant(variantName: string) {
164
+ if (!sourceFile || !spec) return
165
+ await runPipeline(sourceFile, [variantName])
166
+ }
167
+
168
+ // ─── Per-tile direct upload (bypasses AI) ───────────────────────────────
169
+ // Empty cells render as drop targets when onDirectUpload is provided.
170
+ // Hover styling tracked per-cell so we only highlight the one being
171
+ // dragged over. Each cell maintains its own loading state so a slow
172
+ // upload doesn't block the others.
173
+ let dragOverTile = $state<string | null>(null)
174
+ let directUploading = $state<Record<string, boolean>>({})
175
+
176
+ async function handleDirectUpload(variantName: string, file: File) {
177
+ if (!onDirectUpload) return
178
+ // Validate against the same accept/size limits as the AI dropzone — keeps
179
+ // the two paths consistent and avoids relying on server-side feedback.
180
+ const allowedTypes = accept.split(",").map((s: string) => s.trim())
181
+ if (!allowedTypes.includes(file.type)) {
182
+ console.error("[SmartImageEditor] direct upload type rejected:", file.type)
183
+ return
184
+ }
185
+ if (file.size > maxBytes) {
186
+ console.error("[SmartImageEditor] direct upload too large:", file.size)
187
+ return
188
+ }
189
+ directUploading = { ...directUploading, [variantName]: true }
190
+ variantStates = {
191
+ ...variantStates,
192
+ [variantName]: { status: "loading" },
193
+ }
194
+ try {
195
+ const result = await onDirectUpload(variantName, file)
196
+ if (result) {
197
+ variantStates = {
198
+ ...variantStates,
199
+ [variantName]: { status: "done", result },
200
+ }
201
+ onComplete?.([result])
202
+ } else {
203
+ // Host returned null = failed; reset to idle so the cell becomes a
204
+ // drop target again.
205
+ const next = { ...variantStates }
206
+ delete next[variantName]
207
+ variantStates = next
208
+ }
209
+ } catch (err) {
210
+ variantStates = {
211
+ ...variantStates,
212
+ [variantName]: {
213
+ status: "error",
214
+ message: err instanceof Error ? err.message : "Upload failed",
215
+ },
216
+ }
217
+ } finally {
218
+ const next = { ...directUploading }
219
+ delete next[variantName]
220
+ directUploading = next
221
+ }
222
+ }
223
+
224
+ function onTileDragOver(variantName: string, e: DragEvent) {
225
+ if (!onDirectUpload) return
226
+ e.preventDefault()
227
+ dragOverTile = variantName
228
+ }
229
+ function onTileDragLeave(variantName: string, e: DragEvent) {
230
+ if (dragOverTile !== variantName) return
231
+ if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) {
232
+ dragOverTile = null
233
+ }
234
+ }
235
+ function onTileDrop(variantName: string, e: DragEvent) {
236
+ e.preventDefault()
237
+ dragOverTile = null
238
+ const file = e.dataTransfer?.files[0]
239
+ if (file) handleDirectUpload(variantName, file)
240
+ }
241
+ function onTileFileInput(variantName: string, e: Event) {
242
+ const input = e.target as HTMLInputElement
243
+ const file = input.files?.[0]
244
+ if (file) handleDirectUpload(variantName, file)
245
+ input.value = ""
246
+ }
247
+
248
+ // ─── Per-variant delete (with confirm) ──────────────────────────────────
249
+ let deleteTarget = $state<string | null>(null)
250
+ let deleting = $state(false)
251
+
252
+ function requestDelete(variantName: string) {
253
+ deleteTarget = variantName
254
+ }
255
+ function cancelDelete() {
256
+ if (deleting) return
257
+ deleteTarget = null
258
+ }
259
+ async function confirmDelete() {
260
+ if (!deleteTarget || !onDelete) {
261
+ deleteTarget = null
262
+ return
263
+ }
264
+ deleting = true
265
+ try {
266
+ const ok = await onDelete(deleteTarget)
267
+ if (ok !== false) {
268
+ const next = { ...variantStates }
269
+ delete next[deleteTarget]
270
+ variantStates = next
271
+ }
272
+ } catch (err) {
273
+ console.error("[SmartImageEditor] delete failed:", err)
274
+ } finally {
275
+ deleting = false
276
+ deleteTarget = null
277
+ }
278
+ }
279
+
280
+ // ─── Drag handlers ────────────────────────────────────────────────────────
281
+ function onDragOver(e: DragEvent) {
282
+ e.preventDefault()
283
+ isDragging = true
284
+ }
285
+ function onDragLeave(e: DragEvent) {
286
+ if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) {
287
+ isDragging = false
288
+ }
289
+ }
290
+ function onDrop(e: DragEvent) {
291
+ e.preventDefault()
292
+ isDragging = false
293
+ const file = e.dataTransfer?.files[0]
294
+ if (file) handleFile(file)
295
+ }
296
+ function onFileInput(e: Event) {
297
+ const input = e.target as HTMLInputElement
298
+ const file = input.files?.[0]
299
+ if (file) handleFile(file)
300
+ // Reset so same file can be re-selected
301
+ input.value = ""
302
+ }
303
+ function onDropZoneKey(e: KeyboardEvent) {
304
+ if (e.key === "Enter" || e.key === " ") {
305
+ e.preventDefault()
306
+ ;(e.currentTarget as HTMLElement).querySelector("input")?.click()
307
+ }
308
+ }
309
+
310
+ function clearSimple() {
311
+ if (value.startsWith("blob:")) URL.revokeObjectURL(value)
312
+ value = ""
313
+ error = ""
314
+ }
315
+
316
+ $effect(() => {
317
+ return () => {
318
+ if (value.startsWith("blob:")) URL.revokeObjectURL(value)
319
+ }
320
+ })
321
+
322
+ // ─── Helpers ──────────────────────────────────────────────────────────────
323
+ function variantLabel(name: string) {
324
+ return name
325
+ .replace(/_/g, " ")
326
+ .replace(/\b\w/g, (c) => c.toUpperCase())
327
+ }
328
+
329
+ // OG image is 2-col wide (wider aspect)
330
+ function isWideVariant(name: string) {
331
+ return name === "og_image" || name === "og"
332
+ }
333
+ </script>
334
+
335
+ <!-- ─── Simple mode ─────────────────────────────────────────────────────── -->
336
+ {#if !isEditorMode}
337
+ {#if value}
338
+ <div class="relative w-full h-40 bg-base-200 rounded-md overflow-hidden">
339
+ <img src={value} alt="Uploaded" class="w-full h-full object-contain" />
340
+ <button
341
+ type="button"
342
+ class="btn btn-sm btn-circle absolute top-2 right-2 bg-base-100/80"
343
+ onclick={clearSimple}
344
+ aria-label="Remove image"
345
+ >
346
+ <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>
347
+ </button>
348
+ </div>
349
+ {:else}
350
+ <label
351
+ class="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-md cursor-pointer transition-all duration-200
352
+ {isDragging
353
+ ? 'border-primary bg-primary/8 scale-[1.01]'
354
+ : 'border-base-content/20 bg-base-200 hover:bg-base-300 hover:border-base-content/30'}"
355
+ ondragover={onDragOver}
356
+ ondragleave={onDragLeave}
357
+ ondrop={onDrop}
358
+ >
359
+ <div class="flex flex-col items-center justify-center pt-5 pb-6 pointer-events-none">
360
+ {#if isDragging}
361
+ <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 text-primary"><path d="M12 15V3"/><path d="M7 10l5 5 5-5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></svg>
362
+ <p class="mb-2 text-sm font-semibold text-primary">Drop to upload</p>
363
+ {:else}
364
+ <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 text-base-content/40"><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>
365
+ <p class="mb-2 text-sm"><span class="font-semibold">Click to upload</span> or drag and drop</p>
366
+ <p class="text-xs text-base-content/45">PNG, JPG, WebP or SVG (max {maxSizeMb} MB)</p>
367
+ {/if}
368
+ </div>
369
+ <input type="file" class="hidden" {accept} onchange={onFileInput} />
370
+ </label>
371
+ {/if}
372
+
373
+ {#if error}
374
+ <p class="text-error text-sm mt-1" role="alert">{error}</p>
375
+ {/if}
376
+
377
+ <!-- ─── Editor mode ──────────────────────────────────────────────────────── -->
378
+ {:else}
379
+ <div class="w-full space-y-4">
380
+
381
+ <!-- AI dropzone — re-framed as the "smart" path. The variant grid below
382
+ offers the "direct upload to specific slot" path. Both are valid
383
+ workflows and labeled distinctly so the user knows which is which. -->
384
+ {#if isEditorMode}
385
+ <div class="flex items-center justify-between gap-2 text-xs">
386
+ <div class="flex items-center gap-1.5 text-base-content/70 font-medium">
387
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-primary"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/></svg>
388
+ AI mode — regenerates all 4 slots from one source
389
+ </div>
390
+ {#if onDirectUpload}
391
+ <span class="text-base-content/45">or upload each slot directly below ↓</span>
392
+ {/if}
393
+ </div>
394
+ {/if}
395
+ <div
396
+ role="button"
397
+ tabindex="0"
398
+ class="w-full border-2 border-dashed rounded-md px-6 py-8 text-center cursor-pointer transition-all duration-200 focus-visible:outline-2 focus-visible:outline-primary
399
+ {isDragging
400
+ ? 'border-primary bg-primary/8 scale-[1.005]'
401
+ : 'border-base-content/20 bg-base-200 hover:bg-base-300 hover:border-base-content/30'}"
402
+ ondragover={onDragOver}
403
+ ondragleave={onDragLeave}
404
+ ondrop={onDrop}
405
+ onkeydown={onDropZoneKey}
406
+ aria-label="Drop image here for AI variant generation, or press Enter to browse"
407
+ >
408
+ <label class="flex flex-col items-center gap-2 cursor-pointer">
409
+ {#if processing}
410
+ <!-- Processing indicator -->
411
+ <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-primary animate-spin motion-reduce:hidden" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
412
+ <span class="sr-only motion-reduce:not-sr-only text-sm">Processing…</span>
413
+ <p class="text-sm text-primary font-medium motion-reduce:hidden" aria-live="polite">Removing background and generating variants…</p>
414
+ {:else if isDragging}
415
+ <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-primary"><path d="M12 15V3"/><path d="M7 10l5 5 5-5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></svg>
416
+ <p class="text-sm font-semibold text-primary">Drop to process</p>
417
+ {:else}
418
+ <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-base-content/40"><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>
419
+ <p class="text-sm">
420
+ <span class="font-semibold">Drop your master logo here</span> or click to browse
421
+ </p>
422
+ <p class="text-xs text-base-content/45">PNG, JPG, WebP or SVG · max {maxSizeMb} MB · replaces all 4 slots</p>
423
+ {/if}
424
+ <input type="file" class="hidden" {accept} onchange={onFileInput} disabled={processing} />
425
+ </label>
426
+ </div>
427
+
428
+ <!-- Top-level error -->
429
+ {#if error}
430
+ <div class="flex items-start gap-2 p-3 rounded-md bg-error/10 border border-error/20" role="alert">
431
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-error mt-0.5 shrink-0"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>
432
+ <div class="flex-1 min-w-0">
433
+ <p class="text-sm text-error">{error}</p>
434
+ </div>
435
+ <button
436
+ type="button"
437
+ class="btn btn-xs btn-ghost text-error"
438
+ onclick={() => { error = ""; if (sourceFile) runPipeline(sourceFile, spec!.variants.map((v: ImageVariantSpec) => v.name)) }}
439
+ aria-label="Retry processing"
440
+ >
441
+ Retry
442
+ </button>
443
+ </div>
444
+ {/if}
445
+
446
+ <!-- Variant grid — always rendered in editor mode so empty slots are
447
+ visible up front. Each cell decides its render based on state
448
+ (loading / done / error) or falls through to the "idle" drop-target
449
+ when there's no state yet AND onDirectUpload is wired in. Without
450
+ onDirectUpload, idle cells show a passive "waiting for AI" hint
451
+ (preserves the original behaviour where the grid only appeared
452
+ after processing kicked off). -->
453
+ {#if spec}
454
+ <div class="grid grid-cols-4 gap-3">
455
+
456
+ {#each spec.variants as variant (variant.name)}
457
+ {@const state = variantStates[variant.name]}
458
+ {@const wide = isWideVariant(variant.name)}
459
+
460
+ <div
461
+ class="relative rounded-lg overflow-hidden border border-base-content/10 bg-base-100
462
+ {wide ? 'col-span-2' : 'col-span-1'}"
463
+ >
464
+ {#if !state && onDirectUpload}
465
+ <!-- Idle cell: direct-upload drop target. Same drag/click UX
466
+ as the main AI dropzone but scoped to this variant. -->
467
+ <!-- svelte-ignore a11y_label_has_associated_control -->
468
+ <label
469
+ class="cursor-pointer {wide ? 'aspect-video' : 'aspect-square'} flex flex-col items-center justify-center gap-1 border-2 border-dashed transition-colors duration-150 px-2 text-center
470
+ {dragOverTile === variant.name
471
+ ? 'border-primary bg-primary/10'
472
+ : 'border-base-content/15 bg-base-200/40 hover:border-base-content/30 hover:bg-base-200'}"
473
+ ondragover={(e) => onTileDragOver(variant.name, e)}
474
+ ondragleave={(e) => onTileDragLeave(variant.name, e)}
475
+ ondrop={(e) => onTileDrop(variant.name, e)}
476
+ aria-label="Upload {variantLabel(variant.name)} directly"
477
+ >
478
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-base-content/40">
479
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
480
+ <polyline points="17 8 12 3 7 8"/>
481
+ <line x1="12" x2="12" y1="3" y2="15"/>
482
+ </svg>
483
+ <p class="text-[0.7rem] font-medium text-base-content/70 leading-tight">
484
+ Upload {variantLabel(variant.name).toLowerCase()}
485
+ </p>
486
+ <p class="text-[0.65rem] text-base-content/40 leading-tight">
487
+ Drop or click
488
+ </p>
489
+ <input
490
+ type="file"
491
+ class="hidden"
492
+ {accept}
493
+ onchange={(e) => onTileFileInput(variant.name, e)}
494
+ />
495
+ </label>
496
+
497
+ {:else if !state}
498
+ <!-- Idle cell, no direct upload — neutral placeholder so the
499
+ grid stays uniform when only AI mode is wired. -->
500
+ <div class="{wide ? 'aspect-video' : 'aspect-square'} flex flex-col items-center justify-center gap-1 bg-base-200/40 text-center px-2">
501
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-base-content/30">
502
+ <rect x="3" y="3" width="18" height="18" rx="2"/>
503
+ <circle cx="9" cy="9" r="2"/>
504
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
505
+ </svg>
506
+ <p class="text-[0.7rem] text-base-content/45">{variantLabel(variant.name)}</p>
507
+ </div>
508
+
509
+ {:else if state.status === "loading"}
510
+ <!-- Skeleton cell -->
511
+ <div class="aspect-square flex flex-col">
512
+ <div class="flex-1 animate-skeleton-pulse bg-base-200"></div>
513
+ <div class="p-2 space-y-1">
514
+ <div class="h-3 w-2/3 rounded animate-skeleton-pulse bg-base-200"></div>
515
+ <div class="h-2 w-1/2 rounded animate-skeleton-pulse bg-base-200"></div>
516
+ </div>
517
+ </div>
518
+
519
+ {:else if state.status === "done"}
520
+ <!-- Resolved cell — also accepts drag-drop/click-to-replace when
521
+ onDirectUpload is wired so the user can replace an existing
522
+ asset without having to delete first. -->
523
+ {@const result = state.result}
524
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
525
+ <div
526
+ class="group relative {wide ? 'aspect-video' : 'aspect-square'} checkerboard"
527
+ ondragover={onDirectUpload ? (e) => onTileDragOver(variant.name, e) : undefined}
528
+ ondragleave={onDirectUpload ? (e) => onTileDragLeave(variant.name, e) : undefined}
529
+ ondrop={onDirectUpload ? (e) => onTileDrop(variant.name, e) : undefined}
530
+ >
531
+ <img
532
+ src={result.url}
533
+ alt={variantLabel(variant.name)}
534
+ class="w-full h-full object-contain"
535
+ />
536
+
537
+ <!-- Drag-over replace overlay -->
538
+ {#if onDirectUpload && dragOverTile === variant.name}
539
+ <div class="absolute inset-0 z-20 flex items-center justify-center bg-primary/25 border-2 border-primary rounded-sm pointer-events-none">
540
+ <span class="text-xs font-semibold text-primary drop-shadow-sm">Drop to replace</span>
541
+ </div>
542
+ {/if}
543
+
544
+ <!-- Background removal failed badge (logo cell only) -->
545
+ {#if backgroundRemovalFailed && variant.name === "logo"}
546
+ <div class="absolute top-1 left-1 badge badge-warning badge-sm text-xs gap-1 max-w-full">
547
+ <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
548
+ Background not removed
549
+ </div>
550
+ {/if}
551
+ <!-- AI variant edit failed — fell back to Sharp-only resize -->
552
+ {#if result.aiEditFailed}
553
+ <div
554
+ class="absolute bottom-1 left-1 badge badge-warning badge-sm text-xs gap-1 max-w-full"
555
+ title="Gemini variant edit unavailable — Sharp resize fallback used"
556
+ >
557
+ <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
558
+ AI edit skipped
559
+ </div>
560
+ {/if}
561
+ <!-- Top-left: Regenerate (AI flow, only when sourceFile is in memory) -->
562
+ {#if sourceFile}
563
+ <button
564
+ type="button"
565
+ class="absolute top-1 left-1 btn btn-xs btn-circle bg-base-100/85 backdrop-blur-sm shadow-sm opacity-0 group-hover:opacity-100 transition-opacity tooltip tooltip-right z-10"
566
+ data-tip="Regenerate this variant"
567
+ onclick={() => regenerateVariant(variant.name)}
568
+ aria-label="Regenerate {variantLabel(variant.name)}"
569
+ >
570
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
571
+ </button>
572
+ {/if}
573
+
574
+ <!-- Top-right: Delete (only when onDelete is wired in) -->
575
+ {#if onDelete}
576
+ <button
577
+ type="button"
578
+ class="absolute top-1 right-1 btn btn-xs btn-circle bg-base-100/85 backdrop-blur-sm shadow-sm text-error hover:bg-error hover:text-error-content opacity-0 group-hover:opacity-100 transition-opacity tooltip tooltip-left z-10"
579
+ data-tip="Remove permanently"
580
+ onclick={() => requestDelete(variant.name)}
581
+ aria-label="Delete {variantLabel(variant.name)}"
582
+ >
583
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
584
+ </button>
585
+ {/if}
586
+
587
+ <!-- Bottom-left: Replace (direct upload, bypasses AI) -->
588
+ {#if onDirectUpload}
589
+ <!-- svelte-ignore a11y_label_has_associated_control -->
590
+ <label
591
+ class="absolute bottom-1 left-1 btn btn-xs btn-circle bg-base-100/85 backdrop-blur-sm shadow-sm opacity-0 group-hover:opacity-100 transition-opacity tooltip tooltip-right z-10 cursor-pointer"
592
+ data-tip="Replace with your own file"
593
+ aria-label="Replace {variantLabel(variant.name)}"
594
+ >
595
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
596
+ <input type="file" class="hidden" {accept} onchange={(e) => onTileFileInput(variant.name, e)} />
597
+ </label>
598
+ {/if}
599
+
600
+ <!-- Bottom-right: Download -->
601
+ <a
602
+ href={result.url}
603
+ download="{variant.name}.png"
604
+ target="_blank"
605
+ rel="noopener"
606
+ class="absolute bottom-1 right-1 btn btn-xs btn-circle bg-base-100/85 backdrop-blur-sm shadow-sm opacity-0 group-hover:opacity-100 transition-opacity tooltip tooltip-left z-10"
607
+ data-tip="Download PNG"
608
+ aria-label="Download {variantLabel(variant.name)}"
609
+ onclick={(e) => e.stopPropagation()}
610
+ >
611
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
612
+ </a>
613
+ </div>
614
+ <div class="p-2">
615
+ <p class="text-xs font-medium truncate">{variantLabel(variant.name)}</p>
616
+ {#if result.width && result.height}
617
+ <p class="text-xs text-base-content/45">{result.width}×{result.height}</p>
618
+ {/if}
619
+ </div>
620
+
621
+ {:else if state.status === "error"}
622
+ <!-- Error cell -->
623
+ <div class="{wide ? 'aspect-video' : 'aspect-square'} flex flex-col items-center justify-center gap-1 bg-error/5 p-3" role="alert">
624
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-error"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>
625
+ <p class="text-xs text-error text-center line-clamp-2">{state.message}</p>
626
+ <button
627
+ type="button"
628
+ class="btn btn-xs btn-ghost text-error mt-1"
629
+ onclick={() => regenerateVariant(variant.name)}
630
+ >
631
+ Retry
632
+ </button>
633
+ </div>
634
+ <div class="p-2">
635
+ <p class="text-xs font-medium truncate">{variantLabel(variant.name)}</p>
636
+ </div>
637
+ {/if}
638
+ </div>
639
+ {/each}
640
+
641
+ </div>
642
+
643
+ <!-- backgroundRemovalFailed warning (non-blocking, below grid) -->
644
+ {#if backgroundRemovalFailed && hasResults}
645
+ <p class="text-xs text-warning">
646
+ Background removal unavailable (Gemini API). You can still save — variants were processed without it.
647
+ </p>
648
+ {/if}
649
+ {/if}
650
+
651
+ </div>
652
+ {/if}
653
+
654
+ <!-- Delete confirmation modal — daisyUI modal pattern (open-class controlled) -->
655
+ {#if deleteTarget}
656
+ <div
657
+ class="modal modal-open"
658
+ role="dialog"
659
+ aria-modal="true"
660
+ aria-labelledby="sie-del-title"
661
+ >
662
+ <div class="modal-box">
663
+ <h3 id="sie-del-title" class="font-semibold text-lg flex items-center gap-2">
664
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-error"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
665
+ Remove {variantLabel(deleteTarget)}?
666
+ </h3>
667
+ <p class="text-sm text-base-content/70 mt-2">
668
+ This permanently deletes the {variantLabel(deleteTarget).toLowerCase()} file from storage and clears its URL. <strong>It cannot be undone</strong> — you'll need to upload again to get it back.
669
+ </p>
670
+ <div class="modal-action">
671
+ <button
672
+ type="button"
673
+ class="btn btn-ghost"
674
+ onclick={cancelDelete}
675
+ disabled={deleting}
676
+ >
677
+ Cancel
678
+ </button>
679
+ <button
680
+ type="button"
681
+ class="btn btn-error"
682
+ onclick={confirmDelete}
683
+ disabled={deleting}
684
+ >
685
+ {#if deleting}
686
+ <span class="loading loading-spinner loading-xs"></span>
687
+ Deleting…
688
+ {:else}
689
+ Delete permanently
690
+ {/if}
691
+ </button>
692
+ </div>
693
+ </div>
694
+ <button type="button" class="modal-backdrop" onclick={cancelDelete} aria-label="Close"></button>
695
+ </div>
696
+ {/if}
697
+
698
+ <style>
699
+ .checkerboard {
700
+ background-color: #fff;
701
+ background-image: repeating-conic-gradient(#e0e0e0 0% 25%, transparent 0% 50%);
702
+ background-size: 16px 16px;
703
+ }
704
+
705
+ :global(.animate-scale-in-center) {
706
+ animation: scale-in-center 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
707
+ }
708
+
709
+ @keyframes scale-in-center {
710
+ 0% { transform: scale(0.85); opacity: 0; }
711
+ 100% { transform: scale(1); opacity: 1; }
712
+ }
713
+
714
+ @media (prefers-reduced-motion: reduce) {
715
+ :global(.animate-scale-in-center),
716
+ :global(.animate-skeleton-pulse) {
717
+ animation: none !important;
718
+ }
719
+ }
720
+ </style>