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>
This commit is contained in:
+412
-57
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { getGalleryFolders, getGalleryFiles, getSettings } from '../api'
|
||||
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
|
||||
|
||||
@@ -11,14 +12,12 @@ function GalleryThumbnail({ file }) {
|
||||
const [errored, setErrored] = useState(false)
|
||||
const [retries, setRetries] = useState(0)
|
||||
|
||||
const imgSrc = file.type === 'video'
|
||||
? `/api/gallery/thumb/${encodeURIComponent(file.folder)}/${encodeURIComponent(file.filename)}`
|
||||
: file.url
|
||||
const imgSrc = `/api/gallery/thumb/${encodeURIComponent(file.folder)}/${encodeURIComponent(file.filename)}${retries > 0 ? `?r=${retries}` : ''}`
|
||||
|
||||
// Images — lazy load with retry
|
||||
const handleError = () => {
|
||||
if (retries < 2) {
|
||||
setTimeout(() => setRetries(r => r + 1), 1000 + retries * 1500)
|
||||
if (retries < 4) {
|
||||
setTimeout(() => setRetries(r => r + 1), 2000 + retries * 2000)
|
||||
} else {
|
||||
setErrored(true)
|
||||
}
|
||||
@@ -68,6 +67,116 @@ const TYPE_OPTIONS = [
|
||||
{ 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([])
|
||||
@@ -79,12 +188,19 @@ export default function Gallery() {
|
||||
const [activeFolder, setActiveFolder] = useState(null) // kept for API compat
|
||||
const [checkedFolders, setCheckedFolders] = useState(new Set())
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [shuffle, setShuffle] = useState(false)
|
||||
const [lightbox, setLightbox] = useState(null)
|
||||
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(() => {
|
||||
@@ -94,6 +210,7 @@ export default function Gallery() {
|
||||
getGalleryFolders().then((data) => {
|
||||
if (!data.error) setFolders(Array.isArray(data) ? data : [])
|
||||
})
|
||||
markGallerySeen()
|
||||
}, [])
|
||||
|
||||
// Close popover on click outside
|
||||
@@ -148,9 +265,12 @@ export default function Gallery() {
|
||||
const data = await getGalleryFiles({
|
||||
...getFilterParams(),
|
||||
type: typeFilter !== 'all' ? typeFilter : undefined,
|
||||
sort: shuffle ? 'shuffle' : 'latest',
|
||||
sort: sortOption,
|
||||
offset,
|
||||
limit: PAGE_SIZE,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
search: searchText || undefined,
|
||||
})
|
||||
|
||||
if (data.error) {
|
||||
@@ -162,16 +282,37 @@ export default function Gallery() {
|
||||
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}, [getFilterParams, typeFilter, shuffle, files.length])
|
||||
}, [getFilterParams, typeFilter, sortOption, dateFrom, dateTo, searchText, files.length])
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles(true)
|
||||
}, [activeFolder, checkedFolders, typeFilter, shuffle])
|
||||
}, [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 (
|
||||
@@ -300,21 +441,11 @@ export default function Gallery() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Shuffle Toggle */}
|
||||
<button
|
||||
onClick={() => setShuffle((s) => !s)}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-lg border transition-colors ${
|
||||
shuffle
|
||||
? 'border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]'
|
||||
: 'border-[#333] bg-[#161616] text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<ShuffleIcon className="w-4 h-4" />
|
||||
Shuffle
|
||||
</button>
|
||||
{/* Sort Dropdown */}
|
||||
<SortDropdown value={sortOption} onChange={setSortOption} />
|
||||
|
||||
{/* Reshuffle Button */}
|
||||
{shuffle && (
|
||||
{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"
|
||||
@@ -324,6 +455,22 @@ export default function Gallery() {
|
||||
</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)}
|
||||
@@ -332,8 +479,67 @@ export default function Gallery() {
|
||||
<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 ? (
|
||||
@@ -355,9 +561,11 @@ export default function Gallery() {
|
||||
<div
|
||||
key={`${file.folder}-${file.filename}-${i}`}
|
||||
className="relative group bg-[#161616] rounded-lg overflow-hidden cursor-pointer aspect-square"
|
||||
onClick={() => setLightbox(file)}
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
>
|
||||
<GalleryThumbnail file={file} />
|
||||
<VideoPreviewThumbnail file={file}>
|
||||
<GalleryThumbnail file={file} />
|
||||
</VideoPreviewThumbnail>
|
||||
|
||||
{/* Date badge */}
|
||||
{file.postedAt && (
|
||||
@@ -368,8 +576,24 @@ export default function Gallery() {
|
||||
</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">
|
||||
<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>
|
||||
@@ -377,7 +601,7 @@ export default function Gallery() {
|
||||
|
||||
{/* Video badge */}
|
||||
{file.type === 'video' && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<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>
|
||||
@@ -398,8 +622,29 @@ export default function Gallery() {
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightbox && (
|
||||
<Lightbox file={lightbox} hlsEnabled={hlsEnabled} onClose={() => setLightbox(null)} />
|
||||
{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 */}
|
||||
@@ -407,54 +652,133 @@ export default function Gallery() {
|
||||
<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({ file, hlsEnabled, onClose }) {
|
||||
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])
|
||||
}, [onClose, index, hasPrev, hasNext, setIndex])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-white/70 hover:text-white z-10"
|
||||
>
|
||||
<svg className="w-8 h-8" 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 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
|
||||
hlsSrc={hlsEnabled && file.type === 'video' ? `/api/hls/${encodeURIComponent(file.folder)}/${encodeURIComponent(file.filename)}/master.m3u8` : null}
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={file.url}
|
||||
alt=""
|
||||
className="max-w-full max-h-[90vh] rounded-lg object-contain"
|
||||
/>
|
||||
<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}</p>
|
||||
<p className="text-center text-sm text-gray-400 mt-3">
|
||||
@{file.folder} · {index + 1} / {files.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -471,12 +795,12 @@ function Slideshow({ filterParams, typeFilter, hlsEnabled, onClose }) {
|
||||
const current = items[index] || null
|
||||
const isVideo = current?.type === 'video'
|
||||
|
||||
// Load shuffled batch respecting type filter
|
||||
// Load ALL matching files shuffled
|
||||
const loadBatch = useCallback(async () => {
|
||||
const params = {
|
||||
...filterParams,
|
||||
sort: 'shuffle',
|
||||
limit: 500,
|
||||
limit: 100000,
|
||||
}
|
||||
if (typeFilter === 'image') params.type = 'image'
|
||||
else if (typeFilter === 'video') params.type = 'video'
|
||||
@@ -553,14 +877,29 @@ function Slideshow({ filterParams, typeFilter, hlsEnabled, onClose }) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-black flex items-center justify-center">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-white/50 hover:text-white z-10"
|
||||
>
|
||||
<svg className="w-8 h-8" 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 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">
|
||||
@@ -602,6 +941,14 @@ function Slideshow({ filterParams, typeFilter, hlsEnabled, onClose }) {
|
||||
)
|
||||
}
|
||||
|
||||
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}>
|
||||
@@ -634,6 +981,14 @@ function UsersFilterIcon({ className }) {
|
||||
)
|
||||
}
|
||||
|
||||
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}>
|
||||
|
||||
Reference in New Issue
Block a user