import { useState, useEffect, useCallback, useRef } from 'react' import { getGalleryFolders, getGalleryFiles, getSettings, deleteMediaFile, markGallerySeen } from '../api' import Spinner from '../components/Spinner' import LoadMoreButton from '../components/LoadMoreButton' import HlsVideo from '../components/HlsVideo' import GridWall, { GridWallPicker } from '../components/GridWall' const PAGE_SIZE = 50 function GalleryThumbnail({ file }) { const [loaded, setLoaded] = useState(false) const [errored, setErrored] = useState(false) const [retries, setRetries] = useState(0) const imgSrc = `/api/gallery/thumb/${encodeURIComponent(file.folder)}/${encodeURIComponent(file.filename)}${retries > 0 ? `?r=${retries}` : ''}` // Images — lazy load with retry const handleError = () => { if (retries < 4) { setTimeout(() => setRetries(r => r + 1), 2000 + retries * 2000) } else { setErrored(true) } } return ( <> {!loaded && !errored && (
)} {errored ? (
{file.type === 'video' ? ( ) : ( )}
) : ( setLoaded(true)} onError={handleError} className={`w-full h-full object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`} /> )} ) } function formatShortDate(dateStr) { if (!dateStr) return '' const d = new Date(dateStr) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } const TYPE_OPTIONS = [ { value: 'all', label: 'All' }, { value: 'image', label: 'Images' }, { value: 'video', label: 'Videos' }, ] const SORT_OPTIONS = [ { value: 'latest', label: 'Latest' }, { value: 'oldest', label: 'Oldest' }, { value: 'largest', label: 'Largest' }, { value: 'smallest', label: 'Smallest' }, { value: 'name', label: 'Name' }, { value: 'shuffle', label: 'Shuffle' }, ] function VideoPreviewThumbnail({ file, children }) { const [hovering, setHovering] = useState(false) const videoRef = useRef(null) const hoverTimerRef = useRef(null) const stopTimerRef = useRef(null) const isTouchDevice = typeof window !== 'undefined' && 'ontouchstart' in window if (file.type !== 'video' || isTouchDevice) return children const handleMouseEnter = () => { hoverTimerRef.current = setTimeout(() => setHovering(true), 400) } const handleMouseLeave = () => { clearTimeout(hoverTimerRef.current) clearTimeout(stopTimerRef.current) setHovering(false) if (videoRef.current) { videoRef.current.pause() videoRef.current.removeAttribute('src') videoRef.current.load() } } const handleLoadedMetadata = () => { const video = videoRef.current if (!video) return if (video.duration > 15) { video.currentTime = video.duration / 2 stopTimerRef.current = setTimeout(() => { if (videoRef.current) { videoRef.current.pause() } }, 7000) } } return (
{children} {hovering && (
) } function SortDropdown({ value, onChange }) { const [open, setOpen] = useState(false) const ref = useRef(null) const current = SORT_OPTIONS.find((o) => o.value === value) useEffect(() => { if (!open) return const handleClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [open]) return (
{open && (
{SORT_OPTIONS.map((opt) => ( ))}
)}
) } export default function Gallery() { const [folders, setFolders] = useState([]) const [files, setFiles] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const [error, setError] = useState(null) const [activeFolder, setActiveFolder] = useState(null) // kept for API compat const [checkedFolders, setCheckedFolders] = useState(new Set()) const [typeFilter, setTypeFilter] = useState('all') const [sortOption, setSortOption] = useState('latest') const [lightboxIndex, setLightboxIndex] = useState(null) const [slideshow, setSlideshow] = useState(false) const [hlsEnabled, setHlsEnabled] = useState(false) const [userFilterOpen, setUserFilterOpen] = useState(false) const [userSearch, setUserSearch] = useState('') const [filtersExpanded, setFiltersExpanded] = useState(false) const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [searchText, setSearchText] = useState('') const [gridWallLayout, setGridWallLayout] = useState(null) const [gridPickerOpen, setGridPickerOpen] = useState(false) const gridPickerRef = useRef(null) const filterRef = useRef(null) useEffect(() => { getSettings().then((data) => { if (!data.error) setHlsEnabled(data.hls_enabled === 'true') }) getGalleryFolders().then((data) => { if (!data.error) setFolders(Array.isArray(data) ? data : []) }) markGallerySeen() }, []) // Close popover on click outside useEffect(() => { if (!userFilterOpen) return const handleClick = (e) => { if (filterRef.current && !filterRef.current.contains(e.target)) { setUserFilterOpen(false) setUserSearch('') } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [userFilterOpen]) const togglePill = (name) => { setActiveFolder(null) setCheckedFolders((prev) => { const next = new Set(prev) if (next.has(name)) next.delete(name) else next.add(name) return next }) } const clearFilters = () => { setActiveFolder(null) setCheckedFolders(new Set()) } // Build the folder/folders param for the API // Checked folders take priority over active (clicked) folder const getFilterParams = useCallback(() => { if (checkedFolders.size > 0) { return { folders: Array.from(checkedFolders) } } if (activeFolder) { return { folder: activeFolder } } return {} }, [activeFolder, checkedFolders]) const loadFiles = useCallback(async (reset = true) => { if (reset) { setLoading(true) setError(null) } else { setLoadingMore(true) } const offset = reset ? 0 : files.length const data = await getGalleryFiles({ ...getFilterParams(), type: typeFilter !== 'all' ? typeFilter : undefined, sort: sortOption, offset, limit: PAGE_SIZE, dateFrom: dateFrom || undefined, dateTo: dateTo || undefined, search: searchText || undefined, }) if (data.error) { setError(data.error) } else { setFiles((prev) => (reset ? data.files : [...prev, ...data.files])) setTotal(data.total) } setLoading(false) setLoadingMore(false) }, [getFilterParams, typeFilter, sortOption, dateFrom, dateTo, searchText, files.length]) useEffect(() => { loadFiles(true) }, [activeFolder, checkedFolders, typeFilter, sortOption, dateFrom, dateTo, searchText]) const handleReshuffle = () => { loadFiles(true) } // Grid wall: fetch shuffled items from current filter const fetchGridItems = useCallback(async (limit) => { const data = await getGalleryFiles({ ...getFilterParams(), type: typeFilter !== 'all' ? typeFilter : undefined, sort: 'shuffle', limit, dateFrom: dateFrom || undefined, dateTo: dateTo || undefined, search: searchText || undefined, }) return data.error ? [] : data.files }, [getFilterParams, typeFilter, dateFrom, dateTo, searchText]) const hasAdvancedFilters = dateFrom || dateTo || searchText const clearAdvancedFilters = () => { setDateFrom('') setDateTo('') setSearchText('') } const hasMore = files.length < total return (
{/* Header */}

Gallery

{total} file{total !== 1 ? 's' : ''}{checkedFolders.size === 0 ? ' saved locally' : ''}

{/* Filters */}
{/* User Filter Popover */}
{userFilterOpen && (
{/* Search */}
setUserSearch(e.target.value)} placeholder="Search users..." autoFocus className="w-full px-3 py-1.5 bg-[#111] border border-[#333] rounded-md text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6]" />
{/* List */}
{folders .filter((f) => f.name.toLowerCase().includes(userSearch.toLowerCase())) .map((f) => ( ))}
{/* Footer */} {checkedFolders.size > 0 && (
)}
)}
{/* Selected user tags */} {checkedFolders.size > 0 && checkedFolders.size <= 5 && ( Array.from(checkedFolders).map((name) => ( {name} )) )} {/* Type Filter */}
{TYPE_OPTIONS.map((opt) => ( ))}
{/* Sort Dropdown */} {/* Reshuffle Button */} {sortOption === 'shuffle' && ( )} {/* Filters Toggle */} {/* Slideshow Button */} {/* Grid Wall Button */}
{gridPickerOpen && ( { setGridWallLayout(layout); setGridPickerOpen(false) }} onClose={() => setGridPickerOpen(false)} /> )}
{/* Advanced Filters Panel */} {filtersExpanded && (
setDateFrom(e.target.value)} className="px-2.5 py-1.5 text-sm rounded-md border border-[#333] bg-[#111] text-gray-300 focus:outline-none focus:border-[#0095f6]" />
setDateTo(e.target.value)} className="px-2.5 py-1.5 text-sm rounded-md border border-[#333] bg-[#111] text-gray-300 focus:outline-none focus:border-[#0095f6]" />
setSearchText(e.target.value)} placeholder="Filename or folder..." className="w-full px-2.5 py-1.5 text-sm rounded-md border border-[#333] bg-[#111] text-gray-300 placeholder-gray-600 focus:outline-none focus:border-[#0095f6]" />
{hasAdvancedFilters && ( )}
)} {loading ? ( ) : error ? (

{error}

) : files.length === 0 ? (

No media files found

Download media from the Users or Search page

) : ( <>
{files.map((file, i) => (
setLightboxIndex(i)} > {/* Date badge */} {file.postedAt && (
{formatShortDate(file.postedAt)}
)} {/* Delete button */} {/* Overlay */}

@{file.folder}

{/* Video badge */} {file.type === 'video' && (
)}
))}
loadFiles(false)} loading={loadingMore} hasMore={hasMore} />
)} {/* Lightbox */} {lightboxIndex !== null && files[lightboxIndex] && ( setLightboxIndex(null)} hasMore={hasMore} onLoadMore={() => loadFiles(false)} onDelete={async (i) => { const f = files[i] const res = await deleteMediaFile(f.folder, f.filename) if (!res.error) { const newFiles = files.filter((_, idx) => idx !== i) setFiles(newFiles) if (newFiles.length === 0) { setLightboxIndex(null) } else if (i >= newFiles.length) { setLightboxIndex(newFiles.length - 1) } } }} /> )} {/* Slideshow */} {slideshow && ( setSlideshow(false)} /> )} {/* Grid Wall */} {gridWallLayout && ( setGridWallLayout(null)} /> )}
) } function Lightbox({ files, index, setIndex, hlsEnabled, onClose, onDelete, hasMore, onLoadMore }) { const file = files[index] const hasPrev = index > 0 const hasNext = index < files.length - 1 || hasMore const loadingMoreRef = useRef(false) // Auto-load more files when navigating near the end useEffect(() => { if (hasMore && index >= files.length - 5 && !loadingMoreRef.current) { loadingMoreRef.current = true onLoadMore() setTimeout(() => { loadingMoreRef.current = false }, 1000) } }, [index, files.length, hasMore, onLoadMore]) useEffect(() => { const handleKey = (e) => { if (e.key === 'Escape') onClose() if (e.key === 'ArrowRight' && hasNext) setIndex(index + 1) if (e.key === 'ArrowLeft' && hasPrev) setIndex(index - 1) } window.addEventListener('keydown', handleKey) return () => window.removeEventListener('keydown', handleKey) }, [onClose, index, hasPrev, hasNext, setIndex]) return (
e.stopPropagation()} className="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors" title="Open in new window" >
{/* Prev arrow */} {hasPrev && ( )} {/* Next arrow */} {hasNext && ( )}
e.stopPropagation()}> {file.type === 'video' ? ( ) : ( )}

@{file.folder} · {index + 1} / {files.length}

) } function Slideshow({ filterParams, typeFilter, hlsEnabled, onClose }) { const [items, setItems] = useState([]) const [index, setIndex] = useState(0) const [paused, setPaused] = useState(false) const [fadeKey, setFadeKey] = useState(0) const videoRef = useRef(null) const timerRef = useRef(null) const current = items[index] || null const isVideo = current?.type === 'video' // Load ALL matching files shuffled const loadBatch = useCallback(async () => { const params = { ...filterParams, sort: 'shuffle', limit: 100000, } if (typeFilter === 'image') params.type = 'image' else if (typeFilter === 'video') params.type = 'video' // 'all' — no type filter const data = await getGalleryFiles(params) if (!data.error && data.files.length > 0) { setItems(data.files) setIndex(0) setFadeKey((k) => k + 1) } }, [filterParams, typeFilter]) useEffect(() => { loadBatch() }, []) // Advance to next item (or reload batch if at end) const advance = useCallback(() => { setIndex((prev) => { const next = prev + 1 if (next >= items.length) { loadBatch() return prev } setFadeKey((k) => k + 1) return next }) }, [items.length, loadBatch]) // For images (and images in 'all' mode): auto-advance after 5s useEffect(() => { if (items.length === 0 || paused) return if (isVideo) return // videos advance on ended event timerRef.current = setTimeout(advance, 5000) return () => clearTimeout(timerRef.current) }, [index, items.length, paused, isVideo, advance]) // When a video ends, advance const handleVideoEnded = useCallback(() => { if (!paused) advance() }, [paused, advance]) // Keyboard controls useEffect(() => { const handleKey = (e) => { if (e.key === 'Escape') onClose() if (e.key === ' ') { e.preventDefault() setPaused((p) => { const next = !p // If unpausing a video, resume playback if (!next && videoRef.current && videoRef.current.paused) { videoRef.current.play() } else if (next && videoRef.current && !videoRef.current.paused) { videoRef.current.pause() } return next }) } if (e.key === 'ArrowRight' && items.length > 0) { clearTimeout(timerRef.current) advance() } if (e.key === 'ArrowLeft' && items.length > 0) { setIndex((prev) => { const next = Math.max(prev - 1, 0) setFadeKey((k) => k + 1) return next }) } } window.addEventListener('keydown', handleKey) return () => window.removeEventListener('keydown', handleKey) }, [onClose, items, advance]) return (
{current && ( )}
{paused && (
Paused
)} {current ? ( isVideo ? ( ) : ( ) ) : ( )} {current && (
@{current.folder} · {index + 1} / {items.length} {isVideo && ' (video)'}
)}
) } function GridWallIcon({ className }) { return ( ) } function SlideshowIcon({ className }) { return ( ) } function ShuffleIcon({ className }) { return ( ) } function RefreshIcon({ className }) { return ( ) } function UsersFilterIcon({ className }) { return ( ) } function FilterIcon({ className }) { return ( ) } function GalleryIcon({ className }) { return ( ) }