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:
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<title>OFApp</title>
|
||||
|
||||
+244
-52
@@ -1,7 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
|
||||
import { getMe } from './api'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'
|
||||
import { getMe, getNewMediaCount, checkAuth, appAuthLogout } from './api'
|
||||
import { useAuth } from './AuthContext'
|
||||
import AppLogin from './pages/AppLogin'
|
||||
import AppSetup from './pages/AppSetup'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Feed from './pages/Feed'
|
||||
import Users from './pages/Users'
|
||||
import UserPosts from './pages/UserPosts'
|
||||
@@ -10,35 +14,99 @@ import Search from './pages/Search'
|
||||
import Gallery from './pages/Gallery'
|
||||
import Duplicates from './pages/Duplicates'
|
||||
import Scrape from './pages/Scrape'
|
||||
import Videos from './pages/Videos'
|
||||
import VideoDetail from './pages/VideoDetail'
|
||||
import VideoUpload from './pages/VideoUpload'
|
||||
import UserManagement from './pages/UserManagement'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/feed', label: 'Feed', icon: FeedIcon },
|
||||
{ to: '/users', label: 'Users', icon: UsersIcon },
|
||||
{ to: '/search', label: 'Search', icon: SearchIcon },
|
||||
{ to: '/downloads', label: 'Downloads', icon: DownloadIcon },
|
||||
{ to: '/gallery', label: 'Gallery', icon: GalleryNavIcon },
|
||||
{ to: '/scrape', label: 'Scrape', icon: ScrapeIcon },
|
||||
{ to: '/', label: 'Settings', icon: SettingsIcon },
|
||||
// Route key mapping for nav items (null = always visible, undefined = no check needed)
|
||||
const allNavItems = [
|
||||
{ to: '/', label: 'Home', icon: HomeIcon, exact: true, routeKey: 'dashboard' },
|
||||
{ to: '/feed', label: 'Feed', icon: FeedIcon, routeKey: 'feed' },
|
||||
{ to: '/users', label: 'Users', icon: UsersIcon, routeKey: 'users' },
|
||||
{ to: '/search', label: 'Search', icon: SearchIcon, routeKey: 'users' },
|
||||
{ to: '/downloads', label: 'Downloads', icon: DownloadIcon, routeKey: 'downloads' },
|
||||
{ to: '/gallery', label: 'Gallery', icon: GalleryNavIcon, routeKey: 'gallery' },
|
||||
{ to: '/videos', label: 'Videos', icon: VideoNavIcon, routeKey: 'videos' },
|
||||
{ to: '/scrape', label: 'Scrape', icon: ScrapeIcon, routeKey: 'scrape' },
|
||||
{ to: '/settings', label: 'Settings', icon: SettingsIcon, routeKey: 'settings' },
|
||||
]
|
||||
|
||||
// Bottom bar shows a subset of nav items (most used) to avoid crowding
|
||||
const mobileNavItems = [
|
||||
{ to: '/feed', label: 'Feed', icon: FeedIcon },
|
||||
{ to: '/users', label: 'Users', icon: UsersIcon },
|
||||
{ to: '/gallery', label: 'Gallery', icon: GalleryNavIcon },
|
||||
{ to: '/search', label: 'Search', icon: SearchIcon },
|
||||
const allMobileNavItems = [
|
||||
{ to: '/', label: 'Home', icon: HomeIcon, exact: true, routeKey: 'dashboard' },
|
||||
{ to: '/users', label: 'Users', icon: UsersIcon, routeKey: 'users' },
|
||||
{ to: '/gallery', label: 'Gallery', icon: GalleryNavIcon, routeKey: 'gallery' },
|
||||
{ to: '/search', label: 'Search', icon: SearchIcon, routeKey: 'users' },
|
||||
{ to: '/more', label: 'More', icon: MoreIcon },
|
||||
]
|
||||
|
||||
const moreNavItems = [
|
||||
{ to: '/downloads', label: 'Downloads', icon: DownloadIcon },
|
||||
{ to: '/scrape', label: 'Scrape', icon: ScrapeIcon },
|
||||
{ to: '/', label: 'Settings', icon: SettingsIcon },
|
||||
const allMoreNavItems = [
|
||||
{ to: '/feed', label: 'Feed', icon: FeedIcon, routeKey: 'feed' },
|
||||
{ to: '/downloads', label: 'Downloads', icon: DownloadIcon, routeKey: 'downloads' },
|
||||
{ to: '/videos', label: 'Videos', icon: VideoNavIcon, routeKey: 'videos' },
|
||||
{ to: '/scrape', label: 'Scrape', icon: ScrapeIcon, routeKey: 'scrape' },
|
||||
{ to: '/settings', label: 'Settings', icon: SettingsIcon, routeKey: 'settings' },
|
||||
]
|
||||
|
||||
function ProtectedRoute({ routeKey, children }) {
|
||||
const { hasRoute, appUser } = useAuth()
|
||||
// 'admin' is a special key — only admin role can access
|
||||
if (routeKey === 'admin') {
|
||||
if (appUser?.role !== 'admin') return <Navigate to="/" replace />
|
||||
return children
|
||||
}
|
||||
if (!hasRoute(routeKey)) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { appUser, setupRequired, loading: authLoading, hasRoute } = useAuth()
|
||||
|
||||
// Show auth screens if needed
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-[#0095f6] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (setupRequired) return <AppSetup />
|
||||
if (!appUser) return <AppLogin />
|
||||
|
||||
// Filter nav items based on route permissions
|
||||
const navItems = allNavItems.filter((item) =>
|
||||
!item.routeKey || hasRoute(item.routeKey)
|
||||
)
|
||||
const mobileNavItems = allMobileNavItems.filter((item) =>
|
||||
!item.routeKey || hasRoute(item.routeKey)
|
||||
)
|
||||
const moreNavItems = allMoreNavItems.filter((item) =>
|
||||
!item.routeKey || hasRoute(item.routeKey)
|
||||
)
|
||||
|
||||
// Add admin nav item for admins
|
||||
if (appUser.role === 'admin') {
|
||||
navItems.push({ to: '/admin/users', label: 'Admin', icon: AdminIcon })
|
||||
moreNavItems.push({ to: '/admin/users', label: 'Admin', icon: AdminIcon })
|
||||
}
|
||||
|
||||
return <AppShell
|
||||
navItems={navItems}
|
||||
mobileNavItems={mobileNavItems}
|
||||
moreNavItems={moreNavItems}
|
||||
hasRoute={hasRoute}
|
||||
appUser={appUser}
|
||||
/>
|
||||
}
|
||||
|
||||
function AppShell({ navItems, mobileNavItems, moreNavItems, hasRoute, appUser }) {
|
||||
const { logout } = useAuth()
|
||||
const [currentUser, setCurrentUser] = useState(null)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [authWarning, setAuthWarning] = useState(null)
|
||||
const authPollRef = useRef(null)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,11 +117,48 @@ export default function App() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Auth validity polling (every 5 min)
|
||||
useEffect(() => {
|
||||
const poll = () => {
|
||||
checkAuth().then((data) => {
|
||||
if (data && !data.error && !data.valid) {
|
||||
setAuthWarning(data.error || 'Auth expired')
|
||||
} else if (data?.valid) {
|
||||
setAuthWarning(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
poll()
|
||||
authPollRef.current = setInterval(poll, 5 * 60 * 1000)
|
||||
return () => clearInterval(authPollRef.current)
|
||||
}, [])
|
||||
|
||||
// Close "more" menu on route change
|
||||
useEffect(() => {
|
||||
setMoreOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
// New media badge
|
||||
const [newMediaCount, setNewMediaCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCount = () => {
|
||||
getNewMediaCount().then((data) => {
|
||||
if (!data.error) setNewMediaCount(data.count || 0)
|
||||
})
|
||||
}
|
||||
fetchCount()
|
||||
const id = setInterval(fetchCount, 60000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// Instantly clear badge when on gallery page
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith('/gallery')) {
|
||||
setNewMediaCount(0)
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
const refreshUser = () => {
|
||||
getMe().then((data) => {
|
||||
if (!data.error) {
|
||||
@@ -62,12 +167,17 @@ export default function App() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await appAuthLogout()
|
||||
logout()
|
||||
}
|
||||
|
||||
const isMoreActive = moreNavItems.some((item) =>
|
||||
item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to)
|
||||
item.to !== '/more' && location.pathname.startsWith(item.to)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#0a0a0a]">
|
||||
<div className="flex min-h-screen bg-[#0a0a0a] overflow-x-hidden w-full">
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden md:flex fixed left-0 top-0 bottom-0 w-60 bg-[#111] border-r border-[#222] flex-col z-50">
|
||||
{/* Logo */}
|
||||
@@ -81,10 +191,9 @@ export default function App() {
|
||||
<nav className="flex-1 py-4 px-3">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive =
|
||||
item.to === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.to)
|
||||
const isActive = item.exact
|
||||
? location.pathname === item.to
|
||||
: location.pathname.startsWith(item.to)
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
@@ -98,15 +207,20 @@ export default function App() {
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
{item.to === '/gallery' && newMediaCount > 0 && (
|
||||
<span className="ml-auto bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center rounded-full px-1">
|
||||
{newMediaCount > 99 ? '99+' : newMediaCount}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Current User */}
|
||||
{currentUser && (
|
||||
<div className="p-4 border-t border-[#222]">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Current User + Logout */}
|
||||
<div className="p-4 border-t border-[#222]">
|
||||
{currentUser && (
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<img
|
||||
src={currentUser.avatar}
|
||||
alt={currentUser.name}
|
||||
@@ -124,23 +238,61 @@ export default function App() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-300 text-sm transition-colors w-full px-1"
|
||||
>
|
||||
<LogoutIcon className="w-4 h-4" />
|
||||
<span>Sign out ({appUser.username})</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="md:ml-60 flex-1 min-h-screen pb-20 md:pb-0">
|
||||
<main className="md:ml-60 flex-1 min-h-screen pb-20 md:pb-0 overflow-x-hidden">
|
||||
{/* Auth Warning Banner */}
|
||||
{authWarning && (
|
||||
<div className="bg-amber-500/10 border-b border-amber-500/30 px-4 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
<span className="text-sm text-amber-300">
|
||||
Auth expired —{' '}
|
||||
<NavLink to="/settings" className="underline hover:text-amber-200">
|
||||
Update credentials in Settings
|
||||
</NavLink>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAuthWarning(null)}
|
||||
className="text-amber-400 hover:text-amber-200 p-1"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
)}
|
||||
|
||||
<div className="max-w-5xl mx-auto p-4 md:p-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Login onAuth={refreshUser} />} />
|
||||
<Route path="/feed" element={<Feed />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/users/:userId" element={<UserPosts />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/downloads" element={<Downloads />} />
|
||||
<Route path="/gallery" element={<Gallery />} />
|
||||
<Route path="/duplicates" element={<Duplicates />} />
|
||||
<Route path="/scrape" element={<Scrape />} />
|
||||
<Route path="/" element={<ProtectedRoute routeKey="dashboard"><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute routeKey="settings"><Login onAuth={refreshUser} /></ProtectedRoute>} />
|
||||
<Route path="/feed" element={<ProtectedRoute routeKey="feed"><Feed /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute routeKey="users"><Users /></ProtectedRoute>} />
|
||||
<Route path="/users/:userId" element={<ProtectedRoute routeKey="users"><UserPosts /></ProtectedRoute>} />
|
||||
<Route path="/search" element={<ProtectedRoute routeKey="users"><Search /></ProtectedRoute>} />
|
||||
<Route path="/downloads" element={<ProtectedRoute routeKey="downloads"><Downloads /></ProtectedRoute>} />
|
||||
<Route path="/gallery" element={<ProtectedRoute routeKey="gallery"><Gallery /></ProtectedRoute>} />
|
||||
<Route path="/duplicates" element={<ProtectedRoute routeKey="gallery"><Duplicates /></ProtectedRoute>} />
|
||||
<Route path="/videos" element={<ProtectedRoute routeKey="videos"><Videos /></ProtectedRoute>} />
|
||||
<Route path="/videos/upload" element={<ProtectedRoute routeKey="videos"><VideoUpload /></ProtectedRoute>} />
|
||||
<Route path="/videos/:id" element={<ProtectedRoute routeKey="videos"><VideoDetail /></ProtectedRoute>} />
|
||||
<Route path="/scrape" element={<ProtectedRoute routeKey="scrape"><Scrape /></ProtectedRoute>} />
|
||||
<Route path="/admin/users" element={<ProtectedRoute routeKey="admin"><UserManagement /></ProtectedRoute>} />
|
||||
<Route path="/login" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
@@ -166,21 +318,25 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
const isActive =
|
||||
item.to === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.to)
|
||||
const isActive = item.exact
|
||||
? location.pathname === item.to
|
||||
: location.pathname.startsWith(item.to)
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={`flex flex-col items-center justify-center gap-0.5 w-full h-full transition-colors ${
|
||||
className={`relative flex flex-col items-center justify-center gap-0.5 w-full h-full transition-colors ${
|
||||
isActive ? 'text-[#0095f6]' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-[10px]">{item.label}</span>
|
||||
{item.to === '/gallery' && newMediaCount > 0 && (
|
||||
<span className="absolute top-1 right-1/4 bg-red-500 text-white text-[9px] font-bold min-w-[16px] h-[16px] flex items-center justify-center rounded-full px-1">
|
||||
{newMediaCount > 99 ? '99+' : newMediaCount}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
@@ -193,10 +349,7 @@ export default function App() {
|
||||
<div className="absolute bottom-full left-0 right-0 bg-[#161616] border-t border-[#222] z-50 shadow-xl">
|
||||
{moreNavItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive =
|
||||
item.to === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.to)
|
||||
const isActive = location.pathname.startsWith(item.to)
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
@@ -211,6 +364,13 @@ export default function App() {
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 px-5 py-3.5 transition-colors text-gray-500 hover:text-gray-300 w-full border-t border-[#222]"
|
||||
>
|
||||
<LogoutIcon className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Sign out ({appUser.username})</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -221,6 +381,22 @@ export default function App() {
|
||||
|
||||
/* Icon Components */
|
||||
|
||||
function AdminIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LogoutIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -285,3 +461,19 @@ function MoreIcon({ className }) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoNavIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
import { appAuthStatus, appAuthMe } from './api'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [appUser, setAppUser] = useState(null)
|
||||
const [setupRequired, setSetupRequired] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
appAuthStatus().then((data) => {
|
||||
if (data.error) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (data.setupRequired) {
|
||||
setSetupRequired(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
// Not setup mode — fetch current user
|
||||
appAuthMe().then((meData) => {
|
||||
if (meData.error) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (meData.setupRequired) {
|
||||
setSetupRequired(true)
|
||||
} else if (meData.user) {
|
||||
setAppUser(meData.user)
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const login = useCallback((userData) => {
|
||||
setAppUser(userData)
|
||||
setSetupRequired(false)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setAppUser(null)
|
||||
}, [])
|
||||
|
||||
const hasRoute = useCallback((routeKey) => {
|
||||
if (!appUser) return false
|
||||
if (appUser.role === 'admin') return true
|
||||
return appUser.routes?.includes(routeKey) || false
|
||||
}, [appUser])
|
||||
|
||||
const hasFolder = useCallback((folderName) => {
|
||||
if (!appUser) return false
|
||||
if (appUser.role === 'admin') return true
|
||||
if (appUser.folders === null) return true // null = all access
|
||||
return appUser.folders?.includes(folderName) || false
|
||||
}, [appUser])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ appUser, setupRequired, loading, login, logout, hasRoute, hasFolder }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
+274
-6
@@ -1,6 +1,13 @@
|
||||
async function request(url, options = {}) {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
// Handle 401 — redirect to login (skip for auth endpoints)
|
||||
if (response.status === 401 && !url.startsWith('/api/app-auth/')) {
|
||||
window.location.href = '/login';
|
||||
return { error: 'Authentication required' };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -78,6 +85,14 @@ export function startDownload(userId, limit, resume, username) {
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadPost(userId, username, postId, media, postedAt) {
|
||||
return request('/api/download/post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId, username, postId, media, postedAt }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getDownloadStatus(userId) {
|
||||
return request(`/api/download/${userId}/status`);
|
||||
}
|
||||
@@ -110,8 +125,8 @@ export function updateSettings(settings) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getGalleryFiles({ folder, folders, type, sort, offset, limit } = {}) {
|
||||
const query = buildQuery({ folder, folders: folders ? folders.join(',') : undefined, type, sort, offset, limit });
|
||||
export function getGalleryFiles({ folder, folders, type, sort, offset, limit, dateFrom, dateTo, minSize, maxSize, search } = {}) {
|
||||
const query = buildQuery({ folder, folders: folders ? folders.join(',') : undefined, type, sort, offset, limit, dateFrom, dateTo, minSize, maxSize, search });
|
||||
return request(`/api/gallery/files${query}`);
|
||||
}
|
||||
|
||||
@@ -131,8 +146,8 @@ export function getThumbsStatus() {
|
||||
return request('/api/gallery/generate-thumbs/status');
|
||||
}
|
||||
|
||||
export function scanDuplicates() {
|
||||
return request('/api/gallery/scan-duplicates', { method: 'POST' });
|
||||
export function scanDuplicates(mode = 'everywhere') {
|
||||
return request(`/api/gallery/scan-duplicates?mode=${mode}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function getDuplicateScanStatus() {
|
||||
@@ -152,6 +167,18 @@ export function deleteMediaFile(folder, filename) {
|
||||
return request(`/api/gallery/media/${encodeURIComponent(folder)}/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function getNewMediaCount() {
|
||||
return request('/api/gallery/new-count');
|
||||
}
|
||||
|
||||
export function markGallerySeen() {
|
||||
return request('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gallery_last_seen: new Date().toISOString() }),
|
||||
});
|
||||
}
|
||||
|
||||
export function startForumScrape(config) {
|
||||
return request('/api/scrape/forum', {
|
||||
method: 'POST',
|
||||
@@ -176,6 +203,30 @@ export function startMediaLinkScrape(config) {
|
||||
});
|
||||
}
|
||||
|
||||
export function startMegaScrape(config) {
|
||||
return request('/api/scrape/mega', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export function startLeakGalleryScrape(config) {
|
||||
return request('/api/scrape/leakgallery', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export function startYtdlpScrape(config) {
|
||||
return request('/api/scrape/ytdlp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export function getScrapeJobs() {
|
||||
return request('/api/scrape/jobs');
|
||||
}
|
||||
@@ -188,10 +239,227 @@ export function cancelScrapeJob(jobId) {
|
||||
return request(`/api/scrape/jobs/${jobId}/cancel`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function detectForumPages(url) {
|
||||
export function detectForumPages(url, cookies) {
|
||||
return request('/api/scrape/forum/detect-pages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
body: JSON.stringify({ url, cookies }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- FlareSolverr ---
|
||||
|
||||
export function getFlareSolverrStatus() {
|
||||
return request('/api/flaresolverr/status');
|
||||
}
|
||||
|
||||
export function refreshForumCookies(siteId) {
|
||||
return request(`/api/flaresolverr/refresh/${siteId}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// --- Forum Sites ---
|
||||
|
||||
export function getForumSites() {
|
||||
return request('/api/scrape/forum-sites');
|
||||
}
|
||||
|
||||
export function createForumSite(data) {
|
||||
return request('/api/scrape/forum-sites', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateForumSite(id, data) {
|
||||
return request(`/api/scrape/forum-sites/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteForumSite(id) {
|
||||
return request(`/api/scrape/forum-sites/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// --- Auto-download ---
|
||||
|
||||
export function getAutoDownloadUsers() {
|
||||
return request('/api/download/auto');
|
||||
}
|
||||
|
||||
export function addAutoDownloadUser(userId, username) {
|
||||
return request(`/api/download/auto/${userId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
}
|
||||
|
||||
export function removeAutoDownloadUser(userId) {
|
||||
return request(`/api/download/auto/${userId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// --- Auto-scrape ---
|
||||
|
||||
export function getAutoScrapeJobs() {
|
||||
return request('/api/scrape/auto');
|
||||
}
|
||||
|
||||
export function addAutoScrapeJob(config) {
|
||||
return request('/api/scrape/auto', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export function removeAutoScrapeJob(id) {
|
||||
return request(`/api/scrape/auto/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// --- Dashboard / Health ---
|
||||
|
||||
export function checkAuth() {
|
||||
return request('/api/auth/check');
|
||||
}
|
||||
|
||||
export function getDashboard() {
|
||||
return request('/api/dashboard');
|
||||
}
|
||||
|
||||
export function getHealth() {
|
||||
return request('/api/health');
|
||||
}
|
||||
|
||||
export function getActiveDownloadDetails() {
|
||||
return request('/api/download/active/details');
|
||||
}
|
||||
|
||||
// --- Videos ---
|
||||
|
||||
export function getVideos({ search, tags, minDuration, maxDuration, minWidth, sort, offset, limit } = {}) {
|
||||
const query = buildQuery({
|
||||
search, tags: tags ? tags.join(',') : undefined,
|
||||
minDuration, maxDuration, minWidth, sort, offset, limit,
|
||||
});
|
||||
return request(`/api/videos${query}`);
|
||||
}
|
||||
|
||||
export function getVideo(id) {
|
||||
return request(`/api/videos/${id}`);
|
||||
}
|
||||
|
||||
export function updateVideoMeta(id, data) {
|
||||
return request(`/api/videos/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteVideo(id) {
|
||||
return request(`/api/videos/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function uploadVideo(file, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/videos/upload');
|
||||
|
||||
if (onProgress) {
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) onProgress(e.loaded / e.total);
|
||||
};
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
resolve(data);
|
||||
} catch {
|
||||
reject(new Error('Invalid response'));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('Upload failed'));
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('video', file);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
export function scanVideos() {
|
||||
return request('/api/videos/scan', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function getVideoScanStatus() {
|
||||
return request('/api/videos/scan/status');
|
||||
}
|
||||
|
||||
export function getVideoTags(search) {
|
||||
const query = buildQuery({ search });
|
||||
return request(`/api/videos/tags${query}`);
|
||||
}
|
||||
|
||||
// --- App Auth ---
|
||||
|
||||
export function appAuthStatus() {
|
||||
return request('/api/app-auth/status');
|
||||
}
|
||||
|
||||
export function appAuthLogin(username, password) {
|
||||
return request('/api/app-auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function appAuthLogout() {
|
||||
return request('/api/app-auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function appAuthMe() {
|
||||
return request('/api/app-auth/me');
|
||||
}
|
||||
|
||||
export function appAuthSetup(username, password) {
|
||||
return request('/api/app-auth/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Admin User Management ---
|
||||
|
||||
export function getAppUsers() {
|
||||
return request('/api/admin/users');
|
||||
}
|
||||
|
||||
export function createAppUser(data) {
|
||||
return request('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAppUser(id, data) {
|
||||
return request(`/api/admin/users/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAppUser(id) {
|
||||
return request(`/api/admin/users/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function getAvailableFolders() {
|
||||
return request('/api/admin/available-folders');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import HlsVideo from './HlsVideo'
|
||||
|
||||
const SLIDESHOW_INTERVAL = 5000
|
||||
|
||||
// URL helpers (pure functions, no hooks)
|
||||
function getItemUrl(item) {
|
||||
if (!item) return ''
|
||||
if (item.folder) {
|
||||
return `/api/gallery/media/${encodeURIComponent(item.folder)}/${encodeURIComponent(item.filename)}`
|
||||
}
|
||||
if (item.id) {
|
||||
return `/api/videos/${item.id}/stream`
|
||||
}
|
||||
return item.url || ''
|
||||
}
|
||||
|
||||
function getItemThumbUrl(item) {
|
||||
if (!item) return ''
|
||||
if (item.folder) {
|
||||
return `/api/gallery/thumb/${encodeURIComponent(item.folder)}/${encodeURIComponent(item.filename)}`
|
||||
}
|
||||
if (item.id) {
|
||||
return `/api/videos/${item.id}/thumbnail`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getItemHlsUrl(item, hlsEnabled) {
|
||||
if (!item) return null
|
||||
if (item.id && !item.folder) {
|
||||
return `/api/video-hls/${item.id}/master.m3u8`
|
||||
}
|
||||
if (hlsEnabled && item.folder) {
|
||||
return `/api/hls/${encodeURIComponent(item.folder)}/${encodeURIComponent(item.filename)}/master.m3u8`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getItemLabel(item) {
|
||||
if (!item) return ''
|
||||
if (item.folder) return `@${item.folder}`
|
||||
if (item.title) return item.title
|
||||
return ''
|
||||
}
|
||||
|
||||
export default function GridCell({ queue, onNeedMore, paused, hlsEnabled }) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
// Two-layer crossfade: both layers always rendered, activeLayer controls which is on top
|
||||
const [activeLayer, setActiveLayer] = useState(0)
|
||||
const [layerSrcs, setLayerSrcs] = useState(['', ''])
|
||||
const [muted, setMuted] = useState(true)
|
||||
const videoRef = useRef(null)
|
||||
const timerRef = useRef(null)
|
||||
const fadingRef = useRef(false)
|
||||
|
||||
const current = queue[currentIndex] || null
|
||||
const isVideo = current?.type === 'video'
|
||||
|
||||
// Initialize first layer when first item arrives
|
||||
useEffect(() => {
|
||||
if (current && !layerSrcs[0] && !layerSrcs[1]) {
|
||||
setLayerSrcs([getItemUrl(current), ''])
|
||||
setActiveLayer(0)
|
||||
}
|
||||
}, [current]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Request more items when queue is running low
|
||||
useEffect(() => {
|
||||
if (queue.length - currentIndex < 5 && onNeedMore) {
|
||||
onNeedMore()
|
||||
}
|
||||
}, [currentIndex, queue.length, onNeedMore])
|
||||
|
||||
const advance = useCallback(() => {
|
||||
if (fadingRef.current) return
|
||||
const nextIdx = currentIndex + 1
|
||||
if (nextIdx >= queue.length) {
|
||||
if (onNeedMore) onNeedMore()
|
||||
return
|
||||
}
|
||||
|
||||
const upcoming = queue[nextIdx]
|
||||
const upcomingIsVideo = upcoming?.type === 'video'
|
||||
|
||||
if (!isVideo && !upcomingIsVideo) {
|
||||
// Preload the next image, then crossfade
|
||||
const nextLayer = activeLayer === 0 ? 1 : 0
|
||||
const url = getItemUrl(upcoming)
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
const doFade = () => {
|
||||
fadingRef.current = true
|
||||
setLayerSrcs(prev => {
|
||||
const next = [...prev]
|
||||
next[nextLayer] = url
|
||||
return next
|
||||
})
|
||||
setActiveLayer(nextLayer)
|
||||
setTimeout(() => {
|
||||
setCurrentIndex(nextIdx)
|
||||
fadingRef.current = false
|
||||
}, 600)
|
||||
}
|
||||
img.onload = doFade
|
||||
img.onerror = doFade // still transition even on error
|
||||
} else {
|
||||
setCurrentIndex(nextIdx)
|
||||
setLayerSrcs([getItemUrl(upcoming), ''])
|
||||
setActiveLayer(0)
|
||||
}
|
||||
}, [currentIndex, queue, isVideo, onNeedMore, activeLayer])
|
||||
|
||||
const skip = useCallback(() => {
|
||||
clearTimeout(timerRef.current)
|
||||
advance()
|
||||
}, [advance])
|
||||
|
||||
// Image slideshow timer
|
||||
useEffect(() => {
|
||||
if (!current || paused || isVideo) return
|
||||
timerRef.current = setTimeout(advance, SLIDESHOW_INTERVAL)
|
||||
return () => clearTimeout(timerRef.current)
|
||||
}, [currentIndex, current, paused, isVideo, advance])
|
||||
|
||||
const handleVideoEnded = useCallback(() => {
|
||||
if (!paused) advance()
|
||||
}, [paused, advance])
|
||||
|
||||
// Pause/resume video when master pause toggles
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || !isVideo) return
|
||||
if (paused) {
|
||||
videoRef.current.pause()
|
||||
} else {
|
||||
videoRef.current.play().catch(() => {})
|
||||
}
|
||||
}, [paused, isVideo])
|
||||
|
||||
if (!current) {
|
||||
return (
|
||||
<div className="w-full h-full bg-[#111] flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-white/20 border-t-white/60 rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full min-h-0 min-w-0 overflow-hidden bg-black group">
|
||||
{isVideo ? (
|
||||
<HlsVideo
|
||||
key={`video-${currentIndex}`}
|
||||
ref={videoRef}
|
||||
hlsSrc={getItemHlsUrl(current, hlsEnabled)}
|
||||
src={getItemUrl(current)}
|
||||
autoPlay={!paused}
|
||||
muted={muted}
|
||||
playsInline
|
||||
onEnded={handleVideoEnded}
|
||||
poster={getItemThumbUrl(current)}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="relative w-full h-full">
|
||||
{/* Layer 0 */}
|
||||
<img
|
||||
src={layerSrcs[0]}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-contain transition-opacity duration-[600ms] ease-in-out"
|
||||
style={{ opacity: activeLayer === 0 ? 1 : 0, zIndex: activeLayer === 0 ? 2 : 1 }}
|
||||
/>
|
||||
{/* Layer 1 */}
|
||||
<img
|
||||
src={layerSrcs[1]}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-contain transition-opacity duration-[600ms] ease-in-out"
|
||||
style={{ opacity: activeLayer === 1 ? 1 : 0, zIndex: activeLayer === 1 ? 2 : 1 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all z-10">
|
||||
{isVideo && (
|
||||
<>
|
||||
{/* Mute/Unmute */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setMuted(m => {
|
||||
const next = !m
|
||||
if (videoRef.current) videoRef.current.muted = next
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="p-1.5 bg-black/50 hover:bg-black/70 rounded-full text-white/60 hover:text-white transition-all"
|
||||
title={muted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{muted ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{/* Seek 25/50/75% */}
|
||||
{[25, 50, 75].map(pct => (
|
||||
<button
|
||||
key={pct}
|
||||
onClick={() => {
|
||||
if (videoRef.current && videoRef.current.duration) {
|
||||
videoRef.current.currentTime = videoRef.current.duration * (pct / 100)
|
||||
}
|
||||
}}
|
||||
className="px-1.5 py-0.5 bg-black/50 hover:bg-black/70 rounded text-[10px] text-white/60 hover:text-white transition-all font-medium"
|
||||
title={`Seek to ${pct}%`}
|
||||
>
|
||||
{pct}%
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={skip}
|
||||
className="p-1.5 bg-black/50 hover:bg-black/70 rounded-full text-white/60 hover:text-white transition-all"
|
||||
title="Next"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8.688c0-.864.933-1.405 1.683-.977l7.108 4.062a1.125 1.125 0 010 1.953l-7.108 4.062A1.125 1.125 0 013 16.81V8.688zM12.75 8.688c0-.864.933-1.405 1.683-.977l7.108 4.062a1.125 1.125 0 010 1.953l-7.108 4.062a1.125 1.125 0 01-1.683-.977V8.688z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div className="absolute bottom-2 left-2 text-xs text-white/50 opacity-0 group-hover:opacity-100 transition-opacity z-10 bg-black/40 px-1.5 py-0.5 rounded">
|
||||
{getItemLabel(current)}
|
||||
</div>
|
||||
|
||||
{/* Video indicator */}
|
||||
{isVideo && (
|
||||
<div className="absolute top-2 right-2 bg-black/50 rounded px-1.5 py-0.5 text-xs text-white/60">
|
||||
<svg className="w-3 h-3 inline" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import GridCell from './GridCell'
|
||||
|
||||
function useFullscreen(ref) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement)
|
||||
document.addEventListener('fullscreenchange', onChange)
|
||||
document.addEventListener('webkitfullscreenchange', onChange)
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', onChange)
|
||||
document.removeEventListener('webkitfullscreenchange', onChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen?.() || document.webkitExitFullscreen?.()
|
||||
} else {
|
||||
ref.current.requestFullscreen?.() || ref.current.webkitRequestFullscreen?.()
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
return { isFullscreen, toggle }
|
||||
}
|
||||
|
||||
const GRID_LAYOUTS = [
|
||||
{ label: '1\u00d71', cols: 1, rows: 1 },
|
||||
{ label: '2\u00d71', cols: 2, rows: 1 },
|
||||
{ label: '2\u00d72', cols: 2, rows: 2 },
|
||||
{ label: '3\u00d72', cols: 3, rows: 2 },
|
||||
{ label: '3\u00d73', cols: 3, rows: 3 },
|
||||
]
|
||||
|
||||
const BATCH_SIZE = 200
|
||||
|
||||
export default function GridWall({ layout, fetchItems, hlsEnabled, onClose }) {
|
||||
const cellCount = layout.cols * layout.rows
|
||||
const [queues, setQueues] = useState(() => Array.from({ length: cellCount }, () => []))
|
||||
const [paused, setPaused] = useState(false)
|
||||
const usedIdsRef = useRef(new Set())
|
||||
const fetchingRef = useRef(false)
|
||||
const containerRef = useRef(null)
|
||||
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(containerRef)
|
||||
|
||||
// Deal items round-robin into cell queues, avoiding duplicates
|
||||
const dealItems = useCallback((items) => {
|
||||
const fresh = items.filter(item => {
|
||||
const key = item.folder ? `${item.folder}/${item.filename}` : `vid-${item.id}`
|
||||
if (usedIdsRef.current.has(key)) return false
|
||||
usedIdsRef.current.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
if (fresh.length === 0) return
|
||||
|
||||
setQueues(prev => {
|
||||
const next = prev.map(q => [...q])
|
||||
fresh.forEach((item, i) => {
|
||||
next[i % cellCount].push(item)
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [cellCount])
|
||||
|
||||
// Fetch a batch and deal into queues
|
||||
const fetchAndDeal = useCallback(async () => {
|
||||
if (fetchingRef.current) return
|
||||
fetchingRef.current = true
|
||||
try {
|
||||
const items = await fetchItems(BATCH_SIZE)
|
||||
dealItems(items)
|
||||
} catch (err) {
|
||||
console.error('[GridWall] fetch error:', err)
|
||||
} finally {
|
||||
fetchingRef.current = false
|
||||
}
|
||||
}, [fetchItems, dealItems])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetchAndDeal()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Keyboard controls
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape' && !document.fullscreenElement) onClose()
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setPaused(p => !p)
|
||||
}
|
||||
if (e.key === 'f' || e.key === 'F') toggleFullscreen()
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [onClose, toggleFullscreen])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="fixed inset-0 z-[100] bg-black flex flex-col">
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-black/80 border-b border-white/10 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-white/60 text-sm font-medium">{layout.label} Grid</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={() => setPaused(p => !p)}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors"
|
||||
title={paused ? 'Play all' : 'Pause all'}
|
||||
>
|
||||
{paused ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors"
|
||||
title={isFullscreen ? 'Exit fullscreen (F)' : 'Fullscreen (F)'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div
|
||||
className="flex-1 grid gap-0.5 overflow-hidden min-h-0"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${layout.cols}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${layout.rows}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{queues.map((queue, i) => (
|
||||
<GridCell
|
||||
key={i}
|
||||
queue={queue}
|
||||
onNeedMore={fetchAndDeal}
|
||||
paused={paused}
|
||||
hlsEnabled={hlsEnabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Paused indicator */}
|
||||
{paused && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-black/60 text-white/50 text-sm px-3 py-1 rounded-full">
|
||||
Paused · Space to resume
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Picker popover component
|
||||
export function GridWallPicker({ onSelect, onClose }) {
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full left-0 mt-2 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-50 overflow-hidden py-1 w-36"
|
||||
>
|
||||
{GRID_LAYOUTS.map(layout => (
|
||||
<button
|
||||
key={layout.label}
|
||||
onClick={() => onSelect(layout)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-400 hover:text-white hover:bg-[#252525] transition-colors"
|
||||
>
|
||||
<GridIcon cols={layout.cols} rows={layout.rows} />
|
||||
{layout.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GridIcon({ cols, rows }) {
|
||||
const size = 16
|
||||
const gap = 1
|
||||
const cellW = (size - gap * (cols - 1)) / cols
|
||||
const cellH = (size - gap * (rows - 1)) / rows
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="flex-shrink-0">
|
||||
{Array.from({ length: rows }, (_, r) =>
|
||||
Array.from({ length: cols }, (_, c) => (
|
||||
<rect
|
||||
key={`${r}-${c}`}
|
||||
x={c * (cellW + gap)}
|
||||
y={r * (cellH + gap)}
|
||||
width={cellW}
|
||||
height={cellH}
|
||||
rx={1}
|
||||
fill="currentColor"
|
||||
opacity={0.6}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -37,8 +37,10 @@ const HlsVideo = forwardRef(function HlsVideo({ hlsSrc, src, autoPlay, ...props
|
||||
// Always use hls.js when supported (including Safari) for consistent behavior
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
maxBufferLength: 10,
|
||||
maxMaxBufferLength: 30,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
capLevelToPlayerSize: true,
|
||||
startLevel: -1,
|
||||
emeEnabled: true,
|
||||
})
|
||||
hlsRef.current = hls
|
||||
|
||||
@@ -33,13 +33,16 @@ function timeAgo(dateStr) {
|
||||
return 'just now'
|
||||
}
|
||||
|
||||
export default function PostCard({ post }) {
|
||||
export default function PostCard({ post, onDownloadPost, downloadingPosts }) {
|
||||
const author = post.author || post.fromUser || {}
|
||||
const media = post.media || []
|
||||
const text = post.text || post.rawText || ''
|
||||
const postedAt = post.postedAt || post.createdAt || post.publishedAt
|
||||
const [showText, setShowText] = useState(false)
|
||||
|
||||
const isDownloading = downloadingPosts?.has?.(post.id)
|
||||
const hasMedia = media.length > 0
|
||||
|
||||
return (
|
||||
<article className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
|
||||
{/* Author Row */}
|
||||
@@ -62,6 +65,29 @@ export default function PostCard({ post }) {
|
||||
{postedAt && <span>{timeAgo(postedAt)}</span>}
|
||||
</p>
|
||||
</div>
|
||||
{onDownloadPost && hasMedia && (
|
||||
<button
|
||||
onClick={() => onDownloadPost(post)}
|
||||
disabled={isDownloading}
|
||||
className={`flex-shrink-0 p-2 rounded-lg transition-colors ${
|
||||
isDownloading
|
||||
? 'text-[#0095f6] bg-[#0095f6]/10'
|
||||
: 'text-gray-500 hover:text-[#0095f6] hover:bg-[#0095f6]/10'
|
||||
}`}
|
||||
title={isDownloading ? 'Downloading...' : `Download ${media.length} file${media.length !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Media */}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { getVideoTags } from '../api'
|
||||
|
||||
export default function TagInput({ tags = [], onChange }) {
|
||||
const [input, setInput] = useState('')
|
||||
const [suggestions, setSuggestions] = useState([])
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
const containerRef = useRef(null)
|
||||
const debounceRef = useRef(null)
|
||||
|
||||
// Close suggestions on click outside
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
const fetchSuggestions = useCallback((query) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
if (!query.trim()) {
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
return
|
||||
}
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const data = await getVideoTags(query.trim())
|
||||
if (Array.isArray(data)) {
|
||||
// Filter out already-selected tags
|
||||
const filtered = data.filter(t => !tags.some(
|
||||
existing => existing.toLowerCase() === t.name.toLowerCase()
|
||||
))
|
||||
setSuggestions(filtered)
|
||||
setShowSuggestions(filtered.length > 0)
|
||||
}
|
||||
}, 300)
|
||||
}, [tags])
|
||||
|
||||
const addTag = (name) => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
if (tags.some(t => t.toLowerCase() === trimmed.toLowerCase())) return
|
||||
onChange([...tags, trimmed])
|
||||
setInput('')
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const removeTag = (index) => {
|
||||
onChange(tags.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if ((e.key === 'Enter' || e.key === ',') && input.trim()) {
|
||||
e.preventDefault()
|
||||
addTag(input)
|
||||
}
|
||||
if (e.key === 'Backspace' && !input && tags.length > 0) {
|
||||
removeTag(tags.length - 1)
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
const val = e.target.value
|
||||
// If user types comma, add tag
|
||||
if (val.includes(',')) {
|
||||
const parts = val.split(',')
|
||||
for (const part of parts) {
|
||||
if (part.trim()) addTag(part)
|
||||
}
|
||||
return
|
||||
}
|
||||
setInput(val)
|
||||
fetchSuggestions(val)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className="flex flex-wrap gap-1.5 p-2 bg-[#111] border border-[#333] rounded-lg focus-within:border-[#0095f6] transition-colors min-h-[42px]">
|
||||
{tags.map((tag, i) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-xs bg-[#0095f6]/15 text-[#0095f6] rounded-md border border-[#0095f6]/30"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => removeTag(i)}
|
||||
className="hover:text-white transition-colors ml-0.5"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => { if (suggestions.length > 0) setShowSuggestions(true) }}
|
||||
placeholder={tags.length === 0 ? 'Add tags...' : ''}
|
||||
className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-50 overflow-hidden max-h-48 overflow-y-auto">
|
||||
{suggestions.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => addTag(tag.name)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-300 hover:text-white hover:bg-[#252525] transition-colors text-left"
|
||||
>
|
||||
<span>{tag.name}</span>
|
||||
<span className="text-xs text-gray-600">{tag.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ function decodeHTML(str) {
|
||||
return el.value
|
||||
}
|
||||
|
||||
export default function UserCard({ user, onDownload, downloading }) {
|
||||
export default function UserCard({ user, onDownload, downloading, autoDownload, onToggleAutoDownload }) {
|
||||
const handleDownloadClick = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -16,6 +16,14 @@ export default function UserCard({ user, onDownload, downloading }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoToggle = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (onToggleAutoDownload) {
|
||||
onToggleAutoDownload(user.id, user.username)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-3 md:p-4 hover:border-[#333] transition-colors duration-200 hover-lift">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -47,6 +55,21 @@ export default function UserCard({ user, onDownload, downloading }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-download toggle */}
|
||||
<button
|
||||
onClick={handleAutoToggle}
|
||||
className={`flex-shrink-0 p-2 rounded-lg transition-colors ${
|
||||
autoDownload
|
||||
? 'text-yellow-400 bg-yellow-400/10'
|
||||
: 'text-gray-600 hover:text-yellow-400 hover:bg-yellow-400/10'
|
||||
}`}
|
||||
title={autoDownload ? 'Auto-download enabled (every 12h)' : 'Enable auto-download (every 12h)'}
|
||||
>
|
||||
<svg className="w-4 h-4" fill={autoDownload ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Download Button */}
|
||||
<button
|
||||
onClick={handleDownloadClick}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return ''
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatResolution(width, height) {
|
||||
if (!height) return ''
|
||||
if (height >= 2160) return '4K'
|
||||
if (height >= 1080) return '1080p'
|
||||
if (height >= 720) return '720p'
|
||||
if (height >= 480) return '480p'
|
||||
return `${height}p`
|
||||
}
|
||||
|
||||
export default function VideoCard({ video }) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [errored, setErrored] = useState(false)
|
||||
|
||||
const thumbSrc = `/api/videos/${video.id}/thumbnail`
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/videos/${video.id}`}
|
||||
className="group block bg-[#161616] rounded-lg overflow-hidden hover:ring-1 hover:ring-[#333] transition-all"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="relative aspect-video bg-[#111]">
|
||||
{!loaded && !errored && (
|
||||
<div className="absolute inset-0 bg-[#1a1a1a] animate-pulse" />
|
||||
)}
|
||||
{errored ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-[#1a1a1a]">
|
||||
<svg className="w-10 h-10 text-white/20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={video.title}
|
||||
loading="lazy"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setErrored(true)}
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Duration badge */}
|
||||
{video.duration > 0 && (
|
||||
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[11px] font-medium px-1.5 py-0.5 rounded">
|
||||
{formatDuration(video.duration)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Resolution badge */}
|
||||
{video.height > 0 && (
|
||||
<span className="absolute top-1.5 right-1.5 bg-black/60 text-white/80 text-[10px] font-medium px-1.5 py-0.5 rounded">
|
||||
{formatResolution(video.width, video.height)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="p-2.5">
|
||||
<h3 className="text-sm font-medium text-gray-200 group-hover:text-white truncate transition-colors">
|
||||
{video.title}
|
||||
</h3>
|
||||
{video.tags && video.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{video.tags.slice(0, 3).map(t => (
|
||||
<span
|
||||
key={t.id}
|
||||
className="text-[10px] px-1.5 py-0.5 bg-[#0095f6]/10 text-[#0095f6]/70 rounded"
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
))}
|
||||
{video.tags.length > 3 && (
|
||||
<span className="text-[10px] text-gray-600">+{video.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
+4
-1
@@ -1,13 +1,16 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { AuthProvider } from './AuthContext'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react'
|
||||
import { appAuthLogin } from '../api'
|
||||
import { useAuth } from '../AuthContext'
|
||||
|
||||
export default function AppLogin() {
|
||||
const { login } = useAuth()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
const data = await appAuthLogin(username, password)
|
||||
setLoading(false)
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
} else if (data.user) {
|
||||
login(data.user)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">
|
||||
<span className="text-[#0095f6]">OF</span>App
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-2 text-sm">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-[#111] border border-[#222] rounded-xl p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2.5 text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
placeholder="Enter username"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2.5 text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
placeholder="Enter password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password}
|
||||
className="w-full bg-[#0095f6] hover:bg-[#0080d6] disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react'
|
||||
import { appAuthSetup } from '../api'
|
||||
import { useAuth } from '../AuthContext'
|
||||
|
||||
export default function AppSetup() {
|
||||
const { login } = useAuth()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
if (password.length < 4) {
|
||||
setError('Password must be at least 4 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const data = await appAuthSetup(username, password)
|
||||
setLoading(false)
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
} else if (data.user) {
|
||||
login(data.user)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">
|
||||
<span className="text-[#0095f6]">OF</span>App
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-2 text-sm">Welcome! Create your admin account to get started.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-[#111] border border-[#222] rounded-xl p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Admin Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2.5 text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
placeholder="Choose a username"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2.5 text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
placeholder="Choose a password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1.5">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2.5 text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
placeholder="Confirm password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password || !confirmPassword}
|
||||
className="w-full bg-[#0095f6] hover:bg-[#0080d6] disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create Admin Account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { getDashboard, getHealth } from '../api'
|
||||
import Spinner from '../components/Spinner'
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds) return '--'
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (d > 0) return `${d}d ${h}h`
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState(null)
|
||||
const [health, setHealth] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const loadData = async () => {
|
||||
const [dashData, healthData] = await Promise.all([
|
||||
getDashboard(),
|
||||
getHealth(),
|
||||
])
|
||||
if (!dashData.error) setData(dashData)
|
||||
if (!healthData.error) setHealth(healthData)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
pollRef.current = setInterval(loadData, 30000)
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [])
|
||||
|
||||
if (loading) return <Spinner />
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-red-400">Failed to load dashboard</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const maxFolderSize = data.topFolders?.[0]?.total_size || 1
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-white mb-1">Dashboard</h1>
|
||||
<p className="text-gray-500 text-sm">System overview and storage insights</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
label="Total Files"
|
||||
value={data.stats.totalFiles?.toLocaleString() || '0'}
|
||||
icon={<FilesIcon />}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Storage"
|
||||
value={formatBytes(data.stats.totalStorage)}
|
||||
icon={<StorageIcon />}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
label="Creators"
|
||||
value={data.stats.totalFolders?.toLocaleString() || '0'}
|
||||
icon={<FoldersIcon />}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
label="Downloads Today"
|
||||
value={data.stats.downloadsToday?.toLocaleString() || '0'}
|
||||
icon={<TodayIcon />}
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active Jobs */}
|
||||
{(data.activeJobs.downloads > 0 || data.activeJobs.scrapes > 0) && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Active Jobs</h2>
|
||||
<div className="space-y-2">
|
||||
{data.activeJobs.downloadList?.map((dl) => {
|
||||
const pct = dl.total > 0 ? Math.round((dl.completed / dl.total) * 100) : 0
|
||||
return (
|
||||
<div key={dl.userId} className="bg-[#161616] border border-[#222] rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
<span className="text-sm text-white">Download — {dl.username ? `@${dl.username}` : dl.userId}</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#0095f6] font-medium">{pct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#1a1a1a] rounded-full h-1.5 mb-1">
|
||||
<div className="bg-[#0095f6] h-1.5 rounded-full transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{dl.completed} / {dl.total} files
|
||||
{dl.errors > 0 && <span className="text-red-400 ml-1">({dl.errors} errors)</span>}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{data.activeJobs.scrapeList?.map((job, i) => {
|
||||
const pct = job.progress.total > 0 ? Math.round((job.progress.completed / job.progress.total) * 100) : 0
|
||||
return (
|
||||
<div key={i} className="bg-[#161616] border border-[#222] rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-400 animate-pulse" />
|
||||
<span className="text-sm text-white">
|
||||
<span className="text-orange-400 text-xs font-medium mr-1.5">{job.type}</span>
|
||||
{job.folderName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#0095f6] font-medium">{pct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#1a1a1a] rounded-full h-1.5 mb-1">
|
||||
<div className="bg-orange-400 h-1.5 rounded-full transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{job.progress.completed} / {job.progress.total} {job.type === 'forum' ? 'pages' : 'files'}
|
||||
{job.progress.errors > 0 && <span className="text-red-400 ml-1">({job.progress.errors} errors)</span>}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-6 mb-6">
|
||||
{/* Storage Breakdown */}
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4">Top Creators by Storage</h2>
|
||||
{data.topFolders?.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{data.topFolders.slice(0, 7).map((f) => (
|
||||
<div key={f.folder}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-gray-300 truncate mr-2">{f.folder}</span>
|
||||
<span className="text-xs text-gray-500 flex-shrink-0">
|
||||
{formatBytes(f.total_size)} · {f.file_count} files
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#1a1a1a] rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-[#0095f6] h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${Math.max((f.total_size / maxFolderSize) * 100, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-600 text-sm">No media indexed yet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* System Health */}
|
||||
{health && (
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4">System Health</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<HealthItem label="Uptime" value={formatUptime(health.uptime)} ok />
|
||||
<HealthItem label="SQLite" value={health.sqlite ? 'OK' : 'Error'} ok={health.sqlite} />
|
||||
<HealthItem label="Auth" value={health.authConfigured ? 'Configured' : 'Missing'} ok={health.authConfigured} />
|
||||
<HealthItem label="Media Dir" value={health.mediaPathWritable ? 'Writable' : 'Error'} ok={health.mediaPathWritable} />
|
||||
<HealthItem label="FFmpeg" value={health.ffmpegAvailable ? 'Available' : 'Missing'} ok={health.ffmpegAvailable} />
|
||||
<HealthItem label="Python" value={health.pythonAvailable ? 'Available' : 'Missing'} ok={health.pythonAvailable} />
|
||||
<HealthItem label="WVD File" value={health.wvdPresent ? 'Present' : 'Missing'} ok={health.wvdPresent} />
|
||||
{health.diskSpace && (
|
||||
<HealthItem
|
||||
label="Disk Space"
|
||||
value={`${formatBytes(health.diskSpace.free)} free`}
|
||||
ok={health.diskSpace.free > 1024 * 1024 * 1024}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scheduler */}
|
||||
{(data.scheduler.autoDownloadCount > 0 || data.scheduler.autoScrapeCount > 0) && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Scheduler</h2>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{data.scheduler.autoDownloadCount > 0 && (
|
||||
<div className="px-4 py-3 bg-[#161616] border border-[#222] rounded-lg">
|
||||
<span className="text-sm text-gray-400">{data.scheduler.autoDownloadCount} auto-download user{data.scheduler.autoDownloadCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.scheduler.autoScrapeCount > 0 && (
|
||||
<div className="px-4 py-3 bg-[#161616] border border-[#222] rounded-lg">
|
||||
<span className="text-sm text-gray-400">{data.scheduler.autoScrapeCount} auto-scrape job{data.scheduler.autoScrapeCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Downloads */}
|
||||
{data.recentDownloads?.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Recent Downloads</h2>
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
|
||||
{data.recentDownloads.map((dl, i) => (
|
||||
<div
|
||||
key={`${dl.filename}-${i}`}
|
||||
className={`flex items-center justify-between px-4 py-2.5 ${
|
||||
i < data.recentDownloads.length - 1 ? 'border-b border-[#1a1a1a]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0 ${
|
||||
dl.media_type === 'video' ? 'bg-purple-500/10 text-purple-400' : 'bg-blue-500/10 text-blue-400'
|
||||
}`}>
|
||||
{dl.media_type === 'video' ? 'VID' : 'IMG'}
|
||||
</span>
|
||||
<span className="text-sm text-white flex-shrink-0">@{dl.user_id}</span>
|
||||
<span className="text-xs text-gray-600 truncate">{dl.filename}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 flex-shrink-0 ml-3">
|
||||
{dl.downloaded_at ? new Date(dl.downloaded_at + 'Z').toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon, color }) {
|
||||
const colors = {
|
||||
blue: 'bg-blue-500/10 text-blue-400',
|
||||
purple: 'bg-purple-500/10 text-purple-400',
|
||||
green: 'bg-green-500/10 text-green-400',
|
||||
amber: 'bg-amber-500/10 text-amber-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">{label}</span>
|
||||
<div className={`p-1.5 rounded-lg ${colors[color]}`}>{icon}</div>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-white">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthItem({ label, value, ok }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${ok ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||
<span className="text-sm text-gray-400">{label}:</span>
|
||||
<span className={`text-sm font-medium ${ok ? 'text-gray-300' : 'text-red-400'}`}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilesIcon() {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StorageIcon() {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function FoldersIcon() {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<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 TodayIcon() {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { getDownloadHistory, getActiveDownloads, getUser, getScrapeJobs } from '../api'
|
||||
import { getDownloadHistory, getActiveDownloadDetails, getUser, getScrapeJobs } from '../api'
|
||||
import Spinner from '../components/Spinner'
|
||||
|
||||
export default function Downloads() {
|
||||
@@ -10,6 +10,8 @@ export default function Downloads() {
|
||||
const [error, setError] = useState(null)
|
||||
const pollRef = useRef(null)
|
||||
const [usernames, setUsernames] = useState({})
|
||||
const prevCompleted = useRef({})
|
||||
const [speeds, setSpeeds] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
@@ -26,7 +28,7 @@ export default function Downloads() {
|
||||
|
||||
const [histData, activeData, scrapeData] = await Promise.all([
|
||||
getDownloadHistory(),
|
||||
getActiveDownloads(),
|
||||
getActiveDownloadDetails(),
|
||||
getScrapeJobs(),
|
||||
])
|
||||
|
||||
@@ -47,11 +49,24 @@ export default function Downloads() {
|
||||
const startPolling = () => {
|
||||
pollRef.current = setInterval(async () => {
|
||||
const [activeData, scrapeData] = await Promise.all([
|
||||
getActiveDownloads(),
|
||||
getActiveDownloadDetails(),
|
||||
getScrapeJobs(),
|
||||
])
|
||||
if (!activeData.error) {
|
||||
const list = Array.isArray(activeData) ? activeData : []
|
||||
// Calculate download speed (files/sec) based on completed delta
|
||||
const newSpeeds = {}
|
||||
for (const dl of list) {
|
||||
const uid = dl.user_id
|
||||
const prev = prevCompleted.current[uid] || 0
|
||||
const delta = (dl.completed || 0) - prev
|
||||
prevCompleted.current[uid] = dl.completed || 0
|
||||
if (delta > 0) {
|
||||
newSpeeds[uid] = (delta / 2).toFixed(1) // 2s poll interval
|
||||
}
|
||||
}
|
||||
setSpeeds((prev) => ({ ...prev, ...newSpeeds }))
|
||||
|
||||
setActive((prev) => {
|
||||
if (prev.length > 0 && list.length < prev.length) {
|
||||
getDownloadHistory().then((h) => {
|
||||
@@ -141,6 +156,11 @@ export default function Downloads() {
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{dl.completed || 0} / {dl.total || '?'} files
|
||||
{speeds[uid] && (
|
||||
<span className="text-gray-400 ml-2">
|
||||
({speeds[uid]} files/s)
|
||||
</span>
|
||||
)}
|
||||
{dl.errors > 0 && (
|
||||
<span className="text-red-400 ml-2">
|
||||
({dl.errors} error{dl.errors !== 1 ? 's' : ''})
|
||||
@@ -154,12 +174,26 @@ export default function Downloads() {
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full bg-[#1a1a1a] rounded-full h-1.5">
|
||||
<div className="w-full bg-[#1a1a1a] rounded-full h-1.5 mb-2">
|
||||
<div
|
||||
className="bg-[#0095f6] h-1.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent File Log */}
|
||||
{dl.recentFiles && dl.recentFiles.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{dl.recentFiles.map((f, fi) => (
|
||||
<div key={fi} className="flex items-center gap-2 text-xs">
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
||||
f.status === 'ok' ? 'bg-green-400' : 'bg-red-400'
|
||||
}`} />
|
||||
<span className="text-gray-500 truncate">{f.filename?.slice(0, 50)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getFeed } from '../api'
|
||||
import { getFeed, downloadPost } from '../api'
|
||||
import PostCard from '../components/PostCard'
|
||||
import Spinner from '../components/Spinner'
|
||||
import LoadMoreButton from '../components/LoadMoreButton'
|
||||
@@ -11,6 +11,7 @@ export default function Feed() {
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [downloadingPosts, setDownloadingPosts] = useState(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
loadFeed()
|
||||
@@ -63,6 +64,22 @@ export default function Feed() {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const handleDownloadPost = async (post) => {
|
||||
const author = post.author || post.fromUser || {}
|
||||
const media = post.media || []
|
||||
if (!author.id || media.length === 0) return
|
||||
|
||||
setDownloadingPosts((prev) => new Set([...prev, post.id]))
|
||||
const postedAt = post.postedAt || post.createdAt || post.publishedAt || null
|
||||
const result = await downloadPost(author.id, author.username, post.id, media, postedAt)
|
||||
if (result.error) console.error('Download failed:', result.error)
|
||||
setDownloadingPosts((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(post.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) return <Spinner />
|
||||
|
||||
if (error) {
|
||||
@@ -100,7 +117,7 @@ export default function Feed() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||
{posts.map((post) => (
|
||||
<div key={post.id}>
|
||||
<PostCard post={post} />
|
||||
<PostCard post={post} onDownloadPost={handleDownloadPost} downloadingPosts={downloadingPosts} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+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}>
|
||||
|
||||
+36
-20
@@ -106,8 +106,8 @@ export default function Login({ onAuth }) {
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const startDupScan = async () => {
|
||||
const result = await scanDuplicates()
|
||||
const startDupScan = async (mode = 'everywhere') => {
|
||||
const result = await scanDuplicates(mode)
|
||||
if (result.error) return
|
||||
if (result.status === 'already_running') {
|
||||
navigate('/duplicates')
|
||||
@@ -397,9 +397,9 @@ export default function Login({ onAuth }) {
|
||||
<div className="border-t border-[#222] mt-5 pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-300">Video Thumbnails</p>
|
||||
<p className="text-sm font-medium text-gray-300">Thumbnails</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Generate preview thumbnails for all downloaded videos. New videos get thumbnails automatically when browsed.
|
||||
Generate preview thumbnails for all downloaded media. New files get thumbnails automatically when browsed.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -414,25 +414,31 @@ export default function Login({ onAuth }) {
|
||||
{thumbGen?.running && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-400 mb-1.5">
|
||||
<span>{thumbGen.done} of {thumbGen.total} videos</span>
|
||||
<span>{Math.round((thumbGen.done / Math.max(thumbGen.total, 1)) * 100)}%</span>
|
||||
<span>{thumbGen.done + (thumbGen.skipped || 0) + thumbGen.errors} of {thumbGen.total} files</span>
|
||||
<span>{Math.round(((thumbGen.done + (thumbGen.skipped || 0) + thumbGen.errors) / Math.max(thumbGen.total, 1)) * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-[#222] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#0095f6] rounded-full transition-all duration-300"
|
||||
style={{ width: `${(thumbGen.done / Math.max(thumbGen.total, 1)) * 100}%` }}
|
||||
style={{ width: `${((thumbGen.done + (thumbGen.skipped || 0) + thumbGen.errors) / Math.max(thumbGen.total, 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{thumbGen.errors > 0 && (
|
||||
<p className="text-xs text-yellow-500 mt-1">{thumbGen.errors} failed</p>
|
||||
)}
|
||||
<div className="flex gap-3 mt-1 text-xs">
|
||||
{thumbGen.done > 0 && <span className="text-green-400">{thumbGen.done} generated</span>}
|
||||
{(thumbGen.skipped || 0) > 0 && <span className="text-gray-500">{thumbGen.skipped} skipped</span>}
|
||||
{thumbGen.errors > 0 && <span className="text-yellow-500">{thumbGen.errors} failed</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{thumbGen && !thumbGen.running && thumbGen.message && (
|
||||
<p className="text-xs text-green-400 mt-2">{thumbGen.message}</p>
|
||||
)}
|
||||
{thumbGen && !thumbGen.running && !thumbGen.message && thumbGen.done > 0 && (
|
||||
<p className="text-xs text-green-400 mt-2">Done! Generated {thumbGen.done} thumbnails{thumbGen.errors > 0 ? `, ${thumbGen.errors} failed` : ''}</p>
|
||||
{thumbGen && !thumbGen.running && !thumbGen.message && (thumbGen.done > 0 || (thumbGen.skipped || 0) > 0) && (
|
||||
<p className="text-xs text-green-400 mt-2">
|
||||
Done! Generated {thumbGen.done} thumbnails
|
||||
{(thumbGen.skipped || 0) > 0 ? `, ${thumbGen.skipped} audio-only skipped` : ''}
|
||||
{thumbGen.errors > 0 ? `, ${thumbGen.errors} failed` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-[#222] mt-5 pt-5">
|
||||
@@ -443,14 +449,24 @@ export default function Login({ onAuth }) {
|
||||
Scan downloaded media for duplicate files and review them side by side.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dupScan?.running}
|
||||
onClick={startDupScan}
|
||||
className="px-4 py-2 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors flex-shrink-0"
|
||||
>
|
||||
{dupScan?.running ? 'Scanning...' : 'Find Duplicates'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
disabled={dupScan?.running}
|
||||
onClick={() => startDupScan('everywhere')}
|
||||
className="px-3 py-2 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{dupScan?.running ? 'Scanning...' : 'Everywhere'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dupScan?.running}
|
||||
onClick={() => startDupScan('same-folder')}
|
||||
className="px-3 py-2 bg-[#222] hover:bg-[#333] disabled:opacity-50 text-gray-300 text-sm font-medium rounded-lg border border-[#333] transition-colors"
|
||||
>
|
||||
Same Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{dupScan?.running && (
|
||||
<div className="mt-3">
|
||||
|
||||
+882
-52
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,425 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getAppUsers, createAppUser, updateAppUser, deleteAppUser, getAvailableFolders } from '../api'
|
||||
|
||||
const ALL_ROUTES = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'feed', label: 'Feed' },
|
||||
{ key: 'users', label: 'Users / Search' },
|
||||
{ key: 'downloads', label: 'Downloads' },
|
||||
{ key: 'gallery', label: 'Gallery' },
|
||||
{ key: 'videos', label: 'Videos' },
|
||||
{ key: 'scrape', label: 'Scrape' },
|
||||
{ key: 'settings', label: 'Settings' },
|
||||
{ key: 'duplicates', label: 'Duplicates' },
|
||||
]
|
||||
|
||||
export default function UserManagement() {
|
||||
const [users, setUsers] = useState([])
|
||||
const [folders, setFolders] = useState([])
|
||||
const [editing, setEditing] = useState(null) // null | 'new' | user object
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Form state
|
||||
const [form, setForm] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
display_name: '',
|
||||
role: 'user',
|
||||
routes: [],
|
||||
folders: [],
|
||||
})
|
||||
const [folderSearch, setFolderSearch] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadData = async () => {
|
||||
const [usersData, foldersData] = await Promise.all([
|
||||
getAppUsers(),
|
||||
getAvailableFolders(),
|
||||
])
|
||||
if (!usersData.error) setUsers(usersData)
|
||||
if (!foldersData.error) setFolders(foldersData)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
|
||||
const openNew = () => {
|
||||
setEditing('new')
|
||||
setForm({ username: '', password: '', display_name: '', role: 'user', routes: [], folders: [] })
|
||||
setError('')
|
||||
setFolderSearch('')
|
||||
}
|
||||
|
||||
const openEdit = (user) => {
|
||||
setEditing(user)
|
||||
setForm({
|
||||
username: user.username,
|
||||
password: '',
|
||||
display_name: user.display_name || '',
|
||||
role: user.role,
|
||||
routes: [...user.routes],
|
||||
folders: [...user.folders],
|
||||
})
|
||||
setError('')
|
||||
setFolderSearch('')
|
||||
}
|
||||
|
||||
const closeForm = () => {
|
||||
setEditing(null)
|
||||
setError('')
|
||||
}
|
||||
|
||||
const toggleRoute = (key) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
routes: f.routes.includes(key)
|
||||
? f.routes.filter((r) => r !== key)
|
||||
: [...f.routes, key],
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleFolder = (folder) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
folders: f.folders.includes(folder)
|
||||
? f.folders.filter((fl) => fl !== folder)
|
||||
: [...f.folders, folder],
|
||||
}))
|
||||
}
|
||||
|
||||
const selectAllRoutes = () => {
|
||||
setForm((f) => ({ ...f, routes: ALL_ROUTES.map((r) => r.key) }))
|
||||
}
|
||||
|
||||
const selectNoRoutes = () => {
|
||||
setForm((f) => ({ ...f, routes: [] }))
|
||||
}
|
||||
|
||||
const selectAllFolders = () => {
|
||||
setForm((f) => ({ ...f, folders: [...folders] }))
|
||||
}
|
||||
|
||||
const selectNoFolders = () => {
|
||||
setForm((f) => ({ ...f, folders: [] }))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setError('')
|
||||
setSaving(true)
|
||||
|
||||
const payload = {
|
||||
username: form.username,
|
||||
display_name: form.display_name,
|
||||
role: form.role,
|
||||
routes: form.routes,
|
||||
folders: form.folders,
|
||||
}
|
||||
|
||||
if (form.password) payload.password = form.password
|
||||
|
||||
let result
|
||||
if (editing === 'new') {
|
||||
if (!form.password) {
|
||||
setError('Password is required for new users')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
payload.password = form.password
|
||||
result = await createAppUser(payload)
|
||||
} else {
|
||||
result = await updateAppUser(editing.id, payload)
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
closeForm()
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (user) => {
|
||||
if (!confirm(`Delete user "${user.username}"? This cannot be undone.`)) return
|
||||
const result = await deleteAppUser(user.id)
|
||||
if (result.error) {
|
||||
alert(result.error)
|
||||
} else {
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (user) => {
|
||||
const result = await updateAppUser(user.id, { enabled: user.enabled ? 0 : 1 })
|
||||
if (result.error) {
|
||||
alert(result.error)
|
||||
} else {
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const filteredFolders = folderSearch
|
||||
? folders.filter((f) => f.toLowerCase().includes(folderSearch.toLowerCase()))
|
||||
: folders
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-[#0095f6] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">User Management</h1>
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="bg-[#0095f6] hover:bg-[#0080d6] text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User Table */}
|
||||
<div className="bg-[#111] border border-[#222] rounded-xl overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[#222]">
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-4 py-3">User</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-4 py-3 hidden sm:table-cell">Role</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-4 py-3 hidden md:table-cell">Routes</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-4 py-3 hidden md:table-cell">Folders</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-[#222] last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{user.username}</p>
|
||||
{user.display_name && user.display_name !== user.username && (
|
||||
<p className="text-xs text-gray-500">{user.display_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-amber-500/10 text-amber-400 border border-amber-500/30'
|
||||
: 'bg-gray-500/10 text-gray-400 border border-gray-500/30'
|
||||
}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
{!user.enabled && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-500/10 text-red-400 border border-red-500/30">
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<span className="text-xs text-gray-500">
|
||||
{user.role === 'admin' ? 'All' : user.routes.length === 0 ? 'None' : user.routes.length}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<span className="text-xs text-gray-500">
|
||||
{user.role === 'admin' ? 'All' : user.folders.length === 0 ? 'None' : user.folders.length}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(user)}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||
user.enabled
|
||||
? 'text-green-400 hover:bg-green-500/10'
|
||||
: 'text-red-400 hover:bg-red-500/10'
|
||||
}`}
|
||||
title={user.enabled ? 'Disable user' : 'Enable user'}
|
||||
>
|
||||
{user.enabled ? 'On' : 'Off'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEdit(user)}
|
||||
className="text-xs text-[#0095f6] hover:text-[#0080d6] px-2 py-1 rounded hover:bg-[#0095f6]/10 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user)}
|
||||
className="text-xs text-red-400 hover:text-red-300 px-2 py-1 rounded hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500 text-sm">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit User Modal */}
|
||||
{editing !== null && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-start justify-center pt-[10vh] px-4 overflow-y-auto">
|
||||
<div className="bg-[#111] border border-[#222] rounded-xl w-full max-w-lg p-6 mb-10">
|
||||
<h2 className="text-lg font-bold text-white mb-4">
|
||||
{editing === 'new' ? 'Add User' : `Edit: ${editing.username}`}
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg px-3 py-2 mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
|
||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
Password {editing !== 'new' && <span className="text-gray-600">(leave blank to keep)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
placeholder={editing === 'new' ? 'Required' : 'Leave blank to keep current'}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Display Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, display_name: e.target.value }))}
|
||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Role</label>
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(e) => setForm((f) => ({ ...f, role: e.target.value }))}
|
||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Route Access (only for non-admin) */}
|
||||
{form.role !== 'admin' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-gray-400">Page Access</label>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={selectAllRoutes} className="text-xs text-[#0095f6] hover:underline">All</button>
|
||||
<button onClick={selectNoRoutes} className="text-xs text-gray-500 hover:underline">None</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{ALL_ROUTES.map((route) => (
|
||||
<label key={route.key} className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-[#1a1a1a] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.routes.includes(route.key)}
|
||||
onChange={() => toggleRoute(route.key)}
|
||||
className="rounded border-[#333] bg-[#1a1a1a] text-[#0095f6] focus:ring-[#0095f6] focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">{route.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folder Access (only for non-admin) */}
|
||||
{form.role !== 'admin' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-gray-400">
|
||||
Gallery Folders ({form.folders.length}/{folders.length})
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={selectAllFolders} className="text-xs text-[#0095f6] hover:underline">All</button>
|
||||
<button onClick={selectNoFolders} className="text-xs text-gray-500 hover:underline">None</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={folderSearch}
|
||||
onChange={(e) => setFolderSearch(e.target.value)}
|
||||
placeholder="Search folders..."
|
||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-1.5 text-white text-sm mb-2 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto border border-[#222] rounded-lg">
|
||||
{filteredFolders.map((folder) => (
|
||||
<label key={folder} className="flex items-center gap-2 px-3 py-1.5 hover:bg-[#1a1a1a] cursor-pointer border-b border-[#222] last:border-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.folders.includes(folder)}
|
||||
onChange={() => toggleFolder(folder)}
|
||||
className="rounded border-[#333] bg-[#1a1a1a] text-[#0095f6] focus:ring-[#0095f6] focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-300 truncate">{folder}</span>
|
||||
</label>
|
||||
))}
|
||||
{filteredFolders.length === 0 && (
|
||||
<p className="text-xs text-gray-600 px-3 py-2">No folders found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-[#222]">
|
||||
<button
|
||||
onClick={closeForm}
|
||||
className="text-sm text-gray-400 hover:text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.username}
|
||||
className="bg-[#0095f6] hover:bg-[#0080d6] disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
{saving ? 'Saving...' : editing === 'new' ? 'Create User' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getSubscriptions, getUser, startDownload } from '../api'
|
||||
import { getSubscriptions, getUser, startDownload, getAutoDownloadUsers, addAutoDownloadUser, removeAutoDownloadUser } from '../api'
|
||||
import UserCard from '../components/UserCard'
|
||||
import Spinner from '../components/Spinner'
|
||||
import LoadMoreButton from '../components/LoadMoreButton'
|
||||
@@ -14,9 +14,15 @@ export default function Users() {
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [downloadingUsers, setDownloadingUsers] = useState(new Set())
|
||||
const [autoDownloadSet, setAutoDownloadSet] = useState(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
getAutoDownloadUsers().then((data) => {
|
||||
if (!data.error && Array.isArray(data)) {
|
||||
setAutoDownloadSet(new Set(data.map((u) => String(u.user_id))))
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const enrichUsers = (items) => {
|
||||
@@ -70,6 +76,21 @@ export default function Users() {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const handleToggleAutoDownload = async (userId, username) => {
|
||||
const id = String(userId)
|
||||
if (autoDownloadSet.has(id)) {
|
||||
await removeAutoDownloadUser(userId)
|
||||
setAutoDownloadSet((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
await addAutoDownloadUser(userId, username)
|
||||
setAutoDownloadSet((prev) => new Set([...prev, id]))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (userId, username) => {
|
||||
setDownloadingUsers((prev) => new Set([...prev, userId]))
|
||||
|
||||
@@ -129,6 +150,8 @@ export default function Users() {
|
||||
user={user}
|
||||
onDownload={handleDownload}
|
||||
downloading={downloadingUsers.has(user.id)}
|
||||
autoDownload={autoDownloadSet.has(String(user.id))}
|
||||
onToggleAutoDownload={handleToggleAutoDownload}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { getVideo, updateVideoMeta, deleteVideo } from '../api'
|
||||
import HlsVideo from '../components/HlsVideo'
|
||||
import TagInput from '../components/TagInput'
|
||||
import Spinner from '../components/Spinner'
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return '—'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
if (h > 0) return `${h}h ${m}m ${s}s`
|
||||
if (m > 0) return `${m}m ${s}s`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '—'
|
||||
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`
|
||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`
|
||||
return `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '—'
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export default function VideoDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [video, setVideo] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleDraft, setTitleDraft] = useState('')
|
||||
const [editingDesc, setEditingDesc] = useState(false)
|
||||
const [descDraft, setDescDraft] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const videoRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
getVideo(id).then(data => {
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
} else {
|
||||
setVideo(data)
|
||||
setTitleDraft(data.title)
|
||||
setDescDraft(data.description || '')
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
}, [id])
|
||||
|
||||
const saveTitle = async () => {
|
||||
if (!titleDraft.trim() || titleDraft === video.title) {
|
||||
setEditingTitle(false)
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
const res = await updateVideoMeta(id, { title: titleDraft.trim() })
|
||||
if (!res.error) {
|
||||
setVideo(res)
|
||||
setTitleDraft(res.title)
|
||||
}
|
||||
setSaving(false)
|
||||
setEditingTitle(false)
|
||||
}
|
||||
|
||||
const saveDesc = async () => {
|
||||
if (descDraft === (video.description || '')) {
|
||||
setEditingDesc(false)
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
const res = await updateVideoMeta(id, { description: descDraft })
|
||||
if (!res.error) {
|
||||
setVideo(res)
|
||||
setDescDraft(res.description || '')
|
||||
}
|
||||
setSaving(false)
|
||||
setEditingDesc(false)
|
||||
}
|
||||
|
||||
const handleTagsChange = async (newTags) => {
|
||||
setSaving(true)
|
||||
const res = await updateVideoMeta(id, { tags: newTags })
|
||||
if (!res.error) {
|
||||
setVideo(res)
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await deleteVideo(id)
|
||||
if (!res.error) {
|
||||
navigate('/videos', { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Spinner />
|
||||
if (error) return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-400 mb-4">{error}</p>
|
||||
<button onClick={() => navigate('/videos')} className="text-sm text-[#0095f6] hover:underline">
|
||||
Back to Videos
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
if (!video) return null
|
||||
|
||||
const hlsSrc = `/api/video-hls/${video.id}/master.m3u8`
|
||||
const tags = (video.tags || []).map(t => t.name)
|
||||
|
||||
const skipForward = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = Math.min(videoRef.current.currentTime + 10, videoRef.current.duration || Infinity)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => navigate('/videos')}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-white mb-4 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
Videos
|
||||
</button>
|
||||
|
||||
{/* Player */}
|
||||
<div className="relative group rounded-lg overflow-hidden bg-black mb-6">
|
||||
<HlsVideo
|
||||
ref={videoRef}
|
||||
hlsSrc={hlsSrc}
|
||||
src={null}
|
||||
controls
|
||||
autoPlay
|
||||
className="w-full max-h-[70vh]"
|
||||
/>
|
||||
<button
|
||||
onClick={skipForward}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-2.5 bg-black/50 hover:bg-black/70 rounded-full text-white/70 hover:text-white opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Skip 10 seconds"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8.688c0-.864.933-1.405 1.683-.977l7.108 4.062a1.125 1.125 0 010 1.953l-7.108 4.062A1.125 1.125 0 013 16.81V8.688zM12.75 8.688c0-.864.933-1.405 1.683-.977l7.108 4.062a1.125 1.125 0 010 1.953l-7.108 4.062a1.125 1.125 0 01-1.683-.977V8.688z" />
|
||||
</svg>
|
||||
<span className="absolute -bottom-5 left-1/2 -translate-x-1/2 text-[10px] text-white/50 whitespace-nowrap">+10s</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: Title, description, tags */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* Title */}
|
||||
{editingTitle ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={titleDraft}
|
||||
onChange={e => setTitleDraft(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') saveTitle(); if (e.key === 'Escape') setEditingTitle(false) }}
|
||||
autoFocus
|
||||
className="flex-1 px-3 py-2 text-lg font-bold bg-[#111] border border-[#333] rounded-lg text-white focus:outline-none focus:border-[#0095f6]"
|
||||
/>
|
||||
<button onClick={saveTitle} disabled={saving} className="px-3 py-2 text-sm bg-[#0095f6] text-white rounded-lg hover:bg-[#0095f6]/80 transition-colors disabled:opacity-50">
|
||||
Save
|
||||
</button>
|
||||
<button onClick={() => { setEditingTitle(false); setTitleDraft(video.title) }} className="px-3 py-2 text-sm text-gray-400 hover:text-white transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<h1
|
||||
onClick={() => setEditingTitle(true)}
|
||||
className="text-xl md:text-2xl font-bold text-white cursor-pointer hover:text-gray-300 transition-colors"
|
||||
title="Click to edit"
|
||||
>
|
||||
{video.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{editingDesc ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={descDraft}
|
||||
onChange={e => setDescDraft(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') setEditingDesc(false) }}
|
||||
autoFocus
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm bg-[#111] border border-[#333] rounded-lg text-gray-300 focus:outline-none focus:border-[#0095f6] resize-none"
|
||||
placeholder="Add a description..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={saveDesc} disabled={saving} className="px-3 py-1.5 text-sm bg-[#0095f6] text-white rounded-lg hover:bg-[#0095f6]/80 transition-colors disabled:opacity-50">
|
||||
Save
|
||||
</button>
|
||||
<button onClick={() => { setEditingDesc(false); setDescDraft(video.description || '') }} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
onClick={() => setEditingDesc(true)}
|
||||
className={`text-sm cursor-pointer transition-colors ${
|
||||
video.description ? 'text-gray-400 hover:text-gray-200' : 'text-gray-600 hover:text-gray-400'
|
||||
}`}
|
||||
title="Click to edit"
|
||||
>
|
||||
{video.description || 'Add a description...'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1.5">Tags</label>
|
||||
<TagInput tags={tags} onChange={handleTagsChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Metadata */}
|
||||
<div className="space-y-3">
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-4 space-y-3">
|
||||
<MetaRow label="Resolution" value={video.width && video.height ? `${video.width}x${video.height}` : '—'} />
|
||||
<MetaRow label="Duration" value={formatDuration(video.duration)} />
|
||||
<MetaRow label="File Size" value={formatBytes(video.file_size)} />
|
||||
<MetaRow label="Codec" value={video.codec || '—'} />
|
||||
<MetaRow label="FPS" value={video.fps ? `${video.fps}` : '—'} />
|
||||
<MetaRow label="Bitrate" value={video.bitrate ? `${Math.round(video.bitrate / 1000)} kbps` : '—'} />
|
||||
<MetaRow label="Audio" value={video.has_audio ? 'Yes' : 'No'} />
|
||||
<MetaRow label="Added" value={formatDate(video.created_at)} />
|
||||
<MetaRow label="File" value={video.filename} mono />
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="pt-2">
|
||||
{confirmDelete ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex-1 px-3 py-2 text-sm bg-red-500/20 hover:bg-red-500/30 text-red-400 hover:text-red-300 border border-red-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
className="px-3 py-2 text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="w-full px-3 py-2 text-sm text-gray-500 hover:text-red-400 border border-[#333] hover:border-red-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
Delete Video
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaRow({ label, value, mono }) {
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-gray-500">{label}</span>
|
||||
<span className={`text-sm text-gray-300 text-right truncate max-w-[60%] ${mono ? 'font-mono text-xs' : ''}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { uploadVideo, scanVideos, getVideoScanStatus } from '../api'
|
||||
|
||||
export default function VideoUpload() {
|
||||
const navigate = useNavigate()
|
||||
const fileInputRef = useRef(null)
|
||||
const [uploads, setUploads] = useState([]) // { id, file, progress, status, error, video }
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [scanStatus, setScanStatus] = useState(null)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const processFiles = useCallback((files) => {
|
||||
const videoFiles = Array.from(files).filter(f => {
|
||||
const ext = f.name.split('.').pop().toLowerCase()
|
||||
return ['mp4', 'mov', 'avi', 'webm', 'mkv', 'm4v', 'wmv', 'flv', 'ts'].includes(ext)
|
||||
})
|
||||
|
||||
if (videoFiles.length === 0) return
|
||||
|
||||
const newUploads = videoFiles.map((file, i) => ({
|
||||
id: `${Date.now()}-${i}`,
|
||||
file,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
error: null,
|
||||
video: null,
|
||||
}))
|
||||
|
||||
setUploads(prev => [...prev, ...newUploads])
|
||||
|
||||
// Start uploads sequentially
|
||||
;(async () => {
|
||||
for (const item of newUploads) {
|
||||
setUploads(prev => prev.map(u =>
|
||||
u.id === item.id ? { ...u, status: 'uploading' } : u
|
||||
))
|
||||
|
||||
try {
|
||||
const result = await uploadVideo(item.file, (progress) => {
|
||||
setUploads(prev => prev.map(u =>
|
||||
u.id === item.id ? { ...u, progress } : u
|
||||
))
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
setUploads(prev => prev.map(u =>
|
||||
u.id === item.id ? { ...u, status: 'error', error: result.error } : u
|
||||
))
|
||||
} else {
|
||||
setUploads(prev => prev.map(u =>
|
||||
u.id === item.id ? { ...u, status: 'done', progress: 1, video: result.video } : u
|
||||
))
|
||||
}
|
||||
} catch (err) {
|
||||
setUploads(prev => prev.map(u =>
|
||||
u.id === item.id ? { ...u, status: 'error', error: err.message } : u
|
||||
))
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
processFiles(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
processFiles(e.target.files)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true)
|
||||
setScanStatus(null)
|
||||
const res = await scanVideos()
|
||||
if (res.error) {
|
||||
setScanStatus({ error: res.error })
|
||||
setScanning(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Poll for status
|
||||
pollRef.current = setInterval(async () => {
|
||||
const status = await getVideoScanStatus()
|
||||
setScanStatus(status)
|
||||
if (!status.running) {
|
||||
clearInterval(pollRef.current)
|
||||
setScanning(false)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const doneCount = uploads.filter(u => u.status === 'done').length
|
||||
const errorCount = uploads.filter(u => u.status === 'error').length
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => navigate('/videos')}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-white mb-4 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
Videos
|
||||
</button>
|
||||
|
||||
<h1 className="text-xl md:text-2xl font-bold text-white mb-6">Add Videos</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Upload Zone */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-gray-400 mb-3">Upload Files</h2>
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all ${
|
||||
dragging
|
||||
? 'border-[#0095f6] bg-[#0095f6]/5'
|
||||
: 'border-[#333] hover:border-[#555] bg-[#111]'
|
||||
}`}
|
||||
>
|
||||
<UploadIcon className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-400 mb-1">
|
||||
Drag & drop video files here
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
or click to browse — MP4, MKV, MOV, AVI, WebM
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Folder Scan */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-gray-400 mb-3">Scan Folder</h2>
|
||||
<div className="bg-[#111] border border-[#333] rounded-xl p-6">
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Scan the server's video directory for new files.
|
||||
Videos already indexed will be skipped.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
className="px-4 py-2 text-sm bg-[#0095f6] hover:bg-[#0095f6]/80 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{scanning ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Scanning...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ScanIcon className="w-4 h-4" />
|
||||
Scan for Videos
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{scanStatus && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{scanStatus.error ? (
|
||||
<p className="text-sm text-red-400">{scanStatus.error}</p>
|
||||
) : (
|
||||
<>
|
||||
{scanStatus.running && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Progress</span>
|
||||
<span>{scanStatus.done} / {scanStatus.total}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[#222] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#0095f6] transition-all duration-300 rounded-full"
|
||||
style={{ width: `${scanStatus.total > 0 ? (scanStatus.done / scanStatus.total * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!scanStatus.running && scanStatus.total > 0 && (
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="text-green-400">{scanStatus.added} new video{scanStatus.added !== 1 ? 's' : ''} added</p>
|
||||
{scanStatus.skipped > 0 && (
|
||||
<p className="text-gray-500">{scanStatus.skipped} already indexed</p>
|
||||
)}
|
||||
{scanStatus.errors > 0 && (
|
||||
<p className="text-red-400">{scanStatus.errors} error{scanStatus.errors !== 1 ? 's' : ''}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload List */}
|
||||
{uploads.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h2 className="text-sm font-medium text-gray-400">
|
||||
Uploads
|
||||
{doneCount > 0 && <span className="text-green-400 ml-2">{doneCount} done</span>}
|
||||
{errorCount > 0 && <span className="text-red-400 ml-2">{errorCount} failed</span>}
|
||||
</h2>
|
||||
{uploads.every(u => u.status === 'done' || u.status === 'error') && (
|
||||
<button
|
||||
onClick={() => setUploads([])}
|
||||
className="text-xs text-gray-500 hover:text-white transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{uploads.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 bg-[#111] border border-[#222] rounded-lg p-3"
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{item.status === 'done' ? (
|
||||
<svg className="w-5 h-5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : item.status === 'error' ? (
|
||||
<svg className="w-5 h-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-[#0095f6] animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-300 truncate">{item.file.name}</p>
|
||||
{item.status === 'uploading' && (
|
||||
<div className="mt-1.5 h-1 bg-[#222] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#0095f6] transition-all duration-300 rounded-full"
|
||||
style={{ width: `${Math.round(item.progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{item.error && (
|
||||
<p className="text-xs text-red-400 mt-0.5">{item.error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress / action */}
|
||||
<div className="flex-shrink-0 text-xs text-gray-500">
|
||||
{item.status === 'uploading' && `${Math.round(item.progress * 100)}%`}
|
||||
{item.status === 'done' && item.video && (
|
||||
<button
|
||||
onClick={() => navigate(`/videos/${item.video.id}`)}
|
||||
className="text-[#0095f6] hover:underline"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UploadIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ScanIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getVideos, getVideoTags } from '../api'
|
||||
import VideoCard from '../components/VideoCard'
|
||||
import LoadMoreButton from '../components/LoadMoreButton'
|
||||
import Spinner from '../components/Spinner'
|
||||
import GridWall, { GridWallPicker } from '../components/GridWall'
|
||||
|
||||
const PAGE_SIZE = 48
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'latest', label: 'Latest' },
|
||||
{ value: 'oldest', label: 'Oldest' },
|
||||
{ value: 'longest', label: 'Longest' },
|
||||
{ value: 'shortest', label: 'Shortest' },
|
||||
{ value: 'largest', label: 'Largest' },
|
||||
{ value: 'title', label: 'Title' },
|
||||
]
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ value: '', label: 'Any Length' },
|
||||
{ value: '0-300', label: 'Under 5 min' },
|
||||
{ value: '300-1200', label: '5–20 min' },
|
||||
{ value: '1200-3600', label: '20–60 min' },
|
||||
{ value: '3600-', label: 'Over 1 hour' },
|
||||
]
|
||||
|
||||
const RESOLUTION_OPTIONS = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: '480', label: '480p+' },
|
||||
{ value: '720', label: '720p+' },
|
||||
{ value: '1080', label: '1080p+' },
|
||||
]
|
||||
|
||||
export default function Videos() {
|
||||
const navigate = useNavigate()
|
||||
const [videos, setVideos] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortOption, setSortOption] = useState('latest')
|
||||
const [duration, setDuration] = useState('')
|
||||
const [resolution, setResolution] = useState('')
|
||||
const [selectedTags, setSelectedTags] = useState([])
|
||||
const [tagFilterOpen, setTagFilterOpen] = useState(false)
|
||||
const [tagSearch, setTagSearch] = useState('')
|
||||
const [allTags, setAllTags] = useState([])
|
||||
const [gridWallLayout, setGridWallLayout] = useState(null)
|
||||
const [gridPickerOpen, setGridPickerOpen] = useState(false)
|
||||
const gridPickerRef = useRef(null)
|
||||
const tagRef = useRef(null)
|
||||
|
||||
// Load tags for filter
|
||||
useEffect(() => {
|
||||
getVideoTags().then(data => {
|
||||
if (Array.isArray(data)) setAllTags(data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Close tag filter on click outside
|
||||
useEffect(() => {
|
||||
if (!tagFilterOpen) return
|
||||
const handleClick = (e) => {
|
||||
if (tagRef.current && !tagRef.current.contains(e.target)) {
|
||||
setTagFilterOpen(false)
|
||||
setTagSearch('')
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [tagFilterOpen])
|
||||
|
||||
const loadVideos = useCallback(async (reset = true) => {
|
||||
if (reset) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
|
||||
const offset = reset ? 0 : videos.length
|
||||
const [minDuration, maxDuration] = duration ? duration.split('-') : ['', '']
|
||||
|
||||
const data = await getVideos({
|
||||
search: search || undefined,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
minDuration: minDuration || undefined,
|
||||
maxDuration: maxDuration || undefined,
|
||||
minWidth: resolution || undefined,
|
||||
sort: sortOption,
|
||||
offset,
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
} else {
|
||||
setVideos(prev => reset ? data.videos : [...prev, ...data.videos])
|
||||
setTotal(data.total)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}, [search, sortOption, duration, resolution, selectedTags, videos.length])
|
||||
|
||||
useEffect(() => {
|
||||
loadVideos(true)
|
||||
}, [search, sortOption, duration, resolution, selectedTags])
|
||||
|
||||
const toggleTag = (name) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(name) ? prev.filter(t => t !== name) : [...prev, name]
|
||||
)
|
||||
}
|
||||
|
||||
// Grid wall: fetch shuffled videos from current filter
|
||||
const fetchGridItems = useCallback(async (limit) => {
|
||||
const [minDuration, maxDuration] = duration ? duration.split('-') : ['', '']
|
||||
const data = await getVideos({
|
||||
search: search || undefined,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
minDuration: minDuration || undefined,
|
||||
maxDuration: maxDuration || undefined,
|
||||
minWidth: resolution || undefined,
|
||||
sort: 'shuffle',
|
||||
limit,
|
||||
})
|
||||
if (data.error) return []
|
||||
// Normalize video items to have type='video' for GridCell
|
||||
return data.videos.map(v => ({ ...v, type: 'video' }))
|
||||
}, [search, selectedTags, duration, resolution])
|
||||
|
||||
const hasMore = videos.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">Videos</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-gray-500 text-sm">
|
||||
{total} video{total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/videos/upload')}
|
||||
className="px-3 py-1.5 text-sm bg-[#0095f6] hover:bg-[#0095f6]/80 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Add Videos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2 md:gap-3 mb-4 md:mb-6">
|
||||
{/* Tag Filter */}
|
||||
<div className="relative" ref={tagRef}>
|
||||
<button
|
||||
onClick={() => { setTagFilterOpen(v => !v); setTagSearch('') }}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-lg border transition-colors ${
|
||||
selectedTags.length > 0
|
||||
? 'border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]'
|
||||
: 'border-[#333] bg-[#161616] text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<TagIcon className="w-4 h-4" />
|
||||
Tags
|
||||
{selectedTags.length > 0 && (
|
||||
<span className="bg-[#0095f6] text-white text-xs rounded-full w-5 h-5 flex items-center justify-center font-medium">
|
||||
{selectedTags.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{tagFilterOpen && (
|
||||
<div className="absolute top-full left-0 mt-2 w-64 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<div className="p-2 border-b border-[#333]">
|
||||
<input
|
||||
type="text"
|
||||
value={tagSearch}
|
||||
onChange={(e) => setTagSearch(e.target.value)}
|
||||
placeholder="Search tags..."
|
||||
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>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{allTags
|
||||
.filter(t => t.name.toLowerCase().includes(tagSearch.toLowerCase()))
|
||||
.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => toggleTag(t.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 ${
|
||||
selectedTags.includes(t.name)
|
||||
? 'bg-[#0095f6] border-[#0095f6]'
|
||||
: 'border-[#555]'
|
||||
}`}>
|
||||
{selectedTags.includes(t.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">{t.name}</span>
|
||||
<span className="text-xs text-gray-600 flex-shrink-0">{t.count}</span>
|
||||
</button>
|
||||
))}
|
||||
{allTags.length === 0 && (
|
||||
<p className="px-3 py-4 text-sm text-gray-600 text-center">No tags yet</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="p-2 border-t border-[#333]">
|
||||
<button
|
||||
onClick={() => { setSelectedTags([]); setTagFilterOpen(false) }}
|
||||
className="w-full py-1.5 text-xs text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected tag pills */}
|
||||
{selectedTags.length > 0 && selectedTags.length <= 5 && (
|
||||
selectedTags.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={() => toggleTag(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>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Duration Dropdown */}
|
||||
<DurationDropdown value={duration} onChange={setDuration} />
|
||||
|
||||
{/* Resolution Segmented */}
|
||||
<div className="flex rounded-lg overflow-hidden border border-[#333]">
|
||||
{RESOLUTION_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setResolution(opt.value)}
|
||||
className={`px-3 py-2 text-sm transition-colors ${
|
||||
resolution === 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} />
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[140px]">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search videos..."
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-[#333] bg-[#161616] text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-400 mb-4">{error}</p>
|
||||
</div>
|
||||
) : videos.length === 0 ? (
|
||||
<div className="text-center py-16 bg-[#161616] border border-[#222] rounded-lg">
|
||||
<VideoIcon className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-sm">No videos found</p>
|
||||
<p className="text-gray-600 text-xs mt-1">
|
||||
Upload videos or scan a folder to get started
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/videos/upload')}
|
||||
className="mt-4 px-4 py-2 text-sm bg-[#0095f6] hover:bg-[#0095f6]/80 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Add Videos
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 md:gap-3">
|
||||
{videos.map(v => (
|
||||
<VideoCard key={v.id} video={v} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<LoadMoreButton
|
||||
onClick={() => loadVideos(false)}
|
||||
loading={loadingMore}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Grid Wall */}
|
||||
{gridWallLayout && (
|
||||
<GridWall
|
||||
layout={gridWallLayout}
|
||||
fetchItems={fetchGridItems}
|
||||
hlsEnabled={false}
|
||||
onClose={() => setGridWallLayout(null)}
|
||||
/>
|
||||
)}
|
||||
</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 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>
|
||||
)
|
||||
}
|
||||
|
||||
function DurationDropdown({ value, onChange }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const current = DURATION_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 transition-colors ${
|
||||
value
|
||||
? 'border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]'
|
||||
: 'border-[#333] bg-[#161616] text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{current?.label || 'Duration'}
|
||||
<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-40 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-50 overflow-hidden py-1">
|
||||
{DURATION_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>
|
||||
)
|
||||
}
|
||||
|
||||
function TagIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 01-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0118 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0118 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 016 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user