summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--src/App.svelte67
-rw-r--r--src/lib/components/ImagePreview.svelte10
-rw-r--r--src/lib/flashair/client.ts22
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.
*/