@joewinke/jatui 0.1.11 → 0.1.19
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/README.md +123 -0
- package/package.json +2 -1
- package/src/lib/actions/railNav.ts +473 -0
- package/src/lib/components/AnnotationLayer.svelte +108 -0
- package/src/lib/components/AnnotationPanel.svelte +319 -0
- package/src/lib/components/AudioWaveform.svelte +9 -5
- package/src/lib/components/AvailabilityModal.svelte +7 -3
- package/src/lib/components/AvatarUpload.svelte +27 -4
- package/src/lib/components/BookingForm.svelte +11 -9
- package/src/lib/components/BurndownChart.svelte +778 -0
- package/src/lib/components/Button.svelte +10 -1
- package/src/lib/components/CalendarPicker.svelte +3 -3
- package/src/lib/components/Card.svelte +2 -2
- package/src/lib/components/ChipInput.svelte +21 -15
- package/src/lib/components/ColorSelector.svelte +17 -13
- package/src/lib/components/CommentThread.svelte +773 -0
- package/src/lib/components/ConfirmDialog.svelte +348 -0
- package/src/lib/components/ConfirmModal.svelte +78 -11
- package/src/lib/components/ContextMenu.svelte +59 -19
- package/src/lib/components/CountdownTimer.svelte +1 -1
- package/src/lib/components/DateRangePicker.svelte +6 -4
- package/src/lib/components/Drawer.svelte +36 -3
- package/src/lib/components/EntityPreviewCard.svelte +104 -0
- package/src/lib/components/FileDropzone.svelte +493 -0
- package/src/lib/components/FilePicker.svelte +83 -14
- package/src/lib/components/FileThumbnail.svelte +80 -0
- package/src/lib/components/FilterDropdown.svelte +11 -11
- package/src/lib/components/HunkDiffView.svelte +348 -0
- package/src/lib/components/ImageLightbox.svelte +274 -0
- package/src/lib/components/ImageUpload.svelte +58 -9
- package/src/lib/components/InlineEdit.svelte +15 -9
- package/src/lib/components/InputDialog.svelte +327 -0
- package/src/lib/components/LazyImage.svelte +1 -0
- package/src/lib/components/LinkShortener.svelte +1 -1
- package/src/lib/components/LoadingSpinner.svelte +6 -2
- package/src/lib/components/MarkupEditor.svelte +485 -0
- package/src/lib/components/MarkupOverlay.svelte +55 -0
- package/src/lib/components/MediaWorkbench.svelte +871 -0
- package/src/lib/components/MilestoneCard.svelte +1 -1
- package/src/lib/components/MilestoneTimeline.svelte +1 -1
- package/src/lib/components/Modal.svelte +39 -4
- package/src/lib/components/PDFViewer.svelte +105 -0
- package/src/lib/components/PdfThumbnail.svelte +3 -1
- package/src/lib/components/PhoneInput.svelte +1 -1
- package/src/lib/components/ResizablePanel.svelte +4 -4
- package/src/lib/components/SearchDropdown.svelte +26 -13
- package/src/lib/components/SelectInput.svelte +26 -4
- package/src/lib/components/SidebarUserFooter.svelte +1 -1
- package/src/lib/components/SignaturePad.svelte +8 -4
- package/src/lib/components/SmartImageEditor.svelte +720 -0
- package/src/lib/components/SortDropdown.svelte +9 -3
- package/src/lib/components/Sparkline.svelte +9 -0
- package/src/lib/components/StatusBadge.svelte +20 -18
- package/src/lib/components/TextArea.svelte +24 -5
- package/src/lib/components/TextInput.svelte +29 -6
- package/src/lib/components/ThemeSelector.svelte +15 -4
- package/src/lib/components/TimeSlotPicker.svelte +7 -7
- package/src/lib/components/UserAvatar.svelte +14 -1
- package/src/lib/components/VariablePicker.svelte +170 -0
- package/src/lib/components/VoicePlayer.svelte +4 -3
- package/src/lib/components/markup.ts +287 -0
- package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
- package/src/lib/components/messaging/ChannelList.svelte +1 -1
- package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
- package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
- package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
- package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
- package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
- package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
- package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
- package/src/lib/components/messaging/MessageInput.svelte +1 -1
- package/src/lib/components/messaging/MessageItem.svelte +6 -3
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
- package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
- package/src/lib/components/messaging/StartDMModal.svelte +1 -1
- package/src/lib/components/pipeline/Pipeline.svelte +4 -4
- package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
- package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
- package/src/lib/index.ts +91 -0
- package/src/lib/stores/confirmDialog.svelte.ts +48 -0
- package/src/lib/stores/inputDialog.svelte.ts +51 -0
- package/src/lib/styles/rail.css +63 -0
- package/src/lib/types/annotation.ts +38 -0
- package/src/lib/types/comments.ts +97 -0
- package/src/lib/types/entityPreview.ts +45 -0
- package/src/lib/types/filePicker.ts +2 -0
- package/src/lib/types/smartImageEditor.ts +39 -0
- package/src/lib/types/templateVars.ts +36 -0
- package/src/lib/utils/dateFormatters.ts +12 -10
- 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>
|