Files
OFApp/client/src/pages/Gallery.jsx
T
Trey T 236f36aae6 Add app auth, dashboard, scheduler, video management, and new scrapers
- JWT-based app authentication with user roles, folder/route access control
- Dashboard with storage stats, health checks, and recent activity
- Auto-download/scrape scheduler (12h interval) with per-user and per-job configs
- Video upload, tagging, HLS transcoding, and detail pages
- New scrapers: LeakGallery, Mega (megajs), yt-dlp
- FlareSolverr integration for Cloudflare-protected sites
- Gallery: advanced filtering (date, size, search), sort modes, equal-mix shuffle
- Forum sites management with stored cookies/auth
- GridWall/GridCell components for responsive media grid
- Media API with folder-access permissions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 07:48:10 -05:00

999 lines
38 KiB
React

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 && (
<div className="absolute inset-0 bg-[#1a1a1a] animate-pulse" />
)}
{errored ? (
<div className="w-full h-full bg-[#1a1a1a] flex items-center justify-center">
{file.type === 'video' ? (
<svg className="w-10 h-10 text-white/20" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M18 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75z" />
</svg>
)}
</div>
) : (
<img
key={retries}
src={imgSrc}
alt=""
loading="lazy"
onLoad={() => 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 (
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className="relative w-full h-full">
{children}
{hovering && (
<video
ref={videoRef}
src={`/api/gallery/media/${encodeURIComponent(file.folder)}/${encodeURIComponent(file.filename)}`}
muted
autoPlay
playsInline
onLoadedMetadata={handleLoadedMetadata}
className="absolute inset-0 w-full h-full object-cover z-[5]"
/>
)}
</div>
)
}
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 (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[#333] bg-[#161616] text-gray-400 hover:text-white transition-colors"
>
{current?.label || 'Sort'}
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
{open && (
<div className="absolute top-full left-0 mt-1 w-36 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-50 overflow-hidden py-1">
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => { onChange(opt.value); setOpen(false) }}
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
opt.value === value
? 'text-[#0095f6] bg-[#0095f6]/10'
: 'text-gray-400 hover:text-white hover:bg-[#252525]'
}`}
>
{opt.label}
</button>
))}
</div>
)}
</div>
)
}
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 (
<div>
{/* Header */}
<div className="mb-4">
<div className="flex items-baseline justify-between">
<h1 className="text-xl md:text-2xl font-bold text-white">Gallery</h1>
<p className="text-gray-500 text-sm">
{total} file{total !== 1 ? 's' : ''}{checkedFolders.size === 0 ? ' saved locally' : ''}
</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-2 md:gap-3 mb-4 md:mb-6">
{/* User Filter Popover */}
<div className="relative" ref={filterRef}>
<button
onClick={() => { setUserFilterOpen((v) => !v); setUserSearch('') }}
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-lg border transition-colors ${
checkedFolders.size > 0
? 'border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]'
: 'border-[#333] bg-[#161616] text-gray-400 hover:text-white'
}`}
>
<UsersFilterIcon className="w-4 h-4" />
Users
{checkedFolders.size > 0 && (
<span className="bg-[#0095f6] text-white text-xs rounded-full w-5 h-5 flex items-center justify-center font-medium">
{checkedFolders.size}
</span>
)}
</button>
{userFilterOpen && (
<div className="absolute top-full left-0 mt-2 w-64 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-50 overflow-hidden">
{/* Search */}
<div className="p-2 border-b border-[#333]">
<input
type="text"
value={userSearch}
onChange={(e) => 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]"
/>
</div>
{/* List */}
<div className="max-h-72 overflow-y-auto">
{folders
.filter((f) => f.name.toLowerCase().includes(userSearch.toLowerCase()))
.map((f) => (
<button
key={f.name}
onClick={() => togglePill(f.name)}
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-[#252525] transition-colors text-left"
>
<div className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center ${
checkedFolders.has(f.name)
? 'bg-[#0095f6] border-[#0095f6]'
: 'border-[#555]'
}`}>
{checkedFolders.has(f.name) && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className="text-sm text-gray-300 truncate flex-1">{f.name}</span>
<span className="text-xs text-gray-600 flex-shrink-0">{f.total}</span>
</button>
))}
</div>
{/* Footer */}
{checkedFolders.size > 0 && (
<div className="p-2 border-t border-[#333]">
<button
onClick={() => { clearFilters(); setUserFilterOpen(false) }}
className="w-full py-1.5 text-xs text-gray-400 hover:text-white transition-colors"
>
Clear all
</button>
</div>
)}
</div>
)}
</div>
{/* Selected user tags */}
{checkedFolders.size > 0 && checkedFolders.size <= 5 && (
Array.from(checkedFolders).map((name) => (
<span
key={name}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs bg-[#0095f6]/10 text-[#0095f6] rounded-lg border border-[#0095f6]/30"
>
{name}
<button
onClick={() => togglePill(name)}
className="hover:text-white transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))
)}
{/* Type Filter */}
<div className="flex rounded-lg overflow-hidden border border-[#333]">
{TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
className={`px-3 py-2 text-sm transition-colors ${
typeFilter === opt.value
? 'bg-[#0095f6] text-white'
: 'bg-[#161616] text-gray-400 hover:text-white'
}`}
>
{opt.label}
</button>
))}
</div>
{/* Sort Dropdown */}
<SortDropdown value={sortOption} onChange={setSortOption} />
{/* Reshuffle Button */}
{sortOption === 'shuffle' && (
<button
onClick={handleReshuffle}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[#333] bg-[#161616] text-gray-400 hover:text-white transition-colors"
>
<RefreshIcon className="w-4 h-4" />
Reshuffle
</button>
)}
{/* Filters Toggle */}
<button
onClick={() => setFiltersExpanded((v) => !v)}
className={`relative flex items-center gap-2 px-3 py-2 text-sm rounded-lg border transition-colors ${
filtersExpanded || hasAdvancedFilters
? 'border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]'
: 'border-[#333] bg-[#161616] text-gray-400 hover:text-white'
}`}
>
<FilterIcon className="w-4 h-4" />
Filters
{hasAdvancedFilters && (
<span className="w-2 h-2 rounded-full bg-[#0095f6] absolute -top-0.5 -right-0.5" />
)}
</button>
{/* Slideshow Button */}
<button
onClick={() => setSlideshow(true)}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[#333] bg-[#161616] text-gray-400 hover:text-white transition-colors"
>
<SlideshowIcon className="w-4 h-4" />
Slideshow
</button>
{/* Grid Wall Button */}
<div className="relative" ref={gridPickerRef}>
<button
onClick={() => setGridPickerOpen(v => !v)}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[#333] bg-[#161616] text-gray-400 hover:text-white transition-colors"
>
<GridWallIcon className="w-4 h-4" />
Grid Wall
</button>
{gridPickerOpen && (
<GridWallPicker
onSelect={(layout) => { setGridWallLayout(layout); setGridPickerOpen(false) }}
onClose={() => setGridPickerOpen(false)}
/>
)}
</div>
</div>
{/* Advanced Filters Panel */}
{filtersExpanded && (
<div className="flex flex-wrap items-end gap-3 mb-4 p-3 bg-[#161616] border border-[#222] rounded-lg">
<div>
<label className="block text-xs text-gray-500 mb-1">From</label>
<input
type="date"
value={dateFrom}
onChange={(e) => 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]"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">To</label>
<input
type="date"
value={dateTo}
onChange={(e) => 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]"
/>
</div>
<div className="flex-1 min-w-[150px]">
<label className="block text-xs text-gray-500 mb-1">Search</label>
<input
type="text"
value={searchText}
onChange={(e) => 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]"
/>
</div>
{hasAdvancedFilters && (
<button
onClick={clearAdvancedFilters}
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white border border-[#333] rounded-md transition-colors"
>
Clear
</button>
)}
</div>
)}
{loading ? (
<Spinner />
) : error ? (
<div className="text-center py-12">
<p className="text-red-400 mb-4">{error}</p>
</div>
) : files.length === 0 ? (
<div className="text-center py-16 bg-[#161616] border border-[#222] rounded-lg">
<GalleryIcon className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500 text-sm">No media files found</p>
<p className="text-gray-600 text-xs mt-1">
Download media from the Users or Search page
</p>
</div>
) : (
<>
<div className="grid grid-cols-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-1 md:gap-2">
{files.map((file, i) => (
<div
key={`${file.folder}-${file.filename}-${i}`}
className="relative group bg-[#161616] rounded-lg overflow-hidden cursor-pointer aspect-square"
onClick={() => setLightboxIndex(i)}
>
<VideoPreviewThumbnail file={file}>
<GalleryThumbnail file={file} />
</VideoPreviewThumbnail>
{/* Date badge */}
{file.postedAt && (
<div className="absolute top-2 left-2">
<span className="bg-black/50 text-white/80 text-[10px] px-1.5 py-0.5 rounded">
{formatShortDate(file.postedAt)}
</span>
</div>
)}
{/* Delete button */}
<button
onClick={(e) => {
e.stopPropagation()
deleteMediaFile(file.folder, file.filename).then((res) => {
if (!res.error) setFiles((prev) => prev.filter((_, idx) => idx !== i))
})
}}
className="absolute top-2 right-2 p-1.5 bg-black/60 hover:bg-red-500/80 rounded-full text-white/70 hover:text-white opacity-0 group-hover:opacity-100 transition-all z-10"
title="Delete"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
{/* Overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-end pointer-events-none">
<div className="w-full p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-xs text-white truncate">@{file.folder}</p>
</div>
</div>
{/* Video badge */}
{file.type === 'video' && (
<div className="absolute bottom-2 right-2 pointer-events-none">
<svg className="w-5 h-5 text-white drop-shadow-lg" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
)}
</div>
))}
</div>
<div className="mt-6">
<LoadMoreButton
onClick={() => loadFiles(false)}
loading={loadingMore}
hasMore={hasMore}
/>
</div>
</>
)}
{/* Lightbox */}
{lightboxIndex !== null && files[lightboxIndex] && (
<Lightbox
files={files}
index={lightboxIndex}
setIndex={setLightboxIndex}
hlsEnabled={hlsEnabled}
onClose={() => 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 && (
<Slideshow
filterParams={getFilterParams()}
typeFilter={typeFilter}
sortOption={sortOption}
hlsEnabled={hlsEnabled}
onClose={() => setSlideshow(false)}
/>
)}
{/* Grid Wall */}
{gridWallLayout && (
<GridWall
layout={gridWallLayout}
fetchItems={fetchGridItems}
hlsEnabled={hlsEnabled}
onClose={() => setGridWallLayout(null)}
/>
)}
</div>
)
}
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 (
<div
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center"
onClick={onClose}
>
<div className="absolute top-4 right-4 flex items-center gap-3 z-10">
<a
href={file.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors"
title="Open in new window"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
<button
onClick={(e) => { e.stopPropagation(); onDelete(index) }}
className="p-2 bg-white/10 hover:bg-red-500/70 rounded-full text-white transition-colors"
title="Delete"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
<button
onClick={onClose}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Prev arrow */}
{hasPrev && (
<button
onClick={(e) => { e.stopPropagation(); setIndex(index - 1) }}
className="absolute left-3 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-10"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
)}
{/* Next arrow */}
{hasNext && (
<button
onClick={(e) => { e.stopPropagation(); setIndex(index + 1) }}
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-10"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
)}
<div className="max-w-[90vw] max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
{file.type === 'video' ? (
<HlsVideo
key={index}
hlsSrc={hlsEnabled ? `/api/hls/${encodeURIComponent(file.folder)}/${encodeURIComponent(file.filename)}/master.m3u8` : null}
src={file.url}
controls
autoPlay
className="max-w-full max-h-[90vh] rounded-lg"
/>
) : (
<a href={file.url} target="_blank" rel="noopener noreferrer" className="cursor-pointer">
<img
src={file.url}
alt=""
className="max-w-full max-h-[90vh] rounded-lg object-contain"
/>
</a>
)}
<p className="text-center text-sm text-gray-400 mt-3">
@{file.folder} &middot; {index + 1} / {files.length}
</p>
</div>
</div>
)
}
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 (
<div className="fixed inset-0 z-[100] bg-black flex items-center justify-center">
<div className="absolute top-4 right-4 flex items-center gap-3 z-10">
{current && (
<a
href={current.url}
target="_blank"
rel="noopener noreferrer"
className="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors"
title="Open in new window"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
)}
<button
onClick={onClose}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{paused && (
<div className="absolute top-4 left-4 text-white/50 text-sm z-10">
Paused
</div>
)}
{current ? (
isVideo ? (
<HlsVideo
key={fadeKey}
ref={videoRef}
hlsSrc={hlsEnabled ? `/api/hls/${encodeURIComponent(current.folder)}/${encodeURIComponent(current.filename)}/master.m3u8` : null}
src={current.url}
controls
autoPlay
onEnded={handleVideoEnded}
className="max-w-full max-h-full object-contain animate-fadeIn"
/>
) : (
<img
key={fadeKey}
src={current.url}
alt=""
className="max-w-full max-h-full object-contain animate-fadeIn"
/>
)
) : (
<Spinner />
)}
{current && (
<div className="absolute bottom-4 left-0 right-0 text-center text-white/40 text-sm">
@{current.folder} &middot; {index + 1} / {items.length}
{isVideo && ' (video)'}
</div>
)}
</div>
)
}
function GridWallIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
)
}
function SlideshowIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
)
}
function ShuffleIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
)
}
function RefreshIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
</svg>
)
}
function UsersFilterIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
}
function FilterIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
</svg>
)
}
function GalleryIcon({ className }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M18 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75z" />
</svg>
)
}