Initial commit — OFApp client + server

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-12 20:07:06 -06:00
commit c60de19348
43 changed files with 8679 additions and 0 deletions

18
client/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

24
client/package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

170
client/src/App.jsx Normal file
View 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
View 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}`);
}

View 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}
/>
)
}

View 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} />
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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>
)

View 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
View 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>
)
}

View 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} &middot; {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
View 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
View 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>
)
}

View 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
View 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
View 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
View 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'
}
}
})