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 && (
)}
{loading ? (
) : 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 */}
{/* 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 (
{/* 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 (
{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 (
)
}