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:
Trey T
2026-04-16 07:48:10 -05:00
parent 4903b84aef
commit 236f36aae6
54 changed files with 9986 additions and 420 deletions
+1 -1
View File
@@ -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
View File
@@ -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 &mdash;{' '}
<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>
)
}
+71
View File
@@ -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
View File
@@ -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');
}
+252
View File
@@ -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>
)
}
+238
View File
@@ -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 &middot; 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>
)
}
+4 -2
View File
@@ -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
+27 -1
View File
@@ -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 */}
+132
View File
@@ -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>
)
}
+24 -1
View File
@@ -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}
+93
View File
@@ -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
View File
@@ -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>
)
+78
View File
@@ -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>
)
}
+102
View File
@@ -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>
)
}
+309
View File
@@ -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 &mdash; {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)} &middot; {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>
)
}
+38 -4
View File
@@ -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>
)
})}
+19 -2
View File
@@ -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
View File
@@ -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} &middot; {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
View File
@@ -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">
File diff suppressed because it is too large Load Diff
+425
View File
@@ -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>
)
}
+24 -1
View File
@@ -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>
+287
View File
@@ -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>
)
}
+310
View File
@@ -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>
)
}
+473
View File
@@ -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: '520 min' },
{ value: '1200-3600', label: '2060 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>
)
}