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)
|
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")
|
@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:
|
try:
|
||||||
_track("download")
|
_track("download")
|
||||||
s3 = _client()
|
s3 = _client()
|
||||||
obj = s3.get_object(Bucket=bucket, Key=key)
|
obj = s3.get_object(Bucket=bucket, Key=key)
|
||||||
body = obj["Body"]
|
body = obj["Body"]
|
||||||
filename = key.split("/")[-1] or "download"
|
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():
|
def stream():
|
||||||
while chunk := body.read(1024 * 256):
|
while chunk := body.read(1024 * 256):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
|
disp = "inline" if inline else "attachment"
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
stream(),
|
stream(),
|
||||||
media_type=media,
|
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:
|
except ClientError as exc:
|
||||||
return JSONResponse({"ok": False, "error": str(exc)}, status_code=404)
|
return JSONResponse({"ok": False, "error": str(exc)}, status_code=404)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Activity, BarChart3, Boxes, ChevronRight, Clock, Database, Download, ExternalLink,
|
Activity, BarChart3, Boxes, ChevronLeft, ChevronRight, Clock, Database, Download, ExternalLink,
|
||||||
Files, Folder, Gauge, HardDrive, LayoutGrid, Loader2, RefreshCw, Search, TrendingUp,
|
Files, FileText, Folder, Gauge, GalleryThumbnails, HardDrive, LayoutGrid, List, Loader2,
|
||||||
|
RefreshCw, Search, TrendingUp, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||||
@@ -350,6 +351,46 @@ export function StorageView() {
|
|||||||
/* ── Browser (the original explorer, now a tab) ──────────────────────────── */
|
/* ── Browser (the original explorer, now a tab) ──────────────────────────── */
|
||||||
|
|
||||||
const TYPE_CHIPS = ['json', 'jpeg', 'png', 'csv', 'log', 'parquet', 'avro', 'pdf', 'txt']
|
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() {
|
function BucketBrowser() {
|
||||||
const [buckets, setBuckets] = useState<Bucket[]>([])
|
const [buckets, setBuckets] = useState<Bucket[]>([])
|
||||||
@@ -367,8 +408,20 @@ function BucketBrowser() {
|
|||||||
const [recursive, setRecursive] = useState(false)
|
const [recursive, setRecursive] = useState(false)
|
||||||
const [nextToken, setNextToken] = useState<string | null>(null)
|
const [nextToken, setNextToken] = useState<string | null>(null)
|
||||||
const [scanned, setScanned] = useState(0)
|
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 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 () => {
|
const loadBuckets = useCallback(async () => {
|
||||||
setLoading(true); setError(null)
|
setLoading(true); setError(null)
|
||||||
@@ -395,6 +448,7 @@ function BucketBrowser() {
|
|||||||
const r = await fetch(`/api/storage/s3/buckets/${encodeURIComponent(b)}/objects?${params}`)
|
const r = await fetch(`/api/storage/s3/buckets/${encodeURIComponent(b)}/objects?${params}`)
|
||||||
const j = await r.json()
|
const j = await r.json()
|
||||||
if (!r.ok || !j.ok) { setError(j.error || 'List failed'); return }
|
if (!r.ok || !j.ok) { setError(j.error || 'List failed'); return }
|
||||||
|
if (!append) setLightbox(null)
|
||||||
setFolders(j.flat ? [] : (j.folders || []))
|
setFolders(j.flat ? [] : (j.folders || []))
|
||||||
setObjects((prev) => (append ? [...prev, ...(j.objects || [])] : (j.objects || [])))
|
setObjects((prev) => (append ? [...prev, ...(j.objects || [])] : (j.objects || [])))
|
||||||
setNextToken(j.next_token || null)
|
setNextToken(j.next_token || null)
|
||||||
@@ -455,6 +509,15 @@ function BucketBrowser() {
|
|||||||
{(q || ext || recursive) && (
|
{(q || ext || recursive) && (
|
||||||
<button type="button" onClick={clearSearch} className={cn('rounded-md px-2 py-1 text-[10px]', subTabIdle)}>Clear</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{bucket && !flat && (
|
{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>}
|
{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>}
|
{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">
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto">
|
||||||
<table className="w-full text-left text-[11px]">
|
<table className="w-full text-left text-[11px]">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -525,7 +640,12 @@ function BucketBrowser() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user