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.
This commit is contained in:
+19
-3
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6" onClick={onClose}>
|
||||
<button type="button" className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20" onClick={onClose}><X className="h-4 w-4" /></button>
|
||||
{items.length > 1 && (
|
||||
<>
|
||||
<button type="button" className="absolute left-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); onNav((index - 1 + items.length) % items.length) }}><ChevronLeft className="h-5 w-5" /></button>
|
||||
<button type="button" className="absolute right-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20" style={{ top: '50%' }} onClick={(e) => { e.stopPropagation(); onNav((index + 1) % items.length) }}><ChevronRight className="h-5 w-5" /></button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex max-h-full max-w-full flex-col items-center gap-3" onClick={(e) => e.stopPropagation()}>
|
||||
<img src={objUrl(bucket, o.key, true)} alt={o.key} className="max-h-[80vh] max-w-[85vw] rounded-lg object-contain shadow-2xl" />
|
||||
<div className="flex items-center gap-3 text-[11px] text-white/80">
|
||||
<span className="font-mono">{o.key}</span>
|
||||
<span>·</span>
|
||||
<span>{o.size_human}</span>
|
||||
<span>·</span>
|
||||
<span>{index + 1}/{items.length}</span>
|
||||
<a href={objUrl(bucket, o.key)} className="inline-flex items-center gap-1 rounded bg-white/10 px-2 py-1 hover:bg-white/20"><Download className="h-3 w-3" /> Download</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BucketBrowser() {
|
||||
const [buckets, setBuckets] = useState<Bucket[]>([])
|
||||
@@ -367,8 +408,20 @@ function BucketBrowser() {
|
||||
const [recursive, setRecursive] = useState(false)
|
||||
const [nextToken, setNextToken] = useState<string | null>(null)
|
||||
const [scanned, setScanned] = useState(0)
|
||||
// gallery
|
||||
const [view, setView] = useState<'list' | 'grid'>('list')
|
||||
const [userPickedView, setUserPickedView] = useState(false)
|
||||
const [lightbox, setLightbox] = useState<number | null>(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) && (
|
||||
<button type="button" onClick={clearSearch} className={cn('rounded-md px-2 py-1 text-[10px]', subTabIdle)}>Clear</button>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{images.length > 0 && <span className="text-[9px] text-foreground-faint">{images.length} image{images.length === 1 ? '' : 's'}</span>}
|
||||
<div className="flex rounded-md border border-border p-0.5">
|
||||
<button type="button" title="List" onClick={() => { setView('list'); setUserPickedView(true) }}
|
||||
className={cn('rounded p-1', view === 'list' ? subTabActive : subTabIdle)}><List className="h-3 w-3" /></button>
|
||||
<button type="button" title="Gallery" onClick={() => { setView('grid'); setUserPickedView(true) }}
|
||||
className={cn('rounded p-1', view === 'grid' ? subTabActive : subTabIdle)}><GalleryThumbnails className="h-3 w-3" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bucket && !flat && (
|
||||
@@ -477,6 +540,58 @@ function BucketBrowser() {
|
||||
)}
|
||||
{loading && <p className="flex items-center gap-2 text-[11px] text-foreground-muted"><Loader2 className="h-4 w-4 animate-spin" /> Loading…</p>}
|
||||
{error && <p className="mb-2 text-[11px] text-danger">{error}</p>}
|
||||
|
||||
{view === 'grid' ? (
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto">
|
||||
{!flat && folders.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{folders.map((f) => (
|
||||
<button key={f.prefix} type="button" onClick={() => setPrefix(f.prefix || '')}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-surface-overlay px-2 py-1 text-[10px] font-medium text-docker hover:bg-surface-muted">
|
||||
<Folder className="h-3.5 w-3.5" /> {f.name}/
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(108px,1fr))] gap-2">
|
||||
{objects.map((o) => {
|
||||
const img = isImage(o)
|
||||
const imgIdx = img ? images.findIndex((x) => x.key === o.key) : -1
|
||||
return (
|
||||
<div key={o.key} className="group overflow-hidden rounded-lg border border-border bg-surface-raised">
|
||||
<button type="button" onClick={() => img ? setLightbox(imgIdx) : undefined}
|
||||
className="flex aspect-square w-full items-center justify-center overflow-hidden bg-surface-overlay">
|
||||
{img && o.key && bucket ? (
|
||||
<img src={objUrl(bucket, o.key, true)} alt={o.name || o.key} loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105" />
|
||||
) : (
|
||||
<FileText className="h-7 w-7 text-foreground-faint" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-1 px-1.5 py-1">
|
||||
<span className="truncate font-mono text-[9px] text-foreground-muted" title={o.key}>{(o.name || o.key || '').split('/').pop()}</span>
|
||||
{o.key && bucket && (
|
||||
<a href={objUrl(bucket, o.key)} className="shrink-0 text-docker hover:underline"><Download className="h-3 w-3" /></a>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-1.5 pb-1 text-[8px] text-foreground-faint">{o.size_human}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!loading && objects.length === 0 && bucket && (
|
||||
<p className="py-8 text-center text-sm text-foreground-muted">{flat ? 'No matching objects found.' : 'No files in this prefix.'}</p>
|
||||
)}
|
||||
{nextToken && bucket && (
|
||||
<div className="py-3 text-center">
|
||||
<button type="button" disabled={loadingMore} onClick={() => loadObjects(bucket, prefix, { q, ext, recursive }, nextToken)}
|
||||
className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
||||
{loadingMore ? <Loader2 className="inline h-3 w-3 animate-spin" /> : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto">
|
||||
<table className="w-full text-left text-[11px]">
|
||||
<thead>
|
||||
@@ -525,7 +640,12 @@ function BucketBrowser() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lightbox !== null && bucket && images.length > 0 && (
|
||||
<Lightbox bucket={bucket} items={images} index={Math.min(lightbox, images.length - 1)} onClose={() => setLightbox(null)} onNav={setLightbox} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user