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 ( +
{error}
} + + {view === 'grid' ? ( +{flat ? 'No matching objects found.' : 'No files in this prefix.'}
+ )} + {nextToken && bucket && ( +