Initial commit — OFApp client + server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
18
client/index.html
Normal file
18
client/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OFApp</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: #0a0a0a;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2678
client/package-lock.json
generated
Normal file
2678
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
client/package.json
Normal file
24
client/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "ofapp-client",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"shaka-player": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
170
client/src/App.jsx
Normal file
170
client/src/App.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
|
||||
import { getMe } from './api'
|
||||
import Login from './pages/Login'
|
||||
import Feed from './pages/Feed'
|
||||
import Users from './pages/Users'
|
||||
import UserPosts from './pages/UserPosts'
|
||||
import Downloads from './pages/Downloads'
|
||||
import Search from './pages/Search'
|
||||
import Gallery from './pages/Gallery'
|
||||
|
||||
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: '/', label: 'Settings', icon: SettingsIcon },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
const [currentUser, setCurrentUser] = useState(null)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
getMe().then((data) => {
|
||||
if (!data.error) {
|
||||
setCurrentUser(data)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const refreshUser = () => {
|
||||
getMe().then((data) => {
|
||||
if (!data.error) {
|
||||
setCurrentUser(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#0a0a0a]">
|
||||
{/* Sidebar */}
|
||||
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-[#111] border-r border-[#222] flex flex-col z-50">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-[#222]">
|
||||
<h1 className="text-xl font-bold text-white tracking-tight">
|
||||
<span className="text-[#0095f6]">OF</span>App
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<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)
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg mb-1 transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-[#0095f6]/10 text-[#0095f6]'
|
||||
: 'text-gray-400 hover:text-white hover:bg-[#1a1a1a]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Current User */}
|
||||
{currentUser && (
|
||||
<div className="p-4 border-t border-[#222]">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={currentUser.avatar}
|
||||
alt={currentUser.name}
|
||||
className="w-9 h-9 rounded-full object-cover bg-[#1a1a1a]"
|
||||
onError={(e) => {
|
||||
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect fill="%23333" width="40" height="40" rx="20"/><text x="20" y="25" text-anchor="middle" fill="white" font-size="16">${(currentUser.name || '?')[0]}</text></svg>`
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{currentUser.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
@{currentUser.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="ml-60 flex-1 min-h-screen">
|
||||
<div className="max-w-5xl mx-auto 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 />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Icon Components */
|
||||
|
||||
function FeedIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<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 UsersIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} 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 DownloadIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} 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>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function GalleryNavIcon({ 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 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M18 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
116
client/src/api.js
Normal file
116
client/src/api.js
Normal file
@@ -0,0 +1,116 @@
|
||||
async function request(url, options = {}) {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
let errMsg = data.error || data.message || `Request failed with status ${response.status}`;
|
||||
if (typeof errMsg === 'object') errMsg = errMsg.message || errMsg.error || JSON.stringify(errMsg);
|
||||
return { error: String(errMsg) };
|
||||
}
|
||||
|
||||
// OF API sometimes returns 200 with error body like {code, message} instead of proper HTTP error
|
||||
if (data && typeof data.code !== 'undefined' && !data.id) {
|
||||
return { error: data.message || 'Request failed' };
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
return { error: err.message || 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuery(params) {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
query.set(key, value);
|
||||
}
|
||||
}
|
||||
const str = query.toString();
|
||||
return str ? `?${str}` : '';
|
||||
}
|
||||
|
||||
export function getMe() {
|
||||
return request('/api/me');
|
||||
}
|
||||
|
||||
export function getFeed(beforePublishTime) {
|
||||
const query = buildQuery({ beforePublishTime });
|
||||
return request(`/api/feed${query}`);
|
||||
}
|
||||
|
||||
export function getSubscriptions(offset) {
|
||||
const query = buildQuery({ offset });
|
||||
return request(`/api/subscriptions${query}`);
|
||||
}
|
||||
|
||||
export function getUserPosts(userId, beforePublishTime) {
|
||||
const query = buildQuery({ beforePublishTime });
|
||||
return request(`/api/users/${userId}/posts${query}`);
|
||||
}
|
||||
|
||||
export function getUser(username) {
|
||||
return request(`/api/users/${username}`);
|
||||
}
|
||||
|
||||
export function saveAuth(config) {
|
||||
return request('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export function getAuth() {
|
||||
return request('/api/auth');
|
||||
}
|
||||
|
||||
export function startDownload(userId, limit, resume, username) {
|
||||
const body = {};
|
||||
if (limit) body.limit = limit;
|
||||
if (resume) body.resume = true;
|
||||
if (username) body.username = username;
|
||||
return request(`/api/download/${userId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function getDownloadStatus(userId) {
|
||||
return request(`/api/download/${userId}/status`);
|
||||
}
|
||||
|
||||
export function getActiveDownloads() {
|
||||
return request('/api/download/active');
|
||||
}
|
||||
|
||||
export function getDownloadCursor(userId) {
|
||||
return request(`/api/download/${userId}/cursor`);
|
||||
}
|
||||
|
||||
export function getDownloadHistory() {
|
||||
return request('/api/download/history');
|
||||
}
|
||||
|
||||
export function getGalleryFolders() {
|
||||
return request('/api/gallery/folders');
|
||||
}
|
||||
|
||||
export function getSettings() {
|
||||
return request('/api/settings');
|
||||
}
|
||||
|
||||
export function updateSettings(settings) {
|
||||
return request('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
|
||||
export function getGalleryFiles({ folder, folders, type, sort, offset, limit } = {}) {
|
||||
const query = buildQuery({ folder, folders: folders ? folders.join(',') : undefined, type, sort, offset, limit });
|
||||
return request(`/api/gallery/files${query}`);
|
||||
}
|
||||
123
client/src/components/DrmVideo.jsx
Normal file
123
client/src/components/DrmVideo.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import shaka from 'shaka-player'
|
||||
|
||||
export default function DrmVideo({ dashSrc, cookies, mediaId, entityId, entityType, poster, className = '' }) {
|
||||
const videoRef = useRef(null)
|
||||
const playerRef = useRef(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
// EME (Encrypted Media Extensions) requires a secure context
|
||||
if (!window.isSecureContext) {
|
||||
const httpsUrl = window.location.href.replace(/^http:/, 'https:').replace(':3002', ':3003')
|
||||
setError(`DRM requires HTTPS — open ${httpsUrl}`)
|
||||
return
|
||||
}
|
||||
|
||||
shaka.polyfill.installAll()
|
||||
|
||||
const video = videoRef.current
|
||||
if (!video || !dashSrc) return
|
||||
|
||||
let destroyed = false
|
||||
const player = new shaka.Player()
|
||||
playerRef.current = player
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
await player.attach(video)
|
||||
|
||||
// Build license URL that routes through OF's DRM resolver
|
||||
const licenseParams = new URLSearchParams()
|
||||
if (mediaId) licenseParams.set('mediaId', mediaId)
|
||||
if (entityId) licenseParams.set('entityId', entityId)
|
||||
if (entityType) licenseParams.set('entityType', entityType)
|
||||
if (cookies?.cp) licenseParams.set('cp', cookies.cp)
|
||||
if (cookies?.cs) licenseParams.set('cs', cookies.cs)
|
||||
if (cookies?.ck) licenseParams.set('ck', cookies.ck)
|
||||
const licenseUrl = `/api/drm-license?${licenseParams.toString()}`
|
||||
|
||||
player.configure({
|
||||
drm: {
|
||||
servers: {
|
||||
'com.widevine.alpha': licenseUrl,
|
||||
'com.microsoft.playready': licenseUrl,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Intercept segment and license requests.
|
||||
player.getNetworkingEngine().registerRequestFilter((type, request) => {
|
||||
if (type === shaka.net.NetworkingEngine.RequestType.LICENSE) {
|
||||
request.headers = request.headers || {}
|
||||
request.headers['Content-Type'] = 'application/octet-stream'
|
||||
request.headers['Accept'] = 'application/json, text/plain, */*'
|
||||
return
|
||||
}
|
||||
|
||||
const url = request.uris[0]
|
||||
if (!cookies || !url || url.startsWith('/api/')) return
|
||||
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (!parsed.hostname.endsWith('onlyfans.com')) return
|
||||
request.uris[0] = `/api/drm-hls?url=${encodeURIComponent(url)}&cp=${encodeURIComponent(cookies.cp || '')}&cs=${encodeURIComponent(cookies.cs || '')}&ck=${encodeURIComponent(cookies.ck || '')}`
|
||||
} catch {
|
||||
// Ignore invalid/relative URLs.
|
||||
}
|
||||
})
|
||||
|
||||
player.addEventListener('error', (e) => {
|
||||
console.error('[shaka] Player error:', e.detail)
|
||||
if (!destroyed) setError(e.detail?.message || 'Playback error')
|
||||
})
|
||||
|
||||
if (!destroyed) {
|
||||
await player.load(dashSrc)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[shaka] Load error:', err)
|
||||
console.error('[shaka] Error code:', err.code, 'category:', err.category, 'data:', err.data)
|
||||
if (!destroyed) {
|
||||
const msg = `Shaka ${err.code || 'unknown'}${err.data ? ': ' + JSON.stringify(err.data).substring(0, 200) : ''}`
|
||||
setError(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
destroyed = true
|
||||
if (playerRef.current) {
|
||||
playerRef.current.destroy()
|
||||
playerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [dashSrc, cookies, mediaId, entityId, entityType])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`relative bg-[#1a1a1a] rounded-lg overflow-hidden flex items-center justify-center ${className}`}>
|
||||
{poster && <img src={poster} alt="" className="w-full h-full object-cover opacity-30" />}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<svg className="w-10 h-10 text-gray-500 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<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>
|
||||
<span className="text-gray-400 text-xs text-center px-4">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
preload="metadata"
|
||||
playsInline
|
||||
className={className}
|
||||
poster={poster}
|
||||
/>
|
||||
)
|
||||
}
|
||||
71
client/src/components/HlsVideo.jsx
Normal file
71
client/src/components/HlsVideo.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
export default function HlsVideo({ hlsSrc, src, autoPlay, ...props }) {
|
||||
const videoRef = useRef(null)
|
||||
const hlsRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
function cleanup() {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy()
|
||||
hlsRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
function setDirectSrc(url) {
|
||||
if (url) {
|
||||
video.src = url
|
||||
if (autoPlay) video.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
cleanup()
|
||||
|
||||
// No HLS URL — use direct source
|
||||
if (!hlsSrc) {
|
||||
setDirectSrc(src)
|
||||
return cleanup
|
||||
}
|
||||
|
||||
// Always use hls.js when supported (including Safari) for consistent behavior
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
maxBufferLength: 10,
|
||||
maxMaxBufferLength: 30,
|
||||
emeEnabled: true,
|
||||
})
|
||||
hlsRef.current = hls
|
||||
hls.loadSource(hlsSrc)
|
||||
hls.attachMedia(video)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (autoPlay) video.play().catch(() => {})
|
||||
})
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
console.error('[hls.js] fatal error, falling back to direct src', data)
|
||||
cleanup()
|
||||
setDirectSrc(src)
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}
|
||||
|
||||
// Safari without MSE (older iOS) — use native HLS
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = hlsSrc
|
||||
video.load()
|
||||
if (autoPlay) video.play().catch(() => {})
|
||||
return cleanup
|
||||
}
|
||||
|
||||
// Fallback to direct source
|
||||
setDirectSrc(src)
|
||||
return cleanup
|
||||
}, [hlsSrc, src, autoPlay])
|
||||
|
||||
return <video ref={videoRef} autoPlay={autoPlay} {...props} />
|
||||
}
|
||||
40
client/src/components/LoadMoreButton.jsx
Normal file
40
client/src/components/LoadMoreButton.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
export default function LoadMoreButton({ onClick, loading, hasMore }) {
|
||||
if (!hasMore) return null
|
||||
|
||||
return (
|
||||
<div className="flex justify-center py-6">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-[#1a1a1a] hover:bg-[#222] border border-[#333] text-gray-300 hover:text-white text-sm font-medium rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<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>
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load More'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
client/src/components/MediaGrid.jsx
Normal file
237
client/src/components/MediaGrid.jsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import DrmVideo from './DrmVideo'
|
||||
|
||||
const DRM_ENTITY_TYPES = new Set(['post', 'message', 'story', 'stream'])
|
||||
|
||||
function normalizeDrmEntityType(type) {
|
||||
const normalized = String(type || '').toLowerCase()
|
||||
return DRM_ENTITY_TYPES.has(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
function proxyUrl(url) {
|
||||
if (!url) return null
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.hostname.endsWith('onlyfans.com') && !parsed.hostname.startsWith('public.') && !parsed.hostname.startsWith('thumbs.')) {
|
||||
return `/api/media-proxy?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
} catch {}
|
||||
return url
|
||||
}
|
||||
|
||||
function getMediaUrl(item) {
|
||||
// For videos, source.source has the actual video URL;
|
||||
// files.full/preview are poster frame images
|
||||
if (item.source?.source) return item.source.source
|
||||
if (item.files?.full?.url) return item.files.full.url
|
||||
if (item.files?.preview?.url) return item.files.preview.url
|
||||
if (item.preview) return item.preview
|
||||
if (item.thumb) return item.thumb
|
||||
if (item.src) return item.src
|
||||
return null
|
||||
}
|
||||
|
||||
function getMediaType(item) {
|
||||
if (item.type === 'video' || item.type === 'gif') return 'video'
|
||||
if (item.type === 'photo' || item.type === 'image') return 'image'
|
||||
// Fallback: check file extension
|
||||
const url = getMediaUrl(item) || ''
|
||||
if (/\.(mp4|mov|avi|webm)/i.test(url)) return 'video'
|
||||
return 'image'
|
||||
}
|
||||
|
||||
function getDrmDashInfo(item, { entityId, entityType } = {}) {
|
||||
const drm = item.files?.drm
|
||||
if (!drm?.manifest?.dash || !drm?.signature?.dash) return null
|
||||
const sig = drm.signature.dash
|
||||
|
||||
const normalizedEntityType = normalizeDrmEntityType(entityType)
|
||||
const parsedEntityId = Number.parseInt(entityId, 10)
|
||||
const normalizedEntityId = Number.isFinite(parsedEntityId) && parsedEntityId > 0
|
||||
? String(parsedEntityId)
|
||||
: null
|
||||
|
||||
return {
|
||||
dashSrc: `/api/drm-hls?url=${encodeURIComponent(drm.manifest.dash)}&cp=${encodeURIComponent(sig['CloudFront-Policy'])}&cs=${encodeURIComponent(sig['CloudFront-Signature'])}&ck=${encodeURIComponent(sig['CloudFront-Key-Pair-Id'])}`,
|
||||
cookies: {
|
||||
cp: sig['CloudFront-Policy'],
|
||||
cs: sig['CloudFront-Signature'],
|
||||
ck: sig['CloudFront-Key-Pair-Id'],
|
||||
},
|
||||
mediaId: item.id,
|
||||
entityId: normalizedEntityId,
|
||||
entityType: normalizedEntityType,
|
||||
}
|
||||
}
|
||||
|
||||
function MediaItem({ item, className = '', entityId, entityType }) {
|
||||
const rawUrl = getMediaUrl(item)
|
||||
const url = proxyUrl(rawUrl)
|
||||
const type = getMediaType(item)
|
||||
const canView = item.canView !== false
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-[#1a1a1a] rounded-lg overflow-hidden ${className}`}
|
||||
>
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover blur-lg scale-110"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-[#1a1a1a]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-[#1a1a1a] rounded-lg flex items-center justify-center ${className}`}
|
||||
>
|
||||
<span className="text-gray-600 text-xs">No preview</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'video') {
|
||||
const drmInfo = getDrmDashInfo(item, { entityId, entityType })
|
||||
const posterUrl =
|
||||
proxyUrl(item.files?.preview?.url) ||
|
||||
proxyUrl(item.preview) ||
|
||||
proxyUrl(item.thumb) ||
|
||||
proxyUrl(item.files?.squarePreview?.url) ||
|
||||
undefined
|
||||
|
||||
if (drmInfo) {
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-[#1a1a1a] rounded-lg overflow-hidden ${className}`}
|
||||
>
|
||||
<DrmVideo
|
||||
dashSrc={drmInfo.dashSrc}
|
||||
cookies={drmInfo.cookies}
|
||||
mediaId={drmInfo.mediaId}
|
||||
entityId={drmInfo.entityId}
|
||||
entityType={drmInfo.entityType}
|
||||
poster={posterUrl}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
{item.duration && (
|
||||
<span className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded pointer-events-none">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-[#1a1a1a] rounded-lg overflow-hidden ${className}`}
|
||||
>
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
preload="metadata"
|
||||
playsInline
|
||||
className="w-full h-full object-contain"
|
||||
poster={posterUrl}
|
||||
/>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute top-2 right-2 w-7 h-7 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center transition-colors"
|
||||
title="Open in new tab"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 text-white" 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>
|
||||
{item.duration && (
|
||||
<span className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded pointer-events-none">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Image
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`block bg-[#1a1a1a] rounded-lg overflow-hidden cursor-pointer ${className}`}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="w-full h-auto object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return ''
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function MediaGrid({ media, entityId, entityType }) {
|
||||
if (!media || media.length === 0) return null
|
||||
|
||||
const count = media.length
|
||||
|
||||
if (count === 1) {
|
||||
return (
|
||||
<MediaItem
|
||||
item={media[0]}
|
||||
className="w-full"
|
||||
entityId={entityId}
|
||||
entityType={entityType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (count === 2) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<MediaItem item={media[0]} className="w-full" entityId={entityId} entityType={entityType} />
|
||||
<MediaItem item={media[1]} className="w-full" entityId={entityId} entityType={entityType} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 3 or more items: show all in 2-col grid
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{media.map((item, i) => (
|
||||
<MediaItem key={item.id || i} item={item} className="w-full" entityId={entityId} entityType={entityType} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
client/src/components/PostCard.jsx
Normal file
110
client/src/components/PostCard.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react'
|
||||
import MediaGrid from './MediaGrid'
|
||||
|
||||
function sanitizeHtml(html) {
|
||||
if (!html) return ''
|
||||
// Strip script tags and event handlers
|
||||
return html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/\son\w+\s*=\s*["'][^"']*["']/gi, '')
|
||||
.replace(/\son\w+\s*=\s*[^\s>]*/gi, '')
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const now = Date.now()
|
||||
const date = new Date(dateStr).getTime()
|
||||
const diff = now - date
|
||||
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
const weeks = Math.floor(days / 7)
|
||||
const months = Math.floor(days / 30)
|
||||
const years = Math.floor(days / 365)
|
||||
|
||||
if (years > 0) return `${years}y ago`
|
||||
if (months > 0) return `${months}mo ago`
|
||||
if (weeks > 0) return `${weeks}w ago`
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return 'just now'
|
||||
}
|
||||
|
||||
export default function PostCard({ post }) {
|
||||
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)
|
||||
|
||||
return (
|
||||
<article className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
|
||||
{/* Author Row */}
|
||||
<div className="flex items-center gap-3 p-4 pb-0">
|
||||
<img
|
||||
src={author.avatar}
|
||||
alt={author.name || 'User'}
|
||||
className="w-9 h-9 rounded-full object-cover bg-[#1a1a1a] flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><rect fill="%23333" width="36" height="36" rx="18"/><text x="18" y="23" text-anchor="middle" fill="white" font-size="14">${(author.name || '?')[0]}</text></svg>`
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white truncate">
|
||||
{author.name || 'Unknown'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{author.username && <span>@{author.username}</span>}
|
||||
{author.username && postedAt && <span className="mx-1.5">·</span>}
|
||||
{postedAt && <span>{timeAgo(postedAt)}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media */}
|
||||
{media.length > 0 && (
|
||||
<div className="p-4 pt-3 pb-0">
|
||||
<MediaGrid
|
||||
media={media}
|
||||
entityId={post.id}
|
||||
entityType={post.responseType}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible Post Text */}
|
||||
{text && (
|
||||
<div className="px-4 pt-2 pb-3">
|
||||
<button
|
||||
onClick={() => setShowText((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 transition-transform duration-200 ${showText ? 'rotate-90' : ''}`}
|
||||
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>
|
||||
{showText ? 'Hide text' : 'Show text'}
|
||||
</button>
|
||||
{showText && (
|
||||
<div
|
||||
className="mt-2 text-sm text-gray-300 leading-relaxed [&>p]:mb-2 [&_a]:text-[#0095f6] [&_a]:hover:underline"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(text) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Padding if no text toggle */}
|
||||
{!text && media.length > 0 && <div className="pb-4" />}
|
||||
{!text && media.length === 0 && <div className="pb-4" />}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
26
client/src/components/Spinner.jsx
Normal file
26
client/src/components/Spinner.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export default function Spinner({ size = 'h-8 w-8' }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<svg
|
||||
className={`animate-spin ${size} text-[#0095f6]`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
109
client/src/components/UserCard.jsx
Normal file
109
client/src/components/UserCard.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
function decodeHTML(str) {
|
||||
if (!str) return str
|
||||
const el = document.createElement('textarea')
|
||||
el.innerHTML = str
|
||||
return el.value
|
||||
}
|
||||
|
||||
export default function UserCard({ user, onDownload, downloading }) {
|
||||
const handleDownloadClick = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (onDownload && !downloading) {
|
||||
onDownload(user.id, user.username)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-4 hover:border-[#333] transition-colors duration-200 hover-lift">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar */}
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
className="w-16 h-16 rounded-full object-cover bg-[#1a1a1a] flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect fill="%23333" width="64" height="64" rx="32"/><text x="32" y="40" text-anchor="middle" fill="white" font-size="24">${(user.name || '?')[0]}</text></svg>`
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-white truncate">
|
||||
{decodeHTML(user.name)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">@{user.username}</p>
|
||||
{(user.postsCount !== undefined || user.mediasCount !== undefined || user.photosCount !== undefined || user.videosCount !== undefined) && (
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{[
|
||||
user.postsCount !== undefined && `${user.postsCount.toLocaleString()} posts`,
|
||||
user.mediasCount !== undefined && `${user.mediasCount.toLocaleString()} media`,
|
||||
user.photosCount !== undefined && `${user.photosCount.toLocaleString()} photos`,
|
||||
user.videosCount !== undefined && `${user.videosCount.toLocaleString()} videos`,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download Button */}
|
||||
<button
|
||||
onClick={handleDownloadClick}
|
||||
disabled={downloading}
|
||||
className={`flex-shrink-0 p-2 rounded-lg transition-colors ${
|
||||
downloading
|
||||
? 'text-[#0095f6] bg-[#0095f6]/10'
|
||||
: 'text-gray-500 hover:text-[#0095f6] hover:bg-[#0095f6]/10'
|
||||
}`}
|
||||
title={downloading ? 'Download started' : 'Download all media'}
|
||||
>
|
||||
{downloading ? (
|
||||
<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>
|
||||
|
||||
{/* View Posts Link */}
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
state={{ user }}
|
||||
className="block mt-3 text-center py-2 text-sm text-[#0095f6] hover:bg-[#0095f6]/10 rounded-lg transition-colors"
|
||||
>
|
||||
View Posts
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
client/src/index.css
Normal file
71
client/src/index.css
Normal file
@@ -0,0 +1,71 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
background-color: #0a0a0a;
|
||||
color: #ffffff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #333 #0a0a0a;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for horizontal scroll areas */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Transition utilities */
|
||||
.transition-smooth {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.hover-lift {
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Image loading placeholder */
|
||||
img {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Remove default link styles */
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
13
client/src/main.jsx
Normal file
13
client/src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
258
client/src/pages/Downloads.jsx
Normal file
258
client/src/pages/Downloads.jsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { getDownloadHistory, getActiveDownloads, getUser } from '../api'
|
||||
import Spinner from '../components/Spinner'
|
||||
|
||||
export default function Downloads() {
|
||||
const [history, setHistory] = useState([])
|
||||
const [active, setActive] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const pollRef = useRef(null)
|
||||
const [usernames, setUsernames] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
startPolling()
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadAll = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [histData, activeData] = await Promise.all([
|
||||
getDownloadHistory(),
|
||||
getActiveDownloads(),
|
||||
])
|
||||
|
||||
if (histData.error) {
|
||||
setError(histData.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const histList = Array.isArray(histData) ? histData : histData.list || []
|
||||
setHistory(histList)
|
||||
setActive(Array.isArray(activeData) ? activeData : [])
|
||||
setLoading(false)
|
||||
resolveUsernames(histList)
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
pollRef.current = setInterval(async () => {
|
||||
const activeData = await getActiveDownloads()
|
||||
if (activeData.error) return
|
||||
|
||||
const list = Array.isArray(activeData) ? activeData : []
|
||||
setActive((prev) => {
|
||||
// If something just finished, refresh history
|
||||
if (prev.length > 0 && list.length < prev.length) {
|
||||
getDownloadHistory().then((h) => {
|
||||
if (!h.error) setHistory(Array.isArray(h) ? h : h.list || [])
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const resolveUsernames = async (items) => {
|
||||
const ids = [...new Set(items.map((i) => i.userId || i.user_id).filter(Boolean))]
|
||||
for (const id of ids) {
|
||||
if (usernames[id]) continue
|
||||
const data = await getUser(id)
|
||||
if (data && !data.error && data.username) {
|
||||
setUsernames((prev) => ({ ...prev, [id]: data.username }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '--'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) return <Spinner />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-red-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadAll}
|
||||
className="text-[#0095f6] hover:underline text-sm"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Downloads</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Manage and monitor media downloads
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Active Downloads */}
|
||||
{active.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
|
||||
Active Downloads
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{active.map((dl) => {
|
||||
const uid = dl.user_id
|
||||
const progress =
|
||||
dl.total > 0
|
||||
? Math.round((dl.completed / dl.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={uid}
|
||||
className="bg-[#161616] border border-[#222] rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{usernames[uid] ? `@${usernames[uid]}` : `User ${uid}`}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{dl.completed || 0} / {dl.total || '?'} files
|
||||
{dl.errors > 0 && (
|
||||
<span className="text-red-400 ml-2">
|
||||
({dl.errors} error{dl.errors !== 1 ? 's' : ''})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-[#0095f6] font-medium">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full bg-[#1a1a1a] rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-[#0095f6] h-1.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download History */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
|
||||
History
|
||||
</h2>
|
||||
|
||||
{history.length === 0 && active.length === 0 ? (
|
||||
<div className="text-center py-12 bg-[#161616] border border-[#222] rounded-lg">
|
||||
<svg
|
||||
className="w-12 h-12 text-gray-600 mx-auto mb-3"
|
||||
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.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-gray-500 text-sm">No download history yet</p>
|
||||
<p className="text-gray-600 text-xs mt-1">
|
||||
Start downloading from the Users page
|
||||
</p>
|
||||
</div>
|
||||
) : history.length === 0 ? null : (
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
|
||||
{/* Table Header */}
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 border-b border-[#222] text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
<span>User</span>
|
||||
<span className="text-right">Files</span>
|
||||
<span className="text-right">Status</span>
|
||||
<span className="text-right">Date</span>
|
||||
</div>
|
||||
|
||||
{/* Table Rows */}
|
||||
{history.map((item, index) => {
|
||||
const uid = item.userId || item.user_id
|
||||
return (
|
||||
<div
|
||||
key={uid || index}
|
||||
className={`grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 items-center ${
|
||||
index < history.length - 1 ? 'border-b border-[#1a1a1a]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-[#333] flex-shrink-0" />
|
||||
<span className="text-sm text-white truncate">
|
||||
{usernames[uid] ? `@${usernames[uid]}` : `User ${uid}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-gray-400 text-right tabular-nums">
|
||||
{item.fileCount || item.file_count || 0}
|
||||
</span>
|
||||
|
||||
<span className="text-right">
|
||||
<StatusBadge status="complete" />
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-gray-500 text-right whitespace-nowrap">
|
||||
{formatDate(
|
||||
item.lastDownload ||
|
||||
item.last_download ||
|
||||
item.completedAt ||
|
||||
item.created_at
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const styles = {
|
||||
complete: 'bg-green-500/10 text-green-400',
|
||||
completed: 'bg-green-500/10 text-green-400',
|
||||
running: 'bg-blue-500/10 text-blue-400',
|
||||
error: 'bg-red-500/10 text-red-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
|
||||
styles[status] || 'bg-gray-500/10 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
119
client/src/pages/Feed.jsx
Normal file
119
client/src/pages/Feed.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getFeed } from '../api'
|
||||
import PostCard from '../components/PostCard'
|
||||
import Spinner from '../components/Spinner'
|
||||
import LoadMoreButton from '../components/LoadMoreButton'
|
||||
|
||||
export default function Feed() {
|
||||
const [posts, setPosts] = useState([])
|
||||
const [tailMarker, setTailMarker] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadFeed()
|
||||
}, [])
|
||||
|
||||
const loadFeed = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const data = await getFeed()
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const items = data.list || data || []
|
||||
items.sort((a, b) => {
|
||||
const dateA = new Date(a.postedAt || a.createdAt || a.publishedAt || 0)
|
||||
const dateB = new Date(b.postedAt || b.createdAt || b.publishedAt || 0)
|
||||
return dateB - dateA
|
||||
})
|
||||
setPosts(items)
|
||||
setTailMarker(data.tailMarker || null)
|
||||
setHasMore(items.length > 0 && !!data.tailMarker)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!tailMarker || loadingMore) return
|
||||
|
||||
setLoadingMore(true)
|
||||
const data = await getFeed(tailMarker)
|
||||
|
||||
if (!data.error) {
|
||||
const items = data.list || data || []
|
||||
setPosts((prev) => {
|
||||
const all = [...prev, ...items]
|
||||
all.sort((a, b) => {
|
||||
const dateA = new Date(a.postedAt || a.createdAt || a.publishedAt || 0)
|
||||
const dateB = new Date(b.postedAt || b.createdAt || b.publishedAt || 0)
|
||||
return dateB - dateA
|
||||
})
|
||||
return all
|
||||
})
|
||||
setTailMarker(data.tailMarker || null)
|
||||
setHasMore(items.length > 0 && !!data.tailMarker)
|
||||
}
|
||||
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
if (loading) return <Spinner />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-red-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadFeed}
|
||||
className="text-[#0095f6] hover:underline text-sm"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Feed</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Recent posts from your subscriptions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500">No posts to show</p>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
Make sure you have active subscriptions
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{posts.map((post) => (
|
||||
<div key={post.id}>
|
||||
<PostCard post={post} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<LoadMoreButton
|
||||
onClick={loadMore}
|
||||
loading={loadingMore}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
564
client/src/pages/Gallery.jsx
Normal file
564
client/src/pages/Gallery.jsx
Normal file
@@ -0,0 +1,564 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { getGalleryFolders, getGalleryFiles, getSettings } from '../api'
|
||||
import Spinner from '../components/Spinner'
|
||||
import LoadMoreButton from '../components/LoadMoreButton'
|
||||
import HlsVideo from '../components/HlsVideo'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function formatShortDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'image', label: 'Images' },
|
||||
{ value: 'video', label: 'Videos' },
|
||||
]
|
||||
|
||||
export default function Gallery() {
|
||||
const [folders, setFolders] = useState([])
|
||||
const [files, setFiles] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const [activeFolder, setActiveFolder] = useState(null) // kept for API compat
|
||||
const [checkedFolders, setCheckedFolders] = useState(new Set())
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [shuffle, setShuffle] = useState(false)
|
||||
const [lightbox, setLightbox] = useState(null)
|
||||
const [slideshow, setSlideshow] = useState(false)
|
||||
const [hlsEnabled, setHlsEnabled] = useState(false)
|
||||
const [userFilterOpen, setUserFilterOpen] = useState(false)
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const filterRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
getSettings().then((data) => {
|
||||
if (!data.error) setHlsEnabled(data.hls_enabled === 'true')
|
||||
})
|
||||
getGalleryFolders().then((data) => {
|
||||
if (!data.error) setFolders(Array.isArray(data) ? data : [])
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Close popover on click outside
|
||||
useEffect(() => {
|
||||
if (!userFilterOpen) return
|
||||
const handleClick = (e) => {
|
||||
if (filterRef.current && !filterRef.current.contains(e.target)) {
|
||||
setUserFilterOpen(false)
|
||||
setUserSearch('')
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [userFilterOpen])
|
||||
|
||||
const togglePill = (name) => {
|
||||
setActiveFolder(null)
|
||||
setCheckedFolders((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setActiveFolder(null)
|
||||
setCheckedFolders(new Set())
|
||||
}
|
||||
|
||||
// Build the folder/folders param for the API
|
||||
// Checked folders take priority over active (clicked) folder
|
||||
const getFilterParams = useCallback(() => {
|
||||
if (checkedFolders.size > 0) {
|
||||
return { folders: Array.from(checkedFolders) }
|
||||
}
|
||||
if (activeFolder) {
|
||||
return { folder: activeFolder }
|
||||
}
|
||||
return {}
|
||||
}, [activeFolder, checkedFolders])
|
||||
|
||||
const loadFiles = useCallback(async (reset = true) => {
|
||||
if (reset) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
|
||||
const offset = reset ? 0 : files.length
|
||||
const data = await getGalleryFiles({
|
||||
...getFilterParams(),
|
||||
type: typeFilter !== 'all' ? typeFilter : undefined,
|
||||
sort: shuffle ? 'shuffle' : 'latest',
|
||||
offset,
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
} else {
|
||||
setFiles((prev) => (reset ? data.files : [...prev, ...data.files]))
|
||||
setTotal(data.total)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}, [getFilterParams, typeFilter, shuffle, files.length])
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles(true)
|
||||
}, [activeFolder, checkedFolders, typeFilter, shuffle])
|
||||
|
||||
const handleReshuffle = () => {
|
||||
loadFiles(true)
|
||||
}
|
||||
|
||||
const hasMore = files.length < total
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Gallery</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{total} file{total !== 1 ? 's' : ''}{checkedFolders.size === 0 ? ' saved locally' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-6">
|
||||
{/* User Filter Popover */}
|
||||
<div className="relative" ref={filterRef}>
|
||||
<button
|
||||
onClick={() => { setUserFilterOpen((v) => !v); setUserSearch('') }}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-lg border transition-colors ${
|
||||
checkedFolders.size > 0
|
||||
? 'border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]'
|
||||
: 'border-[#333] bg-[#161616] text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<UsersFilterIcon className="w-4 h-4" />
|
||||
Users
|
||||
{checkedFolders.size > 0 && (
|
||||
<span className="bg-[#0095f6] text-white text-xs rounded-full w-5 h-5 flex items-center justify-center font-medium">
|
||||
{checkedFolders.size}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{userFilterOpen && (
|
||||
<div className="absolute top-full left-0 mt-2 w-64 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="p-2 border-b border-[#333]">
|
||||
<input
|
||||
type="text"
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
placeholder="Search users..."
|
||||
autoFocus
|
||||
className="w-full px-3 py-1.5 bg-[#111] border border-[#333] rounded-md text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{folders
|
||||
.filter((f) => f.name.toLowerCase().includes(userSearch.toLowerCase()))
|
||||
.map((f) => (
|
||||
<button
|
||||
key={f.name}
|
||||
onClick={() => togglePill(f.name)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-[#252525] transition-colors text-left"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center ${
|
||||
checkedFolders.has(f.name)
|
||||
? 'bg-[#0095f6] border-[#0095f6]'
|
||||
: 'border-[#555]'
|
||||
}`}>
|
||||
{checkedFolders.has(f.name) && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-300 truncate flex-1">{f.name}</span>
|
||||
<span className="text-xs text-gray-600 flex-shrink-0">{f.total}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{checkedFolders.size > 0 && (
|
||||
<div className="p-2 border-t border-[#333]">
|
||||
<button
|
||||
onClick={() => { clearFilters(); setUserFilterOpen(false) }}
|
||||
className="w-full py-1.5 text-xs text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected user tags */}
|
||||
{checkedFolders.size > 0 && checkedFolders.size <= 5 && (
|
||||
Array.from(checkedFolders).map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs bg-[#0095f6]/10 text-[#0095f6] rounded-lg border border-[#0095f6]/30"
|
||||
>
|
||||
{name}
|
||||
<button
|
||||
onClick={() => togglePill(name)}
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="flex rounded-lg overflow-hidden border border-[#333]">
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setTypeFilter(opt.value)}
|
||||
className={`px-3 py-2 text-sm transition-colors ${
|
||||
typeFilter === opt.value
|
||||
? 'bg-[#0095f6] text-white'
|
||||
: 'bg-[#161616] text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Reshuffle Button */}
|
||||
{shuffle && (
|
||||
<button
|
||||
onClick={handleReshuffle}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[#333] bg-[#161616] text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<RefreshIcon className="w-4 h-4" />
|
||||
Reshuffle
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Slideshow Button */}
|
||||
<button
|
||||
onClick={() => setSlideshow(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[#333] bg-[#161616] text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<SlideshowIcon className="w-4 h-4" />
|
||||
Slideshow
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-400 mb-4">{error}</p>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-center py-16 bg-[#161616] border border-[#222] rounded-lg">
|
||||
<GalleryIcon className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-sm">No media files found</p>
|
||||
<p className="text-gray-600 text-xs mt-1">
|
||||
Download media from the Users or Search page
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={`${file.folder}-${file.filename}-${i}`}
|
||||
className="relative group bg-[#161616] rounded-lg overflow-hidden cursor-pointer aspect-square"
|
||||
onClick={() => setLightbox(file)}
|
||||
>
|
||||
{file.type === 'video' ? (
|
||||
<video
|
||||
src={`${file.url}#t=0.5`}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={file.url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date badge */}
|
||||
{file.postedAt && (
|
||||
<div className="absolute top-2 left-2">
|
||||
<span className="bg-black/50 text-white/80 text-[10px] px-1.5 py-0.5 rounded">
|
||||
{formatShortDate(file.postedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-end">
|
||||
<div className="w-full p-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-xs text-white truncate">@{file.folder}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video badge */}
|
||||
{file.type === 'video' && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<svg className="w-5 h-5 text-white drop-shadow-lg" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<LoadMoreButton
|
||||
onClick={() => loadFiles(false)}
|
||||
loading={loadingMore}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightbox && (
|
||||
<Lightbox file={lightbox} hlsEnabled={hlsEnabled} onClose={() => setLightbox(null)} />
|
||||
)}
|
||||
|
||||
{/* Slideshow */}
|
||||
{slideshow && (
|
||||
<Slideshow
|
||||
filterParams={getFilterParams()}
|
||||
onClose={() => setSlideshow(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Lightbox({ file, hlsEnabled, onClose }) {
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [onClose])
|
||||
|
||||
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="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}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
<p className="text-center text-sm text-gray-400 mt-3">@{file.folder}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Slideshow({ filterParams, onClose }) {
|
||||
const [current, setCurrent] = useState(null)
|
||||
const [images, setImages] = useState([])
|
||||
const [index, setIndex] = useState(0)
|
||||
const [paused, setPaused] = useState(false)
|
||||
|
||||
// Load a large shuffled batch of images
|
||||
useEffect(() => {
|
||||
getGalleryFiles({ ...filterParams, type: 'image', sort: 'shuffle', limit: 500 }).then((data) => {
|
||||
if (!data.error && data.files.length > 0) {
|
||||
setImages(data.files)
|
||||
setCurrent(data.files[0])
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Auto-advance every 5 seconds
|
||||
useEffect(() => {
|
||||
if (images.length === 0 || paused) return
|
||||
const timer = setInterval(() => {
|
||||
setIndex((prev) => {
|
||||
const next = prev + 1
|
||||
if (next >= images.length) {
|
||||
getGalleryFiles({ ...filterParams, type: 'image', sort: 'shuffle', limit: 500 }).then((data) => {
|
||||
if (!data.error && data.files.length > 0) {
|
||||
setImages(data.files)
|
||||
setCurrent(data.files[0])
|
||||
setIndex(0)
|
||||
}
|
||||
})
|
||||
return prev
|
||||
}
|
||||
setCurrent(images[next])
|
||||
return next
|
||||
})
|
||||
}, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [images, paused])
|
||||
|
||||
// Keyboard controls
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === ' ') { e.preventDefault(); setPaused((p) => !p) }
|
||||
if (e.key === 'ArrowRight' && images.length > 0) {
|
||||
setIndex((prev) => {
|
||||
const next = Math.min(prev + 1, images.length - 1)
|
||||
setCurrent(images[next])
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && images.length > 0) {
|
||||
setIndex((prev) => {
|
||||
const next = Math.max(prev - 1, 0)
|
||||
setCurrent(images[next])
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [onClose, images])
|
||||
|
||||
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>
|
||||
|
||||
{paused && (
|
||||
<div className="absolute top-4 left-4 text-white/50 text-sm z-10">
|
||||
Paused
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current ? (
|
||||
<img
|
||||
key={current.url}
|
||||
src={current.url}
|
||||
alt=""
|
||||
className="max-w-full max-h-full object-contain animate-fadeIn"
|
||||
/>
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
|
||||
{current && (
|
||||
<div className="absolute bottom-4 left-0 right-0 text-center text-white/40 text-sm">
|
||||
@{current.folder} · {index + 1} / {images.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SlideshowIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ShuffleIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function RefreshIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function UsersFilterIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GalleryIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M18 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
279
client/src/pages/Login.jsx
Normal file
279
client/src/pages/Login.jsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { saveAuth, getAuth, getMe, getSettings, updateSettings } from '../api'
|
||||
import Spinner from '../components/Spinner'
|
||||
|
||||
const fields = [
|
||||
{
|
||||
key: 'user_id',
|
||||
label: 'User ID',
|
||||
placeholder: 'Your numeric user ID',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: 'cookie',
|
||||
label: 'Cookie',
|
||||
placeholder: 'Full cookie string from browser (st=...; sess=...; auth_id=...)',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: 'x_bc',
|
||||
label: 'X-BC',
|
||||
placeholder: 'X-BC header value',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: 'app_token',
|
||||
label: 'App Token',
|
||||
placeholder: '33d57ade8c02dbc5a333db99ff9ae26a',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: 'x_of_rev',
|
||||
label: 'X-OF-Rev',
|
||||
placeholder: 'Revision hash value',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: 'user_agent',
|
||||
label: 'User Agent',
|
||||
placeholder: 'Your browser user agent (optional)',
|
||||
mono: false,
|
||||
},
|
||||
]
|
||||
|
||||
export default function Login({ onAuth }) {
|
||||
const [form, setForm] = useState({
|
||||
user_id: '',
|
||||
cookie: '',
|
||||
x_bc: '',
|
||||
app_token: '',
|
||||
x_of_rev: '',
|
||||
user_agent: '',
|
||||
})
|
||||
const [status, setStatus] = useState(null) // { type: 'success'|'error', message }
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
const [hlsEnabled, setHlsEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getAuth().then((data) => {
|
||||
if (!data.error && data) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(
|
||||
Object.entries(data).filter(
|
||||
([key]) => key in prev && data[key]
|
||||
)
|
||||
),
|
||||
}))
|
||||
}
|
||||
setInitialLoading(false)
|
||||
})
|
||||
getSettings().then((data) => {
|
||||
if (!data.error) {
|
||||
setHlsEnabled(data.hls_enabled === 'true')
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
setStatus(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setStatus(null)
|
||||
|
||||
// Validate required fields
|
||||
const required = ['user_id', 'cookie', 'x_bc', 'app_token']
|
||||
for (const key of required) {
|
||||
if (!form[key].trim()) {
|
||||
setStatus({ type: 'error', message: `${key} is required` })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const saveResult = await saveAuth(form)
|
||||
if (saveResult.error) {
|
||||
setStatus({ type: 'error', message: saveResult.error })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate by fetching current user
|
||||
const meResult = await getMe()
|
||||
if (meResult.error) {
|
||||
setStatus({
|
||||
type: 'error',
|
||||
message: `Auth saved but validation failed: ${meResult.error}`,
|
||||
})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setStatus({
|
||||
type: 'success',
|
||||
message: `Connected as ${meResult.name} (@${meResult.username})`,
|
||||
})
|
||||
setLoading(false)
|
||||
|
||||
if (onAuth) onAuth()
|
||||
}
|
||||
|
||||
if (initialLoading) {
|
||||
return <Spinner />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Settings</h1>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Configure your API authentication credentials.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-4 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-300 mb-2">
|
||||
How to find your credentials
|
||||
</h3>
|
||||
<ol className="text-xs text-gray-500 space-y-1.5 list-decimal list-inside">
|
||||
<li>
|
||||
Open the website in your browser and log in
|
||||
</li>
|
||||
<li>
|
||||
Open DevTools (F12) and go to the <strong className="text-gray-400">Network</strong> tab
|
||||
</li>
|
||||
<li>
|
||||
Refresh the page and click on any API request to{' '}
|
||||
<code className="text-[#0095f6] bg-[#0095f6]/10 px-1 rounded">
|
||||
onlyfans.com/api2/v2/
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Copy the header values from the <strong className="text-gray-400">Request Headers</strong> section
|
||||
</li>
|
||||
<li>
|
||||
Your User ID can be found in the <code className="text-[#0095f6] bg-[#0095f6]/10 px-1 rounded">user-id</code> header
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-6 space-y-5">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label
|
||||
htmlFor={field.key}
|
||||
className="block text-sm font-medium text-gray-300 mb-1.5"
|
||||
>
|
||||
{field.label}
|
||||
{field.key !== 'user_agent' && field.key !== 'x_of_rev' && (
|
||||
<span className="text-red-400 ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id={field.key}
|
||||
type="text"
|
||||
value={form[field.key]}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className={`w-full px-3 py-2.5 bg-[#1a1a1a] border border-[#333] rounded-lg text-white text-sm placeholder-gray-600 focus:outline-none focus:border-[#0095f6] focus:ring-1 focus:ring-[#0095f6]/50 transition-colors ${
|
||||
field.mono ? 'font-mono' : ''
|
||||
}`}
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Status Message */}
|
||||
{status && (
|
||||
<div
|
||||
className={`px-4 py-3 rounded-lg text-sm ${
|
||||
status.type === 'success'
|
||||
? 'bg-green-500/10 border border-green-500/30 text-green-400'
|
||||
: 'bg-red-500/10 border border-red-500/30 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition-colors duration-200 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<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>
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
'Save & Connect'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* App Settings */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-bold text-white mb-4">App Settings</h2>
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-300">HLS Video Streaming</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Videos start playing instantly via segmented streaming instead of downloading the full file first
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={hlsEnabled}
|
||||
onClick={async () => {
|
||||
const next = !hlsEnabled
|
||||
setHlsEnabled(next)
|
||||
await updateSettings({ hls_enabled: String(next) })
|
||||
}}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${
|
||||
hlsEnabled ? 'bg-[#0095f6]' : 'bg-[#333]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
hlsEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
295
client/src/pages/Search.jsx
Normal file
295
client/src/pages/Search.jsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getUser, startDownload, getDownloadCursor } from '../api'
|
||||
|
||||
const CACHE_KEY = 'search_state'
|
||||
|
||||
function decodeHTML(str) {
|
||||
if (!str) return str
|
||||
const el = document.createElement('textarea')
|
||||
el.innerHTML = str
|
||||
return el.value
|
||||
}
|
||||
|
||||
function loadCache() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(CACHE_KEY)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function saveCache(query, user, error, searched) {
|
||||
try {
|
||||
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ query, user, error, searched }))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export default function Search() {
|
||||
const cached = loadCache()
|
||||
const [query, setQuery] = useState(cached?.query || '')
|
||||
const [user, setUser] = useState(cached?.user || null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(cached?.error || null)
|
||||
const [searched, setSearched] = useState(cached?.searched || false)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [showDownloadMenu, setShowDownloadMenu] = useState(false)
|
||||
const [cursorInfo, setCursorInfo] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id && user.subscribedBy) {
|
||||
getDownloadCursor(user.id).then((data) => {
|
||||
if (data && data.hasCursor) setCursorInfo(data)
|
||||
else setCursorInfo(null)
|
||||
})
|
||||
}
|
||||
}, [user?.id, user?.subscribedBy])
|
||||
|
||||
const handleSearch = async (e) => {
|
||||
e.preventDefault()
|
||||
const username = query.trim()
|
||||
if (!username) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setUser(null)
|
||||
setSearched(true)
|
||||
|
||||
const data = await getUser(username)
|
||||
if (data.error || !data.id) {
|
||||
const err = data.error || data.message || 'User not found'
|
||||
setError(err)
|
||||
saveCache(username, null, err, true)
|
||||
} else {
|
||||
setUser(data)
|
||||
saveCache(username, data, null, true)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleDownload = async (limit, resume) => {
|
||||
if (!user) return
|
||||
setShowDownloadMenu(false)
|
||||
setDownloading(true)
|
||||
const result = await startDownload(user.id, limit, resume, user.username)
|
||||
if (result.error) {
|
||||
console.error('Download failed:', result.error)
|
||||
}
|
||||
setTimeout(() => {
|
||||
setDownloading(false)
|
||||
if (user?.id) {
|
||||
getDownloadCursor(user.id).then((data) => {
|
||||
if (data && data.hasCursor) setCursorInfo(data)
|
||||
else setCursorInfo(null)
|
||||
})
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const isSubscribed = user && user.subscribedBy
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-6">Search User</h1>
|
||||
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleSearch} className="flex gap-3 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Enter username..."
|
||||
className="flex-1 bg-[#161616] border border-[#333] rounded-lg px-4 py-2.5 text-white text-sm placeholder-gray-500 focus:outline-none focus:border-[#0095f6] transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
className="px-5 py-2.5 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex justify-center py-16">
|
||||
<svg className="w-6 h-6 animate-spin text-[#0095f6]" 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>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !loading && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{searched && !loading && !error && !user && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500 text-sm">No user found</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result Card */}
|
||||
{user && !loading && (
|
||||
<div className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
|
||||
{/* Banner/Header Image */}
|
||||
{user.header && (
|
||||
<div className="w-full h-32 bg-[#1a1a1a]">
|
||||
<img
|
||||
src={user.header}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.target.parentElement.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar */}
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
className="w-20 h-20 rounded-full object-cover bg-[#1a1a1a] flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><rect fill="%23333" width="80" height="80" rx="40"/><text x="40" y="50" text-anchor="middle" fill="white" font-size="28">${(user.name || '?')[0]}</text></svg>`
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-bold text-white truncate">{decodeHTML(user.name)}</h2>
|
||||
{isSubscribed ? (
|
||||
<span className="text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full flex-shrink-0">Subscribed</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500 bg-[#222] px-2 py-0.5 rounded-full flex-shrink-0">
|
||||
{user.subscribePrice ? `$${Number(user.subscribePrice).toFixed(2)}/mo` : 'Free'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">@{user.username}</p>
|
||||
{user.location && (
|
||||
<p className="text-sm text-gray-400 mt-1">{user.location}</p>
|
||||
)}
|
||||
{user.website && (
|
||||
<a
|
||||
href={user.website.startsWith('http') ? user.website : `https://${user.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-[#0095f6] hover:underline mt-0.5 block truncate"
|
||||
>
|
||||
{user.website}
|
||||
</a>
|
||||
)}
|
||||
{user.joinDate && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Joined {new Date(user.joinDate).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
{user.about && (
|
||||
<p className="text-sm text-gray-300 mt-4 whitespace-pre-line">{decodeHTML(user.about)}</p>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{(user.postsCount !== undefined || user.mediasCount !== undefined || user.photosCount !== undefined || user.videosCount !== undefined) && (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-4">
|
||||
{user.postsCount !== undefined && (
|
||||
<span className="text-sm text-gray-400"><span className="text-white font-medium">{user.postsCount.toLocaleString()}</span> posts</span>
|
||||
)}
|
||||
{user.photosCount !== undefined && (
|
||||
<span className="text-sm text-gray-400"><span className="text-white font-medium">{user.photosCount.toLocaleString()}</span> photos</span>
|
||||
)}
|
||||
{user.videosCount !== undefined && (
|
||||
<span className="text-sm text-gray-400"><span className="text-white font-medium">{user.videosCount.toLocaleString()}</span> videos</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 mt-5">
|
||||
{/* View Posts — always shown */}
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
state={{ user }}
|
||||
className="px-4 py-2 text-sm text-[#0095f6] hover:bg-[#0095f6]/10 rounded-lg transition-colors"
|
||||
>
|
||||
View Posts
|
||||
</Link>
|
||||
|
||||
{/* Download Dropdown — only if subscribed */}
|
||||
{isSubscribed && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDownloadMenu((v) => !v)}
|
||||
disabled={downloading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg 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="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>
|
||||
{downloading ? 'Starting...' : 'Download'}
|
||||
<svg className="w-3 h-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showDownloadMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowDownloadMenu(false)} />
|
||||
<div className="absolute right-0 mt-2 w-52 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-20 overflow-hidden">
|
||||
{cursorInfo && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleDownload(50, true)}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-[#0095f6] hover:bg-[#252525] font-medium transition-colors"
|
||||
>
|
||||
Continue (next 50)
|
||||
<span className="block text-xs text-gray-500 font-normal mt-0.5">
|
||||
{cursorInfo.postsDownloaded} posts downloaded
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(100, true)}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-[#0095f6] hover:bg-[#252525] font-medium transition-colors"
|
||||
>
|
||||
Continue (next 100)
|
||||
</button>
|
||||
<div className="border-t border-[#333]" />
|
||||
</>
|
||||
)}
|
||||
{[
|
||||
{ label: 'Last 10 posts', value: 10 },
|
||||
{ label: 'Last 25 posts', value: 25 },
|
||||
{ label: 'Last 50 posts', value: 50 },
|
||||
{ label: 'Last 100 posts', value: 100 },
|
||||
{ label: 'All posts', value: null },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => handleDownload(opt.value)}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-gray-300 hover:bg-[#252525] hover:text-white transition-colors"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
267
client/src/pages/UserPosts.jsx
Normal file
267
client/src/pages/UserPosts.jsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { getUserPosts, getUser, startDownload, getDownloadCursor } from '../api'
|
||||
import PostCard from '../components/PostCard'
|
||||
|
||||
function decodeHTML(str) {
|
||||
if (!str) return str
|
||||
const el = document.createElement('textarea')
|
||||
el.innerHTML = str
|
||||
return el.value
|
||||
}
|
||||
import Spinner from '../components/Spinner'
|
||||
import LoadMoreButton from '../components/LoadMoreButton'
|
||||
|
||||
export default function UserPosts() {
|
||||
const { userId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const [user, setUser] = useState(location.state?.user || null)
|
||||
const [posts, setPosts] = useState([])
|
||||
const [tailMarker, setTailMarker] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [showDownloadMenu, setShowDownloadMenu] = useState(false)
|
||||
const [cursorInfo, setCursorInfo] = useState(null)
|
||||
|
||||
const fetchCursor = () => {
|
||||
getDownloadCursor(userId).then((data) => {
|
||||
if (data && data.hasCursor) setCursorInfo(data)
|
||||
else setCursorInfo(null)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts()
|
||||
fetchCursor()
|
||||
|
||||
// Fetch user info if not passed via location state
|
||||
if (!user) {
|
||||
getUser(userId).then((data) => {
|
||||
if (!data.error) {
|
||||
setUser(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
const loadPosts = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const data = await getUserPosts(userId)
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const items = data.list || data || []
|
||||
setPosts(items)
|
||||
setTailMarker(data.tailMarker || null)
|
||||
setHasMore(items.length > 0 && !!data.tailMarker)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!tailMarker || loadingMore) return
|
||||
|
||||
setLoadingMore(true)
|
||||
const data = await getUserPosts(userId, tailMarker)
|
||||
|
||||
if (!data.error) {
|
||||
const items = data.list || data || []
|
||||
setPosts((prev) => [...prev, ...items])
|
||||
setTailMarker(data.tailMarker || null)
|
||||
setHasMore(items.length > 0 && !!data.tailMarker)
|
||||
}
|
||||
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const handleDownload = async (limit, resume) => {
|
||||
setShowDownloadMenu(false)
|
||||
setDownloading(true)
|
||||
const result = await startDownload(userId, limit, resume, user?.username)
|
||||
if (result.error) {
|
||||
console.error('Download failed:', result.error)
|
||||
}
|
||||
setTimeout(() => {
|
||||
setDownloading(false)
|
||||
fetchCursor()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => navigate('/users')}
|
||||
className="flex items-center gap-2 text-gray-400 hover:text-white text-sm mb-6 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="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
Back to Users
|
||||
</button>
|
||||
|
||||
{/* User Header */}
|
||||
{user && (
|
||||
<div className="flex items-center justify-between bg-[#161616] border border-[#222] rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
className="w-14 h-14 rounded-full object-cover bg-[#1a1a1a]"
|
||||
onError={(e) => {
|
||||
e.target.src = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56"><rect fill="%23333" width="56" height="56" rx="28"/><text x="28" y="35" text-anchor="middle" fill="white" font-size="20">${(user.name || '?')[0]}</text></svg>`
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">{decodeHTML(user.name)}</h1>
|
||||
<p className="text-gray-500 text-sm">@{user.username}</p>
|
||||
{(user.postsCount !== undefined || user.mediasCount !== undefined) && (
|
||||
<p className="text-gray-600 text-xs mt-0.5">
|
||||
{user.postsCount !== undefined && `${user.postsCount.toLocaleString()} posts`}
|
||||
{user.postsCount !== undefined && user.mediasCount !== undefined && ' · '}
|
||||
{user.mediasCount !== undefined && `${user.mediasCount.toLocaleString()} media`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDownloadMenu((v) => !v)}
|
||||
disabled={downloading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[#0095f6] hover:bg-[#0081d6] disabled:opacity-50 text-white text-sm font-medium rounded-lg 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="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>
|
||||
{downloading ? 'Starting...' : 'Download Media'}
|
||||
<svg
|
||||
className="w-3 h-3 ml-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showDownloadMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowDownloadMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-52 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-20 overflow-hidden">
|
||||
{cursorInfo && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleDownload(50, true)}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-[#0095f6] hover:bg-[#252525] font-medium transition-colors"
|
||||
>
|
||||
Continue (next 50)
|
||||
<span className="block text-xs text-gray-500 font-normal mt-0.5">
|
||||
{cursorInfo.postsDownloaded} posts downloaded
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(100, true)}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-[#0095f6] hover:bg-[#252525] font-medium transition-colors"
|
||||
>
|
||||
Continue (next 100)
|
||||
</button>
|
||||
<div className="border-t border-[#333]" />
|
||||
</>
|
||||
)}
|
||||
{[
|
||||
{ label: 'Last 10 posts', value: 10 },
|
||||
{ label: 'Last 25 posts', value: 25 },
|
||||
{ label: 'Last 50 posts', value: 50 },
|
||||
{ label: 'Last 100 posts', value: 100 },
|
||||
{ label: 'All posts', value: null },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => handleDownload(opt.value)}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-gray-300 hover:bg-[#252525] hover:text-white transition-colors"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posts */}
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : error ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-red-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadPosts}
|
||||
className="text-[#0095f6] hover:underline text-sm"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500">No posts found</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<LoadMoreButton
|
||||
onClick={loadMore}
|
||||
loading={loadingMore}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
client/src/pages/Users.jsx
Normal file
147
client/src/pages/Users.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getSubscriptions, getUser, startDownload } from '../api'
|
||||
import UserCard from '../components/UserCard'
|
||||
import Spinner from '../components/Spinner'
|
||||
import LoadMoreButton from '../components/LoadMoreButton'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState([])
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [downloadingUsers, setDownloadingUsers] = useState(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [])
|
||||
|
||||
const enrichUsers = (items) => {
|
||||
items.forEach(async (item) => {
|
||||
const profile = await getUser(item.username)
|
||||
if (profile && !profile.error) {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === item.id
|
||||
? { ...u, postsCount: profile.postsCount, mediasCount: profile.mediasCount, photosCount: profile.photosCount, videosCount: profile.videosCount }
|
||||
: u
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const data = await getSubscriptions(0)
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const items = data.list || data || []
|
||||
setUsers(items)
|
||||
setOffset(PAGE_SIZE)
|
||||
setHasMore(items.length >= PAGE_SIZE)
|
||||
setLoading(false)
|
||||
enrichUsers(items)
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore) return
|
||||
|
||||
setLoadingMore(true)
|
||||
const data = await getSubscriptions(offset)
|
||||
|
||||
if (!data.error) {
|
||||
const items = data.list || data || []
|
||||
setUsers((prev) => [...prev, ...items])
|
||||
setOffset((prev) => prev + PAGE_SIZE)
|
||||
setHasMore(items.length >= PAGE_SIZE)
|
||||
enrichUsers(items)
|
||||
}
|
||||
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const handleDownload = async (userId, username) => {
|
||||
setDownloadingUsers((prev) => new Set([...prev, userId]))
|
||||
|
||||
const result = await startDownload(userId, null, null, username)
|
||||
if (result.error) {
|
||||
console.error('Download failed:', result.error)
|
||||
}
|
||||
|
||||
// Remove from downloading set after a brief delay to show feedback
|
||||
setTimeout(() => {
|
||||
setDownloadingUsers((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(userId)
|
||||
return next
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
if (loading) return <Spinner />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-red-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadUsers}
|
||||
className="text-[#0095f6] hover:underline text-sm"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Subscriptions</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{users.length} user{users.length !== 1 ? 's' : ''} found
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500">No subscriptions found</p>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
Check your auth credentials in Settings
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...users].sort((a, b) => (a.name || '').localeCompare(b.name || '')).map((user) => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
onDownload={handleDownload}
|
||||
downloading={downloadingUsers.has(user.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<LoadMoreButton
|
||||
onClick={loadMore}
|
||||
loading={loadingMore}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
client/tailwind.config.js
Normal file
39
client/tailwind.config.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: {
|
||||
DEFAULT: '#0095f6',
|
||||
hover: '#0081d6',
|
||||
light: '#47b5ff',
|
||||
},
|
||||
surface: {
|
||||
page: '#0a0a0a',
|
||||
sidebar: '#111111',
|
||||
card: '#161616',
|
||||
input: '#1a1a1a',
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#222222',
|
||||
light: '#333333',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
fadeIn: 'fadeIn 0.5s ease-in-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
12
client/vite.config.js
Normal file
12
client/vite.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user