From a408ed44235617f2775eee1392155500ee99a6c7 Mon Sep 17 00:00:00 2001 From: mo Date: Sat, 27 Jun 2026 22:59:50 +0000 Subject: [PATCH] feat(storage): image gallery with thumbnails + lightbox Add a List/Gallery toggle to the bucket browser that auto-switches to a thumbnail grid when a listing is mostly images, and a keyboard-navigable lightbox (prev/next/esc) for full-size previews. Download endpoint serves inline with the correct image/* content-type (ECS stores octet-stream) so previews render instead of forcing a download. --- api/storage_s3.py | 22 +++- ui/src/components/features/StorageView.tsx | 124 ++++++++++++++++++++- 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/api/storage_s3.py b/api/storage_s3.py index acd198a..90d21cd 100644 --- a/api/storage_s3.py +++ b/api/storage_s3.py @@ -218,24 +218,40 @@ async def list_objects( return JSONResponse({"ok": False, "error": str(exc)}, status_code=502) +_MEDIA_BY_EXT = { + "jpeg": "image/jpeg", "jpg": "image/jpeg", "png": "image/png", "gif": "image/gif", + "webp": "image/webp", "bmp": "image/bmp", "svg": "image/svg+xml", +} + + @router.get("/buckets/{bucket}/download") -async def download_object(bucket: str, key: str = Query(...)): +async def download_object(bucket: str, key: str = Query(...), inline: bool = Query(False)): try: _track("download") s3 = _client() obj = s3.get_object(Bucket=bucket, Key=key) body = obj["Body"] filename = key.split("/")[-1] or "download" - media = obj.get("ContentType") or "application/octet-stream" + ext_media = _MEDIA_BY_EXT.get(_ext(key)) + stored = obj.get("ContentType") + # ECS often stores everything as octet-stream — trust the extension for + # known image types so previews render inline instead of downloading. + media = ext_media or stored or "application/octet-stream" + if ext_media and (not stored or stored == "application/octet-stream"): + media = ext_media def stream(): while chunk := body.read(1024 * 256): yield chunk + disp = "inline" if inline else "attachment" return StreamingResponse( stream(), media_type=media, - headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + headers={ + "Content-Disposition": f'{disp}; filename="{filename}"', + "Cache-Control": "private, max-age=300", + }, ) except ClientError as exc: return JSONResponse({"ok": False, "error": str(exc)}, status_code=404) diff --git a/ui/src/components/features/StorageView.tsx b/ui/src/components/features/StorageView.tsx index db097f6..f58b49a 100644 --- a/ui/src/components/features/StorageView.tsx +++ b/ui/src/components/features/StorageView.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { - Activity, BarChart3, Boxes, ChevronRight, Clock, Database, Download, ExternalLink, - Files, Folder, Gauge, HardDrive, LayoutGrid, Loader2, RefreshCw, Search, TrendingUp, + Activity, BarChart3, Boxes, ChevronLeft, ChevronRight, Clock, Database, Download, ExternalLink, + Files, FileText, Folder, Gauge, GalleryThumbnails, HardDrive, LayoutGrid, List, Loader2, + RefreshCw, Search, TrendingUp, X, } from 'lucide-react' import { cn } from '../../lib/utils' import { subTabActive, subTabIdle } from '../../lib/tabActive' @@ -350,6 +351,46 @@ export function StorageView() { /* ── Browser (the original explorer, now a tab) ──────────────────────────── */ const TYPE_CHIPS = ['json', 'jpeg', 'png', 'csv', 'log', 'parquet', 'avro', 'pdf', 'txt'] +const IMG_EXT = new Set(['jpeg', 'jpg', 'png', 'gif', 'webp', 'bmp', 'svg']) +const isImage = (o: S3Item) => IMG_EXT.has((o.ext || '').toLowerCase()) +const objUrl = (bucket: string, key: string, inline = false) => + `/api/storage/s3/buckets/${encodeURIComponent(bucket)}/download?key=${encodeURIComponent(key)}${inline ? '&inline=true' : ''}` + +function Lightbox({ bucket, items, index, onClose, onNav }: { bucket: string; items: S3Item[]; index: number; onClose: () => void; onNav: (i: number) => void }) { + const o = items[index] + useEffect(() => { + const h = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + if (e.key === 'ArrowRight') onNav((index + 1) % items.length) + if (e.key === 'ArrowLeft') onNav((index - 1 + items.length) % items.length) + } + window.addEventListener('keydown', h) + return () => window.removeEventListener('keydown', h) + }, [index, items.length, onClose, onNav]) + if (!o?.key) return null + return ( +
+ + {items.length > 1 && ( + <> + + + + )} +
e.stopPropagation()}> + {o.key} +
+ {o.key} + · + {o.size_human} + · + {index + 1}/{items.length} + Download +
+
+
+ ) +} function BucketBrowser() { const [buckets, setBuckets] = useState([]) @@ -367,8 +408,20 @@ function BucketBrowser() { const [recursive, setRecursive] = useState(false) const [nextToken, setNextToken] = useState(null) const [scanned, setScanned] = useState(0) + // gallery + const [view, setView] = useState<'list' | 'grid'>('list') + const [userPickedView, setUserPickedView] = useState(false) + const [lightbox, setLightbox] = useState(null) const flat = recursive || !!q || !!ext + const images = useMemo(() => objects.filter(isImage), [objects]) + const imageShare = objects.length ? images.length / objects.length : 0 + + // auto-switch to gallery when a listing is mostly images (until user overrides) + useEffect(() => { + if (userPickedView) return + setView(objects.length > 0 && imageShare >= 0.5 ? 'grid' : 'list') + }, [objects.length, imageShare, userPickedView]) const loadBuckets = useCallback(async () => { setLoading(true); setError(null) @@ -395,6 +448,7 @@ function BucketBrowser() { const r = await fetch(`/api/storage/s3/buckets/${encodeURIComponent(b)}/objects?${params}`) const j = await r.json() if (!r.ok || !j.ok) { setError(j.error || 'List failed'); return } + if (!append) setLightbox(null) setFolders(j.flat ? [] : (j.folders || [])) setObjects((prev) => (append ? [...prev, ...(j.objects || [])] : (j.objects || []))) setNextToken(j.next_token || null) @@ -455,6 +509,15 @@ function BucketBrowser() { {(q || ext || recursive) && ( )} +
+ {images.length > 0 && {images.length} image{images.length === 1 ? '' : 's'}} +
+ + +
+
{bucket && !flat && ( @@ -477,6 +540,58 @@ function BucketBrowser() { )} {loading &&

Loading…

} {error &&

{error}

} + + {view === 'grid' ? ( +
+ {!flat && folders.length > 0 && ( +
+ {folders.map((f) => ( + + ))} +
+ )} +
+ {objects.map((o) => { + const img = isImage(o) + const imgIdx = img ? images.findIndex((x) => x.key === o.key) : -1 + return ( +
+ +
+ {(o.name || o.key || '').split('/').pop()} + {o.key && bucket && ( + + )} +
+
{o.size_human}
+
+ ) + })} +
+ {!loading && objects.length === 0 && bucket && ( +

{flat ? 'No matching objects found.' : 'No files in this prefix.'}

+ )} + {nextToken && bucket && ( +
+ +
+ )} +
+ ) : (
@@ -525,7 +640,12 @@ function BucketBrowser() { )} + )} + + {lightbox !== null && bucket && images.length > 0 && ( + setLightbox(null)} onNav={setLightbox} /> + )} ) }