diff options
| -rw-r--r-- | src/App.svelte | 67 | ||||
| -rw-r--r-- | src/lib/components/ImagePreview.svelte | 10 | ||||
| -rw-r--r-- | src/lib/flashair/client.ts | 22 |
3 files changed, 94 insertions, 5 deletions
diff --git a/src/App.svelte b/src/App.svelte index a9a2506..bbc0c43 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { flashair } from './lib/flashair'; import type { FlashAirFileEntry } from './lib/flashair'; + import { imageCache } from './lib/cache'; import ImageList from './lib/components/ImageList.svelte'; import ImagePreview from './lib/components/ImagePreview.svelte'; @@ -9,6 +10,8 @@ let loading = $state(false); let error = $state<string | undefined>(undefined); let isDark = $state(false); + let deleting = $state(false); + let showDeleteConfirm = $state(false); $effect(() => { document.documentElement.setAttribute('data-theme', isDark ? 'black' : 'cmyk'); @@ -33,6 +36,43 @@ function selectImage(file: FlashAirFileEntry) { selectedFile = file; } + + function requestDelete() { + if (selectedFile === undefined) return; + showDeleteConfirm = true; + } + + async function confirmDelete() { + showDeleteConfirm = false; + if (selectedFile === undefined) return; + + const fileToDelete = selectedFile; + deleting = true; + try { + await flashair.deleteFile(fileToDelete.path); + // Remove from cache + void imageCache.delete('thumbnail', fileToDelete.path); + void imageCache.delete('full', fileToDelete.path); + // Remove from list and select next image + const idx = images.findIndex((f) => f.path === fileToDelete.path); + images = images.filter((f) => f.path !== fileToDelete.path); + if (images.length === 0) { + selectedFile = undefined; + } else if (idx >= images.length) { + selectedFile = images[images.length - 1]; + } else { + selectedFile = images[idx]; + } + } catch (e) { + error = `Delete failed: ${e instanceof Error ? e.message : String(e)}`; + } finally { + deleting = false; + } + } + + function cancelDelete() { + showDeleteConfirm = false; + } </script> <div class="flex h-screen bg-base-200"> @@ -94,6 +134,16 @@ </svg> {/if} </button> + <!-- Delete --> + <button class="btn btn-lg btn-circle btn-error" onclick={() => requestDelete()} disabled={selectedFile === undefined || deleting}> + {#if deleting} + <span class="loading loading-spinner loading-sm"></span> + {:else} + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /> + </svg> + {/if} + </button> <!-- Refresh --> <button class="btn btn-lg btn-circle btn-secondary" onclick={() => loadAllImages()}> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> @@ -104,3 +154,20 @@ </svg> </button> </div> + +<!-- Delete confirmation modal --> +{#if showDeleteConfirm && selectedFile !== undefined} + <dialog class="modal modal-open"> + <div class="modal-box"> + <h3 class="text-lg font-bold">Delete photo?</h3> + <p class="py-4">This will permanently delete <strong>{selectedFile.filename}</strong> from the SD card.</p> + <div class="modal-action"> + <button class="btn" onclick={() => cancelDelete()}>Cancel</button> + <button class="btn btn-error" onclick={() => void confirmDelete()}>Delete</button> + </div> + </div> + <form method="dialog" class="modal-backdrop"> + <button onclick={() => cancelDelete()}>close</button> + </form> + </dialog> +{/if} diff --git a/src/lib/components/ImagePreview.svelte b/src/lib/components/ImagePreview.svelte index 4a59d13..f5032a6 100644 --- a/src/lib/components/ImagePreview.svelte +++ b/src/lib/components/ImagePreview.svelte @@ -322,11 +322,7 @@ const abort = new AbortController(); currentAbort = abort; - downloading = true; - progress = 0; - loadError = undefined; - - // Try cache first + // Try cache first — before setting downloading=true to avoid flicker const cached = await imageCache.get('full', entry.path); if (cached !== undefined && !abort.signal.aborted) { const objectUrl = URL.createObjectURL(cached.blob); @@ -337,6 +333,10 @@ return; } + downloading = true; + progress = 0; + loadError = undefined; + const url = flashair.fileUrl(entry.path); const totalBytes = entry.size; diff --git a/src/lib/flashair/client.ts b/src/lib/flashair/client.ts index 978eb60..63ed6fb 100644 --- a/src/lib/flashair/client.ts +++ b/src/lib/flashair/client.ts @@ -197,6 +197,28 @@ export const flashair = { return { blob, meta }; }, /** + * Delete a file from the FlashAir card. + * Requires UPLOAD=1 in the CONFIG file. + * @param path - Absolute path on the card, e.g. "/DCIM/100__TSB/DSC_0001.JPG" + */ + async deleteFile(path: string): Promise<void> { + const base = getBaseUrl(); + const res = await fetch(`${base}/upload.cgi?DEL=${path}`); + if (!res.ok) { + if (res.status === 404) { + throw new Error( + 'File deletion is not enabled. Add UPLOAD=1 to /SD_WLAN/CONFIG on the SD card and restart the FlashAir.' + ); + } + throw new Error(`deleteFile failed: ${res.status} ${res.statusText}`); + } + const text = await res.text(); + if (text.trim() !== 'SUCCESS') { + throw new Error(`deleteFile failed: card returned ${text.trim()}`); + } + }, + + /** * Recursively list all image files on the card starting from a root directory. * Returns files sorted by date, newest first. */ |
