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:
+244
-52
@@ -1,7 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
|
||||
import { getMe } from './api'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'
|
||||
import { getMe, getNewMediaCount, checkAuth, appAuthLogout } from './api'
|
||||
import { useAuth } from './AuthContext'
|
||||
import AppLogin from './pages/AppLogin'
|
||||
import AppSetup from './pages/AppSetup'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Feed from './pages/Feed'
|
||||
import Users from './pages/Users'
|
||||
import UserPosts from './pages/UserPosts'
|
||||
@@ -10,35 +14,99 @@ import Search from './pages/Search'
|
||||
import Gallery from './pages/Gallery'
|
||||
import Duplicates from './pages/Duplicates'
|
||||
import Scrape from './pages/Scrape'
|
||||
import Videos from './pages/Videos'
|
||||
import VideoDetail from './pages/VideoDetail'
|
||||
import VideoUpload from './pages/VideoUpload'
|
||||
import UserManagement from './pages/UserManagement'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/feed', label: 'Feed', icon: FeedIcon },
|
||||
{ to: '/users', label: 'Users', icon: UsersIcon },
|
||||
{ to: '/search', label: 'Search', icon: SearchIcon },
|
||||
{ to: '/downloads', label: 'Downloads', icon: DownloadIcon },
|
||||
{ to: '/gallery', label: 'Gallery', icon: GalleryNavIcon },
|
||||
{ to: '/scrape', label: 'Scrape', icon: ScrapeIcon },
|
||||
{ to: '/', label: 'Settings', icon: SettingsIcon },
|
||||
// Route key mapping for nav items (null = always visible, undefined = no check needed)
|
||||
const allNavItems = [
|
||||
{ to: '/', label: 'Home', icon: HomeIcon, exact: true, routeKey: 'dashboard' },
|
||||
{ to: '/feed', label: 'Feed', icon: FeedIcon, routeKey: 'feed' },
|
||||
{ to: '/users', label: 'Users', icon: UsersIcon, routeKey: 'users' },
|
||||
{ to: '/search', label: 'Search', icon: SearchIcon, routeKey: 'users' },
|
||||
{ to: '/downloads', label: 'Downloads', icon: DownloadIcon, routeKey: 'downloads' },
|
||||
{ to: '/gallery', label: 'Gallery', icon: GalleryNavIcon, routeKey: 'gallery' },
|
||||
{ to: '/videos', label: 'Videos', icon: VideoNavIcon, routeKey: 'videos' },
|
||||
{ to: '/scrape', label: 'Scrape', icon: ScrapeIcon, routeKey: 'scrape' },
|
||||
{ to: '/settings', label: 'Settings', icon: SettingsIcon, routeKey: 'settings' },
|
||||
]
|
||||
|
||||
// Bottom bar shows a subset of nav items (most used) to avoid crowding
|
||||
const mobileNavItems = [
|
||||
{ to: '/feed', label: 'Feed', icon: FeedIcon },
|
||||
{ to: '/users', label: 'Users', icon: UsersIcon },
|
||||
{ to: '/gallery', label: 'Gallery', icon: GalleryNavIcon },
|
||||
{ to: '/search', label: 'Search', icon: SearchIcon },
|
||||
const allMobileNavItems = [
|
||||
{ to: '/', label: 'Home', icon: HomeIcon, exact: true, routeKey: 'dashboard' },
|
||||
{ to: '/users', label: 'Users', icon: UsersIcon, routeKey: 'users' },
|
||||
{ to: '/gallery', label: 'Gallery', icon: GalleryNavIcon, routeKey: 'gallery' },
|
||||
{ to: '/search', label: 'Search', icon: SearchIcon, routeKey: 'users' },
|
||||
{ to: '/more', label: 'More', icon: MoreIcon },
|
||||
]
|
||||
|
||||
const moreNavItems = [
|
||||
{ to: '/downloads', label: 'Downloads', icon: DownloadIcon },
|
||||
{ to: '/scrape', label: 'Scrape', icon: ScrapeIcon },
|
||||
{ to: '/', label: 'Settings', icon: SettingsIcon },
|
||||
const allMoreNavItems = [
|
||||
{ to: '/feed', label: 'Feed', icon: FeedIcon, routeKey: 'feed' },
|
||||
{ to: '/downloads', label: 'Downloads', icon: DownloadIcon, routeKey: 'downloads' },
|
||||
{ to: '/videos', label: 'Videos', icon: VideoNavIcon, routeKey: 'videos' },
|
||||
{ to: '/scrape', label: 'Scrape', icon: ScrapeIcon, routeKey: 'scrape' },
|
||||
{ to: '/settings', label: 'Settings', icon: SettingsIcon, routeKey: 'settings' },
|
||||
]
|
||||
|
||||
function ProtectedRoute({ routeKey, children }) {
|
||||
const { hasRoute, appUser } = useAuth()
|
||||
// 'admin' is a special key — only admin role can access
|
||||
if (routeKey === 'admin') {
|
||||
if (appUser?.role !== 'admin') return <Navigate to="/" replace />
|
||||
return children
|
||||
}
|
||||
if (!hasRoute(routeKey)) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { appUser, setupRequired, loading: authLoading, hasRoute } = useAuth()
|
||||
|
||||
// Show auth screens if needed
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-[#0095f6] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (setupRequired) return <AppSetup />
|
||||
if (!appUser) return <AppLogin />
|
||||
|
||||
// Filter nav items based on route permissions
|
||||
const navItems = allNavItems.filter((item) =>
|
||||
!item.routeKey || hasRoute(item.routeKey)
|
||||
)
|
||||
const mobileNavItems = allMobileNavItems.filter((item) =>
|
||||
!item.routeKey || hasRoute(item.routeKey)
|
||||
)
|
||||
const moreNavItems = allMoreNavItems.filter((item) =>
|
||||
!item.routeKey || hasRoute(item.routeKey)
|
||||
)
|
||||
|
||||
// Add admin nav item for admins
|
||||
if (appUser.role === 'admin') {
|
||||
navItems.push({ to: '/admin/users', label: 'Admin', icon: AdminIcon })
|
||||
moreNavItems.push({ to: '/admin/users', label: 'Admin', icon: AdminIcon })
|
||||
}
|
||||
|
||||
return <AppShell
|
||||
navItems={navItems}
|
||||
mobileNavItems={mobileNavItems}
|
||||
moreNavItems={moreNavItems}
|
||||
hasRoute={hasRoute}
|
||||
appUser={appUser}
|
||||
/>
|
||||
}
|
||||
|
||||
function AppShell({ navItems, mobileNavItems, moreNavItems, hasRoute, appUser }) {
|
||||
const { logout } = useAuth()
|
||||
const [currentUser, setCurrentUser] = useState(null)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [authWarning, setAuthWarning] = useState(null)
|
||||
const authPollRef = useRef(null)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,11 +117,48 @@ export default function App() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Auth validity polling (every 5 min)
|
||||
useEffect(() => {
|
||||
const poll = () => {
|
||||
checkAuth().then((data) => {
|
||||
if (data && !data.error && !data.valid) {
|
||||
setAuthWarning(data.error || 'Auth expired')
|
||||
} else if (data?.valid) {
|
||||
setAuthWarning(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
poll()
|
||||
authPollRef.current = setInterval(poll, 5 * 60 * 1000)
|
||||
return () => clearInterval(authPollRef.current)
|
||||
}, [])
|
||||
|
||||
// Close "more" menu on route change
|
||||
useEffect(() => {
|
||||
setMoreOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
// New media badge
|
||||
const [newMediaCount, setNewMediaCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCount = () => {
|
||||
getNewMediaCount().then((data) => {
|
||||
if (!data.error) setNewMediaCount(data.count || 0)
|
||||
})
|
||||
}
|
||||
fetchCount()
|
||||
const id = setInterval(fetchCount, 60000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// Instantly clear badge when on gallery page
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith('/gallery')) {
|
||||
setNewMediaCount(0)
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
const refreshUser = () => {
|
||||
getMe().then((data) => {
|
||||
if (!data.error) {
|
||||
@@ -62,12 +167,17 @@ export default function App() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await appAuthLogout()
|
||||
logout()
|
||||
}
|
||||
|
||||
const isMoreActive = moreNavItems.some((item) =>
|
||||
item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to)
|
||||
item.to !== '/more' && location.pathname.startsWith(item.to)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#0a0a0a]">
|
||||
<div className="flex min-h-screen bg-[#0a0a0a] overflow-x-hidden w-full">
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden md:flex fixed left-0 top-0 bottom-0 w-60 bg-[#111] border-r border-[#222] flex-col z-50">
|
||||
{/* Logo */}
|
||||
@@ -81,10 +191,9 @@ export default function App() {
|
||||
<nav className="flex-1 py-4 px-3">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive =
|
||||
item.to === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.to)
|
||||
const isActive = item.exact
|
||||
? location.pathname === item.to
|
||||
: location.pathname.startsWith(item.to)
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
@@ -98,15 +207,20 @@ export default function App() {
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
{item.to === '/gallery' && newMediaCount > 0 && (
|
||||
<span className="ml-auto bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center rounded-full px-1">
|
||||
{newMediaCount > 99 ? '99+' : newMediaCount}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Current User */}
|
||||
{currentUser && (
|
||||
<div className="p-4 border-t border-[#222]">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Current User + Logout */}
|
||||
<div className="p-4 border-t border-[#222]">
|
||||
{currentUser && (
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<img
|
||||
src={currentUser.avatar}
|
||||
alt={currentUser.name}
|
||||
@@ -124,23 +238,61 @@ export default function App() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-300 text-sm transition-colors w-full px-1"
|
||||
>
|
||||
<LogoutIcon className="w-4 h-4" />
|
||||
<span>Sign out ({appUser.username})</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="md:ml-60 flex-1 min-h-screen pb-20 md:pb-0">
|
||||
<main className="md:ml-60 flex-1 min-h-screen pb-20 md:pb-0 overflow-x-hidden">
|
||||
{/* Auth Warning Banner */}
|
||||
{authWarning && (
|
||||
<div className="bg-amber-500/10 border-b border-amber-500/30 px-4 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
<span className="text-sm text-amber-300">
|
||||
Auth expired —{' '}
|
||||
<NavLink to="/settings" className="underline hover:text-amber-200">
|
||||
Update credentials in Settings
|
||||
</NavLink>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAuthWarning(null)}
|
||||
className="text-amber-400 hover:text-amber-200 p-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-5xl mx-auto p-4 md:p-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Login onAuth={refreshUser} />} />
|
||||
<Route path="/feed" element={<Feed />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/users/:userId" element={<UserPosts />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/downloads" element={<Downloads />} />
|
||||
<Route path="/gallery" element={<Gallery />} />
|
||||
<Route path="/duplicates" element={<Duplicates />} />
|
||||
<Route path="/scrape" element={<Scrape />} />
|
||||
<Route path="/" element={<ProtectedRoute routeKey="dashboard"><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute routeKey="settings"><Login onAuth={refreshUser} /></ProtectedRoute>} />
|
||||
<Route path="/feed" element={<ProtectedRoute routeKey="feed"><Feed /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute routeKey="users"><Users /></ProtectedRoute>} />
|
||||
<Route path="/users/:userId" element={<ProtectedRoute routeKey="users"><UserPosts /></ProtectedRoute>} />
|
||||
<Route path="/search" element={<ProtectedRoute routeKey="users"><Search /></ProtectedRoute>} />
|
||||
<Route path="/downloads" element={<ProtectedRoute routeKey="downloads"><Downloads /></ProtectedRoute>} />
|
||||
<Route path="/gallery" element={<ProtectedRoute routeKey="gallery"><Gallery /></ProtectedRoute>} />
|
||||
<Route path="/duplicates" element={<ProtectedRoute routeKey="gallery"><Duplicates /></ProtectedRoute>} />
|
||||
<Route path="/videos" element={<ProtectedRoute routeKey="videos"><Videos /></ProtectedRoute>} />
|
||||
<Route path="/videos/upload" element={<ProtectedRoute routeKey="videos"><VideoUpload /></ProtectedRoute>} />
|
||||
<Route path="/videos/:id" element={<ProtectedRoute routeKey="videos"><VideoDetail /></ProtectedRoute>} />
|
||||
<Route path="/scrape" element={<ProtectedRoute routeKey="scrape"><Scrape /></ProtectedRoute>} />
|
||||
<Route path="/admin/users" element={<ProtectedRoute routeKey="admin"><UserManagement /></ProtectedRoute>} />
|
||||
<Route path="/login" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
@@ -166,21 +318,25 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
const isActive =
|
||||
item.to === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.to)
|
||||
const isActive = item.exact
|
||||
? location.pathname === item.to
|
||||
: location.pathname.startsWith(item.to)
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={`flex flex-col items-center justify-center gap-0.5 w-full h-full transition-colors ${
|
||||
className={`relative flex flex-col items-center justify-center gap-0.5 w-full h-full transition-colors ${
|
||||
isActive ? 'text-[#0095f6]' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-[10px]">{item.label}</span>
|
||||
{item.to === '/gallery' && newMediaCount > 0 && (
|
||||
<span className="absolute top-1 right-1/4 bg-red-500 text-white text-[9px] font-bold min-w-[16px] h-[16px] flex items-center justify-center rounded-full px-1">
|
||||
{newMediaCount > 99 ? '99+' : newMediaCount}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
@@ -193,10 +349,7 @@ export default function App() {
|
||||
<div className="absolute bottom-full left-0 right-0 bg-[#161616] border-t border-[#222] z-50 shadow-xl">
|
||||
{moreNavItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive =
|
||||
item.to === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.to)
|
||||
const isActive = location.pathname.startsWith(item.to)
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
@@ -211,6 +364,13 @@ export default function App() {
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 px-5 py-3.5 transition-colors text-gray-500 hover:text-gray-300 w-full border-t border-[#222]"
|
||||
>
|
||||
<LogoutIcon className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Sign out ({appUser.username})</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -221,6 +381,22 @@ export default function App() {
|
||||
|
||||
/* Icon Components */
|
||||
|
||||
function AdminIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LogoutIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -285,3 +461,19 @@ function MoreIcon({ className }) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoNavIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user