Initial commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Todd
2026-03-29 22:42:55 -04:00
commit 0d7b2b1aab
389 changed files with 280296 additions and 0 deletions

1
web/frontend/VERSION Normal file
View File

@@ -0,0 +1 @@
6.33.0

34
web/frontend/index.html Normal file
View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- iOS Home Screen Icons -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- iOS Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Media DL" />
<!-- Theme Color -->
<meta name="theme-color" content="#2563eb" />
<meta name="msapplication-TileColor" content="#2563eb" />
<!-- Description -->
<meta name="description" content="Media Downloader - Automated media archival system for Instagram, TikTok, Snapchat, and forums" />
<title>Media Downloader - Dashboard</title>
</head>
<body>
<div id="root"></div>
<script src="/totp.js"></script>
<script src="/passkeys.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

48
web/frontend/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "media-downloader-ui",
"private": true,
"version": "13.13.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@tanstack/react-query": "^5.17.9",
"@tanstack/react-virtual": "^3.13.12",
"@types/react-window": "^2.0.0",
"@types/react-window-infinite-loader": "^2.0.0",
"@use-gesture/react": "^10.3.1",
"clsx": "^2.1.0",
"date-fns": "^3.0.6",
"hls.js": "^1.6.15",
"lucide-react": "^0.303.0",
"plyr": "^3.8.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^2.2.3",
"react-window-infinite-loader": "^2.0.0",
"recharts": "^2.10.3",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#2563eb"/>
<path d="M16 8v11m0 0l-4-4m4 4l4-4" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 24h14" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -0,0 +1,30 @@
{
"name": "Media Downloader",
"short_name": "Media DL",
"description": "Media Downloader - Automated media archival system",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#2563eb",
"orientation": "portrait-primary",
"icons": [
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -0,0 +1,558 @@
/**
* Passkeys.js - Frontend WebAuthn/Passkey Management
*
* This module handles passkey (WebAuthn) registration, authentication, and management.
* Passkeys provide phishing-resistant, passwordless authentication using biometrics
* or hardware security keys (Touch ID, Face ID, Windows Hello, YubiKey, etc.)
*/
const Passkeys = {
/**
* Check if WebAuthn is supported in the browser
* @returns {boolean}
*/
isSupported() {
return window.PublicKeyCredential !== undefined &&
navigator.credentials !== undefined;
},
/**
* Show browser not supported error
*/
showNotSupportedError() {
showNotification('error', 'Browser Not Supported',
'Your browser does not support passkeys. Please use a modern browser like Chrome, Safari, Edge, or Firefox.');
},
/**
* Register a new passkey
*/
async registerPasskey() {
if (!this.isSupported()) {
this.showNotSupportedError();
return;
}
try {
// Prompt for device name
const deviceName = await this.promptDeviceName();
if (!deviceName) {return;} // User cancelled
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
if (!token) {
showNotification('error', 'Authentication Required', 'Please log in first');
return;
}
// Get registration options from server
showNotification('info', 'Starting Registration', 'Requesting registration options...');
const optionsResponse = await fetch('/api/auth/passkey/register/options', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!optionsResponse.ok) {
const error = await optionsResponse.json();
throw new Error(error.error || 'Failed to get registration options');
}
const { options } = await optionsResponse.json();
// Convert options for WebAuthn API
const publicKeyOptions = this.convertRegistrationOptions(options);
// Prompt user for biometric/security key
showNotification('info', 'Authenticate', 'Please authenticate with your device (Touch ID, Face ID, Windows Hello, or security key)...');
let credential;
try {
credential = await navigator.credentials.create({
publicKey: publicKeyOptions
});
} catch (err) {
if (err.name === 'NotAllowedError') {
showNotification('warning', 'Cancelled', 'Registration was cancelled');
} else if (err.name === 'InvalidStateError') {
showNotification('error', 'Already Registered', 'This authenticator is already registered');
} else {
throw err;
}
return;
}
if (!credential) {
showNotification('error', 'Registration Failed', 'No credential was created');
return;
}
// Send credential to server for verification
const verificationResponse = await fetch('/api/auth/passkey/register/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
response: this.encodeCredential(credential),
deviceName: deviceName
})
});
if (!verificationResponse.ok) {
const error = await verificationResponse.json();
throw new Error(error.error || 'Failed to verify registration');
}
const result = await verificationResponse.json();
showNotification('success', 'Passkey Registered', `${deviceName} has been successfully registered!`);
// Reload passkey list
if (typeof loadPasskeyStatus === 'function') {
loadPasskeyStatus();
}
if (typeof listPasskeys === 'function') {
listPasskeys();
}
} catch (error) {
console.error('Passkey registration error:', error);
showNotification('error', 'Registration Error', error.message);
}
},
/**
* Prompt user for device name
* @returns {Promise<string|null>}
*/
async promptDeviceName() {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2><i class="fas fa-fingerprint"></i> Register Passkey</h2>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove(); event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<p style="margin-bottom: 15px;">Give this passkey a name to help you identify it later:</p>
<div class="form-group">
<label for="passkey-device-name">Device Name</label>
<input type="text" id="passkey-device-name"
placeholder="e.g., iPhone 12, MacBook Pro, YubiKey"
maxlength="100" required autofocus>
<small style="color: var(--gray-500); margin-top: 5px; display: block;">
Examples: "iPhone 15", "Windows PC", "YubiKey 5C"
</small>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="this.closest('.modal-overlay').remove();">
Cancel
</button>
<button class="btn btn-primary" id="continue-registration-btn">
<i class="fas fa-arrow-right"></i> Continue
</button>
</div>
</div>
`;
document.body.appendChild(modal);
const input = modal.querySelector('#passkey-device-name');
const continueBtn = modal.querySelector('#continue-registration-btn');
// Auto-suggest device name
input.value = this.suggestDeviceName();
input.select();
const handleContinue = () => {
const value = input.value.trim();
if (value) {
modal.remove();
resolve(value);
} else {
input.style.borderColor = 'var(--danger)';
showNotification('warning', 'Name Required', 'Please enter a device name');
}
};
continueBtn.addEventListener('click', handleContinue);
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleContinue();
}
});
// Handle modal close
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
resolve(null);
}
});
});
},
/**
* Suggest a device name based on user agent
* @returns {string}
*/
suggestDeviceName() {
const ua = navigator.userAgent;
// Mobile devices
if (/iPhone/.test(ua)) {return 'iPhone';}
if (/iPad/.test(ua)) {return 'iPad';}
if (/Android/.test(ua)) {
if (/Mobile/.test(ua)) {return 'Android Phone';}
return 'Android Tablet';
}
// Desktop OS
if (/Macintosh/.test(ua)) {return 'Mac';}
if (/Windows/.test(ua)) {return 'Windows PC';}
if (/Linux/.test(ua)) {return 'Linux PC';}
if (/CrOS/.test(ua)) {return 'Chromebook';}
return 'My Device';
},
/**
* Convert registration options from server to WebAuthn format
* @param {object} options - Options from server
* @returns {object} - WebAuthn PublicKeyCredentialCreationOptions
*/
convertRegistrationOptions(options) {
return {
challenge: this.base64urlToBuffer(options.challenge),
rp: options.rp,
user: {
id: this.stringToBuffer(options.user.id), // Handle both base64url and plain text
name: options.user.name,
displayName: options.user.displayName
},
pubKeyCredParams: options.pubKeyCredParams,
timeout: options.timeout,
attestation: options.attestation,
excludeCredentials: options.excludeCredentials?.map(cred => ({
id: this.base64urlToBuffer(cred.id),
type: cred.type,
transports: cred.transports
})) || [],
authenticatorSelection: options.authenticatorSelection
};
},
/**
* Convert authentication options from server to WebAuthn format
* @param {object} options - Options from server
* @returns {object} - WebAuthn PublicKeyCredentialRequestOptions
*/
convertAuthenticationOptions(options) {
return {
challenge: this.base64urlToBuffer(options.challenge),
rpId: options.rpId,
timeout: options.timeout,
userVerification: options.userVerification,
allowCredentials: options.allowCredentials?.map(cred => ({
id: this.base64urlToBuffer(cred.id),
type: cred.type,
transports: cred.transports
})) || []
};
},
/**
* Encode credential for sending to server
* @param {PublicKeyCredential} credential
* @returns {object}
*/
encodeCredential(credential) {
const response = credential.response;
return {
id: credential.id,
rawId: this.bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: this.bufferToBase64url(response.clientDataJSON),
attestationObject: this.bufferToBase64url(response.attestationObject),
transports: response.getTransports ? response.getTransports() : []
}
};
},
/**
* Encode authentication credential for sending to server
* @param {PublicKeyCredential} credential
* @returns {object}
*/
encodeAuthCredential(credential) {
const response = credential.response;
return {
id: credential.id,
rawId: this.bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: this.bufferToBase64url(response.clientDataJSON),
authenticatorData: this.bufferToBase64url(response.authenticatorData),
signature: this.bufferToBase64url(response.signature),
userHandle: response.userHandle ? this.bufferToBase64url(response.userHandle) : null
}
};
},
/**
* Convert string to ArrayBuffer (handles both plain text and base64url)
* @param {string} str - String to convert
* @returns {ArrayBuffer}
*/
stringToBuffer(str) {
// Check if it looks like base64url (only contains base64url-safe characters)
if (/^[A-Za-z0-9_-]+$/.test(str) && str.length > 10) {
// Likely base64url encoded, try to decode
try {
return this.base64urlToBuffer(str);
} catch (e) {
// If decoding fails, treat as plain text
console.warn('Failed to decode as base64url, treating as plain text:', e);
}
}
// Plain text - convert to UTF-8 bytes
const encoder = new TextEncoder();
return encoder.encode(str).buffer;
},
/**
* Convert base64url string to ArrayBuffer
* @param {string} base64url
* @returns {ArrayBuffer}
*/
base64urlToBuffer(base64url) {
try {
// Ensure input is a string and trim whitespace
if (typeof base64url !== 'string') {
throw new Error(`Expected string, got ${typeof base64url}`);
}
base64url = base64url.trim();
// Convert base64url to base64
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
// Add padding
const padLen = (4 - (base64.length % 4)) % 4;
const padded = base64 + '='.repeat(padLen);
// Decode base64 to binary string
const binary = atob(padded);
// Convert to ArrayBuffer
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
} catch (error) {
console.error('base64urlToBuffer error:', error);
console.error('Input value:', base64url);
console.error('Input type:', typeof base64url);
throw new Error(`Failed to decode base64url: ${error.message}`);
}
},
/**
* Convert ArrayBuffer to base64url string
* @param {ArrayBuffer} buffer
* @returns {string}
*/
bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
},
/**
* Delete a passkey
* @param {string} credentialId - Credential ID to delete
* @param {string} deviceName - Device name for confirmation
*/
async deletePasskey(credentialId, deviceName) {
if (!confirm(`Are you sure you want to delete the passkey "${deviceName}"?\n\nThis action cannot be undone.`)) {
return;
}
try {
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
if (!token) {
showNotification('error', 'Authentication Required', 'Please log in first');
return;
}
const response = await fetch(`/api/auth/passkey/${encodeURIComponent(credentialId)}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete passkey');
}
showNotification('success', 'Passkey Deleted', `${deviceName} has been removed`);
// Reload passkey list
if (typeof loadPasskeyStatus === 'function') {
loadPasskeyStatus();
}
if (typeof listPasskeys === 'function') {
listPasskeys();
}
} catch (error) {
console.error('Delete passkey error:', error);
showNotification('error', 'Delete Error', error.message);
}
},
/**
* Rename a passkey
* @param {string} credentialId - Credential ID to rename
* @param {string} currentName - Current device name
*/
async renamePasskey(credentialId, currentName) {
const newName = prompt('Enter a new name for this passkey:', currentName);
if (!newName || newName === currentName) {return;}
try {
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
if (!token) {
showNotification('error', 'Authentication Required', 'Please log in first');
return;
}
const response = await fetch(`/api/auth/passkey/${encodeURIComponent(credentialId)}/rename`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ deviceName: newName })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to rename passkey');
}
showNotification('success', 'Passkey Renamed', `Renamed to "${newName}"`);
// Reload passkey list
if (typeof listPasskeys === 'function') {
listPasskeys();
}
} catch (error) {
console.error('Rename passkey error:', error);
showNotification('error', 'Rename Error', error.message);
}
},
/**
* Authenticate with passkey
* @param {string} username - Username (optional for resident keys)
* @returns {Promise<object>} - Authentication result with token
*/
async authenticate(username = null) {
if (!this.isSupported()) {
this.showNotSupportedError();
return null;
}
try {
// Get authentication options from server
const optionsResponse = await fetch('/api/auth/passkey/authenticate/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
if (!optionsResponse.ok) {
const error = await optionsResponse.json();
throw new Error(error.error || 'Failed to get authentication options');
}
const { options } = await optionsResponse.json();
// Convert options for WebAuthn API
const publicKeyOptions = this.convertAuthenticationOptions(options);
// Prompt user for biometric/security key
showNotification('info', 'Authenticate', 'Please authenticate with your passkey...');
let credential;
try {
credential = await navigator.credentials.get({
publicKey: publicKeyOptions
});
} catch (err) {
if (err.name === 'NotAllowedError') {
showNotification('warning', 'Cancelled', 'Authentication was cancelled');
} else {
throw err;
}
return null;
}
if (!credential) {
showNotification('error', 'Authentication Failed', 'No credential was provided');
return null;
}
// Send credential to server for verification
const verificationResponse = await fetch('/api/auth/passkey/authenticate/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username,
response: this.encodeAuthCredential(credential)
})
});
if (!verificationResponse.ok) {
const error = await verificationResponse.json();
throw new Error(error.error || 'Authentication failed');
}
const result = await verificationResponse.json();
showNotification('success', 'Authenticated', 'Successfully logged in with passkey!');
return result;
} catch (error) {
console.error('Passkey authentication error:', error);
showNotification('error', 'Authentication Error', error.message);
return null;
}
}
};
// Make globally available
window.Passkeys = Passkeys;

177
web/frontend/public/sw.js Normal file
View File

@@ -0,0 +1,177 @@
// Service Worker for Media Downloader PWA
const CACHE_NAME = 'media-downloader-v2';
const STATIC_CACHE_NAME = 'media-downloader-static-v2';
// Assets to cache immediately on install
const PRECACHE_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/apple-touch-icon.png',
'/icon-192.png',
'/icon-512.png'
];
// Install event - precache static assets
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...');
event.waitUntil(
caches.open(STATIC_CACHE_NAME)
.then((cache) => {
console.log('[SW] Precaching static assets');
return cache.addAll(PRECACHE_ASSETS);
})
.then(() => {
console.log('[SW] Install complete');
return self.skipWaiting();
})
.catch((err) => {
console.error('[SW] Precache failed:', err);
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE_NAME)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => {
console.log('[SW] Activation complete');
return self.clients.claim();
})
);
});
// Fetch event - serve from cache with network fallback
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip API requests - always go to network
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
return;
}
// Skip external requests
if (url.origin !== self.location.origin) {
return;
}
// For hashed JS/CSS assets (Vite bundles like /assets/index-BYx1TwGc.js),
// use cache-first since the hash guarantees uniqueness - no stale cache risk.
// This prevents full reloads on iOS PWA when the app resumes from background.
if (url.pathname.startsWith('/assets/') && url.pathname.match(/\.[a-f0-9]{8,}\.(js|css)$/)) {
event.respondWith(
caches.match(request)
.then((cachedResponse) => {
if (cachedResponse) return cachedResponse;
return fetch(request).then((networkResponse) => {
if (networkResponse.ok) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone));
}
return networkResponse;
});
})
);
return;
}
// Skip non-hashed JS (sw.js, totp.js, etc) - always go to network
if (url.pathname.endsWith('.js')) {
return;
}
// For static assets (css, images, fonts), use cache-first strategy
if (request.destination === 'style' ||
request.destination === 'image' ||
request.destination === 'font' ||
url.pathname.match(/\.(css|png|jpg|jpeg|gif|svg|woff|woff2|ico)$/)) {
event.respondWith(
caches.match(request)
.then((cachedResponse) => {
if (cachedResponse) {
// Return cached version, but update cache in background
event.waitUntil(
fetch(request)
.then((networkResponse) => {
if (networkResponse.ok) {
caches.open(CACHE_NAME)
.then((cache) => cache.put(request, networkResponse));
}
})
.catch(() => {})
);
return cachedResponse;
}
// Not in cache, fetch from network and cache
return fetch(request)
.then((networkResponse) => {
if (networkResponse.ok) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME)
.then((cache) => cache.put(request, responseClone));
}
return networkResponse;
});
})
);
return;
}
// For HTML/navigation requests, use network-first with cache fallback
if (request.mode === 'navigate' || request.destination === 'document') {
event.respondWith(
fetch(request)
.then((networkResponse) => {
// Cache the latest version
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME)
.then((cache) => cache.put(request, responseClone));
return networkResponse;
})
.catch(() => {
// Network failed, try cache
return caches.match(request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// Fallback to cached index.html for SPA routing
return caches.match('/index.html');
});
})
);
return;
}
});
// Handle messages from the app
self.addEventListener('message', (event) => {
if (event.data === 'skipWaiting') {
self.skipWaiting();
}
if (event.data === 'clearCache') {
caches.keys().then((names) => {
names.forEach((name) => caches.delete(name));
});
}
});

506
web/frontend/public/totp.js Normal file
View File

@@ -0,0 +1,506 @@
/**
* TOTP Client-Side Module
* Handles Two-Factor Authentication setup, verification, and management
*/
const TOTP = {
/**
* Initialize TOTP setup process
*/
async setupTOTP() {
try {
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
if (!token) {
showNotification('Please log in first', 'error');
return;
}
const response = await fetch('/api/auth/totp/setup', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (!data.success) {
showNotification(data.error || 'Failed to setup 2FA', 'error');
return;
}
// Show setup modal with QR code
this.showSetupModal(data.qrCodeDataURL, data.secret);
} catch (error) {
console.error('TOTP setup error:', error);
showNotification('Failed to setup 2FA', 'error');
}
},
/**
* Display TOTP setup modal with QR code
*/
showSetupModal(qrCodeDataURL, secret) {
const modal = document.createElement('div');
modal.id = 'totpSetupModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content totp-modal">
<div class="modal-header">
<h2>
<i class="fas fa-shield-alt"></i>
Enable Two-Factor Authentication
</h2>
<button class="close-btn" onclick="TOTP.closeSetupModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<div class="totp-setup-steps">
<div class="step active" id="step1">
<h3>Step 1: Scan QR Code</h3>
<p>Open your authenticator app (Google Authenticator, Microsoft Authenticator, Authy, etc.) and scan this QR code:</p>
<div class="qr-code-container">
<img src="${qrCodeDataURL}" alt="QR Code" class="qr-code-image">
</div>
<div class="manual-entry">
<p>Can't scan the code? Enter this key manually:</p>
<div class="secret-key">
<code id="secretKey">${secret}</code>
<button class="btn-copy" onclick="TOTP.copySecret('${secret}')">
<i class="fas fa-copy"></i> Copy
</button>
</div>
</div>
<button class="btn btn-primary" onclick="TOTP.showVerifyStep()">
Next: Verify Code
</button>
</div>
<div class="step" id="step2" style="display: none;">
<h3>Step 2: Verify Your Code</h3>
<p>Enter the 6-digit code from your authenticator app:</p>
<div class="verify-input-container">
<input
type="text"
id="verifyCode"
maxlength="6"
pattern="[0-9]{6}"
placeholder="000000"
autocomplete="off"
class="totp-code-input"
>
</div>
<div class="button-group">
<button class="btn btn-secondary" onclick="TOTP.showScanStep()">
<i class="fas fa-arrow-left"></i> Back
</button>
<button class="btn btn-primary" onclick="TOTP.verifySetup()">
<i class="fas fa-check"></i> Verify & Enable
</button>
</div>
</div>
<div class="step" id="step3" style="display: none;">
<h3>Step 3: Save Backup Codes</h3>
<div class="warning-box">
<i class="fas fa-exclamation-triangle"></i>
<p><strong>Important:</strong> Save these backup codes in a safe place.
You can use them to access your account if you lose your authenticator device.</p>
</div>
<div class="backup-codes" id="backupCodes">
<!-- Backup codes will be inserted here -->
</div>
<div class="button-group">
<button class="btn btn-secondary" onclick="TOTP.downloadBackupCodes()">
<i class="fas fa-download"></i> Download
</button>
<button class="btn btn-secondary" onclick="TOTP.printBackupCodes()">
<i class="fas fa-print"></i> Print
</button>
<button class="btn btn-secondary" onclick="TOTP.copyBackupCodes()">
<i class="fas fa-copy"></i> Copy All
</button>
</div>
<div class="confirm-save">
<label>
<input type="checkbox" id="confirmSaved">
I have saved my backup codes
</label>
</div>
<button class="btn btn-success" onclick="TOTP.finishSetup()" disabled id="finishBtn">
<i class="fas fa-check-circle"></i> Finish Setup
</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Enable finish button when checkbox is checked
document.getElementById('confirmSaved')?.addEventListener('change', (e) => {
document.getElementById('finishBtn').disabled = !e.target.checked;
});
// Auto-focus on verify code input when shown
document.getElementById('verifyCode')?.addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/[^0-9]/g, '');
});
},
showScanStep() {
document.getElementById('step1').style.display = 'block';
document.getElementById('step2').style.display = 'none';
document.getElementById('step1').classList.add('active');
document.getElementById('step2').classList.remove('active');
},
showVerifyStep() {
document.getElementById('step1').style.display = 'none';
document.getElementById('step2').style.display = 'block';
document.getElementById('step1').classList.remove('active');
document.getElementById('step2').classList.add('active');
setTimeout(() => document.getElementById('verifyCode')?.focus(), 100);
},
/**
* Verify TOTP code and complete setup
*/
async verifySetup() {
const code = document.getElementById('verifyCode')?.value;
if (!code || code.length !== 6) {
showNotification('Please enter a 6-digit code', 'error');
return;
}
try {
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
const response = await fetch('/api/auth/totp/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
});
const data = await response.json();
if (!data.success) {
showNotification(data.error || 'Invalid code', 'error');
return;
}
// Show backup codes
this.showBackupCodes(data.backupCodes);
document.getElementById('step2').style.display = 'none';
document.getElementById('step3').style.display = 'block';
document.getElementById('step2').classList.remove('active');
document.getElementById('step3').classList.add('active');
showNotification('Two-factor authentication enabled!', 'success');
} catch (error) {
console.error('TOTP verification error:', error);
showNotification('Failed to verify code', 'error');
}
},
/**
* Display backup codes
*/
showBackupCodes(codes) {
const container = document.getElementById('backupCodes');
if (!container) {return;}
this.backupCodes = codes; // Store for later use
container.innerHTML = `
<div class="backup-codes-grid">
${codes.map((code, index) => `
<div class="backup-code-item">
<span class="code-number">${index + 1}.</span>
<code>${code}</code>
</div>
`).join('')}
</div>
`;
},
/**
* Copy secret to clipboard
*/
copySecret(secret) {
navigator.clipboard.writeText(secret).then(() => {
showNotification('Secret key copied to clipboard', 'success');
}).catch(() => {
showNotification('Failed to copy secret key', 'error');
});
},
/**
* Download backup codes as text file
*/
downloadBackupCodes() {
if (!this.backupCodes) {return;}
const content = `Backup Central - Two-Factor Authentication Backup Codes
Generated: ${new Date().toLocaleString()}
IMPORTANT: Keep these codes in a safe place!
Each code can only be used once.
${this.backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n')}
If you lose access to your authenticator app, you can use one of these codes to log in.
`;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `backup-codes-${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification('Backup codes downloaded', 'success');
},
/**
* Print backup codes
*/
printBackupCodes() {
if (!this.backupCodes) {return;}
const printWindow = window.open('', '', 'width=600,height=400');
printWindow.document.write(`
<html>
<head>
<title>Backup Codes</title>
<style>
body { font-family: monospace; padding: 20px; }
h1 { font-size: 18px; }
.code { margin: 10px 0; font-size: 14px; }
@media print {
button { display: none; }
}
</style>
</head>
<body>
<h1>Backup Central - 2FA Backup Codes</h1>
<p>Generated: ${new Date().toLocaleString()}</p>
<hr>
${this.backupCodes.map((code, i) => `<div class="code">${i + 1}. ${code}</div>`).join('')}
<hr>
<p><strong>Keep these codes in a safe place!</strong></p>
<button onclick="window.print()">Print</button>
</body>
</html>
`);
printWindow.document.close();
},
/**
* Copy all backup codes to clipboard
*/
copyBackupCodes() {
if (!this.backupCodes) {return;}
const text = this.backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n');
navigator.clipboard.writeText(text).then(() => {
showNotification('Backup codes copied to clipboard', 'success');
}).catch(() => {
showNotification('Failed to copy backup codes', 'error');
});
},
/**
* Complete setup and close modal
*/
finishSetup() {
this.closeSetupModal();
showNotification('Two-factor authentication is now active!', 'success');
// Refresh 2FA status in UI
if (typeof loadTOTPStatus === 'function') {
loadTOTPStatus();
}
},
/**
* Close setup modal
*/
closeSetupModal() {
const modal = document.getElementById('totpSetupModal');
if (modal) {
modal.remove();
}
},
/**
* Disable TOTP for current user
*/
async disableTOTP() {
const confirmed = confirm('Are you sure you want to disable two-factor authentication?\n\nThis will make your account less secure.');
if (!confirmed) {return;}
const password = prompt('Enter your password to confirm:');
if (!password) {return;}
const code = prompt('Enter your current 2FA code:');
if (!code || code.length !== 6) {
showNotification('Invalid code', 'error');
return;
}
try {
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
const response = await fetch('/api/auth/totp/disable', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ password, code })
});
const data = await response.json();
if (!data.success) {
showNotification(data.error || 'Failed to disable 2FA', 'error');
return;
}
showNotification('Two-factor authentication disabled', 'success');
// Refresh 2FA status in UI
if (typeof loadTOTPStatus === 'function') {
loadTOTPStatus();
}
} catch (error) {
console.error('Disable TOTP error:', error);
showNotification('Failed to disable 2FA', 'error');
}
},
/**
* Regenerate backup codes
*/
async regenerateBackupCodes() {
const confirmed = confirm('This will invalidate your old backup codes.\n\nAre you sure you want to generate new backup codes?');
if (!confirmed) {return;}
const code = prompt('Enter your current 2FA code to confirm:');
if (!code || code.length !== 6) {
showNotification('Invalid code', 'error');
return;
}
try {
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
const response = await fetch('/api/auth/totp/regenerate-backup-codes', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
});
const data = await response.json();
if (!data.success) {
showNotification(data.error || 'Failed to regenerate codes', 'error');
return;
}
// Show new backup codes
this.showBackupCodesModal(data.backupCodes);
} catch (error) {
console.error('Regenerate codes error:', error);
showNotification('Failed to regenerate backup codes', 'error');
}
},
/**
* Show backup codes in a modal (for regeneration)
*/
showBackupCodesModal(codes) {
this.backupCodes = codes;
const modal = document.createElement('div');
modal.id = 'backupCodesModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2>New Backup Codes</h2>
<button class="close-btn" onclick="TOTP.closeBackupCodesModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<div class="warning-box">
<i class="fas fa-exclamation-triangle"></i>
<p><strong>Important:</strong> Save these new backup codes. Your old codes are no longer valid.</p>
</div>
<div class="backup-codes" id="newBackupCodes">
<div class="backup-codes-grid">
${codes.map((code, index) => `
<div class="backup-code-item">
<span class="code-number">${index + 1}.</span>
<code>${code}</code>
</div>
`).join('')}
</div>
</div>
<div class="button-group">
<button class="btn btn-secondary" onclick="TOTP.downloadBackupCodes()">
<i class="fas fa-download"></i> Download
</button>
<button class="btn btn-secondary" onclick="TOTP.printBackupCodes()">
<i class="fas fa-print"></i> Print
</button>
<button class="btn btn-secondary" onclick="TOTP.copyBackupCodes()">
<i class="fas fa-copy"></i> Copy All
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
showNotification('New backup codes generated', 'success');
},
closeBackupCodesModal() {
const modal = document.getElementById('backupCodesModal');
if (modal) {
modal.remove();
}
}
};
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = TOTP;
}

1205
web/frontend/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,432 @@
import { X, Calendar, Tv, Mic, Radio, Film, ExternalLink, CheckCircle, Loader2, MonitorPlay, User, Clapperboard, Megaphone, PenTool, Sparkles } from 'lucide-react'
import { format, parseISO } from 'date-fns'
import { useQuery } from '@tanstack/react-query'
import { api } from '../lib/api'
interface Appearance {
id: number
celebrity_id: number
celebrity_name: string
appearance_type: 'TV' | 'Movie' | 'Podcast' | 'Radio'
show_name: string
episode_title: string | null
network: string | null
appearance_date: string
url: string | null
audio_url: string | null
watch_url: string | null
description: string | null
tmdb_show_id: number | null
season_number: number | null
episode_number: number | null
status: string
poster_url: string | null
episode_count: number
credit_type: 'acting' | 'host' | 'directing' | 'producing' | 'writing' | 'creator' | 'guest' | null
character_name: string | null
job_title: string | null
plex_rating_key: string | null
plex_watch_url: string | null
all_credit_types?: string[]
all_roles?: { credit_type: string; character_name: string | null; job_title: string | null }[]
}
const creditTypeConfig: Record<string, { label: string; color: string; icon: typeof User }> = {
acting: { label: 'Acting', color: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', icon: User },
directing: { label: 'Directing', color: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', icon: Clapperboard },
producing: { label: 'Producing', color: 'bg-green-500/10 text-green-600 dark:text-green-400', icon: Megaphone },
writing: { label: 'Writing', color: 'bg-purple-500/10 text-purple-600 dark:text-purple-400', icon: PenTool },
creator: { label: 'Creator', color: 'bg-pink-500/10 text-pink-600 dark:text-pink-400', icon: Sparkles },
guest: { label: 'Guest', color: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400', icon: User },
host: { label: 'Host', color: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', icon: Mic },
}
interface Props {
appearance: Appearance
onClose: () => void
onMarkWatched?: (id: number) => void
}
export default function AppearanceDetailModal({ appearance, onClose, onMarkWatched }: Props) {
// Fetch all episodes if this is a podcast or TV show with multiple episodes
const { data: episodes, isLoading: episodesLoading } = useQuery({
queryKey: ['show-episodes', appearance.celebrity_id, appearance.show_name, appearance.appearance_type],
queryFn: () => api.getShowEpisodes(appearance.celebrity_id, appearance.show_name, appearance.appearance_type),
enabled: (appearance.appearance_type === 'Podcast' || appearance.appearance_type === 'TV') && appearance.episode_count > 1,
})
const typeIcon = {
TV: Tv,
Movie: Film,
Podcast: Mic,
Radio: Radio,
}[appearance.appearance_type]
const TypeIcon = typeIcon || Tv
return (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<div
className="card-glass-hover rounded-t-2xl sm:rounded-2xl p-4 sm:p-6 w-full sm:max-w-3xl max-h-[85vh] sm:max-h-[90vh] overflow-y-auto sm:mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* Header with Close - sticky on mobile */}
<div className="flex justify-between items-center mb-4 -mx-4 sm:-mx-6 -mt-4 sm:-mt-6 px-4 sm:px-6 pt-4 sm:pt-6 pb-2 sticky top-0 bg-inherit z-10">
{/* Drag handle for mobile */}
<div className="w-10 h-1 bg-slate-300 dark:bg-slate-600 rounded-full sm:hidden mx-auto absolute left-1/2 -translate-x-1/2 top-2" />
<div className="flex-1" />
<button
onClick={onClose}
className="p-2 hover:bg-secondary rounded-lg transition-colors touch-manipulation min-w-[44px] min-h-[44px] flex items-center justify-center"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content with Poster - stack on mobile */}
<div className="flex flex-col sm:flex-row sm:items-start gap-4 sm:gap-6 mb-6">
{/* Poster - centered on mobile */}
{appearance.poster_url ? (
<div className="flex-shrink-0 flex justify-center sm:justify-start">
<img
src={`/api/appearances/poster/${appearance.id}`}
alt={appearance.show_name}
className="w-24 sm:w-48 h-auto object-contain rounded-xl shadow-lg"
/>
</div>
) : (
<div className="flex-shrink-0 flex justify-center sm:justify-start">
<div className="w-24 sm:w-48 h-36 sm:h-72 bg-gradient-to-br from-blue-600 to-cyan-600 rounded-xl flex items-center justify-center shadow-lg">
<TypeIcon className="w-12 sm:w-16 h-12 sm:h-16 text-white" />
</div>
</div>
)}
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 sm:gap-3 mb-4">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-blue-600 to-cyan-600 rounded-xl flex items-center justify-center flex-shrink-0">
<TypeIcon className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
</div>
<div className="min-w-0 flex-1">
<h2 className="text-lg sm:text-2xl font-bold text-foreground truncate">{appearance.celebrity_name}</h2>
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap">
<p className="text-xs sm:text-sm text-muted-foreground">{appearance.appearance_type} Appearance</p>
{/* Show all credit types for this show */}
{(appearance.all_credit_types || [appearance.credit_type]).filter((ct): ct is string => Boolean(ct)).map((creditType) => {
const cc = creditTypeConfig[creditType]
if (!cc) return null
const CreditIcon = cc.icon
return (
<span key={creditType} className={`text-xs px-2 py-0.5 rounded flex items-center gap-1 font-medium ${cc.color}`}>
<CreditIcon className="w-3 h-3" />
{cc.label}
</span>
)
})}
</div>
</div>
</div>
{/* Role/Character Info — show all roles when multiple */}
{appearance.all_roles && appearance.all_roles.length > 1 ? (
<div className="mb-4 space-y-2">
{appearance.all_roles.map((role) => {
const cc = creditTypeConfig[role.credit_type]
const RoleIcon = cc?.icon || User
return (
<div key={role.credit_type} className="flex items-center gap-3 p-3 bg-secondary/50 rounded-xl">
<span className={`text-xs px-2 py-1 rounded flex items-center gap-1 flex-shrink-0 font-medium ${cc?.color || 'bg-slate-500/10 text-slate-400'}`}>
<RoleIcon className="w-3 h-3" />
{cc?.label || role.credit_type}
</span>
{(role.character_name || role.job_title) && (
<p className="text-sm font-medium text-foreground">
{role.credit_type === 'acting' || role.credit_type === 'guest' || role.credit_type === 'host'
? (role.character_name ? `as ${role.character_name}` : role.job_title)
: (role.job_title || role.character_name)}
</p>
)}
</div>
)
})}
</div>
) : (appearance.character_name || appearance.job_title) ? (
<div className="mb-4 p-3 bg-secondary/50 rounded-xl">
<p className="text-xs text-muted-foreground mb-1">
{appearance.credit_type === 'acting' || appearance.credit_type === 'guest' || appearance.credit_type === 'host' ? 'Role' : 'Credit'}
</p>
<p className="text-base font-medium text-foreground">
{appearance.character_name || appearance.job_title}
</p>
</div>
) : null}
{/* Show Info */}
<div className="space-y-3 sm:space-y-4 mb-4 sm:mb-6">
<div>
<h3 className="text-base sm:text-lg font-semibold text-foreground mb-1 break-words">{appearance.show_name}</h3>
{appearance.network && (
<p className="text-xs sm:text-sm text-muted-foreground">{appearance.network}</p>
)}
</div>
{/* For TV/Podcast with multiple episodes, don't show episode-specific info in header */}
{appearance.episode_title && !((appearance.appearance_type === 'TV' || appearance.appearance_type === 'Podcast') && appearance.episode_count > 1) && (
<div>
<p className="text-sm text-muted-foreground">Episode</p>
<div className="flex items-center justify-between gap-2">
<p className="text-base font-medium text-foreground flex-1">
{appearance.season_number && appearance.episode_number && (
<span className="text-muted-foreground mr-2">
S{appearance.season_number}E{appearance.episode_number}
</span>
)}
{appearance.episode_title}
</p>
{appearance.url && (
<a
href={appearance.url}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 p-1.5 hover:bg-blue-500/10 rounded-lg transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</a>
)}
</div>
</div>
)}
{/* Show episode count and latest date for multi-episode shows */}
{(appearance.appearance_type === 'TV' || appearance.appearance_type === 'Podcast') && appearance.episode_count > 1 && (
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4">
<div className="flex items-center gap-2 p-2 sm:p-3 bg-purple-500/10 border border-purple-500/30 rounded-xl flex-1">
<Tv className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600 dark:text-purple-400 flex-shrink-0" />
<div className="min-w-0">
<p className="text-xs text-muted-foreground">Episodes</p>
<p className="text-sm sm:text-base font-semibold text-foreground">
{appearance.episode_count} episodes
</p>
</div>
</div>
<div className="flex items-center gap-2 p-2 sm:p-3 bg-blue-500/10 border border-blue-500/30 rounded-xl flex-1">
<Calendar className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="min-w-0">
<p className="text-xs text-muted-foreground">Latest Episode</p>
<p className="text-sm sm:text-base font-semibold text-foreground">
{format(parseISO(appearance.appearance_date), 'MMM d, yyyy')}
</p>
</div>
</div>
</div>
)}
{/* Air Date - only show for single-episode shows or movies */}
{!((appearance.appearance_type === 'TV' || appearance.appearance_type === 'Podcast') && appearance.episode_count > 1) && (
<div className={`flex items-center gap-2 p-2 sm:p-3 rounded-xl ${appearance.appearance_date.startsWith('1900') ? 'bg-purple-500/10 border border-purple-500/30' : 'bg-blue-500/10 border border-blue-500/30'}`}>
<Calendar className={`w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0 ${appearance.appearance_date.startsWith('1900') ? 'text-purple-600 dark:text-purple-400' : 'text-blue-600 dark:text-blue-400'}`} />
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{appearance.appearance_date.startsWith('1900') ? 'Status' : 'Airs on'}</p>
<p className="text-sm sm:text-base font-semibold text-foreground truncate">
{appearance.appearance_date.startsWith('1900') ? 'Coming Soon' : format(parseISO(appearance.appearance_date), 'MMM d, yyyy')}
</p>
</div>
</div>
)}
{/* Description */}
{appearance.description && (
<div>
<p className="text-sm text-muted-foreground mb-1">Description</p>
<p className="text-sm text-foreground">{appearance.description}</p>
</div>
)}
{/* Audio Player for main episode */}
{appearance.appearance_type === 'Podcast' && appearance.audio_url && (
<div>
<p className="text-sm text-muted-foreground mb-2">Listen</p>
<audio controls className="w-full" preload="metadata">
<source src={appearance.audio_url} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
)}
</div>
</div>
</div>
{/* Episode List (for podcasts and TV shows with multiple episodes) */}
{(appearance.appearance_type === 'Podcast' || appearance.appearance_type === 'TV') && appearance.episode_count > 1 && (
<div className="mb-6 border-t border-slate-200 dark:border-slate-700 pt-6">
{episodesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
</div>
) : episodes && episodes.length > 0 ? (
(() => {
// Filter out placeholder entries (season 0, episode 0 with no real title)
// These are show-level fallback entries, not actual episode appearances
const filteredEpisodes = episodes.filter(ep =>
!(ep.season_number === 0 && ep.episode_number === 0)
)
// For TV: show all episodes, For podcasts: filter out current
const displayEpisodes = appearance.appearance_type === 'TV'
? filteredEpisodes
: filteredEpisodes.filter(ep => ep.id !== appearance.id)
const EpisodeIcon = appearance.appearance_type === 'TV' ? Tv : Mic
// Don't show section if no real episodes after filtering
if (displayEpisodes.length === 0) return null
return (
<>
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<EpisodeIcon className="w-5 h-5" />
{appearance.appearance_type === 'TV' ? 'All Episodes' : 'Other Episodes'} ({displayEpisodes.length})
</h3>
<div className="space-y-2 max-h-64 overflow-y-auto">
{displayEpisodes.map((episode, index) => (
<div
key={episode.id}
className="p-3 bg-secondary/50 rounded-lg hover:bg-secondary transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{appearance.appearance_type === 'TV' && episode.season_number && episode.episode_number ? (
<span className="text-muted-foreground mr-2">S{episode.season_number}E{episode.episode_number}</span>
) : null}
{episode.episode_title || `Episode ${index + 1}`}
</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<p className="text-xs text-muted-foreground">
{format(parseISO(episode.appearance_date), 'MMM d, yyyy')}
</p>
{(episode.all_credit_types || (episode.credit_type ? [episode.credit_type] : [])).map((ct: string) => {
const cc = creditTypeConfig[ct]
return cc ? (
<span key={ct} className={`text-xs px-1.5 py-0.5 rounded font-medium flex items-center gap-1 ${cc.color}`}>
<cc.icon className="w-3 h-3" />
{cc.label}
</span>
) : null
})}
{episode.all_roles && episode.all_roles.length > 0 ? (
episode.all_roles.map((role: { credit_type: string; character_name: string | null; job_title: string | null }) => (
(role.character_name || role.job_title) && (
<span key={role.credit_type + '-role'} className="text-xs text-muted-foreground">
{role.credit_type === 'acting' || role.credit_type === 'guest' || role.credit_type === 'host'
? (role.character_name ? `as ${role.character_name}` : '')
: (role.job_title || '')}
</span>
)
))
) : (
<>
{episode.character_name && (
<span className="text-xs text-muted-foreground">as {episode.character_name}</span>
)}
{episode.job_title && !episode.character_name && (
<span className="text-xs text-muted-foreground">{episode.job_title}</span>
)}
</>
)}
</div>
{episode.audio_url && (
<audio controls className="w-full mt-2" preload="metadata">
<source src={episode.audio_url} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
)}
</div>
{/* Actions on RIGHT, vertically centered */}
<div className="flex items-center gap-2 flex-shrink-0">
{episode.plex_watch_url && (
<a
href={episode.plex_watch_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-2 py-1 bg-gradient-to-r from-amber-600 to-orange-600 text-white text-xs rounded hover:from-amber-700 hover:to-orange-700 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<MonitorPlay className="w-3 h-3" />
Plex
</a>
)}
{episode.url && (
<a
href={episode.url}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 hover:bg-blue-500/10 rounded-lg transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</a>
)}
</div>
</div>
</div>
))}
</div>
</>
)
})()
) : (
<p className="text-sm text-muted-foreground">No episodes available</p>
)}
</div>
)}
{/* Actions - stack on mobile */}
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
{appearance.plex_watch_url && (
<a
href={appearance.plex_watch_url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 bg-gradient-to-r from-amber-600 to-orange-600 text-white rounded-lg py-3 sm:py-2 font-medium flex items-center justify-center gap-2 hover:from-amber-700 hover:to-orange-700 transition-all touch-manipulation min-h-[44px]"
>
<MonitorPlay className="w-4 h-4" />
Watch on Plex
</a>
)}
{appearance.watch_url && (
<a
href={appearance.watch_url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 btn-primary flex items-center justify-center gap-2 py-3 sm:py-2 touch-manipulation min-h-[44px]"
>
<ExternalLink className="w-4 h-4" />
Watch Live
</a>
)}
{onMarkWatched && appearance.status !== 'watched' && (
<button
onClick={() => onMarkWatched(appearance.id)}
className="flex-1 btn-secondary flex items-center justify-center gap-2 py-3 sm:py-2 touch-manipulation min-h-[44px]"
>
<CheckCircle className="w-4 h-4" />
Mark as Watched
</button>
)}
{appearance.status === 'watched' && (
<div className="flex-1 flex items-center justify-center gap-2 text-green-600 dark:text-green-400 bg-green-500/10 border border-green-500/30 rounded-lg py-3 sm:py-2 min-h-[44px]">
<CheckCircle className="w-4 h-4" />
<span className="text-sm font-medium">Watched</span>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,199 @@
import { X, Loader2, CheckCircle2, XCircle } from 'lucide-react'
export interface BatchProgressItem {
id: string
filename: string
status: 'pending' | 'processing' | 'success' | 'error'
error?: string
}
interface BatchProgressModalProps {
isOpen: boolean
title: string
items: BatchProgressItem[]
onClose: () => void
/** Allow closing while still processing */
allowEarlyClose?: boolean
/** Show a summary instead of all files when count is high */
collapseLargeList?: boolean
/** Threshold for collapsing (default: 20) */
collapseThreshold?: number
}
export function BatchProgressModal({
isOpen,
title,
items,
onClose,
allowEarlyClose = false,
collapseLargeList = true,
collapseThreshold = 20,
}: BatchProgressModalProps) {
if (!isOpen) return null
const completed = items.filter(f => f.status === 'success').length
const failed = items.filter(f => f.status === 'error').length
const pending = items.filter(f => f.status === 'pending').length
const processing = items.filter(f => f.status === 'processing').length
const total = items.length
const isComplete = pending === 0 && processing === 0
const progressPercent = total > 0 ? Math.round(((completed + failed) / total) * 100) : 0
// Determine if we should show collapsed view
const showCollapsed = collapseLargeList && total > collapseThreshold
// Get items to display (errors first, then processing, then recent successes)
const getDisplayItems = () => {
if (!showCollapsed) return items
const errors = items.filter(i => i.status === 'error')
const processingItems = items.filter(i => i.status === 'processing')
const successes = items.filter(i => i.status === 'success').slice(-3)
const pendingItems = items.filter(i => i.status === 'pending').slice(0, 2)
return [...errors, ...processingItems, ...pendingItems, ...successes]
}
const displayItems = getDisplayItems()
const hiddenCount = showCollapsed ? total - displayItems.length : 0
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{title}
</h3>
{(isComplete || allowEarlyClose) && (
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 dark:text-slate-400"
>
<X className="w-5 h-5" />
</button>
)}
</div>
{/* Progress Bar */}
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-slate-600 dark:text-slate-400">
{isComplete ? 'Complete' : 'Processing...'}
</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{completed + failed} / {total}
</span>
</div>
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
failed > 0 && isComplete
? 'bg-gradient-to-r from-green-500 to-red-500'
: 'bg-blue-500'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
{/* Status Summary */}
<div className="flex items-center gap-4 mt-2 text-xs">
{completed > 0 && (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{completed} succeeded
</span>
)}
{failed > 0 && (
<span className="flex items-center gap-1 text-red-600 dark:text-red-400">
<XCircle className="w-3.5 h-3.5" />
{failed} failed
</span>
)}
{processing > 0 && (
<span className="flex items-center gap-1 text-blue-600 dark:text-blue-400">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{processing} processing
</span>
)}
{pending > 0 && (
<span className="text-slate-500 dark:text-slate-400">
{pending} waiting
</span>
)}
</div>
</div>
{/* File List */}
<div className="flex-1 overflow-auto p-4 space-y-2 min-h-0">
{showCollapsed && hiddenCount > 0 && (
<div className="text-xs text-slate-500 dark:text-slate-400 text-center py-1">
Showing {displayItems.length} of {total} items
</div>
)}
{displayItems.map((file) => (
<div
key={file.id}
className={`flex items-center gap-3 p-2.5 rounded-lg transition-colors ${
file.status === 'error'
? 'bg-red-50 dark:bg-red-900/20'
: file.status === 'success'
? 'bg-green-50 dark:bg-green-900/20'
: file.status === 'processing'
? 'bg-blue-50 dark:bg-blue-900/20'
: 'bg-slate-50 dark:bg-slate-900/50'
}`}
>
{/* Status Icon */}
<div className="flex-shrink-0">
{file.status === 'pending' && (
<div className="w-5 h-5 rounded-full border-2 border-slate-300 dark:border-slate-600" />
)}
{file.status === 'processing' && (
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
)}
{file.status === 'success' && (
<CheckCircle2 className="w-5 h-5 text-green-500" />
)}
{file.status === 'error' && (
<XCircle className="w-5 h-5 text-red-500" />
)}
</div>
{/* File Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
{file.filename}
</p>
{file.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5 truncate">
{file.error}
</p>
)}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-200 dark:border-slate-700 flex justify-end">
{isComplete ? (
<button
onClick={onClose}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Done
</button>
) : (
<p className="text-sm text-slate-500 dark:text-slate-400">
Please wait while processing completes...
</p>
)}
</div>
</div>
</div>
)
}
export default BatchProgressModal

View File

@@ -0,0 +1,46 @@
import { Link } from 'react-router-dom'
import { ChevronRight, Home } from 'lucide-react'
import { useBreadcrumbContext } from '../contexts/BreadcrumbContext'
export default function Breadcrumb() {
const { breadcrumbs } = useBreadcrumbContext()
// Don't render if no breadcrumbs or just Home
if (breadcrumbs.length <= 1) {
return null
}
return (
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-4 px-1">
{breadcrumbs.map((item, index) => {
const isLast = index === breadcrumbs.length - 1
const isFirst = index === 0
const Icon = item.icon
return (
<div key={index} className="flex items-center gap-1.5">
{index > 0 && (
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground/50" />
)}
{item.path && !isLast ? (
<Link
to={item.path}
className="flex items-center gap-1.5 hover:text-foreground transition-colors rounded px-1.5 py-0.5 hover:bg-secondary/50"
>
{isFirst && <Home className="w-3.5 h-3.5" />}
{Icon && !isFirst && <Icon className="w-3.5 h-3.5" />}
<span className="max-w-[150px] truncate">{item.label}</span>
</Link>
) : (
<span className="flex items-center gap-1.5 text-foreground font-medium px-1.5 py-0.5">
{Icon && <Icon className="w-3.5 h-3.5" />}
<span className="max-w-[200px] truncate">{item.label}</span>
</span>
)}
</div>
)
})}
</nav>
)
}

View File

@@ -0,0 +1,158 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { KeyRound, X, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react'
import { api, wsClient } from '../lib/api'
import { formatRelativeTime } from '../lib/utils'
interface CookieService {
id: string
name: string
type: 'scraper' | 'paid_content' | 'private_gallery'
status: 'healthy' | 'expired' | 'missing' | 'down' | 'degraded' | 'unknown' | 'failed'
last_updated?: string
last_checked?: string
message: string
}
const statusConfig: Record<string, { label: string; color: string }> = {
expired: { label: 'Expired', color: 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300' },
failed: { label: 'Failed', color: 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300' },
down: { label: 'Down', color: 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300' },
degraded: { label: 'Degraded', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300' },
missing: { label: 'Missing', color: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300' },
unknown: { label: 'Unknown', color: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400' },
healthy: { label: 'Healthy', color: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300' },
}
export function CookieHealthBanner() {
const [isExpanded, setIsExpanded] = useState(false)
const [isDismissed, setIsDismissed] = useState(false)
const { data } = useQuery({
queryKey: ['cookie-health'],
queryFn: () => api.getCookieHealth(),
refetchInterval: 60000,
staleTime: 0,
})
// Listen for real-time cookie health alerts
useEffect(() => {
const unsubscribe = wsClient.on('cookie_health_alert', () => {
setIsDismissed(false)
})
return () => { unsubscribe() }
}, [])
if (isDismissed || !data?.has_issues) {
return null
}
const issueServices = data.services.filter(
(s: CookieService) => s.status !== 'healthy' && s.status !== 'unknown'
)
if (issueServices.length === 0) {
return null
}
const getSettingsLink = (_service: CookieService) => '/scrapers'
return (
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 dark:from-amber-900/20 dark:to-yellow-900/20 rounded-lg shadow-sm border border-amber-200 dark:border-amber-800 overflow-hidden">
{/* Main Banner */}
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3 flex-1">
<div className="relative flex-shrink-0">
<KeyRound className="w-6 h-6 text-amber-600 dark:text-amber-400" />
<div className="absolute -top-1 -right-1 w-4 h-4 bg-amber-500 rounded-full flex items-center justify-center">
<span className="text-[10px] font-bold text-white">
{data.issue_count > 9 ? '9+' : data.issue_count}
</span>
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100">
{data.issue_count} Cookie/Session Issue{data.issue_count !== 1 ? 's' : ''} Detected
</h3>
<p className="text-xs text-amber-700 dark:text-amber-300 mt-0.5">
Some services may not be downloading properly
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center space-x-1 px-3 py-1.5 text-sm font-medium text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded-md transition-colors"
>
<span>{isExpanded ? 'Hide' : 'View'} Details</span>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
<button
onClick={() => setIsDismissed(true)}
className="p-1.5 text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded-md transition-colors"
title="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Expanded Service List */}
{isExpanded && (
<div className="border-t border-amber-200 dark:border-amber-800 bg-white/50 dark:bg-slate-900/50">
<div className="max-h-64 overflow-y-auto">
<div className="divide-y divide-amber-100 dark:divide-amber-900/50">
{issueServices.map((service: CookieService) => {
const cfg = statusConfig[service.status] || statusConfig.unknown
return (
<div
key={service.id}
className="p-3 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors"
>
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<span className="font-medium text-sm text-slate-800 dark:text-slate-200">
{service.name}
</span>
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded">
{service.type === 'scraper' ? 'Scraper' : service.type === 'private_gallery' ? 'Private Gallery' : 'Paid Content'}
</span>
<span className={`px-1.5 py-0.5 text-[10px] font-medium rounded ${cfg.color}`}>
{cfg.label}
</span>
</div>
<div className="flex items-center space-x-3 text-xs text-slate-500 dark:text-slate-400">
{service.last_updated && (
<span>Updated {formatRelativeTime(service.last_updated)}</span>
)}
{service.message && (
<span className="truncate max-w-[200px]">{service.message}</span>
)}
</div>
</div>
<Link
to={getSettingsLink(service)}
className="p-1.5 text-slate-500 hover:text-amber-600 dark:text-slate-400 dark:hover:text-amber-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded transition-colors flex-shrink-0"
title="Go to settings"
>
<ExternalLink className="w-4 h-4" />
</Link>
</div>
</div>
)
})}
</div>
</div>
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,268 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { AlertTriangle, X, ChevronDown, ChevronUp, ExternalLink, Check } from 'lucide-react'
import { api, wsClient } from '../lib/api'
import { formatRelativeTime } from '../lib/utils'
interface ErrorItem {
id: number
error_hash: string
module: string
message: string
first_seen: string
last_seen: string
occurrence_count: number
log_file: string | null
line_context: string | null
dismissed_at: string | null
viewed_at: string | null
}
export function ErrorBanner() {
const queryClient = useQueryClient()
const [isExpanded, setIsExpanded] = useState(false)
const [isDismissed, setIsDismissed] = useState(false)
const [displayCount, setDisplayCount] = useState(0)
// Fetch error count (lightweight polling)
const { data: errorCount, isSuccess } = useQuery({
queryKey: ['error-count'],
queryFn: () => api.getErrorCount(),
refetchInterval: 30000, // Poll every 30 seconds
staleTime: 0, // Always fetch fresh on mount
})
// Update display count from API response
useEffect(() => {
if (isSuccess && errorCount) {
setDisplayCount(errorCount.since_last_visit)
}
}, [isSuccess, errorCount])
// Fetch detailed errors when expanded - only show errors since last visit
const { data: errors, refetch: refetchErrors } = useQuery({
queryKey: ['recent-errors'],
queryFn: () => api.getRecentErrors(10, false, true),
enabled: isExpanded,
})
// Listen for real-time error alerts via WebSocket
useEffect(() => {
const unsubscribeError = wsClient.on('error_alert', (data: { count?: number; new_count?: number }) => {
// New errors arrived - show banner again
setIsDismissed(false)
// Add new errors to display count
const newCount = data.new_count || 0
if (newCount > 0) {
setDisplayCount(prev => prev + newCount)
}
// Refresh detailed list if expanded
if (isExpanded) {
queryClient.invalidateQueries({ queryKey: ['recent-errors'] })
}
})
return () => {
unsubscribeError()
}
}, [queryClient, isExpanded])
// Dismiss mutation
const dismissMutation = useMutation({
mutationFn: (errorIds: number[]) => api.dismissErrors(errorIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['recent-errors'] })
},
})
// Mark viewed mutation - also updates visit timestamp
const markViewedMutation = useMutation({
mutationFn: async () => {
await api.markErrorsViewed()
await api.updateDashboardVisit()
},
onSuccess: () => {
setDisplayCount(0)
setIsDismissed(true)
queryClient.invalidateQueries({ queryKey: ['error-count'] })
},
})
// Don't show if no errors or dismissed
if (isDismissed || displayCount === 0) {
return null
}
const handleDismissAll = () => {
if (errors?.errors) {
const errorIds = errors.errors.map(e => e.id)
dismissMutation.mutate(errorIds)
}
setDisplayCount(0)
setIsDismissed(true)
}
const handleMarkViewed = () => {
markViewedMutation.mutate()
}
const handleDismissBanner = () => {
// Just hide the banner, don't mark as viewed
setIsDismissed(true)
}
const handleDismissSingle = (errorId: number) => {
dismissMutation.mutate([errorId])
setDisplayCount(prev => Math.max(0, prev - 1))
setTimeout(() => refetchErrors(), 500)
}
// Build link to logs page with timestamp filter
const getLogLink = (error: ErrorItem) => {
const timestamp = error.last_seen
return `/logs?filter=${encodeURIComponent(error.module)}&time=${encodeURIComponent(timestamp)}`
}
return (
<div className="bg-gradient-to-r from-red-50 to-orange-50 dark:from-red-900/20 dark:to-orange-900/20 rounded-lg shadow-sm border border-red-200 dark:border-red-800 overflow-hidden">
{/* Main Banner */}
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3 flex-1">
<div className="relative flex-shrink-0">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
<div className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center">
<span className="text-[10px] font-bold text-white">
{displayCount > 9 ? '9+' : displayCount}
</span>
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-red-900 dark:text-red-100">
{displayCount} New Error{displayCount !== 1 ? 's' : ''} Detected
</h3>
<p className="text-xs text-red-700 dark:text-red-300 mt-0.5">
Since your last dashboard visit
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center space-x-1 px-3 py-1.5 text-sm font-medium text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-md transition-colors"
>
<span>{isExpanded ? 'Hide' : 'View'} Details</span>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
<button
onClick={handleMarkViewed}
disabled={markViewedMutation.isPending}
className="flex items-center space-x-1 px-3 py-1.5 text-sm font-medium text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-md transition-colors disabled:opacity-50"
title="Mark all as viewed and dismiss banner"
>
<Check className="w-4 h-4" />
<span className="hidden sm:inline">Mark Viewed</span>
</button>
<button
onClick={handleDismissBanner}
className="p-1.5 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-md transition-colors"
title="Dismiss banner (errors remain unviewed)"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Expanded Error List */}
{isExpanded && errors?.errors && (
<div className="border-t border-red-200 dark:border-red-800 bg-white/50 dark:bg-slate-900/50">
<div className="max-h-64 overflow-y-auto">
{errors.errors.length > 0 ? (
<div className="divide-y divide-red-100 dark:divide-red-900/50">
{errors.errors.map((error) => (
<div
key={error.id}
className="p-3 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<span className="px-2 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 rounded">
{error.module}
</span>
{error.occurrence_count > 1 && (
<span className="text-xs text-red-600 dark:text-red-400">
x{error.occurrence_count}
</span>
)}
<span className="text-xs text-slate-500 dark:text-slate-400">
{formatRelativeTime(error.last_seen)}
</span>
</div>
<p className="text-sm text-slate-700 dark:text-slate-300 break-words">
{error.message.length > 150
? error.message.substring(0, 147) + '...'
: error.message}
</p>
</div>
<div className="flex items-center space-x-1 flex-shrink-0">
<Link
to={getLogLink(error)}
className="p-1.5 text-slate-500 hover:text-blue-600 dark:text-slate-400 dark:hover:text-blue-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded transition-colors"
title="View in logs"
>
<ExternalLink className="w-4 h-4" />
</Link>
<button
onClick={() => handleDismissSingle(error.id)}
disabled={dismissMutation.isPending}
className="p-1.5 text-slate-500 hover:text-green-600 dark:text-slate-400 dark:hover:text-green-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded transition-colors"
title="Dismiss this error"
>
<Check className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="p-4 text-center text-slate-500 dark:text-slate-400 text-sm">
No recent errors to display
</div>
)}
</div>
{/* Footer Actions */}
{errors.errors.length > 0 && (
<div className="p-3 border-t border-red-100 dark:border-red-900/50 bg-red-50/50 dark:bg-red-950/30 flex items-center justify-between">
<Link
to="/logs?level=error"
className="text-sm font-medium text-red-700 dark:text-red-300 hover:text-red-800 dark:hover:text-red-200 flex items-center space-x-1"
>
<span>View All Logs</span>
<ExternalLink className="w-3.5 h-3.5" />
</Link>
<button
onClick={handleDismissAll}
disabled={dismissMutation.isPending}
className="px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white rounded-md transition-colors flex items-center space-x-1"
>
<Check className="w-4 h-4" />
<span>Dismiss All</span>
</button>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,102 @@
import { Component, ErrorInfo, ReactNode } from 'react'
import { AlertTriangle, RefreshCw } from 'lucide-react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
errorInfo: ErrorInfo | null
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null, errorInfo: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo)
this.setState({ errorInfo })
}
handleReload = () => {
window.location.reload()
}
handleReset = () => {
this.setState({ hasError: false, error: null, errorInfo: null })
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="min-h-screen bg-slate-100 dark:bg-slate-950 flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-900 rounded-lg shadow-lg border border-slate-200 dark:border-slate-800 p-8 max-w-lg w-full">
<div className="flex items-center space-x-3 mb-6">
<div className="p-3 bg-red-100 dark:bg-red-900/20 rounded-full">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
Something went wrong
</h2>
</div>
<p className="text-slate-600 dark:text-slate-400 mb-4">
An unexpected error occurred. This has been logged for investigation.
</p>
{this.state.error && (
<div className="bg-slate-100 dark:bg-slate-800 rounded-md p-4 mb-6 overflow-auto max-h-40">
<p className="text-sm font-mono text-red-600 dark:text-red-400">
{this.state.error.message}
</p>
{this.state.errorInfo && (
<details className="mt-2">
<summary className="text-xs text-slate-500 cursor-pointer hover:text-slate-700 dark:hover:text-slate-300">
View stack trace
</summary>
<pre className="text-xs text-slate-500 mt-2 whitespace-pre-wrap">
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
)}
<div className="flex space-x-3">
<button
onClick={this.handleReset}
className="flex-1 px-4 py-2 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-md hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
>
Try Again
</button>
<button
onClick={this.handleReload}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors flex items-center justify-center space-x-2"
>
<RefreshCw className="w-4 h-4" />
<span>Reload Page</span>
</button>
</div>
</div>
</div>
)
}
return this.props.children
}
}
export default ErrorBoundary

View File

@@ -0,0 +1,65 @@
/**
* FeatureRoute - Route protection for disabled features
*
* Wraps routes to check if the feature is enabled.
* If disabled, redirects to home page or shows a disabled message.
*/
import { Navigate, useLocation } from 'react-router-dom'
import { useEnabledFeatures } from '../hooks/useEnabledFeatures'
import { AlertTriangle } from 'lucide-react'
interface FeatureRouteProps {
path: string
children: React.ReactNode
redirectTo?: string
showDisabledMessage?: boolean
}
export function FeatureRoute({
path,
children,
redirectTo = '/',
showDisabledMessage = false,
}: FeatureRouteProps) {
const { isFeatureEnabled, isLoading } = useEnabledFeatures()
const location = useLocation()
// While loading, render children to prevent flash
if (isLoading) {
return <>{children}</>
}
// Check if feature is enabled
if (!isFeatureEnabled(path)) {
if (showDisabledMessage) {
return (
<div className="min-h-[50vh] flex items-center justify-center">
<div className="text-center max-w-md">
<AlertTriangle className="w-16 h-16 text-amber-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-2">
Feature Disabled
</h1>
<p className="text-slate-600 dark:text-slate-400 mb-4">
This feature has been disabled by an administrator.
Please contact your administrator if you need access.
</p>
<a
href="/"
className="inline-block px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
Go to Dashboard
</a>
</div>
</div>
)
}
// Redirect to home or specified path
return <Navigate to={redirectTo} state={{ from: location }} replace />
}
return <>{children}</>
}
export default FeatureRoute

View File

@@ -0,0 +1,389 @@
import { useState, useCallback, useMemo } from 'react'
import { Filter, Search, SlidersHorizontal, Calendar, HardDrive } from 'lucide-react'
import { formatPlatformName } from '../lib/utils'
export interface FilterState {
platformFilter: string
sourceFilter: string
typeFilter: 'all' | 'image' | 'video'
searchQuery: string
dateFrom: string
dateTo: string
sizeMin: string
sizeMax: string
sortBy: string
sortOrder: 'asc' | 'desc'
}
export interface FilterBarProps {
filters: FilterState
platforms?: string[]
sources?: string[]
onFilterChange: <K extends keyof FilterState>(key: K, value: FilterState[K]) => void
onReset: () => void
sortOptions?: Array<{ value: string; label: string }>
showFaceRecognitionFilter?: boolean
faceRecognitionFilter?: string
onFaceRecognitionChange?: (value: string) => void
showDeletedFromFilter?: boolean
deletedFromFilter?: string | null
onDeletedFromChange?: (value: string | null) => void
deletedFromStats?: Record<string, { count: number }>
searchPlaceholder?: string
dateLabelPrefix?: string
}
const DEFAULT_SORT_OPTIONS = [
{ value: 'post_date', label: 'Post Date' },
{ value: 'download_date', label: 'Download Date' },
{ value: 'file_size', label: 'File Size' },
{ value: 'filename', label: 'Filename' },
{ value: 'source', label: 'Source' },
{ value: 'platform', label: 'Platform' },
]
export default function FilterBar({
filters,
platforms = [],
sources = [],
onFilterChange,
onReset,
sortOptions = DEFAULT_SORT_OPTIONS,
showFaceRecognitionFilter = false,
faceRecognitionFilter = '',
onFaceRecognitionChange,
showDeletedFromFilter = false,
deletedFromFilter = null,
onDeletedFromChange,
deletedFromStats,
searchPlaceholder = 'Search filenames...',
dateLabelPrefix = 'Date',
}: FilterBarProps) {
const [showAdvanced, setShowAdvanced] = useState(false)
const {
platformFilter,
sourceFilter,
typeFilter,
searchQuery,
dateFrom,
dateTo,
sizeMin,
sizeMax,
sortBy,
sortOrder,
} = filters
// Check if any filters are active
const hasActiveFilters = useMemo(() => {
return !!(
searchQuery ||
typeFilter !== 'all' ||
platformFilter ||
sourceFilter ||
dateFrom ||
dateTo ||
sizeMin ||
sizeMax ||
faceRecognitionFilter ||
deletedFromFilter
)
}, [searchQuery, typeFilter, platformFilter, sourceFilter, dateFrom, dateTo, sizeMin, sizeMax, faceRecognitionFilter, deletedFromFilter])
const handleClearAll = useCallback(() => {
onReset()
if (onFaceRecognitionChange) onFaceRecognitionChange('')
if (onDeletedFromChange) onDeletedFromChange(null)
}, [onReset, onFaceRecognitionChange, onDeletedFromChange])
return (
<div className="card-glass-hover rounded-xl p-4 space-y-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-slate-700 dark:text-slate-300">
<Filter className="w-4 h-4" />
<span className="text-sm font-medium">Filters</span>
</div>
{hasActiveFilters && (
<button
onClick={handleClearAll}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
Clear all
</button>
)}
</div>
{/* Main Filters Row */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Platform Filter */}
<select
value={platformFilter}
onChange={(e) => onFilterChange('platformFilter', e.target.value)}
className={`px-4 py-2 rounded-lg border ${
platformFilter
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800'
} text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
>
<option value="">All Platforms</option>
{platforms.map((platform) => (
<option key={platform} value={platform}>
{formatPlatformName(platform)}
</option>
))}
</select>
{/* Source Filter */}
<select
value={sourceFilter}
onChange={(e) => onFilterChange('sourceFilter', e.target.value)}
className={`px-4 py-2 rounded-lg border ${
sourceFilter
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800'
} text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
>
<option value="">All Sources</option>
{sources.map((source) => (
<option key={source} value={source}>
{source}
</option>
))}
</select>
{/* Type Filter */}
<select
value={typeFilter}
onChange={(e) => onFilterChange('typeFilter', e.target.value as 'all' | 'image' | 'video')}
className={`px-4 py-2 rounded-lg border ${
typeFilter !== 'all'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800'
} text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
>
<option value="all">All Media</option>
<option value="image">Images Only</option>
<option value="video">Videos Only</option>
</select>
{/* Face Recognition Filter (optional) */}
{showFaceRecognitionFilter && onFaceRecognitionChange && (
<select
value={faceRecognitionFilter}
onChange={(e) => onFaceRecognitionChange(e.target.value)}
className={`px-4 py-2 rounded-lg border ${
faceRecognitionFilter
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800'
} text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-green-500`}
>
<option value="">All Files</option>
<option value="matched">Matched Only</option>
<option value="no_match">No Match Only</option>
<option value="not_scanned">Not Scanned</option>
</select>
)}
{/* Deleted From Filter (for RecycleBin) */}
{showDeletedFromFilter && onDeletedFromChange && (
<select
value={deletedFromFilter || ''}
onChange={(e) => onDeletedFromChange(e.target.value || null)}
className={`px-4 py-2 rounded-lg border ${
deletedFromFilter
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800'
} text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
>
<option value="">All Deleted From</option>
<option value="downloads">Downloads ({deletedFromStats?.downloads?.count || 0})</option>
<option value="media">Media ({deletedFromStats?.media?.count || 0})</option>
<option value="review">Review ({deletedFromStats?.review?.count || 0})</option>
<option value="instagram_perceptual_duplicate_detection">
Perceptual Duplicates ({deletedFromStats?.instagram_perceptual_duplicate_detection?.count || 0})
</option>
</select>
)}
</div>
{/* Sort and Advanced Filters Row */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Sort By */}
<select
value={sortBy}
onChange={(e) => onFilterChange('sortBy', e.target.value)}
className="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{/* Sort Order */}
<select
value={sortOrder}
onChange={(e) => onFilterChange('sortOrder', e.target.value as 'asc' | 'desc')}
className="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="desc">Newest First</option>
<option value="asc">Oldest First</option>
</select>
{/* Advanced Filters Toggle */}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className={`flex items-center justify-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm transition-all lg:col-span-2 ${
showAdvanced
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:border-blue-400'
}`}
>
<SlidersHorizontal className="w-4 h-4" />
{showAdvanced ? 'Hide Advanced' : 'Advanced Filters'}
</button>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => onFilterChange('searchQuery', e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Advanced Filters Panel */}
{showAdvanced && (
<div className="pt-4 border-t border-slate-200 dark:border-slate-700 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Date Range */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<Calendar className="w-4 h-4" />
<span>{dateLabelPrefix} From</span>
</label>
<input
type="date"
value={dateFrom}
onChange={(e) => onFilterChange('dateFrom', e.target.value)}
className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<Calendar className="w-4 h-4" />
<span>{dateLabelPrefix} To</span>
</label>
<input
type="date"
value={dateTo}
onChange={(e) => onFilterChange('dateTo', e.target.value)}
className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* File Size Range */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<HardDrive className="w-4 h-4" />
<span>Min Size</span>
</label>
<input
type="number"
value={sizeMin}
onChange={(e) => onFilterChange('sizeMin', e.target.value)}
placeholder="1MB = 1048576"
className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<HardDrive className="w-4 h-4" />
<span>Max Size</span>
</label>
<input
type="number"
value={sizeMax}
onChange={(e) => onFilterChange('sizeMax', e.target.value)}
placeholder="10MB = 10485760"
className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
)}
{/* Active Filters Display */}
{hasActiveFilters && (
<div className="flex items-center gap-2 flex-wrap pt-2 border-t border-slate-200 dark:border-slate-700">
<span className="text-xs text-slate-500 dark:text-slate-400">Active filters:</span>
{platformFilter && (
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md flex items-center gap-1">
Platform: {formatPlatformName(platformFilter)}
<button onClick={() => onFilterChange('platformFilter', '')} className="hover:text-blue-900 dark:hover:text-blue-100">×</button>
</span>
)}
{sourceFilter && (
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md flex items-center gap-1">
Source: {sourceFilter}
<button onClick={() => onFilterChange('sourceFilter', '')} className="hover:text-blue-900 dark:hover:text-blue-100">×</button>
</span>
)}
{typeFilter !== 'all' && (
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md flex items-center gap-1">
Type: {typeFilter === 'image' ? 'Images' : 'Videos'}
<button onClick={() => onFilterChange('typeFilter', 'all')} className="hover:text-blue-900 dark:hover:text-blue-100">×</button>
</span>
)}
{faceRecognitionFilter && (
<span className="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-md flex items-center gap-1">
Face: {faceRecognitionFilter === 'matched' ? 'Matched' : faceRecognitionFilter === 'no_match' ? 'No Match' : 'Not Scanned'}
<button onClick={() => onFaceRecognitionChange?.('')} className="hover:text-green-900 dark:hover:text-green-100">×</button>
</span>
)}
{deletedFromFilter && (
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md flex items-center gap-1">
Deleted From: {deletedFromFilter}
<button onClick={() => onDeletedFromChange?.(null)} className="hover:text-blue-900 dark:hover:text-blue-100">×</button>
</span>
)}
{searchQuery && (
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md flex items-center gap-1">
Search: "{searchQuery}"
<button onClick={() => onFilterChange('searchQuery', '')} className="hover:text-blue-900 dark:hover:text-blue-100">×</button>
</span>
)}
{dateFrom && (
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md flex items-center gap-1">
From: {dateFrom}
<button onClick={() => onFilterChange('dateFrom', '')} className="hover:text-blue-900 dark:hover:text-blue-100">×</button>
</span>
)}
{dateTo && (
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md flex items-center gap-1">
To: {dateTo}
<button onClick={() => onFilterChange('dateTo', '')} className="hover:text-blue-900 dark:hover:text-blue-100">×</button>
</span>
)}
{sizeMin && (
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md flex items-center gap-1">
Min: {sizeMin} bytes
<button onClick={() => onFilterChange('sizeMin', '')} className="hover:text-blue-900 dark:hover:text-blue-100">×</button>
</span>
)}
{sizeMax && (
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md flex items-center gap-1">
Max: {sizeMax} bytes
<button onClick={() => onFilterChange('sizeMax', '')} className="hover:text-blue-900 dark:hover:text-blue-100">×</button>
</span>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,465 @@
import { useState, useRef, useEffect, ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { SlidersHorizontal, X, ChevronDown, Calendar, HardDrive, Search } from 'lucide-react'
export interface FilterSection {
id: string
label: string
type: 'select' | 'radio' | 'checkbox' | 'multiselect'
options: { value: string; label: string }[]
value: string | string[]
onChange: (value: string | string[]) => void
}
function MultiSelectFilter({ section }: { section: FilterSection }) {
const [search, setSearch] = useState('')
const selected = Array.isArray(section.value) ? section.value : []
const filtered = section.options.filter(opt =>
opt.label.toLowerCase().includes(search.toLowerCase())
)
const toggle = (value: string) => {
const newSelected = selected.includes(value)
? selected.filter(v => v !== value)
: [...selected, value]
section.onChange(newSelected)
}
return (
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search..."
className="w-full pl-8 pr-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
</div>
<div className="max-h-[320px] overflow-y-auto space-y-0.5">
{filtered.map(opt => (
<label
key={opt.value}
className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700/50 cursor-pointer"
>
<input
type="checkbox"
checked={selected.includes(opt.value)}
onChange={() => toggle(opt.value)}
className="w-4 h-4 rounded text-blue-500 border-slate-300 focus:ring-blue-500"
/>
<span className="text-sm text-slate-700 dark:text-slate-300 truncate">{opt.label}</span>
</label>
))}
{filtered.length === 0 && (
<p className="text-xs text-slate-400 px-2 py-1">No matches</p>
)}
</div>
{selected.length > 0 && (
<button
onClick={() => section.onChange([])}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
Clear ({selected.length})
</button>
)}
</div>
)
}
interface FilterPopoverProps {
sections: FilterSection[]
activeCount: number
onClear: () => void
}
export function FilterPopover({ sections, activeCount, onClear }: FilterPopoverProps) {
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 })
// Update popover position when opened
useEffect(() => {
if (isOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
const viewportHeight = window.innerHeight
const popoverMaxHeight = viewportHeight * 0.8 // 80vh
const spaceBelow = viewportHeight - rect.bottom - 16 // 16px margin from bottom
// If not enough space below, position above or limit to available space
let top = rect.bottom + 8
if (spaceBelow < 300) {
// Position above the button if very limited space below
top = Math.max(8, rect.top - Math.min(popoverMaxHeight, rect.top - 16))
}
setPopoverPosition({
top,
left: rect.right - 320, // 320px is w-80, align right edge
})
}
}, [isOpen])
// Close on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
const target = event.target as Node
if (
popoverRef.current && !popoverRef.current.contains(target) &&
buttonRef.current && !buttonRef.current.contains(target)
) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
// Close on escape
useEffect(() => {
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') setIsOpen(false)
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}
}, [isOpen])
// Close on scroll to prevent misalignment (but not when scrolling inside the popover)
useEffect(() => {
if (isOpen) {
const handleScroll = (e: Event) => {
if (popoverRef.current && popoverRef.current.contains(e.target as Node)) return
setIsOpen(false)
}
window.addEventListener('scroll', handleScroll, true)
return () => window.removeEventListener('scroll', handleScroll, true)
}
}, [isOpen])
return (
<div className="relative">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-colors ${
activeCount > 0
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
}`}
>
<SlidersHorizontal className="w-4 h-4" />
<span>Filters</span>
{activeCount > 0 && (
<span className="flex items-center justify-center w-5 h-5 text-xs font-medium bg-blue-500 text-white rounded-full">
{activeCount}
</span>
)}
<ChevronDown className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && createPortal(
<div
ref={popoverRef}
className="fixed w-80 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl overflow-hidden"
style={{
top: popoverPosition.top,
left: Math.max(8, popoverPosition.left), // Ensure at least 8px from left edge
zIndex: 9999,
}}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50">
<span className="font-medium text-slate-900 dark:text-slate-100">Filters</span>
<div className="flex items-center gap-2">
{activeCount > 0 && (
<button
onClick={onClear}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
Clear all
</button>
)}
<button
onClick={() => setIsOpen(false)}
className="p-1 rounded hover:bg-slate-200 dark:hover:bg-slate-700"
>
<X className="w-4 h-4 text-slate-500" />
</button>
</div>
</div>
{/* Filter Sections */}
<div className="max-h-[80vh] overflow-y-auto">
{sections.map((section, index) => (
<div
key={section.id}
className={`px-4 py-3 ${
index < sections.length - 1 ? 'border-b border-slate-100 dark:border-slate-700/50' : ''
}`}
>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2">
{section.label}
</label>
{section.type === 'select' && (
<select
value={section.value as string}
onChange={(e) => section.onChange(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
>
{section.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)}
{section.type === 'radio' && (
<div className="space-y-1">
{section.options.map((opt) => (
<label
key={opt.value}
className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700/50 cursor-pointer"
>
<input
type="radio"
name={section.id}
value={opt.value}
checked={section.value === opt.value}
onChange={(e) => section.onChange(e.target.value)}
className="w-4 h-4 text-blue-500 border-slate-300 focus:ring-blue-500"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">{opt.label}</span>
</label>
))}
</div>
)}
{section.type === 'multiselect' && (
<MultiSelectFilter section={section} />
)}
</div>
))}
</div>
</div>,
document.body
)}
</div>
)
}
export interface ActiveFilter {
id: string
label: string
value: string
displayValue: string
onRemove: () => void
}
export interface AdvancedFilters {
dateFrom?: { value: string; onChange: (v: string) => void; label?: string }
dateTo?: { value: string; onChange: (v: string) => void; label?: string }
sizeMin?: { value: string; onChange: (v: string) => void; placeholder?: string }
sizeMax?: { value: string; onChange: (v: string) => void; placeholder?: string }
}
interface FilterBarProps {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
filterSections: FilterSection[]
activeFilters: ActiveFilter[]
onClearAll: () => void
totalCount?: number
countLabel?: string
advancedFilters?: AdvancedFilters
children?: ReactNode
}
export function FilterBar({
searchValue,
onSearchChange,
searchPlaceholder = 'Search...',
filterSections,
activeFilters,
onClearAll,
totalCount,
countLabel = 'items',
advancedFilters,
children,
}: FilterBarProps) {
const [showAdvanced, setShowAdvanced] = useState(false)
const hasAdvanced = advancedFilters && (
advancedFilters.dateFrom || advancedFilters.dateTo ||
advancedFilters.sizeMin || advancedFilters.sizeMax
)
const hasActiveAdvanced = advancedFilters && (
advancedFilters.dateFrom?.value || advancedFilters.dateTo?.value ||
advancedFilters.sizeMin?.value || advancedFilters.sizeMax?.value
)
return (
<div className="space-y-3">
{/* Search + Filter Button Row */}
<div className="flex items-center gap-3">
<div className="flex-1 relative">
<input
type="text"
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="w-full px-4 py-2 pl-10 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{searchValue && (
<button
onClick={() => onSearchChange('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-slate-200 dark:hover:bg-slate-700"
>
<X className="w-4 h-4 text-slate-400" />
</button>
)}
</div>
{hasAdvanced && (
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-colors ${
showAdvanced || hasActiveAdvanced
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
}`}
>
<SlidersHorizontal className="w-4 h-4" />
<span className="hidden sm:inline">Advanced</span>
</button>
)}
<FilterPopover
sections={filterSections}
activeCount={activeFilters.length}
onClear={onClearAll}
/>
</div>
{/* Advanced Filters Panel */}
{hasAdvanced && showAdvanced && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-lg border border-slate-200 dark:border-slate-700">
{advancedFilters.dateFrom && (
<div>
<label className="flex items-center gap-1 text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
<Calendar className="w-3 h-3" />
{advancedFilters.dateFrom.label || 'From'}
</label>
<input
type="date"
value={advancedFilters.dateFrom.value}
onChange={(e) => advancedFilters.dateFrom!.onChange(e.target.value)}
className="w-full px-2 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
{advancedFilters.dateTo && (
<div>
<label className="flex items-center gap-1 text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
<Calendar className="w-3 h-3" />
{advancedFilters.dateTo.label || 'To'}
</label>
<input
type="date"
value={advancedFilters.dateTo.value}
onChange={(e) => advancedFilters.dateTo!.onChange(e.target.value)}
className="w-full px-2 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
{advancedFilters.sizeMin && (
<div>
<label className="flex items-center gap-1 text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
<HardDrive className="w-3 h-3" />
Min Size
</label>
<input
type="number"
value={advancedFilters.sizeMin.value}
onChange={(e) => advancedFilters.sizeMin!.onChange(e.target.value)}
placeholder={advancedFilters.sizeMin.placeholder || '1MB = 1048576'}
className="w-full px-2 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
{advancedFilters.sizeMax && (
<div>
<label className="flex items-center gap-1 text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
<HardDrive className="w-3 h-3" />
Max Size
</label>
<input
type="number"
value={advancedFilters.sizeMax.value}
onChange={(e) => advancedFilters.sizeMax!.onChange(e.target.value)}
placeholder={advancedFilters.sizeMax.placeholder || '10MB = 10485760'}
className="w-full px-2 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
</div>
)}
{/* Custom children slot */}
{children}
{/* Active Filters + Count Row */}
{(activeFilters.length > 0 || totalCount !== undefined) && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-wrap">
{activeFilters.map((filter) => (
<span
key={filter.id}
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md"
>
{filter.label}: {filter.displayValue}
<button
onClick={filter.onRemove}
className="p-0.5 rounded hover:bg-blue-200 dark:hover:bg-blue-800"
>
<X className="w-3 h-3" />
</button>
</span>
))}
{activeFilters.length > 0 && (
<button
onClick={onClearAll}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline ml-1"
>
Clear all
</button>
)}
</div>
{totalCount !== undefined && (
<span className="text-sm text-slate-500 dark:text-slate-400">
{totalCount.toLocaleString()} {countLabel}
</span>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,169 @@
import React from 'react'
import { api } from '../lib/api'
import { useQuery } from '@tanstack/react-query'
export const FlareSolverrStatus: React.FC = () => {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['flaresolverr-health'],
queryFn: () => api.getFlareSolverrHealth(),
refetchInterval: 30000, // Check every 30 seconds
retry: 1
})
const getStatusColor = (status?: string) => {
switch (status) {
case 'healthy':
return 'bg-green-500'
case 'unhealthy':
return 'bg-yellow-500'
case 'offline':
case 'timeout':
case 'error':
return 'bg-red-500'
default:
return 'bg-gray-500'
}
}
const getStatusText = (status?: string) => {
switch (status) {
case 'healthy':
return 'Healthy'
case 'unhealthy':
return 'Unhealthy'
case 'offline':
return 'Offline'
case 'timeout':
return 'Timeout'
case 'error':
return 'Error'
default:
return 'Unknown'
}
}
const getStatusIcon = (status?: string) => {
switch (status) {
case 'healthy':
return (
<svg className="w-4 h-4 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
)
case 'unhealthy':
return (
<svg className="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
)
default:
return (
<svg className="w-4 h-4 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)
}
}
if (isLoading) {
return (
<div className="flex items-center space-x-2 text-sm text-slate-400">
<div className="w-2 h-2 bg-gray-500 rounded-full animate-pulse"></div>
<span>Checking FlareSolverr...</span>
</div>
)
}
if (error) {
return (
<div className="flex items-center space-x-2 text-sm text-red-400">
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
<span>Failed to check FlareSolverr</span>
</div>
)
}
return (
<div className="flex items-center space-x-3">
{/* Status Indicator */}
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 ${getStatusColor(data?.status)} rounded-full ${data?.status === 'healthy' ? 'animate-pulse' : ''}`}></div>
<span className="text-sm font-medium text-slate-200">FlareSolverr</span>
</div>
{/* Status Badge */}
<div className="flex items-center space-x-1.5 px-2 py-1 rounded-md bg-slate-800/50">
{getStatusIcon(data?.status)}
<span className="text-xs font-medium text-slate-300">{getStatusText(data?.status)}</span>
</div>
{/* Response Time (if healthy) */}
{data?.status === 'healthy' && data.response_time_ms && (
<span className="text-xs text-slate-400">
{data.response_time_ms}ms
</span>
)}
{/* Sessions Count (if healthy) */}
{data?.status === 'healthy' && data.sessions && (
<span className="text-xs text-slate-400">
{data.sessions.length} session{data.sessions.length !== 1 ? 's' : ''}
</span>
)}
{/* Error Message (if not healthy) */}
{data?.status !== 'healthy' && data?.error && (
<span className="text-xs text-red-400 max-w-xs truncate" title={data.error}>
{data.error}
</span>
)}
{/* Manual Refresh Button */}
<button
onClick={() => refetch()}
className="p-1 rounded hover:bg-slate-700/50 transition-colors"
title="Refresh status"
>
<svg className="w-4 h-4 text-slate-400 hover:text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
)
}
// Compact version for sidebar or small spaces
export const FlareSolverrStatusCompact: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['flaresolverr-health'],
queryFn: () => api.getFlareSolverrHealth(),
refetchInterval: 30000,
retry: 1
})
const getStatusColor = (status?: string) => {
switch (status) {
case 'healthy':
return 'bg-green-500'
case 'unhealthy':
return 'bg-yellow-500'
case 'offline':
case 'timeout':
case 'error':
return 'bg-red-500'
default:
return 'bg-gray-500'
}
}
if (isLoading) {
return <div className="w-2 h-2 bg-gray-500 rounded-full animate-pulse"></div>
}
return (
<div
className={`w-2 h-2 ${getStatusColor(data?.status)} rounded-full ${data?.status === 'healthy' ? 'animate-pulse' : ''}`}
title={`FlareSolverr: ${data?.status || 'unknown'}`}
></div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { queueThumbnail } from '../lib/thumbnailQueue'
// Base64 placeholder - gray background, avoids network request
const PLACEHOLDER_BASE64 = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iIzM0NDI1NSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzY0NzQ4YiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIFByZXZpZXc8L3RleHQ+PC9zdmc+'
// LazyThumbnail component - only loads when visible via IntersectionObserver
// Uses a global request queue to limit concurrent fetches
export default function LazyThumbnail({
src,
alt,
className,
onError
}: {
src: string
alt: string
className: string
onError?: () => void
}) {
const imgRef = useRef<HTMLImageElement>(null)
const [isVisible, setIsVisible] = useState(false)
const [hasError, setHasError] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const [readySrc, setReadySrc] = useState<string | null>(null)
// IntersectionObserver: detect when thumbnail scrolls into view
useEffect(() => {
const img = imgRef.current
if (!img) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.disconnect()
}
})
},
{
rootMargin: '200px',
threshold: 0
}
)
observer.observe(img)
return () => observer.disconnect()
}, [])
// When visible, queue the fetch instead of setting src directly
useEffect(() => {
if (!isVisible || hasError || !src) return
let cancelled = false
queueThumbnail(src).then(
(loadedSrc) => {
if (!cancelled) setReadySrc(loadedSrc)
},
() => {
if (!cancelled) {
setHasError(true)
onError?.()
}
}
)
return () => { cancelled = true }
}, [isVisible, src, hasError, onError])
const handleError = useCallback(() => {
setHasError(true)
onError?.()
}, [onError])
const handleLoad = useCallback(() => {
setIsLoaded(true)
}, [])
return (
<img
ref={imgRef}
src={hasError ? PLACEHOLDER_BASE64 : (readySrc || PLACEHOLDER_BASE64)}
alt={alt}
className={`${className} ${!isLoaded && isVisible && !hasError ? 'animate-pulse' : ''}`}
loading="lazy"
decoding="async"
onError={handleError}
onLoad={handleLoad}
/>
)
}
export { PLACEHOLDER_BASE64 }

View File

@@ -0,0 +1,208 @@
import { memo, ReactNode } from 'react'
import { Play, Check } from 'lucide-react'
import { formatBytes, isVideoFile } from '../lib/utils'
import ThrottledImage from './ThrottledImage'
export interface MediaItem {
id: string | number
file_path?: string
filename?: string
original_filename?: string
file_size?: number
thumbnail_url?: string
}
export interface MediaGridProps<T extends MediaItem> {
items: T[]
isLoading?: boolean
emptyMessage?: string
emptySubMessage?: string
selectMode?: boolean
selectedItems?: Set<string>
onSelectItem?: (id: string) => void
onItemClick?: (item: T) => void
getThumbnailUrl: (item: T) => string
getItemId: (item: T) => string
getFilename: (item: T) => string
renderHoverOverlay?: (item: T) => ReactNode
renderBadge?: (item: T) => ReactNode
skeletonCount?: number
columns?: {
default: number
md: number
lg: number
xl: number
}
}
function MediaGridSkeleton({ count = 20, columns }: { count?: number; columns?: MediaGridProps<MediaItem>['columns'] }) {
const gridCols = columns || { default: 2, md: 3, lg: 4, xl: 5 }
return (
<div className={`grid grid-cols-${gridCols.default} md:grid-cols-${gridCols.md} lg:grid-cols-${gridCols.lg} xl:grid-cols-${gridCols.xl} gap-4`}>
{[...Array(count)].map((_, i) => (
<div key={i} className="aspect-square bg-slate-200 dark:bg-slate-800 rounded-lg animate-pulse" />
))}
</div>
)
}
function MediaGridEmpty({ message, subMessage }: { message: string; subMessage?: string }) {
return (
<div className="text-center py-20">
<p className="text-slate-500 dark:text-slate-400 text-lg">{message}</p>
{subMessage && (
<p className="text-slate-400 dark:text-slate-500 text-sm mt-2">{subMessage}</p>
)}
</div>
)
}
function MediaGridItemComponent<T extends MediaItem>({
item,
selectMode,
isSelected,
onSelect,
onClick,
getThumbnailUrl,
getFilename,
renderHoverOverlay,
renderBadge,
}: {
item: T
selectMode?: boolean
isSelected: boolean
onSelect?: () => void
onClick?: () => void
getThumbnailUrl: (item: T) => string
getFilename: (item: T) => string
renderHoverOverlay?: (item: T) => ReactNode
renderBadge?: (item: T) => ReactNode
}) {
const filename = getFilename(item)
const isVideo = isVideoFile(filename)
const handleClick = () => {
if (selectMode && onSelect) {
onSelect()
} else if (onClick) {
onClick()
}
}
return (
<div
onClick={handleClick}
className={`group relative aspect-square bg-slate-100 dark:bg-slate-800 rounded-lg overflow-hidden cursor-pointer hover:ring-2 card-lift thumbnail-zoom ${
isSelected ? 'ring-2 ring-blue-500' : 'hover:ring-blue-500'
}`}
>
{/* Thumbnail */}
<ThrottledImage
src={getThumbnailUrl(item)}
alt={filename}
className="w-full h-full object-cover"
/>
{/* Video indicator */}
{isVideo && (
<div className="absolute top-2 right-2 bg-black/70 rounded-full p-1.5 z-10">
<Play className="w-4 h-4 text-white fill-white" />
</div>
)}
{/* Custom badge (e.g., face recognition confidence) */}
{renderBadge && (
<div className={`absolute ${selectMode ? 'top-10' : 'top-2'} left-2 z-10`}>
{renderBadge(item)}
</div>
)}
{/* Select checkbox */}
{selectMode && (
<div className="absolute top-2 left-2 z-20">
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-colors ${
isSelected
? 'bg-blue-600 border-blue-600'
: 'bg-white/90 border-slate-300'
}`}>
{isSelected && (
<Check className="w-4 h-4 text-white" />
)}
</div>
</div>
)}
{/* Hover overlay with actions */}
{!selectMode && renderHoverOverlay && (
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center space-y-2 p-2">
{renderHoverOverlay(item)}
</div>
)}
{/* File info at bottom - only show on hover when not in select mode */}
{!selectMode && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-white text-xs truncate">{filename}</p>
{item.file_size && (
<p className="text-white/70 text-xs">{formatBytes(item.file_size)}</p>
)}
</div>
)}
</div>
)
}
// Memoize the item component
const MediaGridItem = memo(MediaGridItemComponent) as typeof MediaGridItemComponent
export default function MediaGrid<T extends MediaItem>({
items,
isLoading = false,
emptyMessage = 'No items found',
emptySubMessage,
selectMode = false,
selectedItems = new Set(),
onSelectItem,
onItemClick,
getThumbnailUrl,
getItemId,
getFilename,
renderHoverOverlay,
renderBadge,
skeletonCount = 20,
columns = { default: 2, md: 3, lg: 4, xl: 5 },
}: MediaGridProps<T>) {
if (isLoading) {
return <MediaGridSkeleton count={skeletonCount} columns={columns} />
}
if (items.length === 0) {
return <MediaGridEmpty message={emptyMessage} subMessage={emptySubMessage} />
}
return (
<div className={`grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4`}>
{items.map((item) => {
const itemId = getItemId(item)
return (
<MediaGridItem
key={itemId}
item={item}
selectMode={selectMode}
isSelected={selectedItems.has(itemId)}
onSelect={onSelectItem ? () => onSelectItem(itemId) : undefined}
onClick={onItemClick ? () => onItemClick(item) : undefined}
getThumbnailUrl={getThumbnailUrl}
getFilename={getFilename}
renderHoverOverlay={renderHoverOverlay}
renderBadge={renderBadge}
/>
)
})}
</div>
)
}
// Re-export for convenience
export { MediaGridSkeleton, MediaGridEmpty }

View File

@@ -0,0 +1,94 @@
import React, { useEffect } from 'react'
import { X } from 'lucide-react'
export interface ToastNotification {
id: string
title: string
message: string
icon?: string
type?: 'success' | 'error' | 'info' | 'warning' | 'review'
thumbnailUrl?: string
}
interface NotificationToastProps {
notifications: ToastNotification[]
onDismiss: (id: string) => void
}
// Individual notification component with auto-dismiss
const ToastItem: React.FC<{ notification: ToastNotification; onDismiss: (id: string) => void }> = ({ notification, onDismiss }) => {
useEffect(() => {
// Auto-dismiss after 8 seconds
const timer = setTimeout(() => {
onDismiss(notification.id)
}, 8000)
return () => clearTimeout(timer)
}, [notification.id, onDismiss])
const getColorClasses = (type?: string) => {
switch (type) {
case 'success':
return 'bg-green-50 dark:bg-green-900/90 border-green-200 dark:border-green-700'
case 'error':
return 'bg-red-50 dark:bg-red-900/90 border-red-200 dark:border-red-700'
case 'warning':
case 'review':
return 'bg-yellow-50 dark:bg-yellow-900/90 border-yellow-200 dark:border-yellow-700'
default:
return 'bg-blue-50 dark:bg-blue-900/90 border-blue-200 dark:border-blue-700'
}
}
return (
<div className="animate-slide-in-right">
<div className={`${getColorClasses(notification.type)} border rounded-lg shadow-lg p-3 w-full`}>
<div className="flex items-start space-x-3 h-full">
{notification.icon && (
<div className="flex-shrink-0 text-2xl">
{notification.icon}
</div>
)}
{notification.thumbnailUrl && (
<img
src={notification.thumbnailUrl}
alt="Thumbnail"
className="w-12 h-12 rounded object-cover flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100 line-clamp-2">
{notification.title}
</p>
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1 line-clamp-3">
{notification.message}
</p>
</div>
<button
onClick={() => onDismiss(notification.id)}
className="flex-shrink-0 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors p-2 -mr-2 min-w-[44px] min-h-[44px] flex items-center justify-center"
aria-label="Dismiss notification"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
)
}
const NotificationToast: React.FC<NotificationToastProps> = ({ notifications, onDismiss }) => {
return (
<div className="fixed top-32 md:top-20 z-50 space-y-2 w-96 max-w-[calc(100vw-2rem)] left-1/2 -translate-x-1/2 md:left-auto md:right-4 md:translate-x-0 px-4 md:px-0">
{notifications.map((notification) => (
<ToastItem
key={notification.id}
notification={notification}
onDismiss={onDismiss}
/>
))}
</div>
)
}
export default NotificationToast

View File

@@ -0,0 +1,288 @@
import { X, ExternalLink, Globe, BookOpen, BookOpenCheck, Trash2, Loader2 } from 'lucide-react'
import { parseISO, format } from 'date-fns'
import { useQuery } from '@tanstack/react-query'
import { api } from '../lib/api'
import { useMemo } from 'react'
interface PressArticle {
id: number
celebrity_id: number
celebrity_name: string
title: string
url: string
domain: string
published_date: string
image_url: string | null
language: string
country: string
snippet: string
article_content: string | null
fetched_at: string
read: number
}
// Sanitize HTML - allow safe formatting tags + media elements
function sanitizeHtml(html: string): string {
if (!html) return ''
const div = document.createElement('div')
div.textContent = ''
const temp = document.createElement('div')
temp.innerHTML = html
const allowedTags = new Set([
'P', 'H2', 'H3', 'H4', 'B', 'I', 'EM', 'STRONG', 'A',
'BLOCKQUOTE', 'UL', 'OL', 'LI', 'BR', 'FIGURE', 'FIGCAPTION',
'IMG', 'VIDEO', 'SOURCE',
])
// Attributes allowed per tag
const allowedAttrs: Record<string, Set<string>> = {
'A': new Set(['href']),
'IMG': new Set(['src', 'alt']),
'VIDEO': new Set(['src', 'poster', 'controls', 'width', 'height', 'preload']),
'SOURCE': new Set(['src', 'type']),
}
function walkAndSanitize(node: Node, parent: HTMLElement) {
if (node.nodeType === Node.TEXT_NODE) {
parent.appendChild(document.createTextNode(node.textContent || ''))
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement
if (allowedTags.has(el.tagName)) {
const safe = document.createElement(el.tagName)
// Copy allowed attributes for media/link tags
const attrs = allowedAttrs[el.tagName]
if (attrs) {
for (const attr of attrs) {
const val = el.getAttribute(attr)
if (val != null) {
// Only allow http(s) URLs for src/href/poster/srcset
if (['src', 'href', 'poster', 'srcset'].includes(attr)) {
if (!val.match(/^https?:\/\//) && !val.startsWith('/api/')) continue
}
safe.setAttribute(attr, val)
}
}
}
// Make links open in new tab
if (el.tagName === 'A') {
safe.setAttribute('target', '_blank')
safe.setAttribute('rel', 'noopener noreferrer')
}
el.childNodes.forEach(child => walkAndSanitize(child, safe))
parent.appendChild(safe)
} else {
el.childNodes.forEach(child => walkAndSanitize(child, parent))
}
}
}
temp.childNodes.forEach(child => walkAndSanitize(child, div))
return div.innerHTML
}
interface Props {
articleId: number
onClose: () => void
onMarkRead?: (id: number, read: boolean) => void
onDelete?: (id: number) => void
}
export default function PressArticleModal({ articleId, onClose, onMarkRead, onDelete }: Props) {
const { data, isLoading } = useQuery({
queryKey: ['press', 'article', articleId],
queryFn: () => api.get<{ success: boolean; article: PressArticle }>(`/press/articles/${articleId}`),
})
const article = data?.article
const sanitizedContent = useMemo(() => {
if (!article?.article_content) return ''
// Check if content contains HTML tags
if (/<[a-z][\s\S]*>/i.test(article.article_content)) {
return sanitizeHtml(article.article_content)
}
// Plain text fallback - wrap paragraphs in <p> tags
return article.article_content
.split('\n\n')
.filter(p => p.trim())
.map(p => `<p>${p.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`)
.join('')
}, [article?.article_content])
const formattedDate = useMemo(() => {
if (!article?.published_date) return null
try {
return format(parseISO(article.published_date), 'MMMM d, yyyy h:mm a')
} catch {
return article.published_date
}
}, [article?.published_date])
return (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<div
className="card-glass-hover rounded-t-2xl sm:rounded-2xl w-full sm:max-w-4xl max-h-[90vh] sm:max-h-[85vh] overflow-hidden flex flex-col sm:mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* Sticky header */}
<div className="flex items-center justify-between px-4 sm:px-6 py-3 border-b border-slate-200 dark:border-slate-700 bg-inherit flex-shrink-0">
{/* Drag handle for mobile */}
<div className="w-10 h-1 bg-slate-300 dark:bg-slate-600 rounded-full sm:hidden mx-auto absolute left-1/2 -translate-x-1/2 top-1.5" />
<div className="flex items-center gap-2 min-w-0 flex-1">
{article && (
<>
<span className="px-2 py-0.5 bg-purple-500/10 text-purple-600 dark:text-purple-400 rounded text-xs font-medium flex-shrink-0">
{article.celebrity_name}
</span>
<span className="px-2 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs text-slate-600 dark:text-slate-400 flex-shrink-0">
{article.domain}
</span>
</>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{article && onMarkRead && (
<button
onClick={() => onMarkRead(article.id, !article.read)}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
title={article.read ? 'Mark as unread' : 'Mark as read'}
>
{article.read ? <BookOpenCheck className="w-4 h-4" /> : <BookOpen className="w-4 h-4" />}
</button>
)}
{article && (
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
title="Open original article"
>
<ExternalLink className="w-4 h-4" />
</a>
)}
{article && onDelete && (
<button
onClick={() => {
if (confirm('Delete this article?')) {
onDelete(article.id)
onClose()
}
}}
className="p-2 rounded-lg hover:bg-red-500/10 text-slate-500 hover:text-red-500 dark:hover:text-red-400 transition-colors"
title="Delete article"
>
<Trash2 className="w-4 h-4" />
</button>
)}
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Scrollable content */}
<div className="overflow-y-auto flex-1 px-4 sm:px-8 py-6">
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
</div>
) : article ? (
<div className="max-w-prose mx-auto">
{/* Title */}
<h1 className="text-xl sm:text-2xl font-bold text-slate-900 dark:text-slate-100 leading-tight">
{article.title || 'Untitled Article'}
</h1>
{/* Meta */}
<div className="flex flex-wrap items-center gap-3 mt-3 text-sm text-slate-500 dark:text-slate-400">
{formattedDate && <span>{formattedDate}</span>}
{article.language && article.language !== 'English' && (
<span className="flex items-center gap-1">
<Globe className="w-3.5 h-3.5" />
{article.language}
</span>
)}
{article.country && (
<span>{article.country}</span>
)}
</div>
{/* Hero image */}
{article.image_url && (
<div className="mt-5 rounded-xl overflow-hidden bg-slate-100 dark:bg-slate-800">
<img
src={article.image_url}
alt=""
className="w-full max-h-[400px] object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
)}
{/* Article content */}
{sanitizedContent ? (
<div
className="mt-6 article-content text-base sm:text-lg leading-relaxed text-slate-700 dark:text-slate-300
[&_p]:my-4 [&_p]:leading-relaxed
[&_h2]:text-xl [&_h2]:sm:text-2xl [&_h2]:font-bold [&_h2]:text-slate-900 dark:[&_h2]:text-slate-100 [&_h2]:mt-8 [&_h2]:mb-4
[&_h3]:text-lg [&_h3]:sm:text-xl [&_h3]:font-semibold [&_h3]:text-slate-900 dark:[&_h3]:text-slate-100 [&_h3]:mt-6 [&_h3]:mb-3
[&_h4]:text-base [&_h4]:sm:text-lg [&_h4]:font-semibold [&_h4]:text-slate-900 dark:[&_h4]:text-slate-100 [&_h4]:mt-6 [&_h4]:mb-3
[&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-4 [&_blockquote]:my-6 [&_blockquote]:italic [&_blockquote]:text-slate-600 dark:[&_blockquote]:text-slate-400
[&_strong]:text-slate-900 dark:[&_strong]:text-slate-100 [&_strong]:font-semibold
[&_em]:italic
[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-4
[&_li]:my-2
[&_a]:text-blue-600 dark:[&_a]:text-blue-400 [&_a]:underline [&_a]:underline-offset-2
[&_img]:rounded-xl [&_img]:my-6 [&_img]:max-w-full [&_img]:h-auto
[&_video]:rounded-xl [&_video]:my-6 [&_video]:max-w-full [&_video]:bg-black
[&_figure]:my-6 [&_figcaption]:text-sm [&_figcaption]:text-slate-500 dark:[&_figcaption]:text-slate-400 [&_figcaption]:mt-2 [&_figcaption]:text-center"
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/>
) : (
<div className="mt-6">
{article.snippet ? (
<p className="text-slate-600 dark:text-slate-400 leading-relaxed">{article.snippet}</p>
) : (
<p className="text-slate-500 dark:text-slate-400 italic">No content available.</p>
)}
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
>
<ExternalLink className="w-4 h-4" />
Read on {article.domain}
</a>
</div>
)}
{/* Source link at bottom */}
{sanitizedContent && (
<div className="mt-8 pt-4 border-t border-slate-200 dark:border-slate-700">
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
<ExternalLink className="w-3.5 h-3.5" />
Read original on {article.domain}
</a>
</div>
)}
</div>
) : (
<div className="text-center py-20 text-slate-500">Article not found</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,731 @@
/**
* RecentItemsCard - Dashboard card showing recent items with thumbnails
*
* Displays recent items from Media, Review, or Internet Discovery with:
* - Clickable thumbnails that open lightboxes
* - Hover overlays with action buttons matching source pages
* - Dismiss functionality with localStorage persistence
* - Buffer system for smooth item removal animations
*/
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
X,
Play,
Eye,
Check,
Trash2,
UserPlus,
ExternalLink,
ListVideo,
EyeOff,
LucideIcon
} from 'lucide-react'
import { api } from '../lib/api'
import { notificationManager } from '../lib/notificationManager'
import { isVideoFile } from '../lib/utils'
import EnhancedLightbox from './EnhancedLightbox'
import ThrottledImage from './ThrottledImage'
import { invalidateAllFileCaches } from '../lib/cacheInvalidation'
// Types for items from each location
export interface MediaItem {
id: number
file_path: string
filename: string
source: string
platform: string
media_type: string
file_size: number
added_at: string
width: number | null
height: number | null
}
export interface ReviewItem extends MediaItem {
face_recognition: {
scanned: boolean
matched: boolean
confidence: number | null
matched_person: string | null
} | null
}
export interface DiscoveryItem {
id: number
video_id: string
title: string
thumbnail: string
channel_name: string
platform: string
duration: number
max_resolution: number | null
status: string
discovered_at: string
url: string
view_count: number
upload_date: string | null
celebrity_name: string
}
type RecentItem = MediaItem | ReviewItem | DiscoveryItem
interface RecentItemsCardProps {
title: string
icon: LucideIcon
items: RecentItem[]
allItems: RecentItem[] // Full buffer for lightbox navigation
totalCount: number
location: 'media' | 'review' | 'internet_discovery'
viewAllLink: string
onDismiss: () => void
onItemAction: (itemId: number | string) => void
gradientFrom: string
gradientTo: string
borderColor: string
iconColor: string
}
// Helper to check if item is a discovery item
function isDiscoveryItem(item: RecentItem): item is DiscoveryItem {
return 'video_id' in item
}
// Helper to check if media/review item is video
function isMediaVideo(item: MediaItem | ReviewItem): boolean {
return item.media_type === 'video' || isVideoFile(item.filename)
}
// Format duration for videos
function formatDuration(seconds: number): string {
if (!seconds) return ''
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
return `${m}:${s.toString().padStart(2, '0')}`
}
export function RecentItemsCard({
title,
icon: Icon,
items,
allItems,
totalCount,
location,
viewAllLink,
onDismiss,
onItemAction,
gradientFrom,
gradientTo,
borderColor,
iconColor
}: RecentItemsCardProps) {
const queryClient = useQueryClient()
// Lightbox state for media/review
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
// Video preview state for internet discovery
const [previewVideo, setPreviewVideo] = useState<DiscoveryItem | null>(null)
// Add face reference modal state
const [showAddRefModal, setShowAddRefModal] = useState(false)
const [addRefFile, setAddRefFile] = useState<string | null>(null)
const [addRefPersonName, setAddRefPersonName] = useState('')
// ===================
// MUTATIONS
// ===================
// Media: Delete
const deleteMediaMutation = useMutation({
mutationFn: (filePath: string) => api.batchDeleteMedia([filePath]),
onSuccess: () => {
invalidateAllFileCaches(queryClient)
// Notification handled by websocket batch_delete_completed in App.tsx
},
onError: (err) => notificationManager.deleteError('item', err)
})
// Media: Move to Review
const moveToReviewMutation = useMutation({
mutationFn: (filePath: string) => api.moveToReview([filePath]),
onSuccess: () => {
invalidateAllFileCaches(queryClient)
notificationManager.movedToReview(1)
},
onError: (err) => notificationManager.moveError('item', err)
})
// Review: Keep (move to media)
const keepMutation = useMutation({
mutationFn: (filePath: string) => api.reviewKeep(filePath, ''),
onSuccess: () => {
invalidateAllFileCaches(queryClient)
notificationManager.kept('Image')
},
onError: (err) => notificationManager.moveError('item', err)
})
// Review: Delete
const deleteReviewMutation = useMutation({
mutationFn: (filePath: string) => api.reviewDelete(filePath),
onSuccess: () => {
invalidateAllFileCaches(queryClient)
// Notification handled by websocket in App.tsx
},
onError: (err) => notificationManager.deleteError('item', err)
})
// Add face reference (works for both media and review)
const addReferenceMutation = useMutation({
mutationFn: ({ filePath, personName }: { filePath: string; personName: string }) =>
api.addFaceReference(filePath, personName),
onSuccess: (_, variables) => {
invalidateAllFileCaches(queryClient)
notificationManager.faceReferenceAdded(variables.personName)
setShowAddRefModal(false)
setAddRefFile(null)
setAddRefPersonName('')
},
onError: (err) => notificationManager.faceReferenceError(err)
})
// Quick add face reference (automatic, no modal)
const quickAddFaceMutation = useMutation({
mutationFn: (filePath: string) => api.addFaceReference(filePath, undefined, true),
onSuccess: (data) => {
invalidateAllFileCaches(queryClient)
notificationManager.info('Processing', data.message, '⏳')
},
onError: (err: any) => {
const errorMessage = err?.response?.data?.detail || err?.message || 'Failed to add face reference'
notificationManager.error('Add Reference Failed', errorMessage, '❌')
}
})
// Internet Discovery: Add to queue
const addToQueueMutation = useMutation({
mutationFn: (video: DiscoveryItem) => api.post('/celebrity/queue/add', {
platform: video.platform,
video_id: video.video_id,
url: video.url,
title: video.title,
thumbnail: video.thumbnail,
duration: video.duration,
channel_name: video.channel_name,
celebrity_name: video.celebrity_name
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard-recent-items'] })
queryClient.invalidateQueries({ queryKey: ['video-queue'] })
notificationManager.success('Added to Queue', 'Video added to download queue')
},
onError: (err) => notificationManager.apiError('Add to Queue', err)
})
// Internet Discovery: Update status (ignore)
const updateStatusMutation = useMutation({
mutationFn: ({ videoId, status }: { videoId: number; status: string }) =>
api.put(`/celebrity/videos/${videoId}/status`, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard-recent-items'] })
notificationManager.success('Status Updated', 'Video marked as ignored')
},
onError: (err) => notificationManager.apiError('Update Status', err)
})
// ===================
// ACTION HANDLERS
// ===================
const handleMediaDelete = (item: MediaItem) => {
if (confirm(`Delete "${item.filename}"?`)) {
onItemAction(item.id)
deleteMediaMutation.mutate(item.file_path)
}
}
const handleMoveToReview = (item: MediaItem) => {
onItemAction(item.id)
moveToReviewMutation.mutate(item.file_path)
}
const handleReviewKeep = (item: ReviewItem) => {
onItemAction(item.id)
keepMutation.mutate(item.file_path)
}
const handleReviewDelete = (item: ReviewItem) => {
if (confirm(`Delete "${item.filename}"?`)) {
onItemAction(item.id)
deleteReviewMutation.mutate(item.file_path)
}
}
const handleAddReference = (filePath: string) => {
setAddRefFile(filePath)
setShowAddRefModal(true)
}
const handleDiscoveryAddToQueue = (item: DiscoveryItem) => {
onItemAction(item.id)
addToQueueMutation.mutate(item)
}
const handleDiscoveryIgnore = (item: DiscoveryItem) => {
onItemAction(item.id)
updateStatusMutation.mutate({ videoId: item.id, status: 'ignored' })
}
// ===================
// LIGHTBOX HELPERS
// ===================
const openLightbox = (index: number) => {
setLightboxIndex(index)
setLightboxOpen(true)
}
const getLightboxItems = () => {
// Use allItems (buffer) for lightbox navigation
return allItems.filter(item => !isDiscoveryItem(item)) as (MediaItem | ReviewItem)[]
}
// ===================
// RENDER THUMBNAIL
// ===================
const renderThumbnail = (item: RecentItem, index: number) => {
if (isDiscoveryItem(item)) {
// Internet Discovery thumbnail
return (
<div
key={item.id}
className="group relative w-28 h-20 flex-shrink-0 rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-red-500 transition-all"
onClick={() => setPreviewVideo(item)}
>
{/* Thumbnail */}
{item.thumbnail ? (
<img
src={`/api/celebrity/thumbnail/${item.video_id}`}
alt={item.title}
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
if (target.src !== item.thumbnail) {
target.src = item.thumbnail
}
}}
/>
) : (
<div className="w-full h-full bg-slate-700 flex items-center justify-center">
<Play className="w-6 h-6 text-slate-400" />
</div>
)}
{/* Play icon overlay */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="bg-black/50 rounded-full p-1.5">
<Play className="w-4 h-4 text-white fill-white" />
</div>
</div>
{/* Duration badge */}
{item.duration > 0 && (
<div className="absolute bottom-1 right-1 px-1 py-0.5 bg-black/80 text-white text-[10px] rounded">
{formatDuration(item.duration)}
</div>
)}
{/* Hover overlay with actions */}
<div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); setPreviewVideo(item) }}
className="p-1.5 bg-red-600 hover:bg-red-700 rounded-full"
title="Preview"
>
<Play className="w-3 h-3 text-white fill-white" />
</button>
{item.platform === 'youtube' && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-1.5 bg-white/20 hover:bg-white/30 rounded-full"
title="Open on YouTube"
>
<ExternalLink className="w-3 h-3 text-white" />
</a>
)}
<button
onClick={(e) => { e.stopPropagation(); handleDiscoveryAddToQueue(item) }}
className="p-1.5 bg-indigo-600 hover:bg-indigo-700 rounded-full"
title="Add to Queue"
>
<ListVideo className="w-3 h-3 text-white" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDiscoveryIgnore(item) }}
className="p-1.5 bg-slate-600 hover:bg-slate-700 rounded-full"
title="Ignore"
>
<EyeOff className="w-3 h-3 text-white" />
</button>
</div>
{/* Source label */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-1 opacity-0 group-hover:opacity-0">
<p className="text-white text-[10px] truncate">{item.channel_name}</p>
</div>
</div>
)
}
// Media or Review thumbnail
const mediaItem = item as MediaItem | ReviewItem
const isVideo = isMediaVideo(mediaItem)
const isReview = location === 'review'
return (
<div
key={mediaItem.id}
className={`group relative w-20 h-20 flex-shrink-0 rounded-lg overflow-hidden cursor-pointer hover:ring-2 transition-all ${
isReview ? 'hover:ring-orange-500' : 'hover:ring-blue-500'
}`}
onClick={() => openLightbox(index)}
>
{/* Thumbnail */}
<ThrottledImage
src={api.getMediaThumbnailUrl(mediaItem.file_path, mediaItem.media_type as 'image' | 'video')}
alt={mediaItem.filename}
className="w-full h-full object-cover"
showErrorPlaceholder={true}
priority={index < 5}
/>
{/* Video indicator */}
{isVideo && (
<div className="absolute top-1 right-1 bg-black/70 rounded-full p-0.5">
<Play className="w-2.5 h-2.5 text-white fill-white" />
</div>
)}
{/* Hover overlay with actions */}
<div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 p-1">
{isReview ? (
// Review actions
<>
<button
onClick={(e) => { e.stopPropagation(); handleReviewKeep(mediaItem as ReviewItem) }}
className="p-1.5 bg-green-600 hover:bg-green-700 rounded-full"
title="Keep"
>
<Check className="w-3 h-3 text-white" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAddReference(mediaItem.file_path) }}
className="p-1.5 bg-blue-600 hover:bg-blue-700 rounded-full"
title="Add Reference"
>
<UserPlus className="w-3 h-3 text-white" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleReviewDelete(mediaItem as ReviewItem) }}
className="p-1.5 bg-red-600 hover:bg-red-700 rounded-full"
title="Delete"
>
<Trash2 className="w-3 h-3 text-white" />
</button>
</>
) : (
// Media actions
<>
<button
onClick={(e) => { e.stopPropagation(); handleMoveToReview(mediaItem) }}
className="p-1.5 bg-orange-600 hover:bg-orange-700 rounded-full"
title="Move to Review"
>
<Eye className="w-3 h-3 text-white" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAddReference(mediaItem.file_path) }}
className="p-1.5 bg-blue-600 hover:bg-blue-700 rounded-full"
title="Add Reference"
>
<UserPlus className="w-3 h-3 text-white" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleMediaDelete(mediaItem) }}
className="p-1.5 bg-red-600 hover:bg-red-700 rounded-full"
title="Delete"
>
<Trash2 className="w-3 h-3 text-white" />
</button>
</>
)}
</div>
{/* Source label on hover */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-1 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-white text-[9px] truncate">{mediaItem.source}</p>
</div>
</div>
)
}
// ===================
// RENDER
// ===================
if (items.length === 0) return null
return (
<>
<div className={`card-glass rounded-xl bg-gradient-to-r ${gradientFrom} ${gradientTo} border ${borderColor} p-4`}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<Icon className={`w-5 h-5 ${iconColor}`} />
<h3 className="font-semibold text-foreground">{title}</h3>
<span className="px-2 py-0.5 bg-white/20 dark:bg-black/20 rounded-full text-xs font-medium">
{totalCount} new
</span>
</div>
<div className="flex items-center gap-2">
<Link
to={viewAllLink}
className="text-sm text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
View All
</Link>
<button
onClick={onDismiss}
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-white/20 dark:hover:bg-black/20 rounded-md transition-colors"
title="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Thumbnail Grid */}
<div className="flex gap-3 overflow-x-auto p-1 -m-1 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent">
{items.map((item, index) => renderThumbnail(item, index))}
</div>
</div>
{/* Enhanced Lightbox for Media/Review */}
{lightboxOpen && location !== 'internet_discovery' && (
<EnhancedLightbox
items={getLightboxItems()}
currentIndex={lightboxIndex}
onClose={() => setLightboxOpen(false)}
onNavigate={setLightboxIndex}
onDelete={(item) => {
const mediaItem = item as MediaItem | ReviewItem
onItemAction(mediaItem.id)
if (location === 'review') {
deleteReviewMutation.mutate(mediaItem.file_path)
} else {
deleteMediaMutation.mutate(mediaItem.file_path)
}
}}
getPreviewUrl={(item) => api.getMediaPreviewUrl((item as MediaItem).file_path)}
getThumbnailUrl={(item) => api.getMediaThumbnailUrl((item as MediaItem).file_path, (item as MediaItem).media_type as 'image' | 'video')}
isVideo={(item) => isMediaVideo(item as MediaItem)}
renderActions={(item) => {
const mediaItem = item as MediaItem | ReviewItem
if (location === 'review') {
return (
<>
<button
onClick={() => { handleReviewKeep(mediaItem as ReviewItem); setLightboxOpen(false) }}
className="flex items-center gap-2 px-3 py-1.5 md:px-4 md:py-2 bg-green-600 hover:bg-green-700 text-white text-sm md:text-base rounded-lg transition-colors"
>
<Check className="w-4 h-4" />
<span className="hidden sm:inline">Keep</span>
</button>
<button
onClick={() => handleAddReference(mediaItem.file_path)}
className="flex items-center gap-2 px-3 py-1.5 md:px-4 md:py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm md:text-base rounded-lg transition-colors"
>
<UserPlus className="w-4 h-4" />
<span className="hidden sm:inline">Add Ref</span>
</button>
<button
onClick={() => quickAddFaceMutation.mutate(mediaItem.file_path)}
disabled={quickAddFaceMutation.isPending}
className="flex items-center gap-2 px-3 py-1.5 md:px-4 md:py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm md:text-base rounded-lg transition-colors disabled:opacity-50"
>
<UserPlus className="w-4 h-4" />
<span className="hidden sm:inline">{quickAddFaceMutation.isPending ? 'Adding...' : 'Quick Add'}</span>
</button>
<button
onClick={() => { handleReviewDelete(mediaItem as ReviewItem); setLightboxOpen(false) }}
className="flex items-center gap-2 px-3 py-1.5 md:px-4 md:py-2 bg-red-600 hover:bg-red-700 text-white text-sm md:text-base rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
<span className="hidden sm:inline">Delete</span>
</button>
</>
)
} else {
return (
<>
<button
onClick={() => { handleMoveToReview(mediaItem); setLightboxOpen(false) }}
className="flex items-center gap-2 px-3 py-1.5 md:px-4 md:py-2 bg-orange-600 hover:bg-orange-700 text-white text-sm md:text-base rounded-lg transition-colors"
>
<Eye className="w-4 h-4" />
<span className="hidden sm:inline">Review</span>
</button>
<button
onClick={() => handleAddReference(mediaItem.file_path)}
className="flex items-center gap-2 px-3 py-1.5 md:px-4 md:py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm md:text-base rounded-lg transition-colors"
>
<UserPlus className="w-4 h-4" />
<span className="hidden sm:inline">Add Ref</span>
</button>
<button
onClick={() => quickAddFaceMutation.mutate(mediaItem.file_path)}
disabled={quickAddFaceMutation.isPending}
className="flex items-center gap-2 px-3 py-1.5 md:px-4 md:py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm md:text-base rounded-lg transition-colors disabled:opacity-50"
>
<UserPlus className="w-4 h-4" />
<span className="hidden sm:inline">{quickAddFaceMutation.isPending ? 'Adding...' : 'Quick Add'}</span>
</button>
<button
onClick={() => { handleMediaDelete(mediaItem); setLightboxOpen(false) }}
className="flex items-center gap-2 px-3 py-1.5 md:px-4 md:py-2 bg-red-600 hover:bg-red-700 text-white text-sm md:text-base rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
<span className="hidden sm:inline">Delete</span>
</button>
</>
)
}
}}
/>
)}
{/* Video Preview Modal for Internet Discovery */}
{previewVideo && (
<div
className="fixed inset-0 bg-black/95 z-50 flex items-center justify-center"
onClick={() => setPreviewVideo(null)}
>
<div className="relative w-full max-w-4xl mx-4" onClick={(e) => e.stopPropagation()}>
{/* Close button */}
<button
onClick={() => setPreviewVideo(null)}
className="absolute -top-12 right-0 p-2 text-white/70 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
{/* Video title */}
<h3 className="absolute -top-12 left-0 text-white font-medium truncate max-w-[80%]">
{previewVideo.title}
</h3>
{/* Video preview */}
<div className="relative aspect-video bg-black rounded-xl overflow-hidden">
{previewVideo.platform === 'youtube' ? (
<iframe
src={`https://www.youtube.com/embed/${previewVideo.video_id}?autoplay=1&rel=0`}
title={previewVideo.title}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<img
src={previewVideo.thumbnail}
alt={previewVideo.title}
className="max-w-full max-h-full object-contain"
/>
</div>
)}
</div>
{/* Video info */}
<div className="mt-4 flex items-center justify-between text-white/70 text-sm">
<div className="flex items-center gap-4">
<span>{previewVideo.channel_name}</span>
{previewVideo.duration > 0 && <span>{formatDuration(previewVideo.duration)}</span>}
{previewVideo.max_resolution && <span className="text-emerald-400">{previewVideo.max_resolution}p</span>}
</div>
<span className="text-indigo-400 font-medium">{previewVideo.celebrity_name}</span>
</div>
{/* Action buttons */}
<div className="mt-4 flex items-center justify-center gap-3">
{previewVideo.platform === 'youtube' && (
<a
href={previewVideo.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
<ExternalLink className="w-4 h-4" />
<span>Open on YouTube</span>
</a>
)}
<button
onClick={() => { handleDiscoveryAddToQueue(previewVideo); setPreviewVideo(null) }}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
>
<ListVideo className="w-4 h-4" />
<span>Add to Queue</span>
</button>
</div>
</div>
</div>
)}
{/* Add Face Reference Modal */}
{showAddRefModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={() => setShowAddRefModal(false)}>
<div className="bg-card rounded-xl p-6 max-w-md w-full" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">Add Face Reference</h3>
<input
type="text"
value={addRefPersonName}
onChange={(e) => setAddRefPersonName(e.target.value)}
placeholder="Person name..."
className="w-full px-4 py-2 bg-secondary rounded-lg border border-border focus:outline-none focus:ring-2 focus:ring-primary mb-4"
autoFocus
/>
<div className="flex justify-end gap-3">
<button
onClick={() => { setShowAddRefModal(false); setAddRefPersonName('') }}
className="px-4 py-2 text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={() => {
if (addRefFile && addRefPersonName.trim()) {
addReferenceMutation.mutate({ filePath: addRefFile, personName: addRefPersonName.trim() })
}
}}
disabled={!addRefPersonName.trim() || addReferenceMutation.isPending}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 transition-colors"
>
{addReferenceMutation.isPending ? 'Adding...' : 'Add Reference'}
</button>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,101 @@
import { Suspense, ReactNode } from 'react'
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg'
message?: string
}
function LoadingSpinner({ size = 'md', message }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'w-6 h-6',
md: 'w-10 h-10',
lg: 'w-16 h-16'
}
return (
<div className="flex flex-col items-center justify-center py-12 space-y-4">
<div className={`${sizeClasses[size]} border-4 border-slate-200 dark:border-slate-700 border-t-blue-500 rounded-full animate-spin`} />
{message && (
<p className="text-slate-500 dark:text-slate-400 text-sm">{message}</p>
)}
</div>
)
}
interface PageSkeletonProps {
showHeader?: boolean
showFilters?: boolean
gridCount?: number
}
function PageSkeleton({ showHeader = true, showFilters = true, gridCount = 20 }: PageSkeletonProps) {
return (
<div className="space-y-6 animate-pulse">
{/* Header skeleton */}
{showHeader && (
<div className="flex items-center justify-between">
<div>
<div className="h-8 w-48 bg-slate-200 dark:bg-slate-800 rounded" />
<div className="h-4 w-64 bg-slate-200 dark:bg-slate-800 rounded mt-2" />
</div>
<div className="flex gap-3">
<div className="h-10 w-32 bg-slate-200 dark:bg-slate-800 rounded" />
<div className="h-10 w-32 bg-slate-200 dark:bg-slate-800 rounded" />
</div>
</div>
)}
{/* Filters skeleton */}
{showFilters && (
<div className="bg-slate-100 dark:bg-slate-800/50 rounded-xl p-4 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-10 bg-slate-200 dark:bg-slate-700 rounded-lg" />
))}
</div>
<div className="h-10 bg-slate-200 dark:bg-slate-700 rounded-lg" />
</div>
)}
{/* Grid skeleton */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{[...Array(gridCount)].map((_, i) => (
<div key={i} className="aspect-square bg-slate-200 dark:bg-slate-800 rounded-lg" />
))}
</div>
</div>
)
}
interface SuspenseBoundaryProps {
children: ReactNode
fallback?: ReactNode
loadingMessage?: string
showHeader?: boolean
showFilters?: boolean
gridCount?: number
}
export default function SuspenseBoundary({
children,
fallback,
loadingMessage,
showHeader = true,
showFilters = true,
gridCount = 20,
}: SuspenseBoundaryProps) {
const defaultFallback = fallback || (
loadingMessage
? <LoadingSpinner message={loadingMessage} />
: <PageSkeleton showHeader={showHeader} showFilters={showFilters} gridCount={gridCount} />
)
return (
<Suspense fallback={defaultFallback}>
{children}
</Suspense>
)
}
// Export individual components for flexible use
export { LoadingSpinner, PageSkeleton }

View File

@@ -0,0 +1,117 @@
import { useState, useEffect, useRef, memo } from 'react'
interface ThrottledImageProps {
src: string
alt: string
className: string
showErrorPlaceholder?: boolean
priority?: boolean // Load immediately without waiting for intersection
}
// Shared IntersectionObserver for all images (performance optimization)
let sharedObserver: IntersectionObserver | null = null
const observerCallbacks = new Map<Element, () => void>()
function getSharedObserver(): IntersectionObserver {
if (!sharedObserver) {
sharedObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const callback = observerCallbacks.get(entry.target)
if (callback) {
callback()
sharedObserver?.unobserve(entry.target)
observerCallbacks.delete(entry.target)
}
}
})
},
{
rootMargin: '400px', // Start loading 400px before visible (increased from 50px)
threshold: 0
}
)
}
return sharedObserver
}
/**
* ThrottledImage component - optimized lazy loading for thumbnail grids
*
* Optimizations:
* - Shared IntersectionObserver for all images (less memory/CPU)
* - 400px rootMargin for aggressive preloading
* - No artificial delays - immediate load when in range
* - Memoized to prevent unnecessary re-renders
* - Priority prop for above-the-fold images
*/
function ThrottledImageComponent({ src, alt, className, showErrorPlaceholder = false, priority = false }: ThrottledImageProps) {
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
const [shouldLoad, setShouldLoad] = useState(priority)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (priority || shouldLoad) return
const element = containerRef.current
if (!element) return
const observer = getSharedObserver()
observerCallbacks.set(element, () => setShouldLoad(true))
observer.observe(element)
return () => {
observer.unobserve(element)
observerCallbacks.delete(element)
}
}, [priority, shouldLoad])
return (
<div ref={containerRef} className={className}>
{shouldLoad ? (
showErrorPlaceholder && error ? (
<div className="w-full h-full bg-slate-300 dark:bg-slate-600 flex items-center justify-center">
<svg className="w-8 h-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
) : (
<img
src={src}
alt={alt}
className={`w-full h-full object-cover transition-opacity duration-200 ${
loaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
loading="lazy"
decoding="async"
fetchPriority={priority ? 'high' : 'low'}
/>
)
) : (
<div className="w-full h-full bg-slate-200 dark:bg-slate-700 animate-pulse" />
)}
</div>
)
}
// Memoize to prevent unnecessary re-renders when parent re-renders
const ThrottledImage = memo(ThrottledImageComponent)
export default ThrottledImage
/**
* Prefetch images for better perceived performance.
* Call this with URLs of images that will likely be needed soon.
*/
export function prefetchImages(urls: string[]): void {
urls.forEach(url => {
const link = document.createElement('link')
link.rel = 'prefetch'
link.as = 'image'
link.href = url
document.head.appendChild(link)
})
}

View File

@@ -0,0 +1,293 @@
import { useState } from 'react'
import {
X,
Loader2,
CheckCircle,
XCircle,
Copy,
FileImage,
FileVideo,
Video,
ChevronDown,
ChevronUp,
Download,
} from 'lucide-react'
import ThrottledImage from './ThrottledImage'
export interface ThumbnailProgressItem {
id: string
filename: string
status: 'pending' | 'downloading' | 'processing' | 'success' | 'error' | 'duplicate'
error?: string
thumbnailUrl?: string
localPreview?: string
fileType?: 'image' | 'video'
}
interface ThumbnailProgressModalProps {
isOpen: boolean
title: string
items: ThumbnailProgressItem[]
statusText?: string
downloadProgress?: { downloaded: number; total: number } | null
onClose: () => void
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
export function ThumbnailProgressModal({
isOpen,
title,
items,
statusText,
downloadProgress,
onClose,
}: ThumbnailProgressModalProps) {
const [showErrorsManual, setShowErrorsManual] = useState<boolean | null>(null)
if (!isOpen) return null
const successCount = items.filter(i => i.status === 'success').length
const failedCount = items.filter(i => i.status === 'error').length
const duplicateCount = items.filter(i => i.status === 'duplicate').length
const downloadingCount = items.filter(i => i.status === 'downloading').length
const processingCount = items.filter(i => i.status === 'processing').length
const pendingCount = items.filter(i => i.status === 'pending').length
const total = items.length
const doneCount = successCount + failedCount + duplicateCount
const isComplete = pendingCount === 0 && processingCount === 0 && downloadingCount === 0
const progressPercent = total > 0 ? Math.round((doneCount / total) * 100) : 0
const failedItems = items.filter(i => i.status === 'error')
// Auto-expand errors when complete and there are failures
const showErrors = showErrorsManual !== null ? showErrorsManual : (isComplete && failedItems.length > 0)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-2xl bg-card rounded-xl shadow-2xl border border-border max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{title}</h2>
{isComplete && (
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-secondary transition-colors"
>
<X className="w-5 h-5" />
</button>
)}
</div>
{/* Progress Bar */}
<div className="px-4 sm:px-6 py-3 border-b border-border">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted-foreground">
{isComplete ? 'Complete' : statusText || 'Processing...'}
</span>
<span className="font-medium">
{doneCount} / {total}
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 rounded-full ${
failedCount > 0 && isComplete ? 'bg-amber-500' : 'bg-primary'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
{/* Download progress */}
{downloadProgress && downloadProgress.downloaded > 0 && !isComplete && (
<div className="mt-2">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span className="flex items-center gap-1">
<Download className="w-3 h-3" />
{formatBytes(downloadProgress.downloaded)}
{downloadProgress.total > 0 && ` / ${formatBytes(downloadProgress.total)}`}
</span>
{downloadProgress.total > 0 && (
<span>{Math.round((downloadProgress.downloaded / downloadProgress.total) * 100)}%</span>
)}
</div>
{downloadProgress.total > 0 && (
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-cyan-500 transition-all duration-300 rounded-full"
style={{ width: `${Math.min(100, Math.round((downloadProgress.downloaded / downloadProgress.total) * 100))}%` }}
/>
</div>
)}
</div>
)}
{/* Status Chips */}
<div className="flex flex-wrap items-center gap-2 mt-2">
{successCount > 0 && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600">
<CheckCircle className="w-3 h-3" />
{successCount} success
</span>
)}
{failedCount > 0 && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-600">
<XCircle className="w-3 h-3" />
{failedCount} failed
</span>
)}
{duplicateCount > 0 && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600">
<Copy className="w-3 h-3" />
{duplicateCount} duplicate
</span>
)}
{downloadingCount > 0 && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-600">
<Download className="w-3 h-3 animate-pulse" />
{downloadingCount} downloading
</span>
)}
{processingCount > 0 && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-600">
<Loader2 className="w-3 h-3 animate-spin" />
{processingCount} processing
</span>
)}
{pendingCount > 0 && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-secondary text-muted-foreground">
{pendingCount} pending
</span>
)}
</div>
</div>
{/* Thumbnail Grid */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 min-h-0">
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-1.5 max-h-80 overflow-y-auto">
{items.map((item) => (
<div
key={item.id}
className="relative aspect-square rounded-lg overflow-hidden bg-secondary"
title={item.filename}
>
{/* Thumbnail content */}
{item.localPreview ? (
<img
src={item.localPreview}
alt={item.filename}
className="w-full h-full object-cover"
/>
) : item.thumbnailUrl ? (
<ThrottledImage
src={item.thumbnailUrl}
alt={item.filename}
className="w-full h-full object-cover"
priority
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-1 p-1">
{item.fileType === 'video' ? (
<FileVideo className="w-8 h-8 text-foreground/40" />
) : (
<FileImage className="w-8 h-8 text-foreground/40" />
)}
<p className="text-[9px] leading-tight text-foreground/50 text-center truncate w-full px-0.5">{item.filename.replace(/\.[^.]+$/, '')}</p>
</div>
)}
{/* Video badge */}
{item.fileType === 'video' && (item.localPreview || item.thumbnailUrl) && (
<div className="absolute bottom-0.5 left-0.5 bg-black/70 rounded px-1">
<Video className="w-3 h-3 text-white" />
</div>
)}
{/* Status overlays */}
{item.status === 'pending' && (
<div className="absolute inset-0 bg-black/40" />
)}
{item.status === 'downloading' && (
<div className="absolute inset-0 bg-cyan-500/20 flex items-center justify-center">
<Download className="w-5 h-5 text-cyan-500 animate-pulse" />
</div>
)}
{item.status === 'processing' && (
<div className="absolute inset-0 bg-blue-500/20 flex items-center justify-center">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
</div>
)}
{item.status === 'success' && (
<div className="absolute top-0.5 right-0.5">
<CheckCircle className="w-4 h-4 text-emerald-500 drop-shadow-md" />
</div>
)}
{item.status === 'error' && (
<div className="absolute inset-0 bg-red-500/20 flex items-center justify-center">
<XCircle className="w-5 h-5 text-red-500 drop-shadow-md" />
</div>
)}
{item.status === 'duplicate' && (
<div className="absolute inset-0 bg-amber-500/20 flex items-center justify-center">
<Copy className="w-4 h-4 text-amber-600 drop-shadow-md" />
</div>
)}
</div>
))}
</div>
{/* Error details */}
{failedItems.length > 0 && (
<div className="mt-4">
<button
onClick={() => setShowErrorsManual(!showErrors)}
className="flex items-center gap-2 text-sm text-red-600 hover:text-red-700 transition-colors"
>
{showErrors ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{failedItems.length} failed {failedItems.length === 1 ? 'file' : 'files'}
</button>
{showErrors && (
<div className="mt-2 space-y-1.5">
{failedItems.map((item) => (
<div
key={item.id}
className="flex items-start gap-2 p-2 rounded-lg bg-red-500/10 text-sm"
>
<XCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="font-medium text-red-600 truncate">{item.filename}</p>
<p className="text-red-500/80 text-xs">{item.error || 'Unknown error'}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end px-4 sm:px-6 py-4 border-t border-border">
{isComplete ? (
<button
onClick={onClose}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Done
</button>
) : (
<p className="text-sm text-muted-foreground">
Please wait while processing completes...
</p>
)}
</div>
</div>
</div>
)
}
export default ThumbnailProgressModal

View File

@@ -0,0 +1,181 @@
import { useState } from 'react'
import { Calendar, Tv, Mic, Radio, Film, ChevronRight, User, Clapperboard, Megaphone, PenTool, Sparkles } from 'lucide-react'
import { format, parseISO, formatDistanceToNow, isBefore } from 'date-fns'
import AppearanceDetailModal from './AppearanceDetailModal'
interface Appearance {
id: number
celebrity_id: number
celebrity_name: string
appearance_type: 'TV' | 'Movie' | 'Podcast' | 'Radio'
show_name: string
episode_title: string | null
network: string | null
appearance_date: string
url: string | null
audio_url: string | null
watch_url: string | null
description: string | null
tmdb_show_id: number | null
season_number: number | null
episode_number: number | null
status: string
poster_url: string | null
episode_count: number
credit_type: 'acting' | 'host' | 'directing' | 'producing' | 'writing' | 'creator' | 'guest' | null
character_name: string | null
job_title: string | null
plex_rating_key: string | null
plex_watch_url: string | null
all_credit_types?: string[]
}
interface Props {
appearances: Appearance[]
onMarkWatched?: (id: number) => void
}
export default function UpcomingAppearancesCard({ appearances, onMarkWatched }: Props) {
const [selectedAppearance, setSelectedAppearance] = useState<Appearance | null>(null)
const typeConfig = {
TV: { icon: Tv, color: 'bg-gradient-to-br from-purple-600 to-indigo-600', label: 'TV' },
Movie: { icon: Film, color: 'bg-gradient-to-br from-red-600 to-pink-600', label: 'Movie' },
Podcast: { icon: Mic, color: 'bg-gradient-to-br from-green-600 to-emerald-600', label: 'Podcast' },
Radio: { icon: Radio, color: 'bg-gradient-to-br from-orange-600 to-amber-600', label: 'Radio' },
}
const creditTypeConfig: Record<string, { label: string; color: string; icon: typeof User }> = {
acting: { label: 'Acting', color: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', icon: User },
directing: { label: 'Directing', color: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', icon: Clapperboard },
producing: { label: 'Producing', color: 'bg-green-500/10 text-green-600 dark:text-green-400', icon: Megaphone },
writing: { label: 'Writing', color: 'bg-purple-500/10 text-purple-600 dark:text-purple-400', icon: PenTool },
creator: { label: 'Creator', color: 'bg-pink-500/10 text-pink-600 dark:text-pink-400', icon: Sparkles },
guest: { label: 'Guest', color: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400', icon: User },
host: { label: 'Host', color: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', icon: Mic },
}
return (
<>
<div className="card-glass-hover rounded-xl p-4 md:p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-cyan-600 to-blue-600 rounded-lg flex items-center justify-center">
<Calendar className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Upcoming Appearances
</h3>
</div>
<span className="text-xs text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 px-2 py-1 rounded">
{appearances.length} upcoming
</span>
</div>
{appearances && appearances.length > 0 ? (
<div className="space-y-3">
{appearances.map((appearance) => {
const config = typeConfig[appearance.appearance_type]
const Icon = config.icon
const isSoon = isBefore(parseISO(appearance.appearance_date), new Date(Date.now() + 7 * 24 * 60 * 60 * 1000))
return (
<div
key={appearance.id}
onClick={() => setSelectedAppearance(appearance)}
className="flex items-center justify-between p-4 bg-secondary/50 rounded-xl hover:bg-secondary group cursor-pointer transition-all"
>
{/* Poster/Icon + Info */}
<div className="flex items-center gap-4 min-w-0 flex-1">
{appearance.poster_url ? (
<div className="w-12 h-12 rounded-lg overflow-hidden flex-shrink-0 bg-slate-200 dark:bg-slate-700">
<img
src={`https://image.tmdb.org/t/p/w92${appearance.poster_url}`}
alt={appearance.show_name}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className={`w-12 h-12 ${config.color} rounded-lg flex items-center justify-center flex-shrink-0`}>
<Icon className="w-6 h-6 text-white" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<p className="text-sm font-semibold text-foreground truncate">
{appearance.celebrity_name}
</p>
<span className="text-xs px-2 py-0.5 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded flex-shrink-0">
{config.label}
</span>
{(appearance.all_credit_types || (appearance.credit_type ? [appearance.credit_type] : [])).map((ct) => {
const cc = creditTypeConfig[ct]
if (!cc) return null
const CreditIcon = cc.icon
return (
<span key={ct} className={`text-xs px-2 py-0.5 rounded flex items-center gap-1 flex-shrink-0 ${cc.color}`}>
<CreditIcon className="w-3 h-3" />
{cc.label}
</span>
)
})}
{isSoon && appearance.status === 'upcoming' && (
<span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-600 dark:text-orange-400 rounded flex-shrink-0">
Soon
</span>
)}
</div>
<p className="text-sm text-foreground truncate">
{appearance.show_name}
{appearance.network && (
<span className="text-muted-foreground"> {appearance.network}</span>
)}
</p>
{appearance.episode_title && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{appearance.season_number && appearance.episode_number && (
<span>S{appearance.season_number}E{appearance.episode_number}: </span>
)}
{appearance.episode_title}
</p>
)}
</div>
</div>
{/* Date + Arrow */}
<div className="flex items-center gap-3 ml-4 flex-shrink-0">
<div className="text-right">
<p className="text-xs font-medium text-foreground">
{format(parseISO(appearance.appearance_date), 'MMM d, yyyy')}
</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(parseISO(appearance.appearance_date), { addSuffix: true })}
</p>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
</div>
)
})}
</div>
) : (
<div className="text-center text-slate-500 dark:text-slate-400 py-8">
<Calendar className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No upcoming appearances scheduled</p>
<p className="text-xs mt-1">Sync TMDb to discover appearances</p>
</div>
)}
</div>
{/* Detail Modal */}
{selectedAppearance && (
<AppearanceDetailModal
appearance={selectedAppearance}
onClose={() => setSelectedAppearance(null)}
onMarkWatched={onMarkWatched}
/>
)}
</>
)
}

View File

@@ -0,0 +1,193 @@
import { useState, useRef, useMemo } from 'react'
import { Users, Plus, X, Trash2, Copy } from 'lucide-react'
interface UsernameListEditorProps {
value: string[]
onChange: (usernames: string[]) => void
label: string
placeholder?: string
}
export default function UsernameListEditor({ value, onChange, label, placeholder = 'Add username...' }: UsernameListEditorProps) {
const [input, setInput] = useState('')
const [showPasteModal, setShowPasteModal] = useState(false)
const [pasteText, setPasteText] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const sorted = useMemo(() => [...value].sort((a, b) => a.localeCompare(b)), [value])
const duplicates = useMemo(() => {
const counts: Record<string, number> = {}
for (const u of value) {
counts[u] = (counts[u] || 0) + 1
}
return new Set(Object.keys(counts).filter(k => counts[k] > 1))
}, [value])
const hasDuplicates = duplicates.size > 0
const addUsername = (raw: string) => {
const username = raw.trim().toLowerCase()
if (!username) return
onChange([...value, username].sort((a, b) => a.localeCompare(b)))
setInput('')
inputRef.current?.focus()
}
const removeUsername = (index: number) => {
// index is into the sorted array — find the actual username and remove first occurrence from value
const username = sorted[index]
const idx = value.indexOf(username)
if (idx !== -1) {
const next = [...value]
next.splice(idx, 1)
onChange(next)
}
}
const removeDuplicates = () => {
onChange([...new Set(value)])
}
const handlePaste = () => {
const parsed = pasteText
.split(/[,\n]+/)
.map(u => u.trim().toLowerCase())
.filter(u => u)
if (parsed.length > 0) {
onChange([...value, ...parsed].sort((a, b) => a.localeCompare(b)))
}
setPasteText('')
setShowPasteModal(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
addUsername(input)
}
}
return (
<div>
<div className="flex items-center justify-between mb-2">
<label className="flex items-center gap-1.5 text-sm font-medium text-slate-700 dark:text-slate-300">
<Users className="w-3.5 h-3.5" />
{label}
<span className="ml-1 px-1.5 py-0.5 text-[10px] font-semibold bg-slate-200 dark:bg-slate-600 text-slate-600 dark:text-slate-300 rounded-full">
{value.length}
</span>
</label>
<div className="flex items-center gap-1">
{hasDuplicates && (
<button
type="button"
onClick={removeDuplicates}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-md hover:bg-amber-100 dark:hover:bg-amber-900/50 transition-colors"
>
<Trash2 className="w-3 h-3" />
Remove Duplicates
</button>
)}
<button
type="button"
onClick={() => setShowPasteModal(true)}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium text-slate-600 dark:text-slate-400 bg-slate-100 dark:bg-slate-700 rounded-md hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
<Copy className="w-3 h-3" />
Paste List
</button>
</div>
</div>
{/* Add input */}
<div className="flex gap-2 mb-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 px-3 py-1.5 text-sm border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={placeholder}
/>
<button
type="button"
onClick={() => addUsername(input)}
disabled={!input.trim()}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 dark:disabled:bg-slate-600 text-white rounded-lg transition-colors flex items-center gap-1 text-sm"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Username list */}
{sorted.length === 0 ? (
<div className="border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-4 text-center">
<p className="text-xs text-slate-400 dark:text-slate-500">No usernames added yet</p>
</div>
) : (
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<div className="max-h-48 overflow-y-auto custom-scrollbar">
{sorted.map((username, i) => (
<div
key={`${username}-${i}`}
className="group flex items-center justify-between px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 border-b border-slate-100 dark:border-slate-700/50 last:border-b-0"
>
<span className="font-mono text-xs text-slate-800 dark:text-slate-200 flex items-center gap-1.5">
{username}
{duplicates.has(username) && (
<span className="px-1 py-0.5 text-[9px] font-semibold bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-400 rounded">
DUP
</span>
)}
</span>
<button
type="button"
onClick={() => removeUsername(i)}
className="opacity-0 group-hover:opacity-100 p-0.5 text-slate-400 hover:text-red-500 transition-all"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
)}
{/* Paste Modal */}
{showPasteModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowPasteModal(false)}>
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-xl p-5 w-full max-w-md mx-4" onClick={e => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100 mb-3">Paste Username List</h3>
<textarea
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 font-mono text-xs"
rows={6}
placeholder="Paste usernames separated by commas or newlines..."
autoFocus
/>
<div className="flex justify-end gap-2 mt-3">
<button
type="button"
onClick={() => { setPasteText(''); setShowPasteModal(false) }}
className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handlePaste}
disabled={!pasteText.trim()}
className="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 dark:disabled:bg-slate-600 text-white rounded-lg transition-colors"
>
Add Usernames
</button>
</div>
</div>
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
import { useQuery } from '@tanstack/react-query'
import { api, GalleryGroupCard } from '../../lib/api'
import { THUMB_CACHE_V } from '../../lib/utils'
import { Loader2, Image as ImageIcon, Users } from 'lucide-react'
interface Props {
onSelectGroup: (groupId: number) => void
}
export default function GalleryGroupLanding({ onSelectGroup }: Props) {
const { data: groups, isLoading } = useQuery({
queryKey: ['gallery-groups'],
queryFn: () => api.paidContent.getGalleryGroups(),
})
// Calculate total media across all groups
const totalMedia = groups?.reduce((sum, g) => sum + g.total_media, 0) ?? 0
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Gallery</h1>
<p className="text-muted-foreground mt-1">Browse media by group</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{/* All Media card */}
<button
type="button"
className="relative aspect-[4/3] bg-secondary rounded-xl overflow-hidden cursor-pointer group transition-all hover:ring-2 hover:ring-primary"
onClick={() => onSelectGroup(0)}
>
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
<ImageIcon className="w-12 h-12 text-primary/40" />
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/50 to-transparent p-4">
<div className="text-white font-semibold text-lg">All Media</div>
<div className="text-white/70 text-sm">{totalMedia.toLocaleString()} items</div>
</div>
</button>
{/* Group cards */}
{groups?.map((group: GalleryGroupCard) => (
<button
key={group.id}
type="button"
className="relative aspect-[4/3] bg-secondary rounded-xl overflow-hidden cursor-pointer group transition-all hover:ring-2 hover:ring-primary"
onClick={() => onSelectGroup(group.id)}
>
{group.representative_attachment_id ? (
<img
src={`/api/paid-content/files/thumbnail/${group.representative_attachment_id}?size=large&${THUMB_CACHE_V}`}
alt=""
className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon className="w-12 h-12 text-muted-foreground/30" />
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/50 to-transparent p-4">
<div className="text-white font-semibold text-lg truncate">{group.name}</div>
<div className="text-white/70 text-sm flex items-center gap-2">
<Users className="w-3.5 h-3.5" />
{group.member_count} creator{group.member_count !== 1 ? 's' : ''}
<span className="mx-1">·</span>
{group.total_media.toLocaleString()} items
</div>
</div>
</button>
))}
</div>
{groups && groups.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
No creator groups found. Create groups in the Creators page first.
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,657 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
import { api, GalleryMediaItem, PaidContentPost, PaidContentAttachment } from '../../lib/api'
import { THUMB_CACHE_V } from '../../lib/utils'
import { ArrowLeft, Image as ImageIcon, Video, Loader2, Film, Play, X } from 'lucide-react'
import BundleLightbox from './BundleLightbox'
import TimelineScrubber from './TimelineScrubber'
interface Props {
groupId: number
onBack: () => void
}
const LIMIT = 200
const TARGET_ROW_HEIGHT = 180
const ROW_GAP = 4
const MONTH_NAMES = ['', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December']
const SHORT_MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const SHORT_DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function formatDayLabel(dateStr: string): string {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const [y, m, d] = dateStr.split('-').map(Number)
const date = new Date(y, m - 1, d)
const diffDays = Math.round((today.getTime() - date.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays === 0) return 'Today'
if (diffDays === 1) return 'Yesterday'
if (diffDays > 1 && diffDays < 7) return DAY_NAMES[date.getDay()]
if (y === now.getFullYear()) return `${SHORT_DAY_NAMES[date.getDay()]}, ${SHORT_MONTH_NAMES[date.getMonth()]} ${d}`
return `${SHORT_DAY_NAMES[date.getDay()]}, ${SHORT_MONTH_NAMES[date.getMonth()]} ${d}, ${y}`
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function getAspectRatio(item: GalleryMediaItem): number {
return (item.width && item.height && item.height > 0) ? item.width / item.height : 1
}
/** Build a synthetic PaidContentPost from a GalleryMediaItem for BundleLightbox compatibility */
function buildSyntheticPost(item: GalleryMediaItem): PaidContentPost {
return {
id: item.post_id,
creator_id: item.creator_id,
post_id: String(item.post_id),
title: item.post_title,
content: null,
published_at: item.media_date,
added_at: null,
has_attachments: 1,
attachment_count: 1,
downloaded: 1,
download_date: item.downloaded_at,
is_favorited: item.is_favorited,
is_viewed: 1,
is_pinned: 0,
pinned_at: null,
username: item.username,
platform: item.platform,
service_id: item.service_id,
display_name: item.display_name,
profile_image_url: item.profile_image_url,
identity_id: null,
identity_name: item.identity_name,
attachments: [],
tags: [],
tagged_users: [],
}
}
/** Build a synthetic PaidContentAttachment from a GalleryMediaItem */
function buildSyntheticAttachment(item: GalleryMediaItem): PaidContentAttachment {
return {
id: item.id,
post_id: item.post_id,
attachment_index: 0,
name: item.name,
file_type: item.file_type,
extension: item.extension,
server_path: null,
download_url: null,
file_size: item.file_size,
width: item.width,
height: item.height,
duration: item.duration,
status: 'completed',
local_path: item.local_path,
local_filename: item.local_filename,
file_hash: item.file_hash,
error_message: null,
download_attempts: 0,
downloaded_at: item.downloaded_at,
created_at: null,
}
}
interface JustifiedRow {
items: GalleryMediaItem[]
height: number
}
/** Compute justified rows from items */
function buildJustifiedRows(
items: GalleryMediaItem[],
containerWidth: number,
targetHeight: number,
gap: number
): JustifiedRow[] {
if (containerWidth <= 0 || items.length === 0) return []
const rows: JustifiedRow[] = []
let currentRow: GalleryMediaItem[] = []
let currentWidthSum = 0
for (const item of items) {
const scaledWidth = getAspectRatio(item) * targetHeight
currentRow.push(item)
currentWidthSum += scaledWidth
const totalGap = (currentRow.length - 1) * gap
if (currentWidthSum + totalGap >= containerWidth) {
// Finalize row: compute exact height so items fill the container width
const arSum = currentRow.reduce((sum, it) => sum + getAspectRatio(it), 0)
const rowHeight = (containerWidth - totalGap) / arSum
rows.push({ items: [...currentRow], height: Math.min(rowHeight, targetHeight * 1.5) })
currentRow = []
currentWidthSum = 0
}
}
// Last incomplete row: use target height (don't stretch)
if (currentRow.length > 0) {
rows.push({ items: currentRow, height: targetHeight })
}
return rows
}
/** Memoized section component — only re-renders when its own props change */
const JustifiedSection = memo(function JustifiedSection({
sectionKey,
label,
items,
rows,
onClickItem,
}: {
sectionKey: string
label: string
items: GalleryMediaItem[]
rows: JustifiedRow[]
onClickItem: (item: GalleryMediaItem) => void
}) {
return (
<div id={`gallery-section-${sectionKey}`} className="mb-6">
{/* Section header */}
<div className="flex items-center justify-between mb-2 sticky top-0 z-10 bg-background/95 backdrop-blur-sm py-2">
<h2 className="text-sm font-medium">{label}</h2>
<span className="text-sm text-muted-foreground">{items.length} items</span>
</div>
{/* Justified layout rows */}
<div className="flex flex-col" style={{ gap: `${ROW_GAP}px` }}>
{rows.map((row, rowIdx) => (
<div key={rowIdx} className="flex" style={{ gap: `${ROW_GAP}px`, height: `${row.height}px` }}>
{row.items.map(item => {
const itemWidth = getAspectRatio(item) * row.height
const isVideo = item.file_type === 'video'
const thumbUrl = `/api/paid-content/files/thumbnail/${item.id}?size=large&${
item.file_hash ? `v=${item.file_hash.slice(0, 8)}` : THUMB_CACHE_V
}`
return (
<button
key={item.id}
type="button"
className="relative bg-secondary rounded overflow-hidden cursor-pointer group hover:ring-2 hover:ring-primary transition-all flex-shrink-0"
style={{ width: `${itemWidth}px`, height: `${row.height}px` }}
onClick={() => onClickItem(item)}
>
<img
src={thumbUrl}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-8 h-8 rounded-full bg-black/50 flex items-center justify-center">
<Video className="w-4 h-4 text-white" />
</div>
</div>
)}
{isVideo && item.duration != null && (
<div className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[10px] px-1 py-0.5 rounded">
{formatDuration(item.duration)}
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="text-white text-[10px] truncate">{item.display_name || item.username}</div>
</div>
</button>
)
})}
</div>
))}
</div>
</div>
)
})
export default function GalleryTimeline({ groupId, onBack }: Props) {
const [contentType, setContentType] = useState<string | undefined>(undefined)
const [lightboxIndex, setLightboxIndex] = useState(-1)
const [lightboxPost, setLightboxPost] = useState<PaidContentPost | null>(null)
const [slideshowActive, setSlideshowActive] = useState(false)
const [slideshowShuffled, setSlideshowShuffled] = useState(true) // shuffle on by default
const [shuffleSeed, setShuffleSeed] = useState<number | null>(null)
const [dateFrom, setDateFrom] = useState<string | undefined>(undefined)
const [dateTo, setDateTo] = useState<string | undefined>(undefined)
const [jumpTarget, setJumpTarget] = useState<{ year: number; month: number } | null>(null)
const sentinelRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const roRef = useRef<ResizeObserver | null>(null)
// Callback ref: fires when the container div mounts/unmounts (it's conditionally rendered)
const containerRef = useCallback((node: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect()
roRef.current = null
}
if (node) {
// Sync initial read — no blank frame
setContainerWidth(node.getBoundingClientRect().width)
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width
if (w != null) setContainerWidth(prev => Math.abs(prev - w) > 1 ? w : prev)
})
ro.observe(node)
roRef.current = ro
}
}, [])
// Group name
const { data: groups } = useQuery({
queryKey: ['gallery-groups'],
queryFn: () => api.paidContent.getGalleryGroups(),
staleTime: 60_000,
})
const groupName = groupId === 0 ? 'All Media' : groups?.find(g => g.id === groupId)?.name ?? 'Gallery'
// Gallery media with infinite scroll (normal timeline order)
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ['gallery-media', groupId, contentType, dateFrom, dateTo],
queryFn: ({ pageParam = 0 }) =>
api.paidContent.getGalleryMedia({
creator_group_id: groupId > 0 ? groupId : undefined,
content_type: contentType,
date_from: dateFrom,
date_to: dateTo,
limit: LIMIT,
offset: pageParam * LIMIT,
}),
getNextPageParam: (lastPage, allPages) => {
if (!lastPage?.items || lastPage.items.length < LIMIT) return undefined
return allPages.length
},
initialPageParam: 0,
staleTime: 0,
gcTime: 5 * 60 * 1000,
})
// Shuffled gallery media (only active during slideshow)
const {
data: shuffledData,
hasNextPage: shuffledHasNextPage,
fetchNextPage: fetchNextShuffledPage,
isFetchingNextPage: isFetchingNextShuffledPage,
} = useInfiniteQuery({
queryKey: ['gallery-media-shuffled', groupId, contentType, shuffleSeed],
queryFn: ({ pageParam = 0 }) =>
api.paidContent.getGalleryMedia({
creator_group_id: groupId > 0 ? groupId : undefined,
content_type: contentType,
shuffle: true,
shuffle_seed: shuffleSeed ?? 0,
limit: LIMIT,
offset: pageParam * LIMIT,
}),
getNextPageParam: (lastPage, allPages) => {
if (!lastPage?.items || lastPage.items.length < LIMIT) return undefined
return allPages.length
},
initialPageParam: 0,
enabled: shuffleSeed !== null,
gcTime: 5 * 60 * 1000,
})
// Date range for scrubber
const { data: dateRanges } = useQuery({
queryKey: ['gallery-date-range', groupId, contentType],
queryFn: () => api.paidContent.getGalleryDateRange({
creator_group_id: groupId > 0 ? groupId : undefined,
content_type: contentType,
}),
})
// Flatten all pages
const items = useMemo(() => {
if (!data?.pages) return []
const seen = new Set<number>()
return data.pages.flatMap(page => page.items || []).filter(item => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}, [data])
const totalCount = data?.pages?.[0]?.total ?? 0
// Group items by YYYY-MM-DD
const sections = useMemo(() => {
const map = new Map<string, GalleryMediaItem[]>()
for (const item of items) {
const d = item.media_date ? new Date(item.media_date) : null
const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` : 'unknown'
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(item)
}
return Array.from(map.entries()).map(([key, sectionItems]) => {
if (key === 'unknown') return { key, label: 'Unknown Date', year: 0, month: 0, items: sectionItems }
const [y, m] = key.split('-').map(Number)
return { key, label: formatDayLabel(key), year: y, month: m, items: sectionItems }
})
}, [items])
// Pre-compute all justified rows (memoized — only recomputes when sections or width change)
const sectionRowsMap = useMemo(() => {
const map = new Map<string, JustifiedRow[]>()
for (const section of sections) {
map.set(section.key, buildJustifiedRows(section.items, containerWidth, TARGET_ROW_HEIGHT, ROW_GAP))
}
return map
}, [sections, containerWidth])
// After data loads with a jump target, scroll to the first day-section in that month
useEffect(() => {
if (!jumpTarget || items.length === 0) return
const prefix = `${jumpTarget.year}-${String(jumpTarget.month).padStart(2, '0')}`
// Small delay to let DOM render
const timer = setTimeout(() => {
const firstDaySection = sections.find(s => s.key.startsWith(prefix))
if (firstDaySection) {
const el = document.getElementById(`gallery-section-${firstDaySection.key}`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
setJumpTarget(null)
}, 100)
return () => clearTimeout(timer)
}, [jumpTarget, items.length, sections])
// Flatten shuffled pages
const shuffledItems = useMemo(() => {
if (!shuffledData?.pages) return []
const seen = new Set<number>()
return shuffledData.pages.flatMap(page => page.items || []).filter(item => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}, [shuffledData])
// Pick the right item list depending on mode
const lightboxItems = (slideshowActive && slideshowShuffled) ? shuffledItems : items
// Build flat attachment list for lightbox
const flatAttachments = useMemo(() => lightboxItems.map(buildSyntheticAttachment), [lightboxItems])
// Lightbox attachment-to-post mapping
const itemMap = useMemo(() => {
const m = new Map<number, GalleryMediaItem>()
for (const item of lightboxItems) m.set(item.id, item)
return m
}, [lightboxItems])
// Infinite scroll observer
useEffect(() => {
if (!sentinelRef.current) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
},
{ rootMargin: '600px' }
)
observer.observe(sentinelRef.current)
return () => observer.disconnect()
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
const openLightbox = useCallback((item: GalleryMediaItem) => {
const idx = items.findIndex(i => i.id === item.id)
if (idx >= 0) {
setSlideshowActive(false)
setLightboxPost(buildSyntheticPost(item))
setLightboxIndex(idx)
}
}, [items])
const startSlideshow = useCallback(() => {
const seed = Math.floor(Math.random() * 2147483647)
setShuffleSeed(seed)
setSlideshowShuffled(true)
setSlideshowActive(true)
}, [])
// Open lightbox when slideshow data is ready
useEffect(() => {
if (!slideshowActive || lightboxIndex !== -1) return
const source = slideshowShuffled ? shuffledItems : items
if (source.length > 0) {
setLightboxPost(buildSyntheticPost(source[0]))
setLightboxIndex(0)
}
}, [slideshowActive, slideshowShuffled, shuffledItems, items, lightboxIndex])
// Handle shuffle toggle from lightbox
const handleShuffleChange = useCallback((enabled: boolean) => {
setSlideshowShuffled(enabled)
if (enabled && shuffleSeed === null) {
setShuffleSeed(Math.floor(Math.random() * 2147483647))
}
setLightboxIndex(0)
const source = enabled ? shuffledItems : items
if (source.length > 0) {
setLightboxPost(buildSyntheticPost(source[0]))
}
}, [shuffleSeed, shuffledItems, items])
const handleLightboxNavigate = useCallback((idx: number) => {
setLightboxIndex(idx)
const att = flatAttachments[idx]
if (att) {
const sourceItem = itemMap.get(att.id)
if (sourceItem) setLightboxPost(buildSyntheticPost(sourceItem))
}
if (idx >= flatAttachments.length - 20) {
if (slideshowActive && slideshowShuffled && shuffledHasNextPage && !isFetchingNextShuffledPage) {
fetchNextShuffledPage()
} else if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}
}, [flatAttachments, itemMap, slideshowActive, slideshowShuffled, hasNextPage, isFetchingNextPage, fetchNextPage, shuffledHasNextPage, isFetchingNextShuffledPage, fetchNextShuffledPage])
const handleLightboxClose = useCallback(() => {
setLightboxPost(null)
setLightboxIndex(-1)
setSlideshowActive(false)
}, [])
// Handle timeline jump to a date not yet loaded
const handleJumpToDate = useCallback((year: number, month: number) => {
const df = `${year}-${String(month).padStart(2, '0')}-01`
const lastDay = new Date(year, month, 0).getDate()
const dt = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
setDateFrom(df)
setDateTo(dt)
setJumpTarget({ year, month })
}, [])
const clearDateFrom = useCallback(() => {
setDateFrom(undefined)
setDateTo(undefined)
setJumpTarget(null)
}, [])
const dateFromLabel = useMemo(() => {
if (!dateFrom) return ''
const [y, m] = dateFrom.split('-').map(Number)
return `${MONTH_NAMES[m]} ${y}`
}, [dateFrom])
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-2 sm:gap-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onBack}
className="p-2 rounded-lg hover:bg-secondary transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold">{groupName}</h1>
<p className="text-muted-foreground text-sm">
{totalCount.toLocaleString()} items
</p>
</div>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto justify-center sm:justify-end">
{/* Slideshow button */}
<button
type="button"
onClick={startSlideshow}
disabled={totalCount === 0}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Play className="w-4 h-4" />
Slideshow
</button>
{/* Content type toggle */}
<div className="flex items-center bg-secondary rounded-lg p-1 gap-0.5">
<button
type="button"
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
!contentType ? 'bg-background shadow-sm' : 'hover:bg-background/50'
}`}
onClick={() => setContentType(undefined)}
>
All
</button>
<button
type="button"
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5 ${
contentType === 'image' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
}`}
onClick={() => setContentType('image')}
>
<ImageIcon className="w-4 h-4" />
Images
</button>
<button
type="button"
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5 ${
contentType === 'video' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
}`}
onClick={() => setContentType('video')}
>
<Film className="w-4 h-4" />
Videos
</button>
</div>
</div>
</div>
{/* Date filter chip */}
{dateFrom && (
<div className="flex items-center gap-2">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium">
Showing {dateFromLabel}
<button
type="button"
onClick={clearDateFrom}
className="p-0.5 rounded-full hover:bg-primary/20 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
)}
{/* Empty state */}
{!isLoading && items.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
<ImageIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No media found</p>
</div>
)}
{/* Timeline sections */}
{!isLoading && items.length > 0 && (
<div className="relative">
{/* Main content with right margin for scrubber */}
<div className="md:mr-12" ref={containerRef}>
{sections.map(section => (
<JustifiedSection
key={section.key}
sectionKey={section.key}
label={section.label}
items={section.items}
rows={sectionRowsMap.get(section.key) || []}
onClickItem={openLightbox}
/>
))}
</div>
{/* Timeline Scrubber */}
{dateRanges && dateRanges.length > 0 && (
<TimelineScrubber
ranges={dateRanges}
sections={sections}
onJumpToDate={handleJumpToDate}
/>
)}
</div>
)}
{/* Infinite scroll sentinel */}
<div ref={sentinelRef} className="h-4" />
{isFetchingNextPage && (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Lightbox */}
{lightboxPost && lightboxIndex >= 0 && flatAttachments.length > 0 && (
<BundleLightbox
post={lightboxPost}
attachments={flatAttachments}
currentIndex={lightboxIndex}
initialSlideshow={slideshowActive}
initialInterval={3000}
initialShuffle={false}
autoPlayVideo={true}
isShuffled={slideshowShuffled}
onShuffleChange={slideshowActive ? handleShuffleChange : undefined}
onClose={handleLightboxClose}
onNavigate={handleLightboxNavigate}
totalCount={totalCount}
hasMore={(slideshowActive && slideshowShuffled) ? !!shuffledHasNextPage : !!hasNextPage}
onLoadMore={() => (slideshowActive && slideshowShuffled) ? fetchNextShuffledPage() : fetchNextPage()}
/>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
import { useState, useEffect, useRef, useCallback } from 'react'
interface DateRange {
year: number
month: number
count: number
}
interface Section {
key: string
label: string
year: number
month: number
}
interface Props {
ranges: DateRange[]
sections: Section[]
onJumpToDate?: (year: number, month: number) => void
}
const MONTH_SHORT = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
export default function TimelineScrubber({ ranges, sections, onJumpToDate }: Props) {
const [activeKey, setActiveKey] = useState<string>('')
const observerRef = useRef<IntersectionObserver | null>(null)
// Track which section is in view
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect()
const observer = new IntersectionObserver(
(entries) => {
// Find the topmost visible section
let topEntry: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (entry.isIntersecting) {
if (!topEntry || entry.boundingClientRect.top < topEntry.boundingClientRect.top) {
topEntry = entry
}
}
}
if (topEntry) {
const id = topEntry.target.id
const key = id.replace('gallery-section-', '')
setActiveKey(key)
}
},
{ rootMargin: '-10% 0px -70% 0px', threshold: 0 }
)
observerRef.current = observer
// Observe all section headers
for (const section of sections) {
const el = document.getElementById(`gallery-section-${section.key}`)
if (el) observer.observe(el)
}
return () => observer.disconnect()
}, [sections])
const scrollTo = useCallback((key: string, year: number, month: number) => {
// Try exact key first, then prefix match (daily sections use YYYY-MM-DD keys)
let el = document.getElementById(`gallery-section-${key}`)
if (!el) {
// Find first section element that starts with this month prefix
const prefix = `${year}-${String(month).padStart(2, '0')}`
for (const section of sections) {
if (section.key.startsWith(prefix)) {
el = document.getElementById(`gallery-section-${section.key}`)
if (el) break
}
}
}
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else if (onJumpToDate) {
// Section not in DOM yet (not loaded via infinite scroll) — ask parent to load from this date
onJumpToDate(year, month)
}
}, [onJumpToDate, sections])
// Decide compact mode: if >24 entries, show years + quarters only
const compact = ranges.length > 24
// Group ranges by year
const years = new Map<number, DateRange[]>()
for (const r of ranges) {
if (!years.has(r.year)) years.set(r.year, [])
years.get(r.year)!.push(r)
}
return (
<div className="hidden md:flex fixed right-2 top-16 bottom-4 z-30 flex-col items-end gap-0.5 overflow-y-auto scrollbar-thin pr-1">
{Array.from(years.entries()).map(([year, months]) => (
<div key={year} className="flex flex-col items-end">
{/* Year label */}
<button
type="button"
className={`text-xs font-bold px-1.5 py-0.5 rounded transition-colors ${
months.some(m => activeKey.startsWith(`${m.year}-${String(m.month).padStart(2, '0')}`))
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={() => {
const firstMonth = months[0]
scrollTo(
`${firstMonth.year}-${String(firstMonth.month).padStart(2, '0')}`,
firstMonth.year,
firstMonth.month
)
}}
>
{year}
</button>
{/* Month labels */}
{!compact && months.map(m => {
const key = `${m.year}-${String(m.month).padStart(2, '0')}`
const isActive = activeKey.startsWith(key)
return (
<button
key={key}
type="button"
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors ${
isActive
? 'text-primary font-semibold bg-primary/10'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={() => scrollTo(key, m.year, m.month)}
>
{MONTH_SHORT[m.month]}
</button>
)
})}
{/* Compact: show quarters */}
{compact && (() => {
const quarters = new Map<number, DateRange[]>()
for (const m of months) {
const q = Math.ceil(m.month / 3)
if (!quarters.has(q)) quarters.set(q, [])
quarters.get(q)!.push(m)
}
return Array.from(quarters.entries()).map(([q, qMonths]) => {
const firstMonth = qMonths[0]
const key = `${firstMonth.year}-${String(firstMonth.month).padStart(2, '0')}`
const isActive = qMonths.some(
m => activeKey.startsWith(`${m.year}-${String(m.month).padStart(2, '0')}`)
)
return (
<button
key={`${year}-Q${q}`}
type="button"
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors ${
isActive
? 'text-primary font-semibold bg-primary/10'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={() => scrollTo(key, firstMonth.year, firstMonth.month)}
>
Q{q}
</button>
)
})
})()}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,420 @@
/**
* Copy to Private Gallery Modal
*
* Used from Downloads, Media, Review, and Paid Content pages
* to copy files to the private gallery
*/
import { useState, useRef, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
X,
Copy,
Calendar,
FileText,
Loader2,
Lock,
Video,
} from 'lucide-react'
import { api } from '../../lib/api'
import PersonSelector from './PersonSelector'
import TagSearchSelector from './TagSearchSelector'
import ThrottledImage from '../ThrottledImage'
import ThumbnailProgressModal, { ThumbnailProgressItem } from '../ThumbnailProgressModal'
interface CopyToGalleryModalProps {
open: boolean
onClose: () => void
sourcePaths: string[]
sourceType: 'downloads' | 'media' | 'review' | 'recycle' | 'paid_content'
onSuccess?: () => void
sourceNames?: Record<string, string> // source_path -> display name
}
export function CopyToGalleryModal({
open,
onClose,
sourcePaths,
sourceType,
onSuccess,
sourceNames,
}: CopyToGalleryModalProps) {
const queryClient = useQueryClient()
const [personId, setPersonId] = useState<number | undefined>()
const [selectedTags, setSelectedTags] = useState<number[]>([])
const [tagsManuallyModified, setTagsManuallyModified] = useState(false)
const [mediaDate, setMediaDate] = useState('')
const [description, setDescription] = useState('')
const [jobId, setJobId] = useState<string | null>(null)
const [progressItems, setProgressItems] = useState<ThumbnailProgressItem[]>([])
const [showProgress, setShowProgress] = useState(false)
const isSubmitting = useRef(false)
// Check gallery status
const { data: galleryStatus, isLoading: isLoadingStatus } = useQuery({
queryKey: ['private-gallery-status'],
queryFn: () => api.privateGallery.getStatus(),
enabled: open,
})
const { data: tags = [] } = useQuery({
queryKey: ['private-gallery-tags'],
queryFn: () => api.privateGallery.getTags(),
enabled: open && galleryStatus?.is_unlocked,
})
const copyMutation = useMutation({
mutationFn: (data: {
source_paths: string[]
person_id: number
tag_ids: number[]
media_date?: string
description?: string
original_filenames?: Record<string, string>
}) => api.privateGallery.copy(data),
onSuccess: (result) => {
setJobId(result.job_id)
},
})
// Poll job status
useEffect(() => {
if (!jobId) return
const pollInterval = setInterval(async () => {
try {
const status = await api.privateGallery.getJobStatus(jobId)
setProgressItems(prev => prev.map(item => {
const result = status.results.find(r => {
const resultFilename = r.filename || (r.path ? r.path.split('/').pop() : '')
return resultFilename === item.filename || r.path === item.id
})
if (result) {
return {
...item,
status: result.status === 'created' ? 'success'
: result.status === 'duplicate' ? 'duplicate'
: result.status === 'failed' ? 'error'
: item.status,
error: result.error
}
}
if (status.current_file === item.filename) {
return { ...item, status: 'processing' }
}
return item
}))
if (status.status === 'completed') {
clearInterval(pollInterval)
setJobId(null)
queryClient.invalidateQueries({ queryKey: ['private-gallery-media'] })
queryClient.invalidateQueries({ queryKey: ['private-gallery-posts'] })
queryClient.invalidateQueries({ queryKey: ['private-gallery-albums'] })
queryClient.invalidateQueries({ queryKey: ['private-gallery-stats'] })
// Auto-close if all succeeded
if (status.failed_count === 0) {
setTimeout(() => {
handleClose()
onSuccess?.()
}, 1500)
}
}
} catch (error) {
console.error('Error polling job status:', error)
}
}, 500)
return () => clearInterval(pollInterval)
}, [jobId, queryClient, onSuccess])
const handleClose = () => {
setPersonId(undefined)
setSelectedTags([])
setTagsManuallyModified(false)
setMediaDate('')
setDescription('')
setJobId(null)
setProgressItems([])
setShowProgress(false)
isSubmitting.current = false
copyMutation.reset()
onClose()
}
const handleSubmit = () => {
if (!personId) {
alert('Please select a person')
return
}
if (isSubmitting.current) return
isSubmitting.current = true
// Build progress items from source paths
const items: ThumbnailProgressItem[] = sourcePaths.map((path) => {
const isVideo = /\.(mp4|mov|webm|avi|mkv|flv|m4v)$/i.test(path)
const mediaType = isVideo ? 'video' : 'image'
const filename = sourceNames?.[path] || path.split('/').pop() || path
return {
id: path,
filename,
status: 'pending' as const,
thumbnailUrl: api.getMediaThumbnailUrl(path, mediaType),
fileType: mediaType,
}
})
setProgressItems(items)
setShowProgress(true)
copyMutation.mutate({
source_paths: sourcePaths,
person_id: personId,
tag_ids: selectedTags,
media_date: mediaDate || undefined,
description: description || undefined,
original_filenames: sourceNames,
}, {
onSettled: () => {
isSubmitting.current = false
},
})
}
if (!open) return null
// Show progress modal when processing
if (showProgress) {
return (
<ThumbnailProgressModal
isOpen={true}
title="Copying to Private Gallery"
items={progressItems}
onClose={handleClose}
/>
)
}
// Gallery not setup or locked
if (isLoadingStatus) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-xl shadow-2xl border border-border p-8">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-muted-foreground">Checking gallery status...</p>
</div>
</div>
</div>
)
}
if (!galleryStatus?.is_setup_complete) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-xl shadow-2xl border border-border p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
<Lock className="w-8 h-8 text-primary" />
</div>
<h2 className="text-xl font-semibold">Private Gallery Not Set Up</h2>
<p className="text-muted-foreground">
You need to set up the private gallery before you can copy files to it.
</p>
<div className="flex gap-3 mt-4">
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors"
>
Cancel
</button>
<a
href="/private-gallery"
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Set Up Gallery
</a>
</div>
</div>
</div>
</div>
)
}
if (!galleryStatus?.is_unlocked) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-xl shadow-2xl border border-border p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center">
<Lock className="w-8 h-8 text-amber-500" />
</div>
<h2 className="text-xl font-semibold">Gallery Locked</h2>
<p className="text-muted-foreground">
Please unlock the private gallery first to copy files to it.
</p>
<div className="flex gap-3 mt-4">
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors"
>
Cancel
</button>
<a
href="/private-gallery"
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Unlock Gallery
</a>
</div>
</div>
</div>
</div>
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-2xl bg-card rounded-xl shadow-2xl border border-border max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">Copy to Private Gallery</h2>
<button
onClick={handleClose}
className="p-2 rounded-lg hover:bg-secondary transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6">
{/* File Thumbnails */}
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
<span className="font-medium">{sourcePaths.length}</span>{' '}
{sourcePaths.length === 1 ? 'file' : 'files'} selected from{' '}
<span className="capitalize">{sourceType.replace('_', ' ')}</span>
</p>
<div className="grid grid-cols-4 sm:grid-cols-5 gap-1.5 max-h-48 overflow-y-auto p-1 rounded-lg border border-border bg-secondary/30">
{sourcePaths.map((path, index) => {
const isVideo = /\.(mp4|mov|webm|avi|mkv|flv|m4v)$/i.test(path)
const mediaType = isVideo ? 'video' : 'image'
const thumbnailUrl = api.getMediaThumbnailUrl(path, mediaType)
const filename = path.split('/').pop() || ''
return (
<div key={index} className="relative aspect-square rounded-lg overflow-hidden bg-secondary">
<ThrottledImage
src={thumbnailUrl}
alt={filename}
className="w-full h-full object-cover"
/>
{isVideo && (
<div className="absolute bottom-0.5 left-0.5 bg-black/70 rounded px-1">
<Video className="w-3 h-3 text-white" />
</div>
)}
</div>
)
})}
</div>
</div>
{/* Person Selector */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<span className="text-red-500">*</span>
Person
</label>
<PersonSelector
value={personId}
onChange={(id, person) => {
setPersonId(id)
// Auto-populate tags from person defaults if not manually modified
if (!tagsManuallyModified && person?.default_tag_ids?.length) {
setSelectedTags(person.default_tag_ids)
}
}}
required
/>
</div>
{/* Tags */}
<TagSearchSelector
tags={tags}
selectedTagIds={selectedTags}
onChange={(ids) => {
setTagsManuallyModified(true)
setSelectedTags(ids)
}}
label="Tags (optional)"
/>
{/* Date */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<Calendar className="w-4 h-4" />
Date (optional)
</label>
<input
type="date"
value={mediaDate}
onChange={(e) => setMediaDate(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
/>
<p className="text-xs text-muted-foreground mt-1">
Leave empty to auto-detect from EXIF or filename
</p>
</div>
{/* Description */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<FileText className="w-4 h-4" />
Description (optional)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full px-4 py-2.5 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-none"
placeholder="Add a description..."
/>
</div>
</div>
{/* Footer */}
<div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-end gap-3 px-4 sm:px-6 py-4 border-t border-border">
<button
onClick={handleClose}
className="w-full sm:w-auto px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={copyMutation.isPending || !personId}
className="w-full sm:w-auto px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 transition-colors"
>
{copyMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Copying...
</>
) : (
<>
<Copy className="w-4 h-4" />
Copy to Gallery
</>
)}
</button>
</div>
</div>
</div>
)
}
export default CopyToGalleryModal

View File

@@ -0,0 +1,197 @@
/**
* Person Selector Component
*
* Dropdown to select a person with relationship info
*/
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ChevronDown, Plus, Search } from 'lucide-react'
import { api } from '../../lib/api'
interface Person {
id: number
name: string
sort_name?: string | null
relationship_id: number
relationship: {
id: number
name: string
color: string
}
default_tag_ids: number[]
}
interface PersonSelectorProps {
value?: number
onChange: (personId: number | undefined, person?: Person) => void
placeholder?: string
required?: boolean
className?: string
onCreateNew?: () => void
}
export function PersonSelector({
value,
onChange,
placeholder = 'Select a person',
required = false,
className = '',
onCreateNew,
}: PersonSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState('')
const { data: persons = [], isLoading } = useQuery({
queryKey: ['private-gallery-persons'],
queryFn: () => api.privateGallery.getPersons(),
})
// Find selected person
const selectedPerson = persons.find(p => p.id === value)
// Filter persons by search
const filteredPersons = persons.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.sort_name && p.sort_name.toLowerCase().includes(search.toLowerCase()))
)
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.person-selector')) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
return (
<div className={`relative person-selector ${className}`}>
{/* Trigger Button */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`w-full px-4 py-2.5 rounded-lg border bg-background text-left flex items-center justify-between gap-2 transition-colors ${
isOpen
? 'border-primary ring-2 ring-primary/20'
: 'border-border hover:border-primary/50'
}`}
>
{selectedPerson ? (
<div className="flex items-center gap-2 overflow-hidden">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: selectedPerson.relationship.color }}
/>
<span className="truncate">{selectedPerson.name}</span>
<span className="text-xs text-muted-foreground">
({selectedPerson.relationship.name})
</span>
</div>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<ChevronDown className={`w-4 h-4 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 mt-1 w-full bg-popover rounded-lg shadow-lg border border-border overflow-hidden">
{/* Search */}
<div className="p-2 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search persons..."
className="w-full pl-9 pr-4 py-2 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
autoFocus
/>
</div>
</div>
{/* Options */}
<div className="max-h-64 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-muted-foreground">Loading...</div>
) : filteredPersons.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
{search ? 'No persons found' : 'No persons yet'}
</div>
) : (
filteredPersons.map((person) => (
<button
key={person.id}
type="button"
onClick={() => {
onChange(person.id, person)
setIsOpen(false)
setSearch('')
}}
className={`w-full px-4 py-2.5 text-left flex items-center gap-3 hover:bg-secondary transition-colors ${
person.id === value ? 'bg-primary/10' : ''
}`}
>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: person.relationship.color }}
/>
<div className="flex-1 overflow-hidden">
<div className="truncate font-medium">{person.name}</div>
<div className="text-xs text-muted-foreground truncate">
{person.relationship.name}
{person.sort_name && `${person.sort_name}`}
</div>
</div>
</button>
))
)}
</div>
{/* Create New Option */}
{onCreateNew && (
<div className="p-2 border-t border-border">
<button
type="button"
onClick={() => {
setIsOpen(false)
onCreateNew()
}}
className="w-full px-4 py-2 rounded-md text-sm text-primary hover:bg-primary/10 flex items-center gap-2 transition-colors"
>
<Plus className="w-4 h-4" />
Add New Person
</button>
</div>
)}
{/* Clear Option */}
{!required && value && (
<div className="p-2 border-t border-border">
<button
type="button"
onClick={() => {
onChange(undefined)
setIsOpen(false)
}}
className="w-full px-4 py-2 rounded-md text-sm text-muted-foreground hover:bg-secondary flex items-center gap-2 transition-colors"
>
Clear Selection
</button>
</div>
)}
</div>
)}
</div>
)
}
export default PersonSelector

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
/**
* Private Gallery Auth Context Provider
*
* Provides authentication state and methods to all private gallery components
*/
import { createContext, useContext, ReactNode, useEffect } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { usePrivateGalleryAuth, UsePrivateGalleryAuthResult } from '../../hooks/usePrivateGalleryAuth'
const PrivateGalleryContext = createContext<UsePrivateGalleryAuthResult | null>(null)
export function PrivateGalleryProvider({ children }: { children: ReactNode }) {
const auth = usePrivateGalleryAuth()
// Listen for keyboard shortcut to access private gallery
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+Shift+P or Cmd+Shift+P
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'P') {
e.preventDefault()
window.location.href = '/private-gallery'
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<PrivateGalleryContext.Provider value={auth}>
{children}
</PrivateGalleryContext.Provider>
)
}
export function usePrivateGalleryContext(): UsePrivateGalleryAuthResult {
const context = useContext(PrivateGalleryContext)
if (!context) {
throw new Error('usePrivateGalleryContext must be used within a PrivateGalleryProvider')
}
return context
}
/**
* Wrapper component that requires private gallery to be unlocked.
* Use this to wrap Gallery and Config pages.
*/
export function RequirePrivateGalleryAuth({ children }: { children: ReactNode }) {
const { isLoading, isUnlocked } = usePrivateGalleryContext()
const location = useLocation()
// Show loading spinner while checking auth
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
// Redirect to unlock page if not authenticated
if (!isUnlocked) {
return <Navigate to="/private-gallery/unlock" state={{ from: location }} replace />
}
return <>{children}</>
}
export default PrivateGalleryProvider

View File

@@ -0,0 +1,815 @@
/**
* Private Gallery Upload Modal
*
* Upload files with person, tags, date, and description
* Two-phase progress: upload bytes transfer, then server-side processing
*/
import { useState, useRef, useCallback, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
X,
Upload,
Image as ImageIcon,
Video,
Trash2,
Loader2,
Calendar,
FileText,
CheckCircle,
Link,
FolderOpen,
} from 'lucide-react'
import { api } from '../../lib/api'
import PersonSelector from './PersonSelector'
import TagSearchSelector from './TagSearchSelector'
import ThumbnailProgressModal, { ThumbnailProgressItem } from '../ThumbnailProgressModal'
interface UploadModalProps {
open: boolean
onClose: () => void
onSuccess?: (postId?: number) => void
}
interface SelectedFile {
file: File
preview?: string
type: 'image' | 'video' | 'other'
}
export function PrivateGalleryUploadModal({ open, onClose, onSuccess }: UploadModalProps) {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<SelectedFile[]>([])
const [personId, setPersonId] = useState<number | undefined>()
const [selectedTags, setSelectedTags] = useState<number[]>([])
const [tagsManuallyModified, setTagsManuallyModified] = useState(false)
const [mediaDate, setMediaDate] = useState('')
const [description, setDescription] = useState('')
const [urls, setUrls] = useState('')
const [isDragging, setIsDragging] = useState(false)
const [serverPath, setServerPath] = useState('')
const [recursive, setRecursive] = useState(false)
const [directoryPreview, setDirectoryPreview] = useState<{ file_count: number; total_size: number; files: Array<{ name: string; size: number; type: string }>; subdirectories: string[] } | null>(null)
const [directoryPreviewLoading, setDirectoryPreviewLoading] = useState(false)
const [directoryPreviewError, setDirectoryPreviewError] = useState<string | null>(null)
const [selectedExistingMedia, setSelectedExistingMedia] = useState<number[]>([])
const [uploadProgress, setUploadProgress] = useState<{ loaded: number; total: number; percent: number } | null>(null)
const [processingJobId, setProcessingJobId] = useState<string | null>(null)
const [processingItems, setProcessingItems] = useState<ThumbnailProgressItem[]>([])
const [showProcessingProgress, setShowProcessingProgress] = useState(false)
const [jobStatusText, setJobStatusText] = useState<string | undefined>()
const [jobDownloadProgress, setJobDownloadProgress] = useState<{ downloaded: number; total: number } | null>(null)
const [uploadedPostId, setUploadedPostId] = useState<number | undefined>()
const { data: tags = [] } = useQuery({
queryKey: ['private-gallery-tags'],
queryFn: () => api.privateGallery.getTags(),
enabled: open,
})
const uploadMutation = useMutation({
mutationFn: (data: {
files: File[]
person_id: number
tag_ids: number[]
media_date?: string
description?: string
onProgress?: (progress: { loaded: number; total: number; percent: number }) => void
}) => api.privateGallery.upload(data),
onSuccess: async (result) => {
setUploadProgress(null)
if (result.post_id) setUploadedPostId(result.post_id)
// If there are existing media to attach, use the post_id from the result
if (selectedExistingMedia.length > 0 && result.post_id) {
try {
await api.privateGallery.attachMediaToPost(result.post_id, selectedExistingMedia)
} catch (err) {
console.error('Failed to attach existing media:', err)
}
}
// Build processing items and start polling
const items: ThumbnailProgressItem[] = files.map((f, idx) => ({
id: `upload-${idx}`,
filename: f.file.name,
status: 'pending' as const,
localPreview: f.preview,
fileType: f.type === 'video' ? 'video' as const : 'image' as const,
}))
setProcessingItems(items)
setShowProcessingProgress(true)
setProcessingJobId(result.job_id)
},
})
const createPostMutation = useMutation({
mutationFn: (data: {
person_id: number
tag_ids?: number[]
media_date?: string
description?: string
media_ids?: number[]
}) => api.privateGallery.createPost(data),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['private-gallery-media'] })
queryClient.invalidateQueries({ queryKey: ['private-gallery-albums'] })
queryClient.invalidateQueries({ queryKey: ['private-gallery-stats'] })
queryClient.invalidateQueries({ queryKey: ['private-gallery-posts'] })
const postId = (result as any)?.post_id ?? (result as any)?.id
setTimeout(() => {
handleClose()
onSuccess?.(postId)
}, 500)
},
})
const importUrlsMutation = useMutation({
mutationFn: (data: {
urls: string[]
person_id: number
tag_ids: number[]
media_date?: string
description?: string
}) => api.privateGallery.importUrls(data),
onSuccess: (result) => {
const parsedUrls = urls.split('\n').map(u => u.trim()).filter(u => u.length > 0)
const items: ThumbnailProgressItem[] = parsedUrls.map((url, idx) => {
const urlFilename = url.split('/').pop()?.split('?')[0] || `url-${idx + 1}`
return {
id: `import-${idx}`,
filename: urlFilename,
status: 'pending' as const,
fileType: 'image' as const,
}
})
setProcessingItems(items)
setShowProcessingProgress(true)
setProcessingJobId(result.job_id)
},
})
const importDirectoryMutation = useMutation({
mutationFn: (data: {
directory_path: string
person_id: number
tag_ids: number[]
media_date?: string
description?: string
recursive?: boolean
}) => api.privateGallery.importDirectory(data),
onSuccess: (result) => {
const items: ThumbnailProgressItem[] = (directoryPreview?.files || []).map((f, idx) => ({
id: `dir-${idx}`,
filename: f.name,
status: 'pending' as const,
fileType: f.type === 'video' ? 'video' as const : 'image' as const,
}))
setProcessingItems(items)
setShowProcessingProgress(true)
setProcessingJobId(result.job_id)
},
})
// Keep session alive during upload/import (large files can take minutes to transfer)
useEffect(() => {
if (!uploadMutation.isPending && !importUrlsMutation.isPending && !importDirectoryMutation.isPending && !processingJobId) return
const keepalive = setInterval(() => {
api.privateGallery.getStatus().catch(() => {})
}, 30_000)
return () => clearInterval(keepalive)
}, [uploadMutation.isPending, importUrlsMutation.isPending, importDirectoryMutation.isPending, processingJobId])
// Poll processing job status
useEffect(() => {
if (!processingJobId) return
const pollInterval = setInterval(async () => {
try {
const status = await api.privateGallery.getJobStatus(processingJobId)
const phase = status.current_phase
setJobStatusText(
phase === 'resolving' ? 'Resolving forum thread...' :
phase === 'scraping' ? 'Scraping thread pages...' :
phase === 'filtering' ? 'Checking for duplicates...' :
phase === 'downloading' ? 'Downloading...' :
phase === 'thumbnail' ? 'Generating thumbnail...' :
phase === 'encrypting' ? 'Encrypting...' :
phase === 'processing' ? 'Processing...' :
undefined
)
if (phase === 'downloading' && (status.bytes_downloaded > 0 || status.bytes_total > 0)) {
setJobDownloadProgress({ downloaded: status.bytes_downloaded, total: status.bytes_total })
} else {
setJobDownloadProgress(null)
}
// Rebuild items when backend resolves thread URLs into individual images
setProcessingItems(prev => {
if (status.resolved_filenames && status.resolved_filenames.length > 0 && prev.length !== status.resolved_filenames.length) {
prev = status.resolved_filenames.map((fname, idx) => ({
id: `import-${idx}`,
filename: fname,
status: 'pending' as const,
fileType: 'image' as const,
}))
}
return prev.map(item => {
const result = status.results.find(r => r.filename === item.filename)
if (result) {
const mediaId = result.id || result.existing_id
return {
...item,
status: result.status === 'created' ? 'success'
: result.status === 'duplicate' ? 'duplicate'
: result.status === 'failed' ? 'error'
: item.status,
error: result.error,
thumbnailUrl: mediaId
? api.privateGallery.getThumbnailUrl(mediaId)
: item.thumbnailUrl,
}
}
if (status.current_file === item.filename) {
return { ...item, status: phase === 'downloading' ? 'downloading' as const : 'processing' as const }
}
return item
})
})
if (status.status === 'completed') {
clearInterval(pollInterval)
setProcessingJobId(null)
queryClient.invalidateQueries({ queryKey: ['private-gallery-media'] })
queryClient.invalidateQueries({ queryKey: ['private-gallery-albums'] })
queryClient.invalidateQueries({ queryKey: ['private-gallery-stats'] })
queryClient.invalidateQueries({ queryKey: ['private-gallery-posts'] })
if (status.failed_count === 0) {
const postId = uploadedPostId
setTimeout(() => {
handleClose()
onSuccess?.(postId)
}, 1500)
}
}
} catch (error) {
console.error('Error polling job status:', error)
}
}, 500)
return () => clearInterval(pollInterval)
}, [processingJobId, queryClient, onSuccess, uploadedPostId])
const handleClose = () => {
setFiles([])
setUrls('')
setServerPath('')
setRecursive(false)
setDirectoryPreview(null)
setDirectoryPreviewLoading(false)
setDirectoryPreviewError(null)
setPersonId(undefined)
setSelectedTags([])
setTagsManuallyModified(false)
setMediaDate('')
setDescription('')
setSelectedExistingMedia([])
setUploadProgress(null)
setProcessingJobId(null)
setProcessingItems([])
setShowProcessingProgress(false)
setJobStatusText(undefined)
setJobDownloadProgress(null)
setUploadedPostId(undefined)
uploadMutation.reset()
importUrlsMutation.reset()
importDirectoryMutation.reset()
onClose()
}
const addFiles = useCallback((newFiles: File[]) => {
const videoExtensions = ['.mkv', '.avi', '.wmv', '.flv', '.webm', '.mov', '.mp4', '.m4v', '.ts']
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.avif']
const mediaFiles = newFiles.filter(file => {
const type = file.type.toLowerCase()
const ext = ('.' + file.name.split('.').pop()?.toLowerCase()) || ''
return type.startsWith('image/') || type.startsWith('video/') || videoExtensions.includes(ext) || imageExtensions.includes(ext)
})
const processed: SelectedFile[] = mediaFiles.map(file => {
const type = file.type.toLowerCase()
const ext = ('.' + file.name.split('.').pop()?.toLowerCase()) || ''
const isImage = type.startsWith('image/') || imageExtensions.includes(ext)
const isVideo = type.startsWith('video/') || videoExtensions.includes(ext)
return {
file,
preview: isImage ? URL.createObjectURL(file) : undefined,
type: isImage ? 'image' : isVideo ? 'video' : 'other',
}
})
setFiles(prev => [...prev, ...processed])
}, [])
const handleFileDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const droppedFiles = Array.from(e.dataTransfer.files)
addFiles(droppedFiles)
}, [addFiles])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
addFiles(Array.from(e.target.files))
}
}, [addFiles])
const removeFile = (index: number) => {
setFiles(prev => {
const file = prev[index]
if (file.preview) {
URL.revokeObjectURL(file.preview)
}
return prev.filter((_, i) => i !== index)
})
}
// Query existing media for the selected person (for attach-existing feature)
const { data: existingMedia } = useQuery({
queryKey: ['private-gallery-media', 'person', personId],
queryFn: () => api.privateGallery.getMedia({ person_id: personId, limit: 500, include_attached: true }),
enabled: open && personId !== undefined,
})
const toggleExistingMedia = (mediaId: number) => {
setSelectedExistingMedia(prev =>
prev.includes(mediaId) ? prev.filter(id => id !== mediaId) : [...prev, mediaId]
)
}
const parsedUrls = urls.split('\n').map(u => u.trim()).filter(u => u.length > 0)
const handleSubmit = () => {
if (!personId) {
alert('Please select a person')
return
}
if (files.length === 0 && parsedUrls.length === 0 && selectedExistingMedia.length === 0 && !serverPath) {
alert('Please select files, enter URLs, enter a server path, or select existing media')
return
}
if (files.length > 0) {
uploadMutation.mutate({
files: files.map(f => f.file),
person_id: personId,
tag_ids: selectedTags,
media_date: mediaDate || undefined,
description: description || undefined,
onProgress: (progress) => setUploadProgress(progress),
})
} else if (serverPath && directoryPreview && directoryPreview.file_count > 0) {
importDirectoryMutation.mutate({
directory_path: serverPath,
person_id: personId,
tag_ids: selectedTags,
media_date: mediaDate || undefined,
description: description || undefined,
recursive,
})
} else if (parsedUrls.length > 0) {
importUrlsMutation.mutate({
urls: parsedUrls,
person_id: personId,
tag_ids: selectedTags,
media_date: mediaDate || undefined,
description: description || undefined,
})
} else if (selectedExistingMedia.length > 0) {
createPostMutation.mutate({
person_id: personId,
tag_ids: selectedTags.length > 0 ? selectedTags : undefined,
media_date: mediaDate || undefined,
description: description || undefined,
media_ids: selectedExistingMedia,
})
}
}
if (!open) return null
// Show processing progress modal
if (showProcessingProgress) {
return (
<ThumbnailProgressModal
isOpen={true}
title={processingItems.some(i => i.id.startsWith('dir-')) ? 'Importing from Server' : processingItems.some(i => i.id.startsWith('import-')) ? 'Importing URLs' : 'Processing Uploads'}
items={processingItems}
statusText={jobStatusText}
downloadProgress={jobDownloadProgress}
onClose={handleClose}
/>
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-2xl bg-card rounded-xl shadow-2xl border border-border max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">Upload to Private Gallery</h2>
<button
onClick={handleClose}
className="p-2 rounded-lg hover:bg-secondary transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6">
{/* Upload Progress Bar (bytes transfer phase) */}
{uploadMutation.isPending && uploadProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Uploading files...
</span>
<span className="font-medium">{uploadProgress.percent}%</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 rounded-full"
style={{ width: `${uploadProgress.percent}%` }}
/>
</div>
</div>
)}
{/* File Previews - Show first when files exist */}
{files.length > 0 && !uploadMutation.isPending && (
<div className="space-y-3">
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{files.map((file, index) => (
<div key={index} className="relative group aspect-square rounded-lg overflow-hidden bg-secondary">
{file.type === 'image' && file.preview ? (
<img
src={file.preview}
alt={file.file.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
{file.type === 'video' ? (
<Video className="w-8 h-8 text-muted-foreground" />
) : (
<ImageIcon className="w-8 h-8 text-muted-foreground" />
)}
</div>
)}
<button
onClick={(e) => {
e.stopPropagation()
removeFile(index)
}}
className="absolute top-1 right-1 p-1.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-3 h-3" />
</button>
<div className="absolute inset-x-0 bottom-0 p-1.5 bg-gradient-to-t from-black/80 to-transparent">
<p className="text-xs text-white truncate">{file.file.name}</p>
</div>
</div>
))}
{/* Add more button */}
<div
onClick={() => fileInputRef.current?.click()}
className="aspect-square rounded-lg border-2 border-dashed border-border hover:border-primary/50 flex items-center justify-center cursor-pointer transition-colors"
>
<Upload className="w-6 h-6 text-muted-foreground" />
</div>
</div>
</div>
)}
{/* Drop Zone - Only show large when no files */}
{files.length === 0 && !uploadMutation.isPending && (
<div
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleFileDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragging
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50 hover:bg-secondary/50'
}`}
>
<Upload className="w-10 h-10 mx-auto mb-3 text-muted-foreground" />
<p className="text-foreground font-medium">
Drag and drop files here, or click to select
</p>
<p className="text-sm text-muted-foreground mt-1">
Supports images and videos
</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,video/*,.mkv,.avi,.wmv,.flv,.webm,.mov,.m4v,.ts"
onChange={handleFileSelect}
className="hidden"
/>
{/* URL Import */}
{files.length === 0 && !serverPath && !uploadMutation.isPending && !importUrlsMutation.isPending && !importDirectoryMutation.isPending && (
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<Link className="w-4 h-4" />
Or import from URLs
</label>
<textarea
value={urls}
onChange={(e) => setUrls(e.target.value)}
rows={3}
className="w-full px-4 py-2.5 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-none text-sm"
placeholder={"Paste URLs here (one per line)\nSupports Bunkr, Pixeldrain, Gofile, Cyberdrop, or direct image/video links"}
/>
</div>
)}
{/* Server Directory Import */}
{files.length === 0 && parsedUrls.length === 0 && !uploadMutation.isPending && !importUrlsMutation.isPending && !importDirectoryMutation.isPending && (
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<FolderOpen className="w-4 h-4" />
Or import from server path
</label>
<div className="flex gap-2">
<input
type="text"
value={serverPath}
onChange={(e) => {
setServerPath(e.target.value)
setDirectoryPreview(null)
setDirectoryPreviewError(null)
}}
className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary text-sm"
placeholder="/path/to/media/folder"
/>
<button
type="button"
onClick={async () => {
if (!serverPath.trim()) return
setDirectoryPreviewLoading(true)
setDirectoryPreviewError(null)
try {
const result = await api.privateGallery.listDirectory(serverPath.trim(), recursive)
setDirectoryPreview(result)
} catch (err: any) {
setDirectoryPreviewError(err.message || 'Failed to list directory')
setDirectoryPreview(null)
} finally {
setDirectoryPreviewLoading(false)
}
}}
disabled={!serverPath.trim() || directoryPreviewLoading}
className="px-4 py-2.5 rounded-lg bg-secondary hover:bg-secondary/80 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{directoryPreviewLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Scan'}
</button>
</div>
<label className="flex items-center gap-2 mt-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={recursive}
onChange={async (e) => {
const newRecursive = e.target.checked
setRecursive(newRecursive)
if (serverPath.trim()) {
setDirectoryPreviewLoading(true)
setDirectoryPreviewError(null)
try {
const result = await api.privateGallery.listDirectory(serverPath.trim(), newRecursive)
setDirectoryPreview(result)
} catch (err: any) {
setDirectoryPreviewError(err.message || 'Failed to list directory')
setDirectoryPreview(null)
} finally {
setDirectoryPreviewLoading(false)
}
} else {
setDirectoryPreview(null)
}
}}
className="rounded border-border"
/>
Include subdirectories (recursive)
</label>
{directoryPreviewError && (
<p className="text-sm text-destructive mt-2">{directoryPreviewError}</p>
)}
{directoryPreview && (
<div className="mt-2 p-3 rounded-lg bg-secondary/50 border border-border text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">
{directoryPreview.file_count} media {directoryPreview.file_count === 1 ? 'file' : 'files'} found
</span>
<span className="text-muted-foreground">
{directoryPreview.total_size < 1024 * 1024
? `${(directoryPreview.total_size / 1024).toFixed(1)} KB`
: directoryPreview.total_size < 1024 * 1024 * 1024
? `${(directoryPreview.total_size / (1024 * 1024)).toFixed(1)} MB`
: `${(directoryPreview.total_size / (1024 * 1024 * 1024)).toFixed(2)} GB`}
</span>
</div>
{directoryPreview.subdirectories.length > 0 && !recursive && (
<p className="text-muted-foreground mt-1">
{directoryPreview.subdirectories.length} subdirector{directoryPreview.subdirectories.length === 1 ? 'y' : 'ies'} (enable recursive to include)
</p>
)}
</div>
)}
</div>
)}
{/* Person Selector */}
{!uploadMutation.isPending && !importUrlsMutation.isPending && !importDirectoryMutation.isPending && (
<>
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<span className="text-red-500">*</span>
Person
</label>
<PersonSelector
value={personId}
onChange={(id, person) => {
setPersonId(id)
setSelectedExistingMedia([])
// Auto-populate tags from person defaults if not manually modified
if (!tagsManuallyModified && person?.default_tag_ids?.length) {
setSelectedTags(person.default_tag_ids)
}
}}
required
/>
</div>
{/* Attach Existing Media */}
{personId && existingMedia?.items && (existingMedia.items as Array<{ id: number; filename: string; file_type: string }>).length > 0 && (
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<ImageIcon className="w-4 h-4" />
Attach existing media ({selectedExistingMedia.length} selected)
</label>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-1.5 max-h-40 overflow-y-auto p-1 rounded-lg border border-border bg-background">
{(existingMedia.items as Array<{ id: number; filename: string; file_type: string }>).map((item) => (
<button
key={item.id}
type="button"
onClick={() => toggleExistingMedia(item.id)}
className={`relative aspect-square rounded overflow-hidden border-2 transition-colors ${
selectedExistingMedia.includes(item.id)
? 'border-primary ring-1 ring-primary'
: 'border-transparent hover:border-primary/30'
}`}
>
<img
src={api.privateGallery.getThumbnailUrl(item.id)}
alt={item.filename}
className="w-full h-full object-cover"
/>
{item.file_type === 'video' && (
<div className="absolute bottom-0.5 left-0.5 bg-black/70 rounded px-1">
<Video className="w-3 h-3 text-white" />
</div>
)}
{selectedExistingMedia.includes(item.id) && (
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-primary" />
</div>
)}
</button>
))}
</div>
</div>
)}
{/* Tags */}
<TagSearchSelector
tags={tags}
selectedTagIds={selectedTags}
onChange={(ids) => {
setTagsManuallyModified(true)
setSelectedTags(ids)
}}
label="Tags (optional)"
/>
{/* Date */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<Calendar className="w-4 h-4" />
Date (optional)
</label>
<input
type="date"
value={mediaDate}
onChange={(e) => setMediaDate(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
/>
<p className="text-xs text-muted-foreground mt-1">
Leave empty to auto-detect from EXIF or filename
</p>
</div>
{/* Description */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<FileText className="w-4 h-4" />
Description (optional)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full px-4 py-2.5 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-none"
placeholder="Add a description..."
/>
</div>
</>
)}
</div>
{/* Footer */}
<div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-between gap-3 px-4 sm:px-6 py-4 border-t border-border">
<p className="text-sm text-muted-foreground text-center sm:text-left">
{files.length > 0
? `${files.length} ${files.length === 1 ? 'file' : 'files'} selected`
: directoryPreview && directoryPreview.file_count > 0
? `${directoryPreview.file_count} ${directoryPreview.file_count === 1 ? 'file' : 'files'} in directory`
: parsedUrls.length > 0
? `${parsedUrls.length} ${parsedUrls.length === 1 ? 'URL' : 'URLs'} entered`
: `0 files selected`}
{selectedExistingMedia.length > 0 ? ` + ${selectedExistingMedia.length} existing` : ''}
</p>
<div className="flex gap-3">
<button
onClick={handleClose}
className="flex-1 sm:flex-none px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={uploadMutation.isPending || createPostMutation.isPending || importUrlsMutation.isPending || importDirectoryMutation.isPending || (files.length === 0 && parsedUrls.length === 0 && selectedExistingMedia.length === 0 && !(serverPath && directoryPreview && directoryPreview.file_count > 0)) || !personId}
className="flex-1 sm:flex-none px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 transition-colors"
>
{(uploadMutation.isPending || createPostMutation.isPending || importUrlsMutation.isPending || importDirectoryMutation.isPending) ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{createPostMutation.isPending ? 'Creating...' : importUrlsMutation.isPending ? 'Importing...' : importDirectoryMutation.isPending ? 'Importing...' : 'Uploading...'}
</>
) : (
<>
{serverPath && directoryPreview ? (
<FolderOpen className="w-4 h-4" />
) : parsedUrls.length > 0 && files.length === 0 ? (
<Link className="w-4 h-4" />
) : (
<Upload className="w-4 h-4" />
)}
{files.length > 0 ? 'Upload' : serverPath && directoryPreview ? 'Import' : parsedUrls.length > 0 ? 'Import' : selectedExistingMedia.length > 0 ? 'Create Post' : 'Upload'}
</>
)}
</button>
</div>
</div>
</div>
</div>
)
}
export default PrivateGalleryUploadModal

View File

@@ -0,0 +1,251 @@
/**
* Private Media Edit Modal
*
* Edit metadata for a single media item
*/
import { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
X,
Save,
Calendar,
Tag,
FileText,
Loader2,
Image as ImageIcon,
Video,
} from 'lucide-react'
import { api } from '../../lib/api'
import PersonSelector from './PersonSelector'
interface MediaItem {
id: number
filename: string
description?: string | null
file_type: 'image' | 'video' | 'other'
person_id?: number | null
media_date: string
tags: Array<{ id: number; name: string; color: string }>
thumbnail_url: string
}
interface EditModalProps {
open: boolean
onClose: () => void
item: MediaItem | null
onSuccess?: () => void
}
export function PrivateMediaEditModal({ open, onClose, item, onSuccess }: EditModalProps) {
const queryClient = useQueryClient()
const [personId, setPersonId] = useState<number | undefined>()
const [selectedTags, setSelectedTags] = useState<number[]>([])
const [mediaDate, setMediaDate] = useState('')
const [description, setDescription] = useState('')
// Load item data when it changes
useEffect(() => {
if (item) {
setPersonId(item.person_id ?? undefined)
setSelectedTags(item.tags.map(t => t.id))
setMediaDate(item.media_date)
setDescription(item.description || '')
}
}, [item])
const { data: tags = [] } = useQuery({
queryKey: ['private-gallery-tags'],
queryFn: () => api.privateGallery.getTags(),
enabled: open,
})
const updateMutation = useMutation({
mutationFn: (data: {
description?: string
person_id?: number
media_date?: string
tag_ids?: number[]
}) => api.privateGallery.updateMedia(item!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['private-gallery-media'] })
queryClient.invalidateQueries({ queryKey: ['private-gallery-albums'] })
handleClose()
onSuccess?.()
},
})
const handleClose = () => {
setPersonId(undefined)
setSelectedTags([])
setMediaDate('')
setDescription('')
updateMutation.reset()
onClose()
}
const toggleTag = (tagId: number) => {
setSelectedTags(prev =>
prev.includes(tagId)
? prev.filter(id => id !== tagId)
: [...prev, tagId]
)
}
const handleSubmit = () => {
if (selectedTags.length === 0) {
alert('Please select at least one tag')
return
}
updateMutation.mutate({
description: description || undefined,
person_id: personId || 0, // 0 means clear
media_date: mediaDate,
tag_ids: selectedTags,
})
}
if (!open || !item) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-lg bg-card rounded-xl shadow-2xl border border-border max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">Edit Media</h2>
<button
onClick={handleClose}
className="p-2 rounded-lg hover:bg-secondary transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6">
{/* Preview */}
<div className="flex items-center gap-4 p-4 rounded-lg bg-secondary/50">
<div className="w-16 h-16 rounded-lg overflow-hidden bg-secondary flex-shrink-0">
{item.file_type === 'image' ? (
<img
src={item.thumbnail_url}
alt={item.filename}
className="w-full h-full object-cover"
/>
) : item.file_type === 'video' ? (
<div className="w-full h-full flex items-center justify-center">
<Video className="w-6 h-6 text-muted-foreground" />
</div>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon className="w-6 h-6 text-muted-foreground" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.filename}</p>
<p className="text-sm text-muted-foreground capitalize">{item.file_type}</p>
</div>
</div>
{/* Person Selector */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
Person
</label>
<PersonSelector
value={personId}
onChange={(id) => setPersonId(id)}
/>
</div>
{/* Tags */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<Tag className="w-4 h-4" />
<span className="text-red-500">*</span>
Tags
</label>
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
selectedTags.includes(tag.id)
? 'text-white'
: 'bg-secondary text-muted-foreground hover:text-foreground'
}`}
style={selectedTags.includes(tag.id) ? { backgroundColor: tag.color } : undefined}
>
{tag.name}
</button>
))}
</div>
</div>
{/* Date */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<Calendar className="w-4 h-4" />
Date
</label>
<input
type="date"
value={mediaDate}
onChange={(e) => setMediaDate(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* Description */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<FileText className="w-4 h-4" />
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-4 py-2.5 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-none"
placeholder="Add a description..."
/>
</div>
</div>
{/* Footer */}
<div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-end gap-3 px-4 sm:px-6 py-4 border-t border-border">
<button
onClick={handleClose}
className="w-full sm:w-auto px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={updateMutation.isPending || selectedTags.length === 0}
className="w-full sm:w-auto px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 transition-colors"
>
{updateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4" />
Save Changes
</>
)}
</button>
</div>
</div>
</div>
)
}
export default PrivateMediaEditModal

View File

@@ -0,0 +1,172 @@
/**
* Tag Search Selector Component
*
* Searchable tag selector with type-ahead filtering.
* Shows selected tags as pills, with a search input to find and add more.
*/
import { useState, useEffect, useRef } from 'react'
import { X, Search, Tag } from 'lucide-react'
interface TagItem {
id: number
name: string
slug?: string
color: string
description?: string | null
}
interface TagSearchSelectorProps {
tags: TagItem[]
selectedTagIds: number[]
onChange: (tagIds: number[]) => void
placeholder?: string
label?: string
compact?: boolean
}
export default function TagSearchSelector({
tags,
selectedTagIds,
onChange,
placeholder = 'Search tags...',
label,
compact = false,
}: TagSearchSelectorProps) {
const [search, setSearch] = useState('')
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const selectedTags = tags.filter(t => selectedTagIds.includes(t.id))
const filteredTags = tags.filter(
t => !selectedTagIds.includes(t.id) &&
t.name.toLowerCase().includes(search.toLowerCase())
)
const addTag = (tagId: number) => {
onChange([...selectedTagIds, tagId])
setSearch('')
inputRef.current?.focus()
}
const removeTag = (tagId: number) => {
onChange(selectedTagIds.filter(id => id !== tagId))
}
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
const pillSize = compact ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm'
const removeSize = compact ? 'w-3 h-3' : 'w-3.5 h-3.5'
return (
<div ref={containerRef} className="relative">
{label && (
<label className={`flex items-center gap-2 ${compact ? 'text-xs text-muted-foreground mb-1' : 'text-sm font-medium mb-2'}`}>
<Tag className="w-4 h-4" />
{label}
</label>
)}
{/* Selected tags + search input */}
<div
className={`flex flex-wrap gap-1.5 items-center min-h-[38px] px-3 py-1.5 rounded-lg border bg-background transition-colors cursor-text ${
isOpen ? 'border-primary ring-2 ring-primary/20' : 'border-border hover:border-primary/50'
}`}
onClick={() => {
setIsOpen(true)
inputRef.current?.focus()
}}
>
{selectedTags.map(tag => (
<span
key={tag.id}
className={`inline-flex items-center gap-1 ${pillSize} rounded-full text-white font-medium`}
style={{ backgroundColor: tag.color }}
>
{tag.name}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
removeTag(tag.id)
}}
className="hover:bg-white/20 rounded-full p-0.5"
>
<X className={removeSize} />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value)
setIsOpen(true)
}}
onFocus={() => setIsOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Backspace' && !search && selectedTagIds.length > 0) {
removeTag(selectedTagIds[selectedTagIds.length - 1])
}
if (e.key === 'Escape') {
setIsOpen(false)
inputRef.current?.blur()
}
if (e.key === 'Enter' && filteredTags.length > 0) {
e.preventDefault()
addTag(filteredTags[0].id)
}
}}
placeholder={selectedTags.length === 0 ? placeholder : ''}
className="flex-1 min-w-[80px] bg-transparent outline-none text-sm placeholder:text-muted-foreground"
/>
{selectedTags.length === 0 && !search && (
<Search className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</div>
{/* Dropdown */}
{isOpen && filteredTags.length > 0 && (
<div className="absolute z-50 mt-1 w-full bg-popover rounded-lg shadow-lg border border-border overflow-hidden">
<div className="max-h-48 overflow-y-auto">
{filteredTags.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => addTag(tag.id)}
className="w-full px-3 py-2 text-left flex items-center gap-2 hover:bg-secondary transition-colors text-sm"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span>{tag.name}</span>
</button>
))}
</div>
</div>
)}
{isOpen && search && filteredTags.length === 0 && (
<div className="absolute z-50 mt-1 w-full bg-popover rounded-lg shadow-lg border border-border overflow-hidden">
<div className="px-3 py-3 text-sm text-muted-foreground text-center">
No matching tags
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,126 @@
/**
* Private Gallery Lightbox Adapter
*
* Adapts private gallery media items to the format expected by BundleLightbox
*/
import type { PaidContentPost, PaidContentAttachment } from '../../lib/api'
interface PrivateMediaItem {
id: number
storage_id: string
filename: string
description?: string | null
file_hash: string
file_size: number
file_type: 'image' | 'video' | 'other'
mime_type: string
width?: number | null
height?: number | null
duration?: number | null
person_id?: number | null
person?: {
id: number
name: string
relationship: {
id: number
name: string
color: string
}
} | null
media_date: string
thumbnail_url: string
stream_url: string
file_url: string
tags: Array<{ id: number; name: string; slug: string; color: string }>
}
export interface LightboxAdapterResult {
pseudoPost: PaidContentPost
attachments: PaidContentAttachment[]
}
/**
* Adapt private gallery items to BundleLightbox format
*/
export function adaptForLightbox(
items: PrivateMediaItem[],
currentIndex: number = 0
): LightboxAdapterResult {
const currentItem = items[currentIndex]
// Create a pseudo-post object that BundleLightbox expects
const pseudoPost: PaidContentPost = {
id: 0,
creator_id: 0,
post_id: 'private-gallery',
title: currentItem?.person?.name || 'Private Gallery',
content: currentItem?.description || '',
published_at: currentItem?.media_date || null,
added_at: currentItem?.media_date || null,
has_attachments: items.length > 0 ? 1 : 0,
attachment_count: items.length,
downloaded: 1,
download_date: null,
is_favorited: 0,
is_viewed: 1,
is_pinned: 0,
pinned_at: null,
username: 'private',
platform: 'private-gallery',
service_id: 'private',
display_name: currentItem?.person?.name || null,
profile_image_url: null,
identity_id: null,
attachments: [],
embeds: [],
tags: (currentItem?.tags || []).map(tag => ({
...tag,
description: null, // PaidContentTag requires description field
})),
}
// Convert items to attachments format
const attachments: PaidContentAttachment[] = items.map((item, index) => ({
id: item.id,
post_id: 0,
attachment_index: index,
name: item.filename,
file_type: item.file_type === 'video' ? 'video' : item.file_type === 'image' ? 'image' : 'other',
extension: item.filename.split('.').pop() || '',
server_path: item.file_url,
download_url: item.file_url, // PaidContentAttachment requires download_url field
file_size: item.file_size,
file_hash: item.file_hash,
status: 'completed',
error_message: null,
download_attempts: 0,
local_path: item.file_url,
local_filename: item.filename,
downloaded_at: item.media_date,
created_at: item.media_date,
width: item.width || null,
height: item.height || null,
duration: item.duration || null,
}))
return { pseudoPost, attachments }
}
/**
* Get thumbnail URL for BundleLightbox
* Since private gallery uses different endpoint, we need to map it
*/
export function getThumbnailUrl(item: PrivateMediaItem): string {
// The thumbnail_url already includes the full path
return item.thumbnail_url
}
/**
* Get file URL for BundleLightbox
*/
export function getFileUrl(item: PrivateMediaItem): string {
return item.file_type === 'video' ? item.stream_url : item.file_url
}
export default adaptForLightbox

View File

@@ -0,0 +1,187 @@
import { BreadcrumbItem } from '../contexts/BreadcrumbContext'
// Base breadcrumb configurations for each route
// Pages can extend these with dynamic context (tabs, filters, selected items)
export const breadcrumbConfig: Record<string, BreadcrumbItem[]> = {
'/': [
{ label: 'Home' }
],
'/downloads': [
{ label: 'Home', path: '/' },
{ label: 'Downloads' }
],
'/media': [
{ label: 'Home', path: '/' },
{ label: 'Media Library' }
],
'/gallery': [
{ label: 'Home', path: '/' },
{ label: 'Gallery' }
],
'/review': [
{ label: 'Home', path: '/' },
{ label: 'Review' }
],
'/videos': [
{ label: 'Home', path: '/' },
{ label: 'Video Downloader' }
],
'/celebrities': [
{ label: 'Home', path: '/' },
{ label: 'Internet Discovery' }
],
'/queue': [
{ label: 'Home', path: '/' },
{ label: 'Download Queue' }
],
'/video/channel-monitors': [
{ label: 'Home', path: '/' },
{ label: 'Channel Monitors' }
],
'/import': [
{ label: 'Home', path: '/' },
{ label: 'Manual Import' }
],
'/discovery': [
{ label: 'Home', path: '/' },
{ label: 'Discovery' }
],
'/appearances': [
{ label: 'Home', path: '/' },
{ label: 'Appearances' }
],
'/press': [
{ label: 'Home', path: '/' },
{ label: 'Press' }
],
'/analytics': [
{ label: 'Home', path: '/' },
{ label: 'Analytics' }
],
'/faces': [
{ label: 'Home', path: '/' },
{ label: 'Face Recognition' }
],
'/platforms': [
{ label: 'Home', path: '/' },
{ label: 'Platforms' }
],
'/scheduler': [
{ label: 'Home', path: '/' },
{ label: 'Scheduler' }
],
'/health': [
{ label: 'Home', path: '/' },
{ label: 'System Health' }
],
'/scrapers': [
{ label: 'Home', path: '/' },
{ label: 'Scrapers' }
],
'/monitoring': [
{ label: 'Home', path: '/' },
{ label: 'Monitoring' }
],
'/scraping-monitor': [
{ label: 'Home', path: '/' },
{ label: 'Scraping Monitor' }
],
'/logs': [
{ label: 'Home', path: '/' },
{ label: 'Logs' }
],
'/recycle-bin': [
{ label: 'Home', path: '/' },
{ label: 'Recycle Bin' }
],
'/config': [
{ label: 'Home', path: '/' },
{ label: 'Configuration' }
],
'/notifications': [
{ label: 'Home', path: '/' },
{ label: 'Notifications' }
],
'/changelog': [
{ label: 'Home', path: '/' },
{ label: 'Change Log' }
],
// Paid Content routes
'/paid-content': [
{ label: 'Home', path: '/' },
{ label: 'Paid Content' }
],
'/paid-content/feed': [
{ label: 'Home', path: '/' },
{ label: 'Paid Content', path: '/paid-content' },
{ label: 'Feed' }
],
'/paid-content/creators': [
{ label: 'Home', path: '/' },
{ label: 'Paid Content', path: '/paid-content' },
{ label: 'Creators' }
],
'/paid-content/add': [
{ label: 'Home', path: '/' },
{ label: 'Paid Content', path: '/paid-content' },
{ label: 'Add Content' }
],
'/paid-content/notifications': [
{ label: 'Home', path: '/' },
{ label: 'Paid Content', path: '/paid-content' },
{ label: 'Notifications' }
],
'/paid-content/settings': [
{ label: 'Home', path: '/' },
{ label: 'Paid Content', path: '/paid-content' },
{ label: 'Configuration' }
],
'/paid-content/queue': [
{ label: 'Home', path: '/' },
{ label: 'Paid Content', path: '/paid-content' },
{ label: 'Download Queue' }
],
'/paid-content/messages': [
{ label: 'Home', path: '/' },
{ label: 'Paid Content', path: '/paid-content' },
{ label: 'Messages' }
],
'/paid-content/analytics': [
{ label: 'Home', path: '/' },
{ label: 'Paid Content', path: '/paid-content' },
{ label: 'Analytics' }
],
'/paid-content/watch-later': [
{ label: 'Home', path: '/' },
{ label: 'Paid Content', path: '/paid-content' },
{ label: 'Watch Later' }
],
'/paid-content/gallery': [
{ label: 'Home', path: '/' },
{ label: 'Paid Content', path: '/paid-content' },
{ label: 'Gallery' }
],
// Private Gallery routes
'/private-gallery': [
{ label: 'Home', path: '/' },
{ label: 'Private Gallery' }
],
'/private-gallery/unlock': [
{ label: 'Home', path: '/' },
{ label: 'Private Gallery', path: '/private-gallery' },
{ label: 'Unlock' }
],
'/private-gallery/config': [
{ label: 'Home', path: '/' },
{ label: 'Private Gallery', path: '/private-gallery' },
{ label: 'Settings' }
]
}
// Helper to get breadcrumbs for a path, with fallback
export function getBreadcrumbsForPath(path: string): BreadcrumbItem[] {
return breadcrumbConfig[path] || [
{ label: 'Home', path: '/' },
{ label: 'Unknown Page' }
]
}

View File

@@ -0,0 +1,65 @@
import { createContext, useContext, useState, useCallback, useMemo, ReactNode } from 'react'
import { LucideIcon } from 'lucide-react'
export interface BreadcrumbItem {
label: string
path?: string
icon?: LucideIcon
}
interface BreadcrumbContextType {
breadcrumbs: BreadcrumbItem[]
setBreadcrumbs: (items: BreadcrumbItem[]) => void
appendBreadcrumb: (item: BreadcrumbItem) => void
updateLastBreadcrumb: (label: string) => void
popBreadcrumb: () => void
}
const BreadcrumbContext = createContext<BreadcrumbContextType | undefined>(undefined)
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
const [breadcrumbs, setBreadcrumbsState] = useState<BreadcrumbItem[]>([])
const setBreadcrumbs = useCallback((items: BreadcrumbItem[]) => {
setBreadcrumbsState(items)
}, [])
const appendBreadcrumb = useCallback((item: BreadcrumbItem) => {
setBreadcrumbsState(prev => [...prev, item])
}, [])
const updateLastBreadcrumb = useCallback((label: string) => {
setBreadcrumbsState(prev => {
if (prev.length === 0) return prev
const updated = [...prev]
updated[updated.length - 1] = { ...updated[updated.length - 1], label }
return updated
})
}, [])
const popBreadcrumb = useCallback(() => {
setBreadcrumbsState(prev => prev.slice(0, -1))
}, [])
const value = useMemo(() => ({
breadcrumbs,
setBreadcrumbs,
appendBreadcrumb,
updateLastBreadcrumb,
popBreadcrumb
}), [breadcrumbs, setBreadcrumbs, appendBreadcrumb, updateLastBreadcrumb, popBreadcrumb])
return (
<BreadcrumbContext.Provider value={value}>
{children}
</BreadcrumbContext.Provider>
)
}
export function useBreadcrumbContext() {
const context = useContext(BreadcrumbContext)
if (context === undefined) {
throw new Error('useBreadcrumbContext must be used within a BreadcrumbProvider')
}
return context
}

View File

@@ -0,0 +1,30 @@
import { useEffect, useRef } from 'react'
import { useBreadcrumbContext, BreadcrumbItem } from '../contexts/BreadcrumbContext'
export function useBreadcrumb(baseCrumbs: BreadcrumbItem[]) {
const { setBreadcrumbs, appendBreadcrumb, updateLastBreadcrumb, popBreadcrumb } = useBreadcrumbContext()
const initializedRef = useRef(false)
// Set base breadcrumbs on mount
useEffect(() => {
if (!initializedRef.current) {
setBreadcrumbs(baseCrumbs)
initializedRef.current = true
}
// Reset on unmount for next page
return () => {
initializedRef.current = false
}
}, []) // Empty deps - only run on mount/unmount
return {
setBreadcrumbs,
appendBreadcrumb,
updateLastBreadcrumb,
popBreadcrumb,
setContext: (label: string) => updateLastBreadcrumb(label),
addContext: (label: string, path?: string) => appendBreadcrumb({ label, path }),
removeContext: () => popBreadcrumb()
}
}

View File

@@ -0,0 +1,92 @@
/**
* Hook for managing enabled/disabled features across the app.
*
* Features can be enabled/disabled in the Private Gallery settings.
* This hook provides the current list of enabled features, their order,
* custom labels, and helpers to check if a feature is enabled and sort items by order.
*/
import { useQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import { api } from '../lib/api'
export interface UseEnabledFeaturesResult {
enabledFeatures: string[]
featureOrder: Record<string, string[]>
featureLabels: Record<string, string>
groupOrder: string[]
isLoading: boolean
isFeatureEnabled: (path: string) => boolean
getFeatureLabel: (path: string, defaultLabel: string) => string
sortByOrder: <T extends { path: string }>(items: T[], groupId: string) => T[]
sortGroups: <T extends { id: string }>(groups: T[]) => T[]
refetch: () => void
}
export function useEnabledFeatures(): UseEnabledFeaturesResult {
const { data, isLoading, refetch } = useQuery({
queryKey: ['enabled-features'],
queryFn: () => api.privateGallery.getEnabledFeatures(),
staleTime: 5 * 60 * 1000, // 5 minutes - features don't change often
refetchOnWindowFocus: false,
})
const enabledFeatures = data?.enabled_features || []
const featureOrder = data?.feature_order || {}
const featureLabels = data?.feature_labels || {}
const groupOrder = data?.group_order || []
const isFeatureEnabled = useCallback((path: string): boolean => {
// If still loading, assume enabled to prevent flash of missing content
if (isLoading) return true
// Empty list means all features are enabled (default state)
if (enabledFeatures.length === 0) return true
return enabledFeatures.includes(path)
}, [isLoading, enabledFeatures])
const getFeatureLabel = useCallback((path: string, defaultLabel: string): string => {
return featureLabels[path] || defaultLabel
}, [featureLabels])
const sortByOrder = useCallback(<T extends { path: string }>(items: T[], groupId: string): T[] => {
const order = featureOrder[groupId]
if (!order || order.length === 0) return items
// Sort items based on their position in the order array
return [...items].sort((a, b) => {
const aIndex = order.indexOf(a.path)
const bIndex = order.indexOf(b.path)
// Items not in order array go to the end
const aPos = aIndex === -1 ? 9999 : aIndex
const bPos = bIndex === -1 ? 9999 : bIndex
return aPos - bPos
})
}, [featureOrder])
const sortGroups = useCallback(<T extends { id: string }>(groups: T[]): T[] => {
if (!groupOrder || groupOrder.length === 0) return groups
return [...groups].sort((a, b) => {
const aIndex = groupOrder.indexOf(a.id)
const bIndex = groupOrder.indexOf(b.id)
const aPos = aIndex === -1 ? 9999 : aIndex
const bPos = bIndex === -1 ? 9999 : bIndex
return aPos - bPos
})
}, [groupOrder])
return {
enabledFeatures,
featureOrder,
featureLabels,
groupOrder,
isLoading,
isFeatureEnabled,
getFeatureLabel,
sortByOrder,
sortGroups,
refetch,
}
}
export default useEnabledFeatures

View File

@@ -0,0 +1,225 @@
import { useState, useCallback, useMemo, useTransition, useDeferredValue } from 'react'
/**
* Common state and handlers for media filtering across pages.
* Reduces duplication of filter state management in Media, Review, RecycleBin, etc.
*
* Features:
* - useTransition for non-blocking filter updates
* - useDeferredValue for smooth search input
* - Memoized filter state object
*/
export interface FilterState {
platformFilter: string
sourceFilter: string
typeFilter: 'all' | 'image' | 'video'
searchQuery: string
dateFrom: string
dateTo: string
sizeMin: string
sizeMax: string
sortBy: string
sortOrder: 'asc' | 'desc'
}
export interface UseMediaFilteringOptions {
initialSortBy?: string
initialSortOrder?: 'asc' | 'desc'
initialTypeFilter?: 'all' | 'image' | 'video'
}
export interface UseMediaFilteringResult extends FilterState {
// Filter state object (for query keys)
filters: FilterState
// Deferred filters for expensive operations (use with React Query)
deferredFilters: FilterState
// Individual setters for convenience
setPlatformFilter: (value: string) => void
setSourceFilter: (value: string) => void
setTypeFilter: (value: 'all' | 'image' | 'video') => void
setSearchQuery: (value: string) => void
setDateFrom: (value: string) => void
setDateTo: (value: string) => void
setSizeMin: (value: string) => void
setSizeMax: (value: string) => void
setSortBy: (value: string) => void
setSortOrder: (value: 'asc' | 'desc') => void
// Generic filter change handler (for FilterBar component)
handleFilterChange: <K extends keyof FilterState>(key: K, value: FilterState[K]) => void
// Utility functions
resetFilters: () => void
hasActiveFilters: boolean
// Transition state for loading indicators
isPending: boolean
// Advanced filter visibility
showAdvanced: boolean
setShowAdvanced: (value: boolean) => void
}
export function useMediaFiltering(options: UseMediaFilteringOptions = {}): UseMediaFilteringResult {
const {
initialSortBy = 'post_date',
initialSortOrder = 'desc',
initialTypeFilter = 'all'
} = options
// useTransition for non-blocking filter updates
const [isPending, startTransition] = useTransition()
// Core filter state
const [platformFilter, setPlatformFilterRaw] = useState<string>('')
const [sourceFilter, setSourceFilterRaw] = useState<string>('')
const [typeFilter, setTypeFilterRaw] = useState<'all' | 'image' | 'video'>(initialTypeFilter)
const [searchQuery, setSearchQueryRaw] = useState<string>('')
// Advanced filter state
const [dateFrom, setDateFromRaw] = useState<string>('')
const [dateTo, setDateToRaw] = useState<string>('')
const [sizeMin, setSizeMinRaw] = useState<string>('')
const [sizeMax, setSizeMaxRaw] = useState<string>('')
const [sortBy, setSortByRaw] = useState<string>(initialSortBy)
const [sortOrder, setSortOrderRaw] = useState<'asc' | 'desc'>(initialSortOrder)
// UI state
const [showAdvanced, setShowAdvanced] = useState(false)
// Wrap setters with startTransition for non-blocking updates
const setPlatformFilter = useCallback((value: string) => {
startTransition(() => setPlatformFilterRaw(value))
}, [])
const setSourceFilter = useCallback((value: string) => {
startTransition(() => setSourceFilterRaw(value))
}, [])
const setTypeFilter = useCallback((value: 'all' | 'image' | 'video') => {
startTransition(() => setTypeFilterRaw(value))
}, [])
const setSearchQuery = useCallback((value: string) => {
// Search is immediate for responsive typing, defer the query
setSearchQueryRaw(value)
}, [])
const setDateFrom = useCallback((value: string) => {
startTransition(() => setDateFromRaw(value))
}, [])
const setDateTo = useCallback((value: string) => {
startTransition(() => setDateToRaw(value))
}, [])
const setSizeMin = useCallback((value: string) => {
startTransition(() => setSizeMinRaw(value))
}, [])
const setSizeMax = useCallback((value: string) => {
startTransition(() => setSizeMaxRaw(value))
}, [])
const setSortBy = useCallback((value: string) => {
startTransition(() => setSortByRaw(value))
}, [])
const setSortOrder = useCallback((value: 'asc' | 'desc') => {
startTransition(() => setSortOrderRaw(value))
}, [])
// Combine all filters into single object for query keys
const filters = useMemo<FilterState>(() => ({
platformFilter,
sourceFilter,
typeFilter,
searchQuery,
dateFrom,
dateTo,
sizeMin,
sizeMax,
sortBy,
sortOrder
}), [
platformFilter, sourceFilter, typeFilter, searchQuery,
dateFrom, dateTo, sizeMin, sizeMax, sortBy, sortOrder
])
// Deferred filters for expensive operations (smooth UI during typing)
const deferredFilters = useDeferredValue(filters)
// Generic filter change handler (for FilterBar component)
const handleFilterChange = useCallback(<K extends keyof FilterState>(key: K, value: FilterState[K]) => {
const setters: Record<keyof FilterState, (v: FilterState[K]) => void> = {
platformFilter: setPlatformFilter as (v: FilterState[K]) => void,
sourceFilter: setSourceFilter as (v: FilterState[K]) => void,
typeFilter: setTypeFilter as (v: FilterState[K]) => void,
searchQuery: setSearchQuery as (v: FilterState[K]) => void,
dateFrom: setDateFrom as (v: FilterState[K]) => void,
dateTo: setDateTo as (v: FilterState[K]) => void,
sizeMin: setSizeMin as (v: FilterState[K]) => void,
sizeMax: setSizeMax as (v: FilterState[K]) => void,
sortBy: setSortBy as (v: FilterState[K]) => void,
sortOrder: setSortOrder as (v: FilterState[K]) => void,
}
setters[key](value)
}, [setPlatformFilter, setSourceFilter, setTypeFilter, setSearchQuery, setDateFrom, setDateTo, setSizeMin, setSizeMax, setSortBy, setSortOrder])
// Check if any filters are active
const hasActiveFilters = useMemo(() => {
return !!(
searchQuery ||
typeFilter !== 'all' ||
platformFilter ||
sourceFilter ||
dateFrom ||
dateTo ||
sizeMin ||
sizeMax
)
}, [searchQuery, typeFilter, platformFilter, sourceFilter, dateFrom, dateTo, sizeMin, sizeMax])
// Reset all filters to default
const resetFilters = useCallback(() => {
startTransition(() => {
setPlatformFilterRaw('')
setSourceFilterRaw('')
setTypeFilterRaw('all')
setSearchQueryRaw('')
setDateFromRaw('')
setDateToRaw('')
setSizeMinRaw('')
setSizeMaxRaw('')
setSortByRaw(initialSortBy)
setSortOrderRaw(initialSortOrder)
setShowAdvanced(false)
})
}, [initialSortBy, initialSortOrder])
return {
filters,
deferredFilters,
platformFilter, setPlatformFilter,
sourceFilter, setSourceFilter,
typeFilter, setTypeFilter,
searchQuery, setSearchQuery,
dateFrom, setDateFrom,
dateTo, setDateTo,
sizeMin, setSizeMin,
sizeMax, setSizeMax,
sortBy, setSortBy,
sortOrder, setSortOrder,
handleFilterChange,
resetFilters,
hasActiveFilters,
isPending,
showAdvanced,
setShowAdvanced
}
}
export default useMediaFiltering

View File

@@ -0,0 +1,189 @@
/**
* Private Gallery Authentication Hook
*
* Manages authentication state for the private gallery:
* - Session token stored in memory (not localStorage for security)
* - Auto-lock after timeout
* - Status checking
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { api } from '../lib/api'
export interface PrivateGalleryAuthState {
isLoading: boolean
isSetupComplete: boolean
isUnlocked: boolean
error: string | null
}
export interface UsePrivateGalleryAuthResult extends PrivateGalleryAuthState {
setup: (password: string) => Promise<boolean>
unlock: (password: string) => Promise<boolean>
lock: () => Promise<void>
changePassword: (currentPassword: string, newPassword: string) => Promise<boolean>
refreshStatus: () => Promise<void>
clearError: () => void
}
export function usePrivateGalleryAuth(): UsePrivateGalleryAuthResult {
const [state, setState] = useState<PrivateGalleryAuthState>({
isLoading: true,
isSetupComplete: false,
isUnlocked: false,
error: null,
})
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Check current status
const refreshStatus = useCallback(async () => {
try {
const status = await api.privateGallery.getStatus()
setState(prev => ({
...prev,
isLoading: false,
isSetupComplete: status.is_setup_complete,
isUnlocked: status.is_unlocked,
error: null,
}))
} catch (err) {
setState(prev => ({
...prev,
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to check status',
}))
}
}, [])
// Initial status check
useEffect(() => {
refreshStatus()
}, [refreshStatus])
// Schedule periodic status refresh when unlocked (for auto-lock detection)
useEffect(() => {
if (state.isUnlocked) {
// Refresh every 30 seconds to detect auto-lock
refreshTimeoutRef.current = setInterval(() => {
refreshStatus()
}, 30 * 1000)
return () => {
if (refreshTimeoutRef.current) {
clearInterval(refreshTimeoutRef.current)
}
}
}
}, [state.isUnlocked, refreshStatus])
// Check status when tab becomes visible (user returns to page)
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && state.isUnlocked) {
refreshStatus()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [state.isUnlocked, refreshStatus])
// Check status on window focus (another way user returns)
useEffect(() => {
const handleFocus = () => {
if (state.isUnlocked) {
refreshStatus()
}
}
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
}, [state.isUnlocked, refreshStatus])
const setup = useCallback(async (password: string): Promise<boolean> => {
setState(prev => ({ ...prev, isLoading: true, error: null }))
try {
await api.privateGallery.setup(password)
setState(prev => ({
...prev,
isLoading: false,
isSetupComplete: true,
isUnlocked: true,
}))
return true
} catch (err) {
setState(prev => ({
...prev,
isLoading: false,
error: err instanceof Error ? err.message : 'Setup failed',
}))
return false
}
}, [])
const unlock = useCallback(async (password: string): Promise<boolean> => {
setState(prev => ({ ...prev, isLoading: true, error: null }))
try {
await api.privateGallery.unlock(password)
setState(prev => ({
...prev,
isLoading: false,
isUnlocked: true,
}))
return true
} catch (err) {
setState(prev => ({
...prev,
isLoading: false,
error: err instanceof Error ? err.message : 'Invalid password',
}))
return false
}
}, [])
const lock = useCallback(async (): Promise<void> => {
try {
await api.privateGallery.lock()
} catch {
// Ignore errors - clear local state anyway
}
api.setPrivateGalleryToken(null)
setState(prev => ({
...prev,
isUnlocked: false,
}))
}, [])
const changePassword = useCallback(async (currentPassword: string, newPassword: string): Promise<boolean> => {
setState(prev => ({ ...prev, isLoading: true, error: null }))
try {
await api.privateGallery.changePassword(currentPassword, newPassword)
setState(prev => ({ ...prev, isLoading: false }))
return true
} catch (err) {
setState(prev => ({
...prev,
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to change password',
}))
return false
}
}, [])
const clearError = useCallback(() => {
setState(prev => ({ ...prev, error: null }))
}, [])
return {
...state,
setup,
unlock,
lock,
changePassword,
refreshStatus,
clearError,
}
}
export default usePrivateGalleryAuth

1401
web/frontend/src/index.css Normal file

File diff suppressed because it is too large Load Diff

5282
web/frontend/src/lib/api.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
/**
* Cache Invalidation Utilities
*
* Centralized cache invalidation to ensure consistency across all pages
* when files are moved, deleted, restored, or modified.
*/
import { QueryClient } from '@tanstack/react-query'
/**
* Invalidate all file-related caches
* Use this when files are moved between locations, deleted, restored, or modified
*/
export function invalidateAllFileCaches(queryClient: QueryClient) {
queryClient.invalidateQueries({ queryKey: ['media-gallery'] })
queryClient.invalidateQueries({ queryKey: ['review-queue'] })
queryClient.invalidateQueries({ queryKey: ['review-list'] })
queryClient.invalidateQueries({ queryKey: ['recycle-bin'] })
queryClient.invalidateQueries({ queryKey: ['recycle-bin-stats'] })
queryClient.invalidateQueries({ queryKey: ['downloads'] })
queryClient.invalidateQueries({ queryKey: ['downloads-search'] })
queryClient.invalidateQueries({ queryKey: ['stats'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['dashboard-recent-items'] })
}
/** Remove items from media-gallery cache by file_path */
export function optimisticRemoveFromMediaGallery(
queryClient: QueryClient,
filePaths: Set<string>
) {
queryClient.setQueriesData<{ media: any[]; total: number }>(
{ queryKey: ['media-gallery'] },
(old) => {
if (!old) return old
const filtered = old.media.filter(m => !filePaths.has(m.file_path))
return { ...old, media: filtered, total: old.total - (old.media.length - filtered.length) }
}
)
}
/** Remove items from review-queue cache by file_path */
export function optimisticRemoveFromReviewQueue(
queryClient: QueryClient,
filePaths: Set<string>
) {
queryClient.setQueriesData<{ files: any[]; total: number }>(
{ queryKey: ['review-queue'] },
(old) => {
if (!old) return old
const filtered = old.files.filter(f => !filePaths.has(f.file_path))
return { ...old, files: filtered, total: old.total - (old.files.length - filtered.length) }
}
)
queryClient.setQueriesData<{ files: any[]; total: number }>(
{ queryKey: ['review-list'] },
(old) => {
if (!old) return old
const filtered = old.files.filter(f => !filePaths.has(f.file_path))
return { ...old, files: filtered, total: old.total - (old.files.length - filtered.length) }
}
)
}
/** Remove items from recycle-bin cache by id */
export function optimisticRemoveFromRecycleBin(
queryClient: QueryClient,
ids: Set<string>
) {
queryClient.setQueriesData<{ items: any[]; total: number }>(
{ queryKey: ['recycle-bin'] },
(old) => {
if (!old) return old
const filtered = old.items.filter(item => !ids.has(item.id))
return { ...old, items: filtered, total: old.total - (old.items.length - filtered.length) }
}
)
}
/**
* Invalidate filter caches
* Use this when new files are added that might introduce new platforms/sources
*/
export function invalidateFilterCaches(queryClient: QueryClient) {
queryClient.invalidateQueries({ queryKey: ['download-filters'] })
queryClient.invalidateQueries({ queryKey: ['review-filters'] })
}
/**
* Invalidate all caches (files + filters)
* Use this for operations that might affect both files and filter options
*/
export function invalidateAllCaches(queryClient: QueryClient) {
invalidateAllFileCaches(queryClient)
invalidateFilterCaches(queryClient)
}

View File

@@ -0,0 +1,409 @@
import { ToastNotification } from '../components/NotificationToast'
type NotificationCallback = (notifications: ToastNotification[]) => void
// Centralized icon constants for consistency
export const ICONS = {
// Status
SUCCESS: '✅',
ERROR: '❌',
WARNING: '⚠️',
INFO: '📋',
PROCESSING: '⏳',
// Actions
DELETE: '🗑️',
SAVE: '💾',
SYNC: '🔄',
DOWNLOAD: '📥',
COPY: '📋',
// Features
REVIEW: '👁️',
FACE: '👤',
SCHEDULER: '📅',
// Platforms
INSTAGRAM: '📸',
TIKTOK: '🎵',
SNAPCHAT: '👻',
FORUM: '💬',
YOUTUBE: '▶️',
PLEX: '🎬',
} as const
/**
* Extract error message from various error types consistently
*/
export function extractErrorMessage(err: unknown, fallback: string = 'An error occurred'): string {
if (!err) return fallback
// Axios-style error with response.data.detail
if (typeof err === 'object' && err !== null) {
const e = err as Record<string, unknown>
// Check for axios response structure
if (e.response && typeof e.response === 'object') {
const response = e.response as Record<string, unknown>
if (response.data && typeof response.data === 'object') {
const data = response.data as Record<string, unknown>
if (typeof data.detail === 'string') return data.detail
if (typeof data.message === 'string') return data.message
if (typeof data.error === 'string') return data.error
}
}
// Standard Error object
if (e.message && typeof e.message === 'string') return e.message
// Plain object with error/detail/message
if (typeof e.detail === 'string') return e.detail
if (typeof e.error === 'string') return e.error
}
// String error
if (typeof err === 'string') return err
return fallback
}
class NotificationManager {
private notifications: ToastNotification[] = []
private listeners: Set<NotificationCallback> = new Set()
private idCounter = 0
subscribe(callback: NotificationCallback) {
this.listeners.add(callback)
// Immediately notify with current state
callback(this.notifications)
return () => {
this.listeners.delete(callback)
}
}
private notify() {
this.listeners.forEach(callback => callback([...this.notifications]))
}
show(title: string, message: string, icon?: string, type?: 'success' | 'error' | 'info' | 'warning', thumbnailUrl?: string) {
const notification: ToastNotification = {
id: `notification-${++this.idCounter}-${Date.now()}`,
title,
message,
icon,
type,
thumbnailUrl
}
this.notifications.push(notification)
this.notify()
return notification.id
}
dismiss(id: string) {
this.notifications = this.notifications.filter(n => n.id !== id)
this.notify()
}
// ============================================
// Core convenience methods
// ============================================
success(title: string, message: string, icon: string = ICONS.SUCCESS, thumbnailUrl?: string) {
return this.show(title, message, icon, 'success', thumbnailUrl)
}
error(title: string, message: string, icon: string = ICONS.ERROR, thumbnailUrl?: string) {
return this.show(title, message, icon, 'error', thumbnailUrl)
}
info(title: string, message: string, icon: string = ICONS.INFO, thumbnailUrl?: string) {
return this.show(title, message, icon, 'info', thumbnailUrl)
}
warning(title: string, message: string, icon: string = ICONS.WARNING, thumbnailUrl?: string) {
return this.show(title, message, icon, 'warning', thumbnailUrl)
}
// ============================================
// Settings & Configuration
// ============================================
/** Notify that settings were saved successfully */
settingsSaved(category: string) {
return this.success('Settings Saved', `${category} settings saved successfully`)
}
/** Notify that saving settings failed */
settingsSaveError(err: unknown, category?: string) {
const what = category ? `${category} settings` : 'settings'
return this.error('Save Failed', extractErrorMessage(err, `Failed to save ${what}`))
}
// ============================================
// Connection & Authentication
// ============================================
/** Notify successful connection */
connectionSuccess(service: string, message?: string) {
return this.success('Connected', message || `Connected to ${service}`)
}
/** Notify connection failure */
connectionFailed(service: string, err?: unknown) {
return this.error('Connection Failed', extractErrorMessage(err, `Could not connect to ${service}`))
}
/** Notify disconnection */
disconnected(service: string) {
return this.success('Disconnected', `${service} has been disconnected`)
}
// ============================================
// CRUD Operations
// ============================================
/** Notify successful deletion */
deleted(itemType: string, message?: string) {
return this.success(`${itemType} Deleted`, message || `${itemType} removed successfully`, ICONS.DELETE)
}
/** Notify deletion failure */
deleteError(itemType: string, err?: unknown) {
return this.error('Delete Failed', extractErrorMessage(err, `Failed to delete ${itemType.toLowerCase()}`))
}
/** Notify successful move/keep operation */
moved(itemType: string, destination?: string, count?: number) {
const countStr = count !== undefined ? `${count} ` : ''
const destStr = destination ? ` to ${destination}` : ''
return this.success(`${itemType} Moved`, `${countStr}${itemType.toLowerCase()}${count !== 1 ? 's' : ''} moved${destStr}`)
}
/** Notify move failure */
moveError(itemType: string, err?: unknown) {
return this.error('Move Failed', extractErrorMessage(err, `Failed to move ${itemType.toLowerCase()}`))
}
/** Notify item kept (moved from review to destination) */
kept(itemType: string = 'Image') {
return this.success(`${itemType} Kept`, `${itemType} moved to destination`)
}
// ============================================
// Batch Operations
// ============================================
/** Notify successful batch operation */
batchSuccess(operation: string, count: number, itemType: string = 'item') {
const plural = count !== 1 ? 's' : ''
return this.success(`Batch ${operation}`, `${count} ${itemType}${plural} ${operation.toLowerCase()}`)
}
/** Notify batch operation failure */
batchError(operation: string, err?: unknown) {
return this.error(`Batch ${operation}`, extractErrorMessage(err, `${operation} operation failed`))
}
// ============================================
// Face Recognition
// ============================================
/** Notify face reference added */
faceReferenceAdded(personName: string, filename?: string) {
return this.success('Face Reference Added', filename || `Added as reference for ${personName}`, ICONS.FACE)
}
/** Notify face reference operation failed */
faceReferenceError(err: unknown) {
return this.error('Add Reference Failed', extractErrorMessage(err, 'Failed to add face reference'))
}
/** Notify processing started (for async operations) */
processing(message: string) {
return this.info('Processing', message, ICONS.PROCESSING)
}
/** Notify item moved to review queue */
movedToReview(count: number = 1) {
const plural = count !== 1 ? 's' : ''
return this.success('Moved to Review', `${count} item${plural} moved to review queue`, ICONS.REVIEW)
}
// ============================================
// Sync & Background Operations
// ============================================
/** Notify sync started */
syncStarted(what: string) {
return this.success('Sync Started', `${what} sync started in background`, ICONS.SYNC)
}
/** Notify sync completed */
syncCompleted(what: string) {
return this.success('Sync Complete', `${what} sync completed`, ICONS.SYNC)
}
/** Notify sync failed */
syncError(what: string, err?: unknown) {
return this.error('Sync Failed', extractErrorMessage(err, `Failed to sync ${what.toLowerCase()}`))
}
/** Notify rescan started */
rescanStarted(count?: number) {
const msg = count ? `Scanning ${count} files...` : 'Rescan started'
return this.info('Rescan Started', msg, ICONS.SYNC)
}
/** Notify rescan completed */
rescanCompleted(count: number, matched?: number) {
const matchedStr = matched !== undefined ? `, ${matched} matched` : ''
return this.success('Rescan Complete', `Processed ${count} files${matchedStr}`, ICONS.SYNC)
}
// ============================================
// Scheduler Operations
// ============================================
/** Notify scheduler started */
schedulerStarted() {
return this.success('Scheduler Started', 'The scheduler service has been started successfully', ICONS.SCHEDULER)
}
/** Notify scheduler stopped */
schedulerStopped() {
return this.success('Scheduler Stopped', 'The scheduler service has been stopped', ICONS.SCHEDULER)
}
/** Notify scheduler operation failed */
schedulerError(operation: string, err?: unknown) {
return this.error(`${operation} Failed`, extractErrorMessage(err, `Failed to ${operation.toLowerCase()} scheduler`))
}
/** Notify download stopped */
downloadStopped(platform: string) {
const platformName = this.formatPlatformName(platform)
return this.success('Download Stopped', `${platformName} download has been stopped`)
}
// ============================================
// Clipboard Operations
// ============================================
/** Notify content copied to clipboard */
copied(what: string = 'Content') {
return this.success('Copied', `${what} copied to clipboard`, ICONS.COPY)
}
// ============================================
// Validation
// ============================================
/** Notify validation error */
validationError(message: string) {
return this.error('Required', message)
}
// ============================================
// Date/Time Operations
// ============================================
/** Notify date updated */
dateUpdated(itemType: string = 'File') {
return this.success('Date Updated', `${itemType} date updated successfully`)
}
/** Notify date update failed */
dateUpdateError(err?: unknown) {
return this.error('Date Update Failed', extractErrorMessage(err, 'Failed to update file date'))
}
// ============================================
// Download Operations
// ============================================
/** Notify ZIP download ready */
downloadReady(count: number) {
return this.success('Download Ready', `ZIP file with ${count} items is ready`, ICONS.DOWNLOAD)
}
// ============================================
// Platform-specific notifications
// ============================================
downloadStarted(platform: string, username: string, thumbnailUrl?: string) {
const icons: Record<string, string> = {
instagram: ICONS.INSTAGRAM,
fastdl: ICONS.INSTAGRAM,
imginn: ICONS.INSTAGRAM,
toolzu: ICONS.INSTAGRAM,
tiktok: ICONS.TIKTOK,
snapchat: ICONS.SNAPCHAT,
forum: ICONS.FORUM,
}
const icon = icons[platform.toLowerCase()] || ICONS.DOWNLOAD
const platformName = this.formatPlatformName(platform)
return this.info(`${platformName}`, `Downloading from ${username}...`, icon, thumbnailUrl)
}
downloadCompleted(platform: string, filename: string, username?: string, thumbnailUrl?: string) {
const icons: Record<string, string> = {
instagram: ICONS.INSTAGRAM,
fastdl: ICONS.INSTAGRAM,
imginn: ICONS.INSTAGRAM,
toolzu: ICONS.INSTAGRAM,
tiktok: ICONS.TIKTOK,
snapchat: ICONS.SNAPCHAT,
forum: ICONS.FORUM,
}
const icon = icons[platform.toLowerCase()] || ICONS.DOWNLOAD
const platformName = this.formatPlatformName(platform)
// Don't show " from all" when username is "all" (platform-wide download)
const from = username && username !== 'all' ? ` from ${username}` : ''
return this.success(`${platformName} Download Complete`, `${filename}${from}`, icon, thumbnailUrl)
}
downloadError(platform: string, error: string) {
const platformName = this.formatPlatformName(platform)
return this.error(`${platformName} Error`, error, ICONS.WARNING)
}
reviewQueue(_platform: string, count: number, username?: string) {
const from = username ? ` from ${username}` : ''
const itemText = count === 1 ? 'item' : 'items'
return this.show(
`Review Queue${from ? `: ${username}` : ''}`,
`${count} ${itemText} moved to review queue (no face match)`,
ICONS.REVIEW,
'warning'
)
}
private formatPlatformName(platform: string): string {
const names: Record<string, string> = {
fastdl: 'Instagram',
imginn: 'Instagram',
toolzu: 'Instagram',
instagram: 'Instagram',
tiktok: 'TikTok',
snapchat: 'Snapchat',
forum: 'Forum',
}
return names[platform.toLowerCase()] || platform.charAt(0).toUpperCase() + platform.slice(1)
}
// ============================================
// Generic API Error Handler
// ============================================
/** Show error notification from API response */
apiError(title: string, err: unknown, fallback?: string) {
return this.error(title, extractErrorMessage(err, fallback || 'An error occurred'))
}
}
export const notificationManager = new NotificationManager()

View File

@@ -0,0 +1,160 @@
/**
* Global Task Manager
*
* Tracks background tasks across page navigation and shows notifications on completion.
*/
import { api } from './api'
import { notificationManager } from './notificationManager'
interface TaskInfo {
taskId: string
presetId: number
presetName: string
startedAt: number
}
interface TaskResult {
results_count?: number
new_count?: number
match_count?: number
error?: string
}
interface TaskResponse {
success: boolean
task: {
status: string
result?: TaskResult
}
}
class TaskManager {
private runningTasks: Map<string, TaskInfo> = new Map()
private pollingIntervals: Map<string, ReturnType<typeof setInterval>> = new Map()
private listeners: Set<(tasks: Map<string, TaskInfo>) => void> = new Set()
/**
* Start tracking a background task
*/
trackTask(taskId: string, presetId: number, presetName: string): void {
const taskInfo: TaskInfo = {
taskId,
presetId,
presetName,
startedAt: Date.now()
}
this.runningTasks.set(taskId, taskInfo)
this.notifyListeners()
this.startPolling(taskId)
}
/**
* Check if a preset is currently running
*/
isPresetRunning(presetId: number): boolean {
for (const task of this.runningTasks.values()) {
if (task.presetId === presetId) {
return true
}
}
return false
}
/**
* Get all running preset IDs
*/
getRunningPresetIds(): Set<number> {
const ids = new Set<number>()
for (const task of this.runningTasks.values()) {
ids.add(task.presetId)
}
return ids
}
/**
* Subscribe to task updates
*/
subscribe(listener: (tasks: Map<string, TaskInfo>) => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private notifyListeners(): void {
for (const listener of this.listeners) {
listener(this.runningTasks)
}
}
private startPolling(taskId: string): void {
const maxDuration = 10 * 60 * 1000 // 10 minutes max
const pollInterval = 1000 // 1 second
const poll = async () => {
const taskInfo = this.runningTasks.get(taskId)
if (!taskInfo) {
this.stopPolling(taskId)
return
}
// Check if we've exceeded max duration
if (Date.now() - taskInfo.startedAt > maxDuration) {
this.runningTasks.delete(taskId)
this.stopPolling(taskId)
this.notifyListeners()
notificationManager.warning('Timeout', `${taskInfo.presetName}: Task timed out`)
return
}
try {
const response = await api.get<TaskResponse>(`/celebrity/tasks/${taskId}`)
if (response.task.status === 'completed') {
this.runningTasks.delete(taskId)
this.stopPolling(taskId)
this.notifyListeners()
const result = response.task.result
if (result?.results_count !== undefined || result?.new_count !== undefined) {
const resultsText = result.results_count ? `${result.results_count} found` : ''
const matchText = result.match_count ? `${result.match_count} matched` : ''
const newText = result.new_count !== undefined ? `${result.new_count} new` : ''
const parts = [resultsText, matchText, newText].filter(Boolean).join(', ')
notificationManager.success('Complete', `${taskInfo.presetName}: ${parts || 'Discovery complete'}`)
} else {
notificationManager.success('Complete', `${taskInfo.presetName}: Discovery complete`)
}
} else if (response.task.status === 'failed') {
this.runningTasks.delete(taskId)
this.stopPolling(taskId)
this.notifyListeners()
notificationManager.error('Failed', response.task.result?.error || `${taskInfo.presetName}: Discovery failed`)
}
// Still running - continue polling
} catch (e) {
// API error - continue polling (task might not be ready yet)
console.debug('Task polling error (will retry):', taskId, e)
}
}
// Start polling
const interval = setInterval(poll, pollInterval)
this.pollingIntervals.set(taskId, interval)
// Initial poll after a short delay
setTimeout(poll, 500)
}
private stopPolling(taskId: string): void {
const interval = this.pollingIntervals.get(taskId)
if (interval) {
clearInterval(interval)
this.pollingIntervals.delete(taskId)
}
}
}
// Singleton instance
export const taskManager = new TaskManager()

View File

@@ -0,0 +1,57 @@
/**
* Thumbnail request queue - limits concurrent fetches to avoid
* overwhelming the browser connection pool and nginx.
*
* With HTTP/1.1, browsers open 6-8 connections per domain.
* Without a queue, 100+ thumbnail requests fire simultaneously,
* causing most to wait in the browser's internal queue.
* This queue ensures orderly loading with priority for visible items.
*/
type QueueItem = {
resolve: (value: string) => void
reject: (reason: unknown) => void
src: string
}
const MAX_CONCURRENT = 20
let activeCount = 0
const queue: QueueItem[] = []
function processQueue() {
while (activeCount < MAX_CONCURRENT && queue.length > 0) {
const item = queue.shift()!
activeCount++
const img = new Image()
img.onload = () => {
activeCount--
item.resolve(item.src)
processQueue()
}
img.onerror = () => {
activeCount--
item.reject(new Error('Failed to load'))
processQueue()
}
img.src = item.src
}
}
/**
* Queue a thumbnail URL for loading. Returns a promise that resolves
* when the image has been fetched (and is in the browser cache).
*/
export function queueThumbnail(src: string): Promise<string> {
return new Promise((resolve, reject) => {
queue.push({ resolve, reject, src })
processQueue()
})
}
/**
* Clear all pending items from the queue (e.g., on page navigation).
*/
export function clearThumbnailQueue() {
queue.length = 0
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useRef } from 'react'
interface SwipeGestureHandlers {
onSwipeLeft?: () => void
onSwipeRight?: () => void
onSwipeDown?: () => void
threshold?: number
}
export function useSwipeGestures({
onSwipeLeft,
onSwipeRight,
onSwipeDown,
threshold = 50,
}: SwipeGestureHandlers) {
const touchStartX = useRef<number>(0)
const touchStartY = useRef<number>(0)
const touchEndX = useRef<number>(0)
const touchEndY = useRef<number>(0)
useEffect(() => {
const handleTouchStart = (e: TouchEvent) => {
touchStartX.current = e.changedTouches[0].screenX
touchStartY.current = e.changedTouches[0].screenY
}
const handleTouchEnd = (e: TouchEvent) => {
touchEndX.current = e.changedTouches[0].screenX
touchEndY.current = e.changedTouches[0].screenY
handleGesture()
}
const handleGesture = () => {
const deltaX = touchEndX.current - touchStartX.current
const deltaY = touchEndY.current - touchStartY.current
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
// Horizontal swipe (left/right)
if (absDeltaX > threshold && absDeltaX > absDeltaY) {
if (deltaX > 0 && onSwipeRight) {
onSwipeRight()
} else if (deltaX < 0 && onSwipeLeft) {
onSwipeLeft()
}
}
// Vertical swipe down
else if (absDeltaY > threshold && absDeltaY > absDeltaX && deltaY > 0 && onSwipeDown) {
onSwipeDown()
}
}
document.addEventListener('touchstart', handleTouchStart, { passive: true })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
return () => {
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchend', handleTouchEnd)
}
}, [onSwipeLeft, onSwipeRight, onSwipeDown, threshold])
}

View File

@@ -0,0 +1,294 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
export function formatDate(date: string | undefined | null) {
if (!date) return 'N/A'
// Ensure date is a string (guard against Date objects or numbers)
if (typeof date !== 'string') {
date = String(date)
}
// Database stores dates as 'YYYY-MM-DD HH:MM:SS' in UTC (SQLite CURRENT_TIMESTAMP)
// Parse as UTC and convert to local time for display
const d = date.includes(' ')
? new Date(date.replace(' ', 'T') + 'Z') // UTC from database
: new Date(date) // Already has timezone or is ISO format
return d.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
})
}
export function formatDateOnly(date: string | undefined | null) {
if (!date) return 'N/A'
// Ensure date is a string (guard against Date objects or numbers)
if (typeof date !== 'string') {
date = String(date)
}
// Parse date - handles ISO format (2024-03-15T00:00:00) and simple date (2024-03-15)
const d = date.includes(' ')
? new Date(date.replace(' ', 'T') + 'Z')
: new Date(date)
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
export function formatRelativeTime(date: string) {
if (!date) return 'N/A'
// Ensure date is a string (guard against Date objects or numbers)
if (typeof date !== 'string') {
date = String(date)
}
const now = new Date()
// Handle different date formats:
// - Scheduler API returns local ISO format: "2025-10-30T10:41:30.722479"
// - Database returns UTC format: "2025-10-30 10:41:30" (SQLite CURRENT_TIMESTAMP)
let then: Date
if (date.includes('T') && !date.endsWith('Z') && date.match(/T\d{2}:\d{2}:\d{2}\.\d+$/)) {
// Local timestamp from scheduler API (has 'T' and microseconds, no 'Z')
// Parse as local time directly - do NOT append 'Z'
then = new Date(date)
} else if (date.includes(' ')) {
// UTC timestamp from database (SQLite CURRENT_TIMESTAMP is UTC)
// Append 'Z' to indicate UTC timezone
then = new Date(date.replace(' ', 'T') + 'Z')
} else {
// Already has timezone or is ISO format
then = new Date(date)
}
const diff = now.getTime() - then.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
// Handle future times (next scheduled runs)
if (seconds < 0) {
const absSeconds = Math.abs(seconds)
const absMinutes = Math.floor(absSeconds / 60)
const absHours = Math.floor(absMinutes / 60)
const absDays = Math.floor(absHours / 24)
if (absSeconds < 60) return 'now'
if (absMinutes < 60) return `in ${absMinutes}m`
if (absHours < 24) return `in ${absHours}h ${absMinutes % 60}m`
return `in ${absDays}d ${absHours % 24}h`
}
if (seconds < 60) return 'just now'
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
if (days < 7) return `${days}d ago`
return formatDate(date)
}
/**
* Check if a filename or path represents a video file.
* Centralized utility to avoid duplication across components.
*/
export function isVideoFile(filename: string | undefined | null): boolean {
if (!filename) return false
return /\.(mp4|mov|webm|avi|mkv|flv|m4v|wmv|mpg|mpeg)$/i.test(filename)
}
/**
* Get the media type string based on filename.
*/
export function getMediaType(filename: string | undefined | null): 'video' | 'image' {
return isVideoFile(filename) ? 'video' : 'image'
}
export function formatPlatformName(platform: string | null | undefined): string {
if (!platform) return 'Unknown'
const platformNames: Record<string, string> = {
// Social media platforms
'instagram': 'Instagram',
'fastdl': 'Instagram',
'imginn': 'Instagram',
'instagram_client': 'Instagram',
'toolzu': 'Instagram',
'snapchat': 'Snapchat',
'tiktok': 'TikTok',
'twitter': 'Twitter',
'x': 'Twitter',
'reddit': 'Reddit',
// YouTube
'youtube': 'YouTube',
'youtube_monitor': 'YouTube',
'youtube_channel_monitor': 'YouTube',
// Twitch
'twitch': 'Twitch',
// Pornhub
'pornhub': 'Pornhub',
'xhamster': 'xHamster',
// Paid content platforms
'paid_content': 'Paid Content',
'fansly': 'Fansly',
'fansly_direct': 'Fansly',
'onlyfans': 'OnlyFans',
'onlyfans_direct': 'OnlyFans',
'coomer': 'Coomer',
'kemono': 'Kemono',
// Usenet
'easynews': 'Easynews',
'easynews_monitor': 'Easynews',
'nzb': 'NZB',
// Forums & galleries
'forum': 'Forums',
'forums': 'Forums',
'forum_monitor': 'Forums',
'monitor': 'Forum Monitor',
'coppermine': 'Coppermine',
'bellazon': 'Bellazon',
'hqcelebcorner': 'HQCelebCorner',
'picturepub': 'PicturePub',
'soundgasm': 'Soundgasm',
// Appearances & media databases
'appearances': 'Appearances',
'appearances_sync': 'Appearances',
'tmdb': 'TMDb',
'tvdb': 'TVDB',
'imdb': 'IMDb',
'plex': 'Plex',
'press': 'Press',
// Video platforms
'vimeo': 'Vimeo',
'dailymotion': 'Dailymotion',
// Other
'other': 'Other',
'unknown': 'Unknown'
}
const key = platform.toLowerCase()
if (platformNames[key]) {
return platformNames[key]
}
// Handle underscore-separated names like "easynews_monitor" -> "Easynews Monitor"
// But first check if we have a specific mapping
const withoutSuffix = key.replace(/_monitor$|_sync$|_channel$/, '')
if (platformNames[withoutSuffix]) {
return platformNames[withoutSuffix]
}
// Fall back to title case with underscores replaced by spaces
return platform
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
/**
* Get the appropriate thumbnail URL for a media file.
* Centralizes the logic for determining image vs video type.
*
* @param api - The API client instance
* @param filePath - Path to the media file
* @param filename - Filename to check for video extensions
* @param contentType - Optional content type from server (takes precedence)
*/
export function getMediaThumbnailType(filename: string | undefined | null, contentType?: string): 'image' | 'video' {
if (contentType === 'video') return 'video'
return isVideoFile(filename) ? 'video' : 'image'
}
/**
* Format error messages from API responses consistently.
*/
export function decodeHtmlEntities(text: string | null | undefined): string {
if (!text) return ''
const textarea = document.createElement('textarea')
textarea.innerHTML = text
return textarea.value
}
/**
* Strip Instagram-style dot spacers from captions.
* Collapses sequences of lines that are only dots or blank into a
* single newline, as long as there are at least 3 dot-only lines.
*/
export function cleanCaption(text: string): string {
// Split into lines, find runs of dot/blank lines, collapse if >=3 dots
const lines = text.split('\n')
const result: string[] = []
let i = 0
while (i < lines.length) {
const trimmed = lines[i].trim()
if (trimmed === '.' || trimmed === '') {
// Start of a potential spacer block
let j = i
let dotCount = 0
while (j < lines.length) {
const t = lines[j].trim()
if (t === '.') { dotCount++; j++ }
else if (t === '') { j++ }
else { break }
}
if (dotCount >= 3) {
// Collapse the whole block
result.push('')
i = j
} else {
// Not enough dots, keep original lines
result.push(lines[i])
i++
}
} else {
result.push(lines[i])
i++
}
}
return result.join('\n')
}
/**
* Cache buster for thumbnail URLs. Rotates daily so browsers
* refetch thumbnails once per day but still cache within a day.
*/
export const THUMB_CACHE_V = `v=yt4-${Math.floor(Date.now() / 86_400_000)}`
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
// Check for API error structure
const apiError = error as Error & { response?: { data?: { detail?: string } } }
if (apiError.response?.data?.detail) {
return apiError.response.data.detail
}
return error.message
}
if (typeof error === 'string') return error
return 'An unexpected error occurred'
}

66
web/frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,66 @@
import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App.tsx'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 30 * 1000, // Data considered fresh for 30 seconds
gcTime: 5 * 60 * 1000, // Cache garbage collected after 5 minutes (formerly cacheTime)
},
},
})
// Recovery component that handles QueryClient context loss
// This can happen during chunk loading race conditions or HMR
function QueryClientRecovery({ children }: { children: React.ReactNode }) {
const [mountKey, setMountKey] = useState(0)
useEffect(() => {
const handleError = (event: ErrorEvent) => {
if (event.message?.includes('No QueryClient set')) {
event.preventDefault()
console.warn('[QueryClient] Context lost, recovering...')
setMountKey(k => k + 1)
}
}
window.addEventListener('error', handleError)
return () => window.removeEventListener('error', handleError)
}, [])
return (
<QueryClientProvider key={mountKey} client={queryClient}>
{children}
</QueryClientProvider>
)
}
// Register service worker for PWA support
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('SW registered:', registration.scope)
// Check for updates periodically
setInterval(() => {
registration.update()
}, 60 * 60 * 1000) // Check every hour
})
.catch((error) => {
console.log('SW registration failed:', error)
})
})
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientRecovery>
<App />
</QueryClientRecovery>
</React.StrictMode>,
)

View File

@@ -0,0 +1,328 @@
import { useQuery } from '@tanstack/react-query'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import {
LineChart,
Line,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts'
import { TrendingUp, TrendingDown, Clock, BarChart3, type LucideIcon } from 'lucide-react'
import { api } from '../lib/api'
import { formatBytes, formatPlatformName } from '../lib/utils'
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']
function StatCard({
title,
value,
subtitle,
trend,
icon: Icon,
color = 'blue',
}: {
title: string
value: string | number
subtitle?: string
trend?: { value: number; positive: boolean }
icon: LucideIcon
color?: 'blue' | 'green' | 'purple' | 'orange'
}) {
const colorStyles = {
blue: {
card: 'stat-card-blue shadow-blue-glow',
icon: 'bg-blue-500/20 text-blue-600 dark:text-blue-400',
},
green: {
card: 'stat-card-green shadow-green-glow',
icon: 'bg-emerald-500/20 text-emerald-600 dark:text-emerald-400',
},
purple: {
card: 'stat-card-purple shadow-purple-glow',
icon: 'bg-violet-500/20 text-violet-600 dark:text-violet-400',
},
orange: {
card: 'stat-card-orange shadow-orange-glow',
icon: 'bg-amber-500/20 text-amber-600 dark:text-amber-400',
},
}
const styles = colorStyles[color]
return (
<div className={`card-glass-hover rounded-xl p-6 border ${styles.card}`}>
<div className="flex items-center justify-between mb-4">
<div className={`p-2 rounded-xl ${styles.icon}`}>
<Icon className="w-5 h-5" />
</div>
{trend && (
<div className={`flex items-center space-x-1 text-sm font-medium ${trend.positive ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}>
{trend.positive ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
<span>{Math.abs(trend.value).toFixed(1)}%</span>
</div>
)}
</div>
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
<p className="mt-2 text-2xl font-bold text-foreground animate-count-up">{value}</p>
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
</div>
)
}
export default function Analytics() {
useBreadcrumb(breadcrumbConfig['/analytics'])
const { data: analytics } = useQuery({
queryKey: ['analytics'],
queryFn: () => api.getAnalytics(),
refetchInterval: 30000, // Refresh every 30 seconds
})
if (!analytics) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-slate-500 dark:text-slate-400">Loading analytics...</div>
</div>
)
}
// Prepare data for charts
const downloadsChartData = analytics.downloads_per_day.reverse()
const fileTypeChartData = analytics.file_types.map(ft => ({
name: ft.type.charAt(0).toUpperCase() + ft.type.slice(1),
value: ft.count,
size: ft.size,
}))
const platformStorageData = analytics.storage_by_platform.map(p => ({
platform: formatPlatformName(p.platform),
count: p.count,
size: p.total_size,
avgSize: p.avg_size,
}))
const hourlyData = Array.from({ length: 24 }, (_, i) => {
const hourData = analytics.hourly_distribution.find(h => h.hour === i)
return {
hour: `${i.toString().padStart(2, '0')}:00`,
count: hourData?.count || 0,
}
})
const totalFileTypeCount = fileTypeChartData.reduce((sum, item) => sum + item.value, 0)
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<BarChart3 className="w-8 h-8 text-teal-500" />
Analytics
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Advanced statistics and insights for your downloads
</p>
</div>
{/* Weekly Comparison Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
title="This Week"
value={analytics.weekly_comparison.this_week.toLocaleString()}
subtitle="Downloads this week"
icon={TrendingUp}
color="blue"
/>
<StatCard
title="Last Week"
value={analytics.weekly_comparison.last_week.toLocaleString()}
subtitle="Downloads last week"
icon={Clock}
color="purple"
/>
<StatCard
title="Growth Rate"
value={`${analytics.weekly_comparison.growth_rate >= 0 ? '+' : ''}${analytics.weekly_comparison.growth_rate.toFixed(1)}%`}
subtitle="Week over week"
trend={{
value: analytics.weekly_comparison.growth_rate,
positive: analytics.weekly_comparison.growth_rate >= 0,
}}
icon={TrendingUp}
color="green"
/>
</div>
{/* Downloads Over Time */}
<div className="card-glass-hover rounded-xl p-6">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
Downloads Over Time (Last 30 Days)
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={downloadsChartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-slate-200 dark:stroke-slate-700" />
<XAxis
dataKey="date"
className="text-xs"
tick={{ fill: 'currentColor' }}
tickFormatter={(value) => new Date(value).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
/>
<YAxis className="text-xs" tick={{ fill: 'currentColor' }} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
borderRadius: '0.5rem',
}}
labelFormatter={(value) => new Date(value).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
/>
<Line type="monotone" dataKey="count" stroke="#3b82f6" strokeWidth={2} dot={{ r: 4 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* File Types and Hourly Distribution */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* File Type Distribution */}
<div className="card-glass-hover rounded-xl p-6">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
File Type Distribution
</h3>
<div className="h-80 flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={fileTypeChartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={100}
fill="#8884d8"
dataKey="value"
>
{fileTypeChartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
borderRadius: '0.5rem',
}}
formatter={(value: number) => [value.toLocaleString(), 'Downloads']}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="mt-4 space-y-2">
{fileTypeChartData.map((item, index) => (
<div key={item.name} className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: COLORS[index % COLORS.length] }}
/>
<span className="text-slate-700 dark:text-slate-300">{item.name}</span>
</div>
<div className="text-slate-500 dark:text-slate-400">
{item.value} ({((item.value / totalFileTypeCount) * 100).toFixed(1)}%) - {formatBytes(item.size)}
</div>
</div>
))}
</div>
</div>
{/* Hourly Distribution */}
<div className="card-glass-hover rounded-xl p-6">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
Hourly Activity (Last 7 Days)
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={hourlyData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-slate-200 dark:stroke-slate-700" />
<XAxis
dataKey="hour"
className="text-xs"
tick={{ fill: 'currentColor' }}
interval={2}
/>
<YAxis className="text-xs" tick={{ fill: 'currentColor' }} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
borderRadius: '0.5rem',
}}
/>
<Bar dataKey="count" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Platform Storage Analysis */}
<div className="card-glass-hover rounded-xl p-6">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4">
Storage by Platform
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={platformStorageData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" className="stroke-slate-200 dark:stroke-slate-700" />
<XAxis type="number" className="text-xs" tick={{ fill: 'currentColor' }} tickFormatter={formatBytes} />
<YAxis dataKey="platform" type="category" className="text-xs" tick={{ fill: 'currentColor' }} width={100} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
borderRadius: '0.5rem',
}}
formatter={(value: number) => formatBytes(value)}
/>
<Bar dataKey="size" fill="#8b5cf6" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Top Sources */}
<div className="card-glass-hover rounded-xl p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
Top 10 Sources
</h3>
<div className="space-y-3">
{analytics.top_sources.map((source, index) => (
<div key={`${source.platform}-${source.source}`} className="flex items-center justify-between p-3 bg-secondary/50 rounded-xl transition-all duration-200 hover:bg-secondary">
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400 font-semibold text-sm">
{index + 1}
</div>
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{source.source}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{formatPlatformName(source.platform)}</p>
</div>
</div>
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{source.count.toLocaleString()}
</div>
</div>
))}
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
import { useState, useEffect } from 'react'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import { Clock, Package, AlertCircle, CheckCircle, Loader, Filter, FileText } from 'lucide-react'
import { api } from '../lib/api'
interface ChangelogEntry {
version: string
date: string
title: string
type: 'major' | 'minor' | 'patch'
changes: string[]
breaking_changes: string[]
notes: string
}
type VersionType = 'major' | 'minor' | 'patch'
export default function Changelog() {
useBreadcrumb(breadcrumbConfig['/changelog'])
const [versions, setVersions] = useState<ChangelogEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [typeFilter, setTypeFilter] = useState<VersionType[]>(['major', 'minor', 'patch'])
useEffect(() => {
loadChangelog()
}, [])
const loadChangelog = async () => {
try {
setLoading(true)
const response = await api.get<{ versions: ChangelogEntry[] }>('/changelog')
setVersions(response.versions || [])
} catch (error) {
console.error('Failed to load changelog:', error)
setError('Failed to load changelog')
} finally {
setLoading(false)
}
}
const toggleTypeFilter = (type: VersionType) => {
setTypeFilter(prev =>
prev.includes(type)
? prev.filter(t => t !== type)
: [...prev, type]
)
}
const getTypeColor = (type: string) => {
switch (type) {
case 'major':
return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800'
case 'minor':
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800'
case 'patch':
return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800'
default:
return 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-700'
}
}
const getTypeButtonColor = (type: VersionType, active: boolean) => {
if (!active) {
return 'bg-slate-100 dark:bg-slate-700 text-slate-400 dark:text-slate-500 border-slate-200 dark:border-slate-600'
}
switch (type) {
case 'major':
return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 border-red-300 dark:border-red-700'
case 'minor':
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 border-blue-300 dark:border-blue-700'
case 'patch':
return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 border-green-300 dark:border-green-700'
default:
return 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-700'
}
}
const filteredVersions = versions.filter(v => typeFilter.includes(v.type as VersionType))
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader className="w-8 h-8 animate-spin text-blue-600 dark:text-blue-400" />
</div>
)
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center space-x-2">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
<p className="text-red-700 dark:text-red-400">{error}</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<FileText className="w-8 h-8 text-blue-500" />
Change Log
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Showing {filteredVersions.length} of {versions.length} version{versions.length !== 1 ? 's' : ''}
</p>
</div>
{/* Filter Buttons */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-slate-500 dark:text-slate-400" />
<span className="text-sm font-medium text-slate-600 dark:text-slate-400">Filter:</span>
</div>
<div className="flex flex-wrap gap-2">
{(['major', 'minor', 'patch'] as VersionType[]).map(type => (
<button
key={type}
onClick={() => toggleTypeFilter(type)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-all touch-manipulation ${getTypeButtonColor(
type,
typeFilter.includes(type)
)}`}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</button>
))}
{typeFilter.length < 3 && (
<button
onClick={() => setTypeFilter(['major', 'minor', 'patch'])}
className="px-3 py-1.5 text-xs font-medium text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
>
Reset
</button>
)}
</div>
</div>
</div>
{filteredVersions.length === 0 ? (
<div className="bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg p-8 text-center">
<Package className="w-12 h-12 text-slate-400 dark:text-slate-500 mx-auto mb-3" />
<p className="text-slate-600 dark:text-slate-400">No versions match the selected filters</p>
<button
onClick={() => setTypeFilter(['major', 'minor', 'patch'])}
className="mt-3 px-4 py-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Reset filters
</button>
</div>
) : (
<div className="space-y-6">
{filteredVersions.map((entry) => (
<div
key={entry.version}
className="card-glass-hover rounded-xl overflow-hidden"
>
{/* Header */}
<div className="bg-slate-50 dark:bg-slate-800/50 border-b border-slate-200 dark:border-slate-800 p-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center space-x-3">
<Package className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
v{entry.version}
</h2>
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full border ${getTypeColor(
entry.type
)}`}
>
{entry.type}
</span>
</div>
<div className="flex items-center space-x-2 text-sm text-slate-500 dark:text-slate-400">
<Clock className="w-4 h-4" />
<span>{entry.date}</span>
</div>
</div>
<p className="mt-2 text-slate-700 dark:text-slate-300 font-medium">{entry.title}</p>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Breaking Changes */}
{entry.breaking_changes && entry.breaking_changes.length > 0 && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
<h3 className="font-semibold text-red-900 dark:text-red-100">Breaking Changes</h3>
</div>
<ul className="space-y-1">
{entry.breaking_changes.map((change, idx) => (
<li
key={idx}
className="text-sm text-red-700 dark:text-red-300 ml-4 list-disc"
>
{change}
</li>
))}
</ul>
</div>
)}
{/* Changes */}
{entry.changes && entry.changes.length > 0 && (
<div>
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-3 flex items-center space-x-2">
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400" />
<span>Changes</span>
</h3>
<ul className="space-y-2">
{entry.changes.map((change, idx) => (
<li
key={idx}
className="text-sm text-slate-700 dark:text-slate-300 ml-4 list-none"
>
{change}
</li>
))}
</ul>
</div>
)}
{/* Notes */}
{entry.notes && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-900 dark:text-blue-100">{entry.notes}</p>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,970 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import { Pause, Play, Shield, ShieldCheck, CheckSquare, Square, Trash2, AlertCircle, RefreshCcw, ArrowUpDown, Video, Plus, X } from 'lucide-react'
import { FilterBar, FilterSection, ActiveFilter } from '../components/FilterPopover'
import { api, YouTubeChannel } from '../lib/api'
import { formatDistanceToNow } from 'date-fns'
import { notificationManager } from '../lib/notificationManager'
interface YouTubeCheckProgress {
isRunning: boolean
currentChannel: string | null
channelsChecked: number
totalChannels: number
videosFound: number
}
type StatusFilter = 'all' | 'active' | 'paused_manual' | 'paused_auto' | 'paused_all'
type AlwaysActiveFilter = 'all' | 'always_active' | 'regular'
type SortField = 'name' | 'last_checked' | 'last_video_date' | 'videos_found' | 'created_at'
export default function ChannelMonitors() {
useBreadcrumb(breadcrumbConfig['/video/channel-monitors'])
const queryClient = useQueryClient()
const navigate = useNavigate()
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('active')
const [alwaysActiveFilter, setAlwaysActiveFilter] = useState<AlwaysActiveFilter>('all')
const [sortField, setSortField] = useState<SortField>('name')
const [sortAscending, setSortAscending] = useState(true)
const [selectedChannels, setSelectedChannels] = useState<number[]>([])
const [showAddModal, setShowAddModal] = useState(false)
const [newChannelUrl, setNewChannelUrl] = useState('')
const [newChannelName, setNewChannelName] = useState('')
const [ytCheckProgress, setYtCheckProgress] = useState<YouTubeCheckProgress>({
isRunning: false,
currentChannel: null,
channelsChecked: 0,
totalChannels: 0,
videosFound: 0
})
// Queries
const { data: statistics } = useQuery({
queryKey: ['youtube-monitor-stats'],
queryFn: async () => {
const response = await api.getYouTubeMonitorStatistics()
return response.statistics
}
})
// Fetch YouTube monitor settings to get search phrases
const { data: monitorSettings } = useQuery({
queryKey: ['youtube-monitor-settings'],
queryFn: async () => {
const response = await api.getYouTubeMonitors()
return response.settings
}
})
// Get the first search phrase to use in channel URLs
const searchPhrase = monitorSettings?.phrases?.[0] || 'Eva Longoria'
// Fetch channels with server-side filtering
const { data: channelsData, isLoading } = useQuery({
queryKey: ['youtube-monitors', statusFilter, alwaysActiveFilter, searchTerm, sortField, sortAscending],
queryFn: async () => {
const response = await api.getYouTubeChannelsFiltered({
status: statusFilter !== 'all' ? statusFilter : undefined,
always_active: alwaysActiveFilter !== 'all' ? alwaysActiveFilter : undefined,
search: searchTerm || undefined,
sort_field: sortField,
sort_ascending: sortAscending
})
return response
}
})
const filteredChannels = channelsData?.channels || []
const totalChannels = channelsData?.total || 0
// Check for running task on mount
useEffect(() => {
const checkRunningTask = async () => {
try {
const task = await api.getBackgroundTask('youtube_monitor')
if (task.active) {
setYtCheckProgress({
isRunning: true,
currentChannel: task.detailed_status || task.extra_data?.current_channel || 'Checking...',
channelsChecked: task.progress?.current || 0,
totalChannels: task.progress?.total || 0,
videosFound: task.extra_data?.videos_found || 0
})
}
} catch (error) {
console.error('Error checking for running task:', error)
}
}
checkRunningTask()
}, [])
// Poll for YouTube monitor background task status
useEffect(() => {
if (!ytCheckProgress.isRunning) return
let pollCount = 0
const pollInterval = setInterval(async () => {
try {
const task = await api.getBackgroundTask('youtube_monitor')
if (task.active) {
const videosFound = task.extra_data?.videos_found || 0
const currentChannel = task.detailed_status || task.extra_data?.current_channel || 'Checking...'
setYtCheckProgress(prev => ({
...prev,
currentChannel: currentChannel,
channelsChecked: task.progress?.current || prev.channelsChecked,
totalChannels: task.progress?.total || prev.totalChannels,
videosFound: videosFound,
}))
// Refresh channel data every 3 seconds during scan for real-time updates
pollCount++
if (pollCount % 3 === 0) {
queryClient.invalidateQueries({ queryKey: ['youtube-monitors'] })
queryClient.invalidateQueries({ queryKey: ['youtube-monitor-stats'] })
}
} else if (!task.active && ytCheckProgress.isRunning) {
setYtCheckProgress(prev => ({
...prev,
isRunning: false,
currentChannel: null,
}))
queryClient.invalidateQueries({ queryKey: ['youtube-monitors'] })
queryClient.invalidateQueries({ queryKey: ['youtube-monitor-stats'] })
}
} catch (error) {
console.error('Error polling background task:', error)
}
}, 1000)
return () => clearInterval(pollInterval)
}, [ytCheckProgress.isRunning, queryClient])
// Mutations
const checkAllMutation = useMutation({
mutationFn: () => api.checkAllYouTubeChannels(),
onMutate: () => {
setYtCheckProgress({
isRunning: true,
currentChannel: 'Starting...',
channelsChecked: 0,
totalChannels: filteredChannels?.length || 0,
videosFound: 0
})
},
onSuccess: (data) => {
setYtCheckProgress(prev => ({
...prev,
totalChannels: data?.channels_count || prev.totalChannels
}))
},
onError: () => {
setYtCheckProgress(prev => ({
...prev,
isRunning: false,
currentChannel: null
}))
}
})
const pauseMutation = useMutation({
mutationFn: (id: number) => api.pauseYouTubeChannel(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['youtube-monitors'] })
queryClient.invalidateQueries({ queryKey: ['youtube-monitor-stats'] })
}
})
const resumeMutation = useMutation({
mutationFn: (id: number) => api.resumeYouTubeChannel(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['youtube-monitors'] })
queryClient.invalidateQueries({ queryKey: ['youtube-monitor-stats'] })
}
})
const deleteMutation = useMutation({
mutationFn: (id: number) => api.deleteYouTubeChannel(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['youtube-monitors'] })
queryClient.invalidateQueries({ queryKey: ['youtube-monitor-stats'] })
}
})
const toggleAlwaysActiveMutation = useMutation({
mutationFn: ({ id, value }: { id: number, value: boolean }) =>
api.toggleYouTubeChannelAlwaysActive(id, value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['youtube-monitors'] })
queryClient.invalidateQueries({ queryKey: ['youtube-monitor-stats'] })
}
})
const checkChannelMutation = useMutation({
mutationFn: ({ id }: { id: number, channelName: string }) => {
return api.checkYouTubeChannel(id)
},
onSuccess: (_, variables) => {
notificationManager.syncStarted(`${variables.channelName}`)
queryClient.invalidateQueries({ queryKey: ['youtube-monitors'] })
queryClient.invalidateQueries({ queryKey: ['youtube-monitor-stats'] })
},
onError: (err: unknown, variables) => {
notificationManager.apiError('Check Failed', err, `Failed to check ${variables.channelName}`)
}
})
const checkSelectedMutation = useMutation({
mutationFn: (channelIds: number[]) => {
return api.checkSelectedYouTubeChannels(channelIds)
},
onSuccess: (_, channelIds) => {
notificationManager.syncStarted(`${channelIds.length} channel(s)`)
queryClient.invalidateQueries({ queryKey: ['youtube-monitors'] })
queryClient.invalidateQueries({ queryKey: ['youtube-monitor-stats'] })
setSelectedChannels([])
},
onError: (err: unknown) => {
notificationManager.syncError('Channels', err)
}
})
const addChannelMutation = useMutation({
mutationFn: (data: { channel_url: string, channel_name?: string, enabled?: boolean }) =>
api.addYouTubeChannel(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['youtube-monitors'] })
queryClient.invalidateQueries({ queryKey: ['youtube-monitor-stats'] })
setShowAddModal(false)
setNewChannelUrl('')
setNewChannelName('')
},
onError: (error: any) => {
const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to add channel'
alert(errorMessage)
}
})
const handleAddChannel = () => {
if (!newChannelUrl.trim()) return
addChannelMutation.mutate({
channel_url: newChannelUrl,
channel_name: newChannelName || undefined,
enabled: true
})
}
// Bulk actions
const handleBulkPause = async () => {
if (!confirm(`Pause ${selectedChannels.length} selected channel(s)?`)) return
await Promise.all(selectedChannels.map(id => pauseMutation.mutateAsync(id)))
setSelectedChannels([])
}
const handleBulkResume = async () => {
if (!confirm(`Resume ${selectedChannels.length} selected channel(s)?`)) return
await Promise.all(selectedChannels.map(id => resumeMutation.mutateAsync(id)))
setSelectedChannels([])
}
const handleBulkDelete = async () => {
if (!confirm(`Delete ${selectedChannels.length} selected channel(s)? This cannot be undone!`)) return
await Promise.all(selectedChannels.map(id => deleteMutation.mutateAsync(id)))
setSelectedChannels([])
}
const handleBulkToggleAlwaysActive = async (value: boolean) => {
if (!confirm(`${value ? 'Enable' : 'Disable'} "Always Active" for ${selectedChannels.length} selected channel(s)?`)) return
await Promise.all(
selectedChannels.map(id => toggleAlwaysActiveMutation.mutateAsync({ id, value }))
)
setSelectedChannels([])
}
const handleBulkCheck = async () => {
if (!confirm(`Check ${selectedChannels.length} selected channel(s) for new videos?`)) return
await checkSelectedMutation.mutateAsync(selectedChannels)
}
const handleSelectAll = () => {
if (selectedChannels.length === filteredChannels?.length) {
setSelectedChannels([])
} else {
setSelectedChannels(filteredChannels?.map((c: YouTubeChannel) => c.id) || [])
}
}
const handleToggleChannel = (id: number) => {
setSelectedChannels(prev =>
prev.includes(id) ? prev.filter(cid => cid !== id) : [...prev, id]
)
}
const formatDate = (date: string | null | undefined) => {
if (!date) return 'Never'
try {
// Handle YYYYMMDD format from yt-dlp (e.g., "20200814")
if (date.length === 8 && /^\d{8}$/.test(date)) {
const year = date.substring(0, 4)
const month = date.substring(4, 6)
const day = date.substring(6, 8)
const parsedDate = new Date(`${year}-${month}-${day}`)
return formatDistanceToNow(parsedDate, { addSuffix: true })
}
// Handle ISO format
return formatDistanceToNow(new Date(date), { addSuffix: true })
} catch {
return 'Unknown'
}
}
const getStatusBadge = (channel: YouTubeChannel) => {
if (channel.status === 'active') {
return <span className="px-2 py-1 text-xs font-semibold rounded bg-green-500/20 text-green-400">🟢 Active</span>
}
if (channel.status === 'paused_manual') {
return <span className="px-2 py-1 text-xs font-semibold rounded bg-yellow-500/20 text-yellow-400"> Paused (Manual)</span>
}
if (channel.status === 'paused_auto') {
return <span className="px-2 py-1 text-xs font-semibold rounded bg-orange-500/20 text-orange-400"> Paused (Auto)</span>
}
return null
}
const toggleSortDirection = () => {
setSortAscending(!sortAscending)
}
// Filter sections for FilterBar
const filterSections: FilterSection[] = useMemo(() => [
{
id: 'status',
label: 'Status',
type: 'select',
options: [
{ value: 'all', label: 'All Status' },
{ value: 'active', label: 'Active' },
{ value: 'paused_manual', label: 'Paused (Manual)' },
{ value: 'paused_auto', label: 'Paused (Auto)' },
{ value: 'paused_all', label: 'All Paused' },
],
value: statusFilter,
onChange: (v) => setStatusFilter(v as StatusFilter),
},
{
id: 'alwaysActive',
label: 'Channel Type',
type: 'select',
options: [
{ value: 'all', label: 'All Channels' },
{ value: 'always_active', label: 'Protected' },
{ value: 'regular', label: 'Regular' },
],
value: alwaysActiveFilter,
onChange: (v) => setAlwaysActiveFilter(v as AlwaysActiveFilter),
},
], [statusFilter, alwaysActiveFilter])
// Active filters for FilterBar
const activeFilters: ActiveFilter[] = useMemo(() => {
const filters: ActiveFilter[] = []
if (statusFilter !== 'all') {
const statusLabels: Record<StatusFilter, string> = {
all: 'All',
active: 'Active',
paused_manual: 'Paused (Manual)',
paused_auto: 'Paused (Auto)',
paused_all: 'All Paused',
}
filters.push({
id: 'status',
label: 'Status',
value: statusFilter,
displayValue: statusLabels[statusFilter],
onRemove: () => setStatusFilter('all'),
})
}
if (alwaysActiveFilter !== 'all') {
filters.push({
id: 'alwaysActive',
label: 'Type',
value: alwaysActiveFilter,
displayValue: alwaysActiveFilter === 'always_active' ? 'Protected' : 'Regular',
onRemove: () => setAlwaysActiveFilter('all'),
})
}
if (searchTerm) {
filters.push({
id: 'search',
label: 'Search',
value: searchTerm,
displayValue: `"${searchTerm}"`,
onRemove: () => setSearchTerm(''),
})
}
return filters
}, [statusFilter, alwaysActiveFilter, searchTerm])
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<Video className="w-6 h-6 sm:w-8 sm:h-8 text-indigo-500" />
YouTube Channel Monitors
</h1>
<p className="text-sm sm:text-base text-slate-600 dark:text-slate-400 mt-1">
Manage monitored YouTube channels with auto-pause for inactive channels
</p>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<button
onClick={() => setShowAddModal(true)}
className="flex items-center justify-center gap-2 px-3 sm:px-4 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors min-h-[44px]"
>
<Plus className="w-4 h-4" />
<span>Add Channel</span>
</button>
<button
onClick={() => checkAllMutation.mutate()}
disabled={checkAllMutation.isPending || ytCheckProgress.isRunning || !filteredChannels?.length}
className="flex items-center justify-center gap-2 px-3 sm:px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px]"
>
<RefreshCcw className={`w-4 h-4 ${checkAllMutation.isPending || ytCheckProgress.isRunning ? 'animate-spin' : ''}`} />
<span>Check All</span>
</button>
</div>
</div>
</div>
{/* Running Status Banner */}
{ytCheckProgress.isRunning && (
<div className="card-glass rounded-xl bg-gradient-to-r from-red-500/10 to-rose-500/10 border border-red-500/30 p-4 glow-border">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="relative">
<Video className="w-5 h-5 text-red-600 dark:text-red-400" />
<span className="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
</div>
<h3 className="text-sm font-semibold text-red-900 dark:text-red-100">
YouTube Channel Monitor
</h3>
</div>
<span className="text-xs text-red-600 dark:text-red-400">
{ytCheckProgress.totalChannels || 0} channels
</span>
</div>
{ytCheckProgress.currentChannel && (
<div className="mb-3 px-3 py-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
<div className="flex items-center gap-2 text-sm text-red-800 dark:text-red-200">
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
<span className="truncate">{ytCheckProgress.currentChannel}</span>
</div>
</div>
)}
<div className="mb-2">
<div className="flex justify-between text-xs text-red-700 dark:text-red-300 mb-1">
<span>Progress</span>
<span>{ytCheckProgress.channelsChecked || 0}/{ytCheckProgress.totalChannels || 0}</span>
</div>
<div className="w-full bg-red-200 dark:bg-red-800 rounded-full h-1.5 sm:h-2">
<div
className="bg-red-600 dark:bg-red-400 h-1.5 sm:h-2 rounded-full transition-all duration-300 progress-animated"
style={{ width: `${ytCheckProgress.totalChannels > 0 ? (ytCheckProgress.channelsChecked / ytCheckProgress.totalChannels * 100) : 0}%` }}
></div>
</div>
</div>
{(ytCheckProgress.videosFound ?? 0) > 0 && (
<div className="flex items-center gap-1.5 text-xs text-green-700 dark:text-green-300 font-medium">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Found {ytCheckProgress.videosFound} matching video{ytCheckProgress.videosFound !== 1 ? 's' : ''}</span>
</div>
)}
</div>
)}
{/* Statistics Dashboard */}
{statistics && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2 sm:gap-4">
<div className="card-glass-hover rounded-xl p-2.5 sm:p-4 border stat-card-blue shadow-blue-glow">
<div className="text-lg sm:text-2xl font-bold text-foreground animate-count-up">{statistics.total}</div>
<div className="text-xs sm:text-sm text-muted-foreground">Total</div>
</div>
<div className="card-glass-hover rounded-xl p-2.5 sm:p-4 border stat-card-green shadow-green-glow">
<div className="text-lg sm:text-2xl font-bold text-emerald-600 dark:text-emerald-400 animate-count-up">{statistics.active}</div>
<div className="text-xs sm:text-sm text-muted-foreground">Active</div>
</div>
<div className="card-glass-hover rounded-xl p-2.5 sm:p-4 border stat-card-orange shadow-orange-glow">
<div className="text-lg sm:text-2xl font-bold text-amber-600 dark:text-amber-400 animate-count-up">{statistics.paused_manual}</div>
<div className="text-xs sm:text-sm text-muted-foreground">Paused (Manual)</div>
</div>
<div className="card-glass-hover rounded-xl p-2.5 sm:p-4 border stat-card-red shadow-red-glow">
<div className="text-lg sm:text-2xl font-bold text-red-600 dark:text-red-400 animate-count-up">{statistics.paused_auto}</div>
<div className="text-xs sm:text-sm text-muted-foreground">Paused (Auto)</div>
</div>
<div className="card-glass-hover rounded-xl p-2.5 sm:p-4 border stat-card-cyan">
<div className="text-lg sm:text-2xl font-bold text-cyan-600 dark:text-cyan-400 animate-count-up">{statistics.always_active_count}</div>
<div className="text-xs sm:text-sm text-muted-foreground">Protected</div>
</div>
<div className="card-glass-hover rounded-xl p-2.5 sm:p-4 border bg-gradient-to-br from-purple-500/10 via-purple-500/5 to-indigo-500/10 border-purple-500/20">
<div className="text-lg sm:text-2xl font-bold text-purple-600 dark:text-purple-400 animate-count-up">{statistics.total_videos}</div>
<div className="text-xs sm:text-sm text-muted-foreground">Videos</div>
</div>
</div>
)}
{/* Filters */}
<div className="card-glass-hover rounded-xl p-4 relative z-30">
<FilterBar
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Search channels..."
filterSections={filterSections}
activeFilters={activeFilters}
onClearAll={() => {
setStatusFilter('all')
setAlwaysActiveFilter('all')
setSortField('name')
setSortAscending(true)
setSearchTerm('')
}}
totalCount={filteredChannels?.length || 0}
countLabel={`of ${totalChannels} channels`}
>
{/* Sort Controls */}
<div className="flex gap-2 mt-3">
<select
value={sortField}
onChange={(e) => {
const newField = e.target.value as SortField
setSortField(newField)
if (newField !== 'name') {
setSortAscending(false)
} else {
setSortAscending(true)
}
}}
className="flex-1 px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="name">Sort by: Name</option>
<option value="last_checked">Sort by: Last Checked</option>
<option value="last_video_date">Sort by: Last Video</option>
<option value="videos_found">Sort by: Matched Videos</option>
<option value="created_at">Sort by: Created</option>
</select>
<button
onClick={toggleSortDirection}
className={`px-3 py-2 rounded-lg border ${
!sortAscending
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800'
} text-slate-900 dark:text-slate-100 hover:border-blue-400 transition-colors`}
title={sortAscending ? 'Ascending' : 'Descending'}
>
<ArrowUpDown className={`w-4 h-4 ${sortAscending ? '' : 'rotate-180'} transition-transform`} />
</button>
</div>
</FilterBar>
</div>
{/* Bulk Actions (shown when items selected) */}
{selectedChannels.length > 0 && (
<div className="card-glass-hover rounded-xl p-3 border border-border">
<div className="flex flex-wrap items-center gap-3">
<button
onClick={handleSelectAll}
className="text-muted-foreground hover:text-foreground transition-colors touch-manipulation"
title={selectedChannels.length === filteredChannels?.length ? 'Deselect All' : 'Select All'}
>
{selectedChannels.length === filteredChannels?.length ? (
<CheckSquare className="w-5 h-5" />
) : (
<Square className="w-5 h-5" />
)}
</button>
<span className="text-sm font-medium text-foreground">
{selectedChannels.length} selected
</span>
<div className="flex-1"></div>
<button
onClick={handleBulkPause}
className="px-3 py-2 bg-yellow-600 hover:bg-yellow-700 rounded-lg text-sm font-medium flex items-center gap-1.5 transition-colors min-h-[44px]"
>
<Pause className="w-4 h-4" />
<span className="hidden sm:inline">Pause</span>
</button>
<button
onClick={handleBulkResume}
className="px-3 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm font-medium flex items-center gap-1.5 transition-colors min-h-[44px]"
>
<Play className="w-4 h-4" />
<span className="hidden sm:inline">Resume</span>
</button>
<button
onClick={handleBulkCheck}
disabled={checkSelectedMutation.isPending}
className="px-3 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg text-sm font-medium flex items-center gap-1.5 transition-colors min-h-[44px] disabled:opacity-50 disabled:cursor-not-allowed"
title="Check selected channels for new videos"
>
<RefreshCcw className={`w-4 h-4 ${checkSelectedMutation.isPending ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">{checkSelectedMutation.isPending ? 'Checking...' : 'Check Now'}</span>
</button>
<button
onClick={() => handleBulkToggleAlwaysActive(true)}
className="px-3 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm font-medium flex items-center gap-1.5 transition-colors min-h-[44px]"
>
<ShieldCheck className="w-4 h-4" />
<span className="hidden sm:inline">Protect</span>
</button>
<button
onClick={() => handleBulkToggleAlwaysActive(false)}
className="px-3 py-2 bg-slate-600 hover:bg-slate-700 rounded-lg text-sm font-medium flex items-center gap-1.5 transition-colors min-h-[44px]"
>
<Shield className="w-4 h-4" />
<span className="hidden sm:inline">Unprotect</span>
</button>
<button
onClick={handleBulkDelete}
className="px-3 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-sm font-medium flex items-center gap-1.5 transition-colors min-h-[44px]"
>
<Trash2 className="w-4 h-4" />
<span className="hidden sm:inline">Delete</span>
</button>
</div>
</div>
)}
{/* Channel List */}
<div className="card-glass rounded-xl overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center p-12">
<RefreshCcw className="w-8 h-8 animate-spin text-indigo-500" />
</div>
) : filteredChannels && filteredChannels.length === 0 ? (
<div className="text-center p-12">
<Video className="w-12 h-12 mx-auto text-slate-400 mb-4" />
<h3 className="text-lg font-medium mb-2">No channels found</h3>
<p className="text-slate-600 dark:text-slate-400">
No channels match your current filters
</p>
</div>
) : (
<div className="divide-y divide-slate-200 dark:divide-slate-700">
{filteredChannels.map((channel: YouTubeChannel) => (
<div
key={channel.id}
className="p-3 sm:p-4 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
>
<div className="flex items-start gap-3 sm:gap-4">
{/* Checkbox */}
<button
onClick={() => handleToggleChannel(channel.id)}
className="mt-1 text-muted-foreground hover:text-foreground transition-colors touch-manipulation"
>
{selectedChannels.includes(channel.id) ? (
<CheckSquare className="w-5 h-5 text-indigo-500" />
) : (
<Square className="w-5 h-5" />
)}
</button>
{/* Channel Info */}
<div className="flex-1 min-w-0">
{/* Header Row */}
<div className="flex items-start gap-2 mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3
className={`text-base sm:text-lg font-semibold truncate transition-colors touch-manipulation ${
(channel.total_videos_found || 0) > 0
? 'cursor-pointer hover:text-blue-400'
: 'cursor-default opacity-60'
}`}
onClick={() => {
if ((channel.total_videos_found || 0) > 0) {
navigate(`/celebrities?channel_name=${encodeURIComponent(channel.channel_name || channel.channel_url)}&status=all&watched=`)
}
}}
title={(channel.total_videos_found || 0) > 0 ? "View in Internet Discovery" : "No videos found yet"}
>
{channel.channel_name || channel.channel_url.replace('https://www.youtube.com/@', '@')}
</h3>
{getStatusBadge(channel)}
{channel.always_active === 1 && (
<span className="px-2 py-0.5 text-xs font-semibold rounded bg-blue-500/20 text-blue-400 border border-blue-400/30 whitespace-nowrap">
<ShieldCheck className="w-3 h-3 inline mr-1" />
Protected
</span>
)}
</div>
<a
href={`${channel.channel_url}/search?query=${encodeURIComponent(searchPhrase)}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-slate-500 dark:text-slate-400 hover:text-blue-400 truncate block touch-manipulation"
title={`${channel.channel_url}/search?query=${searchPhrase}`}
>
{channel.channel_url.replace('https://www.youtube.com/', '')}
</a>
</div>
</div>
{/* Stats Row */}
<div className="flex flex-wrap gap-x-4 sm:gap-x-6 gap-y-1 text-xs text-slate-600 dark:text-slate-400 mb-2">
<div className="flex items-center gap-1.5">
<span className="font-medium text-slate-500 dark:text-slate-500">Last Check:</span>
<span>{formatDate(channel.last_check_date)}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-slate-500 dark:text-slate-500">Last Video:</span>
<span>{formatDate(channel.last_video_date)}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-slate-500 dark:text-slate-500">Videos:</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">{channel.total_videos_found || 0}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-slate-500 dark:text-slate-500">Added:</span>
<span>{formatDate(channel.created_at)}</span>
</div>
</div>
{/* Pause Reason */}
{channel.paused_reason && (
<div className="text-xs bg-yellow-500/10 border border-yellow-500/30 text-yellow-600 dark:text-yellow-400 px-3 py-1.5 rounded mb-2 flex items-start gap-2">
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{channel.paused_reason}</span>
</div>
)}
{/* Action Buttons - Mobile Only (Icon Only) */}
<div className="flex gap-2 lg:hidden">
{channel.status === 'active' ? (
<button
onClick={() => pauseMutation.mutate(channel.id)}
className="p-2.5 bg-yellow-600 hover:bg-yellow-700 active:bg-yellow-800 rounded transition-colors touch-manipulation"
title="Pause"
aria-label="Pause channel"
>
<Pause className="w-4 h-4" />
</button>
) : (
<button
onClick={() => resumeMutation.mutate(channel.id)}
className="p-2.5 bg-green-600 hover:bg-green-700 active:bg-green-800 rounded transition-colors touch-manipulation"
title="Resume"
aria-label="Resume channel"
>
<Play className="w-4 h-4" />
</button>
)}
<button
onClick={() => checkChannelMutation.mutate({ id: channel.id, channelName: channel.channel_name || 'Unknown Channel' })}
disabled={checkChannelMutation.isPending}
className="p-2.5 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 rounded transition-colors touch-manipulation disabled:opacity-50 disabled:cursor-not-allowed"
title="Check for new videos"
aria-label="Check channel for new videos"
>
<RefreshCcw className={`w-4 h-4 ${checkChannelMutation.isPending ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => toggleAlwaysActiveMutation.mutate({ id: channel.id, value: !channel.always_active })}
className={`p-2.5 rounded transition-colors touch-manipulation ${
channel.always_active
? 'bg-slate-600 hover:bg-slate-700 active:bg-slate-800'
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800'
}`}
title={channel.always_active ? 'Unprotect' : 'Protect'}
aria-label={channel.always_active ? 'Unprotect channel' : 'Protect channel'}
>
{channel.always_active ? (
<Shield className="w-4 h-4" />
) : (
<ShieldCheck className="w-4 h-4" />
)}
</button>
<button
onClick={() => {
if (confirm(`Delete channel "${channel.channel_name || channel.channel_url}"? This cannot be undone!`)) {
deleteMutation.mutate(channel.id)
}
}}
className="p-2.5 bg-red-600 hover:bg-red-700 active:bg-red-800 rounded transition-colors touch-manipulation"
title="Delete"
aria-label="Delete channel"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Action Buttons - Desktop Only (Right Side) */}
<div className="hidden lg:flex gap-2 flex-shrink-0 items-start">
{channel.status === 'active' ? (
<button
onClick={() => pauseMutation.mutate(channel.id)}
className="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 rounded text-xs font-medium flex items-center gap-1.5 transition-colors whitespace-nowrap"
>
<Pause className="w-3.5 h-3.5" />
Pause
</button>
) : (
<button
onClick={() => resumeMutation.mutate(channel.id)}
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 rounded text-xs font-medium flex items-center gap-1.5 transition-colors whitespace-nowrap"
>
<Play className="w-3.5 h-3.5" />
Resume
</button>
)}
<button
onClick={() => checkChannelMutation.mutate({ id: channel.id, channelName: channel.channel_name || 'Unknown Channel' })}
disabled={checkChannelMutation.isPending}
className="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 rounded text-xs font-medium flex items-center gap-1.5 transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
title="Check this channel for new videos"
>
<RefreshCcw className={`w-3.5 h-3.5 ${checkChannelMutation.isPending ? 'animate-spin' : ''}`} />
{checkChannelMutation.isPending ? 'Checking...' : 'Check Now'}
</button>
<button
onClick={() => toggleAlwaysActiveMutation.mutate({ id: channel.id, value: !channel.always_active })}
className={`px-3 py-1.5 rounded text-xs font-medium flex items-center gap-1.5 transition-colors whitespace-nowrap ${
channel.always_active
? 'bg-slate-600 hover:bg-slate-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{channel.always_active ? (
<>
<Shield className="w-3.5 h-3.5" />
Unprotect
</>
) : (
<>
<ShieldCheck className="w-3.5 h-3.5" />
Protect
</>
)}
</button>
<button
onClick={() => {
if (confirm(`Delete channel "${channel.channel_name || channel.channel_url}"? This cannot be undone!`)) {
deleteMutation.mutate(channel.id)
}
}}
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded text-xs font-medium flex items-center gap-1.5 transition-colors whitespace-nowrap"
>
<Trash2 className="w-3.5 h-3.5" />
Delete
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Add Channel Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Add YouTube Channel
</h3>
<button
onClick={() => {
setShowAddModal(false)
setNewChannelUrl('')
setNewChannelName('')
}}
className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
<X className="w-6 h-6" />
</button>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
Add a new YouTube channel to monitor for matching videos. The channel will be automatically checked based on your configured schedule.
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Channel URL <span className="text-red-500">*</span>
</label>
<input
type="text"
value={newChannelUrl}
onChange={(e) => setNewChannelUrl(e.target.value)}
placeholder="https://www.youtube.com/@channelname or https://www.youtube.com/channel/UC..."
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground placeholder-muted-foreground"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && newChannelUrl.trim()) {
handleAddChannel()
}
}}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Channel Name (optional)
</label>
<input
type="text"
value={newChannelName}
onChange={(e) => setNewChannelName(e.target.value)}
placeholder="Optional custom name for this channel"
className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground placeholder-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter' && newChannelUrl.trim()) {
handleAddChannel()
}
}}
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
If not provided, the channel name will be fetched automatically
</p>
</div>
</div>
<div className="flex justify-end space-x-2 mt-6">
<button
onClick={() => {
setShowAddModal(false)
setNewChannelUrl('')
setNewChannelName('')
}}
className="px-4 py-2.5 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors min-h-[44px]"
>
Cancel
</button>
<button
onClick={handleAddChannel}
disabled={!newChannelUrl.trim() || addChannelMutation.isPending}
className="px-4 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors min-h-[44px]"
>
{addChannelMutation.isPending ? 'Adding...' : 'Add Channel'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,346 @@
import { useQuery } from '@tanstack/react-query'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import { Users, CheckCircle, XCircle, TrendingUp, Clock, ScanFace } from 'lucide-react'
import { api } from '../lib/api'
import { formatDate } from '../lib/utils'
export default function FaceRecognitionDashboard() {
useBreadcrumb(breadcrumbConfig['/faces'])
// Get platform/source color for badges
const getSourceColor = (source: string) => {
const lowerSource = source.toLowerCase()
const colors: Record<string, string> = {
instagram: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
tiktok: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200',
fastdl: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
toolzu: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
imginn: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
youtube: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
twitter: 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-200',
reddit: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
imgur: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
}
// Check if it's a forum source
if (lowerSource.startsWith('forum:')) {
return 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200'
}
return colors[lowerSource] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}
// Format source name for display
const formatSourceName = (source: string) => {
if (!source || source === 'unknown') return 'Unknown'
if (source.startsWith('forum:')) {
return source.replace('forum:', '').split('/')[0] // Get forum name
}
return source.charAt(0).toUpperCase() + source.slice(1)
}
const { data: stats, isLoading } = useQuery({
queryKey: ['face-dashboard-stats'],
queryFn: () => api.getFaceDashboardStats(),
refetchInterval: 30000, // Refresh every 30 seconds
})
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<ScanFace className="w-8 h-8 text-sky-500" />
Face Recognition Dashboard
</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 animate-pulse">
<div className="h-20 bg-slate-200 dark:bg-slate-700 rounded"></div>
</div>
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<ScanFace className="w-8 h-8 text-sky-500" />
Face Recognition Dashboard
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Analytics and insights for face recognition scans
</p>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{/* Total Scans */}
<div className="card-glass-hover rounded-xl p-6 border stat-card-blue shadow-blue-glow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Scans</p>
<p className="text-2xl font-bold text-foreground mt-1 animate-count-up">
{stats?.total_scans?.toLocaleString() || 0}
</p>
</div>
<div className="p-3 bg-blue-500/20 rounded-xl">
<Users className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
</div>
</div>
{/* Matches */}
<div className="card-glass-hover rounded-xl p-6 border stat-card-green shadow-green-glow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Matches</p>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 mt-1">
{stats?.total_matches?.toLocaleString() || 0}
</p>
</div>
<div className="p-3 bg-emerald-500/20 rounded-xl">
<CheckCircle className="w-6 h-6 text-emerald-600 dark:text-emerald-400" />
</div>
</div>
</div>
{/* No Matches */}
<div className="card-glass-hover rounded-xl p-6 border stat-card-red shadow-red-glow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">No Matches</p>
<p className="text-2xl font-bold text-red-600 dark:text-red-400 mt-1">
{stats?.total_no_matches?.toLocaleString() || 0}
</p>
</div>
<div className="p-3 bg-red-500/20 rounded-xl">
<XCircle className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
</div>
</div>
{/* Match Rate */}
<div className="card-glass-hover rounded-xl p-6 border stat-card-purple shadow-purple-glow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Match Rate</p>
<p className="text-2xl font-bold text-violet-600 dark:text-violet-400 mt-1">
{stats?.match_rate ? `${stats.match_rate.toFixed(1)}%` : '0%'}
</p>
</div>
<div className="p-3 bg-violet-500/20 rounded-xl">
<TrendingUp className="w-6 h-6 text-violet-600 dark:text-violet-400" />
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Matches by Person */}
<div className="card-glass-hover rounded-xl">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-foreground">Matches by Person</h3>
</div>
<div className="p-6">
{stats?.by_person && stats.by_person.length > 0 ? (
<div className="space-y-4">
{stats.by_person.slice(0, 10).map((person, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{person.person}
</span>
<span className="text-sm text-slate-600 dark:text-slate-400">
{person.count} matches
</span>
</div>
<div className="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-1.5 sm:h-2">
<div
className="bg-blue-600 dark:bg-blue-500 h-1.5 sm:h-2 rounded-full"
style={{
width: `${(person.count / (stats.by_person[0]?.count || 1)) * 100}%`
}}
></div>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-slate-500 dark:text-slate-400">
Avg: {person.avg_confidence.toFixed(1)}%
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
Range: {person.min_confidence.toFixed(1)}% - {person.max_confidence.toFixed(1)}%
</span>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-center text-slate-500 dark:text-slate-400 py-8">No matches found yet</p>
)}
</div>
</div>
{/* Confidence Distribution */}
<div className="card-glass-hover rounded-xl">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-foreground">Confidence Distribution</h3>
</div>
<div className="p-6">
{stats?.confidence_distribution ? (
<div className="space-y-3">
{Object.entries(stats.confidence_distribution).map(([range, count]) => {
const total = Object.values(stats.confidence_distribution).reduce((a: number, b: number) => a + b, 0)
const percentage = total > 0 ? (count / total) * 100 : 0
return (
<div key={range}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{range}</span>
<span className="text-sm text-slate-600 dark:text-slate-400">
{count} ({percentage.toFixed(1)}%)
</span>
</div>
<div className="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-1.5 sm:h-2">
<div
className="bg-gradient-to-r from-red-500 via-yellow-500 to-green-500 h-1.5 sm:h-2 rounded-full"
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
)
})}
</div>
) : (
<p className="text-center text-slate-500 dark:text-slate-400 py-8">No distribution data available</p>
)}
</div>
</div>
</div>
{/* Recent Matches */}
<div className="card-glass-hover rounded-xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-foreground">Recent Matches</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-secondary/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
File
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Person
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Confidence
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Source
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Date
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{stats?.recent_matches && stats.recent_matches.length > 0 ? (
stats.recent_matches.map((match, index) => (
<tr key={index} className="table-row-hover-stripe">
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
<span className="truncate max-w-xs inline-block" title={match.file_path}>
{match.file_path.split('/').pop()}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
{match.person}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
match.confidence >= 80
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: match.confidence >= 60
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{match.confidence.toFixed(1)}%
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getSourceColor(match.source || 'unknown')}`}>
{formatSourceName(match.source || 'unknown')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
<div className="flex items-center">
<Clock className="w-4 h-4 mr-1" />
{formatDate(match.scan_date)}
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-muted-foreground">
No recent matches
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Scans Over Time */}
{stats?.scans_over_time && stats.scans_over_time.length > 0 && (
<div className="card-glass-hover rounded-xl">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-foreground">Scans Over Time (Last 30 Days)</h3>
</div>
<div className="p-6">
<div className="space-y-2">
{stats.scans_over_time.slice(-14).map((day, index) => {
const maxScans = Math.max(...stats.scans_over_time.map(d => d.total_scans))
const width = maxScans > 0 ? (day.total_scans / maxScans) * 100 : 0
return (
<div key={index} className="flex items-center">
<div className="w-20 text-xs text-slate-600 dark:text-slate-400">
{new Date(day.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</div>
<div className="flex-1 ml-4">
<div className="flex items-center">
<div className="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-6 relative">
<div
className="bg-blue-600 dark:bg-blue-500 h-6 rounded-full flex items-center justify-end pr-2"
style={{ width: `${width}%`, minWidth: day.total_scans > 0 ? '40px' : '0' }}
>
{day.total_scans > 0 && (
<span className="text-xs font-medium text-white">
{day.total_scans}
</span>
)}
</div>
</div>
<span className="ml-2 text-xs text-slate-600 dark:text-slate-400 w-16">
{day.match_rate.toFixed(0)}% match
</span>
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,560 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
import { api, type MediaGalleryItem } from '../lib/api'
import { Image as ImageIcon, Video, Loader2, Film, Play, X, GalleryHorizontalEnd } from 'lucide-react'
import { isVideoFile as isVideoFileUtil } from '../lib/utils'
import GalleryLightbox from '../components/GalleryLightbox'
import TimelineScrubber from '../components/paid-content/TimelineScrubber'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
const LIMIT = 200
const TARGET_ROW_HEIGHT = 180
const ROW_GAP = 4
const MONTH_NAMES = ['', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December']
const SHORT_MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const SHORT_DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function formatDayLabel(dateStr: string): string {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const [y, m, d] = dateStr.split('-').map(Number)
const date = new Date(y, m - 1, d)
const diffDays = Math.round((today.getTime() - date.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays === 0) return 'Today'
if (diffDays === 1) return 'Yesterday'
if (diffDays > 1 && diffDays < 7) return DAY_NAMES[date.getDay()]
if (y === now.getFullYear()) return `${SHORT_DAY_NAMES[date.getDay()]}, ${SHORT_MONTH_NAMES[date.getMonth()]} ${d}`
return `${SHORT_DAY_NAMES[date.getDay()]}, ${SHORT_MONTH_NAMES[date.getMonth()]} ${d}, ${y}`
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function getAspectRatio(item: MediaGalleryItem): number {
return (item.width && item.height && item.height > 0) ? item.width / item.height : 1
}
function isVideoItem(item: MediaGalleryItem): boolean {
if (item.media_type === 'video') return true
return isVideoFileUtil(item.filename)
}
interface JustifiedRow {
items: MediaGalleryItem[]
height: number
}
function buildJustifiedRows(
items: MediaGalleryItem[],
containerWidth: number,
targetHeight: number,
gap: number
): JustifiedRow[] {
if (containerWidth <= 0 || items.length === 0) return []
const rows: JustifiedRow[] = []
let currentRow: MediaGalleryItem[] = []
let currentWidthSum = 0
for (const item of items) {
const scaledWidth = getAspectRatio(item) * targetHeight
currentRow.push(item)
currentWidthSum += scaledWidth
const totalGap = (currentRow.length - 1) * gap
if (currentWidthSum + totalGap >= containerWidth) {
const arSum = currentRow.reduce((sum, it) => sum + getAspectRatio(it), 0)
const rowHeight = (containerWidth - totalGap) / arSum
rows.push({ items: [...currentRow], height: Math.min(rowHeight, targetHeight * 1.5) })
currentRow = []
currentWidthSum = 0
}
}
if (currentRow.length > 0) {
rows.push({ items: currentRow, height: targetHeight })
}
return rows
}
const JustifiedSection = memo(function JustifiedSection({
sectionKey,
label,
items,
rows,
onClickItem,
}: {
sectionKey: string
label: string
items: MediaGalleryItem[]
rows: JustifiedRow[]
onClickItem: (item: MediaGalleryItem) => void
}) {
return (
<div id={`gallery-section-${sectionKey}`} className="mb-6">
<div className="flex items-center justify-between mb-2 sticky top-0 z-10 bg-background/95 backdrop-blur-sm py-2">
<h2 className="text-sm font-medium">{label}</h2>
<span className="text-sm text-muted-foreground">{items.length} items</span>
</div>
<div className="flex flex-col" style={{ gap: `${ROW_GAP}px` }}>
{rows.map((row, rowIdx) => (
<div key={rowIdx} className="flex" style={{ gap: `${ROW_GAP}px`, height: `${row.height}px` }}>
{row.items.map(item => {
const itemWidth = getAspectRatio(item) * row.height
const isVideo = isVideoItem(item)
const thumbUrl = api.getMediaThumbnailUrl(item.file_path, isVideo ? 'video' : 'image')
return (
<button
key={item.id}
type="button"
className="relative bg-secondary rounded overflow-hidden cursor-pointer group hover:ring-2 hover:ring-primary transition-all flex-shrink-0"
style={{ width: `${itemWidth}px`, height: `${row.height}px` }}
onClick={() => onClickItem(item)}
>
<img
src={thumbUrl}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-8 h-8 rounded-full bg-black/50 flex items-center justify-center">
<Video className="w-4 h-4 text-white" />
</div>
</div>
)}
{isVideo && item.duration != null && (
<div className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[10px] px-1 py-0.5 rounded">
{formatDuration(item.duration)}
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="text-white text-[10px] truncate">{item.filename}</div>
</div>
</button>
)
})}
</div>
))}
</div>
</div>
)
})
export default function Gallery() {
useBreadcrumb(breadcrumbConfig['/gallery'])
const [contentType, setContentType] = useState<string | undefined>(undefined)
const [lightboxIndex, setLightboxIndex] = useState(-1)
const [slideshowActive, setSlideshowActive] = useState(false)
const [slideshowShuffled, setSlideshowShuffled] = useState(true)
const [shuffleSeed, setShuffleSeed] = useState<number | null>(null)
const [dateFrom, setDateFrom] = useState<string | undefined>(undefined)
const [dateTo, setDateTo] = useState<string | undefined>(undefined)
const [jumpTarget, setJumpTarget] = useState<{ year: number; month: number } | null>(null)
const sentinelRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const roRef = useRef<ResizeObserver | null>(null)
const containerRef = useCallback((node: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect()
roRef.current = null
}
if (node) {
setContainerWidth(node.getBoundingClientRect().width)
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width
if (w != null) setContainerWidth(prev => Math.abs(prev - w) > 1 ? w : prev)
})
ro.observe(node)
roRef.current = ro
}
}, [])
// Gallery media with infinite scroll
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ['gallery-timeline', contentType, dateFrom, dateTo],
queryFn: ({ pageParam = 0 }) =>
api.getMediaGallery({
media_type: contentType,
date_from: dateFrom,
date_to: dateTo,
sort_by: 'post_date',
sort_order: 'desc',
limit: LIMIT,
offset: pageParam * LIMIT,
}),
getNextPageParam: (lastPage, allPages) => {
if (!lastPage?.media || lastPage.media.length < LIMIT) return undefined
return allPages.length
},
initialPageParam: 0,
staleTime: 0,
gcTime: 5 * 60 * 1000,
})
// Date range for scrubber
const { data: dateRanges } = useQuery({
queryKey: ['gallery-timeline-date-range', contentType],
queryFn: () => api.getMediaGalleryDateRange({
media_type: contentType,
}),
})
// Shuffled gallery media (only active during slideshow)
const {
data: shuffledData,
hasNextPage: shuffledHasNextPage,
fetchNextPage: fetchNextShuffledPage,
isFetchingNextPage: isFetchingNextShuffledPage,
} = useInfiniteQuery({
queryKey: ['gallery-shuffled', contentType, shuffleSeed],
queryFn: ({ pageParam = 0 }) =>
api.getMediaGallery({
media_type: contentType,
shuffle: true,
shuffle_seed: shuffleSeed ?? 0,
limit: LIMIT,
offset: pageParam * LIMIT,
}),
getNextPageParam: (lastPage, allPages) => {
if (!lastPage?.media || lastPage.media.length < LIMIT) return undefined
return allPages.length
},
initialPageParam: 0,
enabled: shuffleSeed !== null,
gcTime: 5 * 60 * 1000,
})
// Flatten shuffled pages
const shuffledItems = useMemo(() => {
if (!shuffledData?.pages) return []
const seen = new Set<number>()
return shuffledData.pages.flatMap(page => page.media || []).filter(item => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}, [shuffledData])
// Flatten all pages
const items = useMemo(() => {
if (!data?.pages) return []
const seen = new Set<number>()
return data.pages.flatMap(page => page.media || []).filter(item => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}, [data])
const totalCount = data?.pages?.[0]?.total ?? 0
// Pick the right item list depending on mode
const lightboxItems = (slideshowActive && slideshowShuffled) ? shuffledItems : items
// Group items by YYYY-MM-DD
const sections = useMemo(() => {
const map = new Map<string, MediaGalleryItem[]>()
for (const item of items) {
const dateStr = item.post_date || item.download_date
const d = dateStr ? new Date(dateStr) : null
const key = d && !isNaN(d.getTime())
? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
: 'unknown'
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(item)
}
return Array.from(map.entries()).map(([key, sectionItems]) => {
if (key === 'unknown') return { key, label: 'Unknown Date', year: 0, month: 0, items: sectionItems }
const [y, m] = key.split('-').map(Number)
return { key, label: formatDayLabel(key), year: y, month: m, items: sectionItems }
})
}, [items])
// Pre-compute justified rows
const sectionRowsMap = useMemo(() => {
const map = new Map<string, JustifiedRow[]>()
for (const section of sections) {
map.set(section.key, buildJustifiedRows(section.items, containerWidth, TARGET_ROW_HEIGHT, ROW_GAP))
}
return map
}, [sections, containerWidth])
// Jump to date after data loads
useEffect(() => {
if (!jumpTarget || items.length === 0) return
const prefix = `${jumpTarget.year}-${String(jumpTarget.month).padStart(2, '0')}`
const timer = setTimeout(() => {
const firstDaySection = sections.find(s => s.key.startsWith(prefix))
if (firstDaySection) {
const el = document.getElementById(`gallery-section-${firstDaySection.key}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
setJumpTarget(null)
}, 200)
return () => clearTimeout(timer)
}, [jumpTarget, items.length, sections])
// Infinite scroll observer
useEffect(() => {
if (!sentinelRef.current) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
},
{ rootMargin: '600px' }
)
observer.observe(sentinelRef.current)
return () => observer.disconnect()
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
const openLightbox = useCallback((item: MediaGalleryItem) => {
const idx = items.findIndex(i => i.id === item.id)
if (idx >= 0) {
setSlideshowActive(false)
setLightboxIndex(idx)
}
}, [items])
const startSlideshow = useCallback(() => {
const seed = Math.floor(Math.random() * 2147483647)
setShuffleSeed(seed)
setSlideshowShuffled(true)
setSlideshowActive(true)
}, [])
// Open lightbox when slideshow data is ready
useEffect(() => {
if (!slideshowActive || lightboxIndex !== -1) return
const source = slideshowShuffled ? shuffledItems : items
if (source.length > 0) {
setLightboxIndex(0)
}
}, [slideshowActive, slideshowShuffled, shuffledItems, items, lightboxIndex])
// Handle shuffle toggle from lightbox
const handleShuffleChange = useCallback((enabled: boolean) => {
setSlideshowShuffled(enabled)
if (enabled && shuffleSeed === null) {
setShuffleSeed(Math.floor(Math.random() * 2147483647))
}
setLightboxIndex(0)
}, [shuffleSeed])
const handleLightboxNavigate = useCallback((idx: number) => {
setLightboxIndex(idx)
// Pre-fetch more if near the end
if (slideshowActive && slideshowShuffled) {
if (idx >= shuffledItems.length - 20 && shuffledHasNextPage && !isFetchingNextShuffledPage) {
fetchNextShuffledPage()
}
} else {
if (idx >= items.length - 20 && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}
}, [items.length, hasNextPage, isFetchingNextPage, fetchNextPage, slideshowActive, slideshowShuffled, shuffledItems.length, shuffledHasNextPage, isFetchingNextShuffledPage, fetchNextShuffledPage])
const handleLightboxClose = useCallback(() => {
setLightboxIndex(-1)
setSlideshowActive(false)
}, [])
const handleJumpToDate = useCallback((year: number, month: number) => {
const df = `${year}-${String(month).padStart(2, '0')}-01`
// Last day of the month
const lastDay = new Date(year, month, 0).getDate()
const dt = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
setDateFrom(df)
setDateTo(dt)
setJumpTarget({ year, month })
}, [])
const clearDateFrom = useCallback(() => {
setDateFrom(undefined)
setDateTo(undefined)
setJumpTarget(null)
}, [])
const dateFromLabel = useMemo(() => {
if (!dateFrom) return ''
const [y, m] = dateFrom.split('-').map(Number)
return `${MONTH_NAMES[m]} ${y}`
}, [dateFrom])
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-2 sm:gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<GalleryHorizontalEnd className="w-8 h-8 text-purple-500" />
Gallery
</h1>
<p className="text-muted-foreground text-sm">
{totalCount.toLocaleString()} items
</p>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto justify-center sm:justify-end">
{/* Slideshow button */}
<button
type="button"
onClick={startSlideshow}
disabled={totalCount === 0}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Play className="w-4 h-4" />
Slideshow
</button>
{/* Content type toggle */}
<div className="flex items-center bg-secondary rounded-lg p-1 gap-0.5">
<button
type="button"
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
!contentType ? 'bg-background shadow-sm' : 'hover:bg-background/50'
}`}
onClick={() => setContentType(undefined)}
>
All
</button>
<button
type="button"
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5 ${
contentType === 'image' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
}`}
onClick={() => setContentType('image')}
>
<ImageIcon className="w-4 h-4" />
Images
</button>
<button
type="button"
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5 ${
contentType === 'video' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
}`}
onClick={() => setContentType('video')}
>
<Film className="w-4 h-4" />
Videos
</button>
</div>
</div>
</div>
{/* Date filter chip */}
{dateFrom && (
<div className="flex items-center gap-2">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium">
Showing {dateFromLabel}
<button
type="button"
onClick={clearDateFrom}
className="p-0.5 rounded-full hover:bg-primary/20 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
)}
{/* Empty state */}
{!isLoading && items.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
<ImageIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No media found</p>
</div>
)}
{/* Timeline sections */}
{!isLoading && items.length > 0 && (
<div className="relative">
<div className="md:mr-12" ref={containerRef}>
{sections.map(section => (
<JustifiedSection
key={section.key}
sectionKey={section.key}
label={section.label}
items={section.items}
rows={sectionRowsMap.get(section.key) || []}
onClickItem={openLightbox}
/>
))}
</div>
{/* Timeline Scrubber */}
{dateRanges && dateRanges.length > 0 && (
<TimelineScrubber
ranges={dateRanges}
sections={sections}
onJumpToDate={handleJumpToDate}
/>
)}
</div>
)}
{/* Infinite scroll sentinel */}
<div ref={sentinelRef} className="h-4" />
{isFetchingNextPage && (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Lightbox */}
{lightboxIndex >= 0 && lightboxItems.length > 0 && (
<GalleryLightbox
items={lightboxItems}
currentIndex={lightboxIndex}
onClose={handleLightboxClose}
onNavigate={handleLightboxNavigate}
initialSlideshow={slideshowActive}
initialInterval={3000}
autoPlayVideo={true}
isShuffled={slideshowActive && slideshowShuffled}
onShuffleChange={handleShuffleChange}
totalCount={totalCount}
hasMore={(slideshowActive && slideshowShuffled) ? !!shuffledHasNextPage : !!hasNextPage}
onLoadMore={() => (slideshowActive && slideshowShuffled) ? fetchNextShuffledPage() : fetchNextPage()}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,618 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import {
Activity,
Server,
Database,
Cpu,
HardDrive,
Clock,
AlertCircle,
CheckCircle,
AlertTriangle,
XCircle,
Package,
RefreshCw,
Wifi,
Image as ImageIcon,
Zap,
Play,
HeartPulse,
} from 'lucide-react'
import { api, wsClient, getErrorMessage } from '../lib/api'
import { formatBytes, formatRelativeTime } from '../lib/utils'
import { notificationManager } from '../lib/notificationManager'
function StatusBadge({ status }: { status: 'healthy' | 'warning' | 'error' | 'unknown' }) {
const styles = {
healthy: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400',
warning: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400',
error: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400',
unknown: 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-400',
}
const icons = {
healthy: CheckCircle,
warning: AlertTriangle,
error: XCircle,
unknown: AlertCircle,
}
const Icon = icons[status]
return (
<span className={`inline-flex items-center space-x-1 px-3 py-1 text-xs font-medium rounded-full ${styles[status]}`}>
<Icon className="w-3 h-3" />
<span className="capitalize">{status}</span>
</span>
)
}
function MetricCard({
title,
value,
unit,
status,
icon: Icon,
subtitle,
color = 'blue',
}: {
title: string
value: number | string
unit?: string
status: 'healthy' | 'warning' | 'error' | 'unknown'
icon: any
subtitle?: string
color?: 'blue' | 'green' | 'purple' | 'orange' | 'cyan'
}) {
const statusColors = {
healthy: 'text-emerald-600 dark:text-emerald-400',
warning: 'text-amber-600 dark:text-amber-400',
error: 'text-red-600 dark:text-red-400',
unknown: 'text-slate-600 dark:text-slate-400',
}
const colorStyles: Record<string, { card: string; icon: string }> = {
blue: {
card: 'stat-card-blue shadow-blue-glow',
icon: 'bg-blue-500/20 text-blue-600 dark:text-blue-400',
},
green: {
card: 'stat-card-green shadow-green-glow',
icon: 'bg-emerald-500/20 text-emerald-600 dark:text-emerald-400',
},
purple: {
card: 'stat-card-purple shadow-purple-glow',
icon: 'bg-violet-500/20 text-violet-600 dark:text-violet-400',
},
orange: {
card: 'stat-card-orange shadow-orange-glow',
icon: 'bg-amber-500/20 text-amber-600 dark:text-amber-400',
},
cyan: {
card: 'stat-card-cyan',
icon: 'bg-cyan-500/20 text-cyan-600 dark:text-cyan-400',
},
}
const styles = colorStyles[color]
return (
<div className={`card-glass-hover rounded-xl p-6 border ${styles.card}`}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className={`p-2 rounded-xl ${styles.icon} ${statusColors[status]}`}>
<Icon className="w-5 h-5" />
</div>
<h3 className="font-medium text-foreground">{title}</h3>
</div>
<StatusBadge status={status} />
</div>
<div className="mt-2">
<div className="flex items-baseline space-x-2">
<span className="text-3xl font-semibold text-foreground">{value}</span>
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
</div>
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
)
}
export default function Health() {
useBreadcrumb(breadcrumbConfig['/health'])
const queryClient = useQueryClient()
const [checkingDeps, setCheckingDeps] = useState(false)
const [runningCleanup, setRunningCleanup] = useState(false)
const [cleanupResult, setCleanupResult] = useState<any>(null)
const { data: healthData, refetch: refetchHealth, error: healthError, isError: isHealthError } = useQuery({
queryKey: ['system-health'],
queryFn: () => api.getSystemHealth(),
refetchInterval: 5000,
})
const { data: depsStatus } = useQuery({
queryKey: ['dependencies-status'],
queryFn: () => api.getDependenciesStatus(),
refetchInterval: 60000, // Refresh every minute
})
const checkDepsMutation = useMutation({
mutationFn: () => api.checkDependencies(),
onMutate: () => {
setCheckingDeps(true)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dependencies-status'] })
setTimeout(() => setCheckingDeps(false), 2000)
},
onError: () => {
setCheckingDeps(false)
},
})
const triggerCacheMutation = useMutation({
mutationFn: () => api.triggerCacheBuilder(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['system-health'] })
},
})
// Handle Database Cleanup
const handleDatabaseCleanup = async (dryRun: boolean = true) => {
try {
setRunningCleanup(true)
setCleanupResult(null)
// Start the cleanup
await api.post('/maintenance/cleanup/missing-files', { dry_run: dryRun })
// Poll for status
const pollStatus = async () => {
const response: any = await api.get('/maintenance/cleanup/status')
if (response.status === 'running') {
setTimeout(pollStatus, 2000) // Check again in 2 seconds
} else if (response.status === 'completed') {
setCleanupResult(response)
setRunningCleanup(false)
notificationManager.success(
'Database Cleanup Complete',
dryRun
? `Found ${response.total_missing} missing files that would be removed`
: `Removed ${response.total_removed} missing file references`
)
} else if (response.status === 'failed') {
setRunningCleanup(false)
notificationManager.error('Cleanup Failed', response.error || 'Unknown error')
}
}
setTimeout(pollStatus, 1000)
} catch (error: unknown) {
console.error('Failed to run cleanup:', error)
notificationManager.error('Cleanup Failed', getErrorMessage(error))
setRunningCleanup(false)
}
}
// Listen for WebSocket updates
useEffect(() => {
const unsubscribe = wsClient.on('*', () => {
refetchHealth()
})
return unsubscribe
}, [refetchHealth])
// Display error if query fails
if (isHealthError) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3 mb-2">
<HeartPulse className="w-8 h-8 text-pink-500" />
System Health
</h1>
<p className="text-slate-600 dark:text-slate-400">Monitor system status and service health</p>
</div>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
<h3 className="text-lg font-semibold text-red-900 dark:text-red-100 mb-2">Error Loading Health Data</h3>
<p className="text-red-700 dark:text-red-300 mb-4">{healthError instanceof Error ? healthError.message : String(healthError)}</p>
<button
onClick={() => refetchHealth()}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
>
Retry
</button>
</div>
</div>
)
}
const getStatusFromValue = (value: number, warningThreshold: number, errorThreshold: number): 'healthy' | 'warning' | 'error' => {
if (value >= errorThreshold) return 'error'
if (value >= warningThreshold) return 'warning'
return 'healthy'
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<HeartPulse className="w-8 h-8 text-pink-500" />
System Health
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Monitor system status and service health
</p>
</div>
{/* Overall Status */}
<div className="card-glass-hover rounded-xl p-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 rounded-xl bg-emerald-500/10">
<Activity className="w-8 h-8 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">System Status</h3>
<p className="text-sm text-muted-foreground">
Last updated: {healthData?.timestamp ? formatRelativeTime(healthData.timestamp) : 'Never'}
</p>
</div>
</div>
<StatusBadge status={healthData?.overall_status || 'unknown'} />
</div>
</div>
{/* Service Status */}
<div>
<h3 className="text-lg font-semibold text-foreground mb-4">Services</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="card-glass-hover rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<Server className="w-4 h-4 text-muted-foreground" />
<span className="font-medium text-foreground">API Server</span>
</div>
<StatusBadge status={healthData?.services?.api || 'unknown'} />
</div>
<p className="text-sm text-muted-foreground">FastAPI backend</p>
</div>
<div className="card-glass-hover rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<Database className="w-4 h-4 text-muted-foreground" />
<span className="font-medium text-foreground">Database</span>
</div>
<StatusBadge status={healthData?.services?.database || 'unknown'} />
</div>
<p className="text-sm text-muted-foreground">
{healthData?.db_performance?.query_time_ms ? `Query: ${healthData.db_performance.query_time_ms}ms` : 'SQLite connection'}
</p>
</div>
<div className="card-glass-hover rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="font-medium text-foreground">Scheduler</span>
</div>
<StatusBadge status={healthData?.services?.scheduler || 'unknown'} />
</div>
<p className="text-sm text-muted-foreground">
{healthData?.scheduler_info?.active_tasks || 0} active tasks
</p>
</div>
<div className="card-glass-hover rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<Wifi className="w-4 h-4 text-muted-foreground" />
<span className="font-medium text-foreground">WebSocket</span>
</div>
<StatusBadge status={healthData?.services?.websocket || 'unknown'} />
</div>
<p className="text-sm text-muted-foreground">
{healthData?.websocket_info?.active_connections || 0} active connections
</p>
</div>
<div className="card-glass-hover rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<ImageIcon className="w-4 h-4 text-muted-foreground" />
<span className="font-medium text-foreground">Cache Builder</span>
</div>
<StatusBadge status={healthData?.services?.cache_builder || 'unknown'} />
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{healthData?.cache_info?.files_cached || 0} files cached
</p>
<button
onClick={() => triggerCacheMutation.mutate()}
disabled={triggerCacheMutation.isPending}
className="p-1 text-primary hover:text-primary/80 disabled:opacity-50"
title="Trigger cache rebuild"
>
<Play className={`w-4 h-4 ${triggerCacheMutation.isPending ? 'animate-pulse' : ''}`} />
</button>
</div>
</div>
<div className="card-glass-hover rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<Zap className="w-4 h-4 text-muted-foreground" />
<span className="font-medium text-foreground">Process</span>
</div>
<span className="text-xs px-2 py-1 bg-secondary rounded-lg text-muted-foreground">
{healthData?.process_info?.threads || 0} threads
</span>
</div>
<p className="text-sm text-muted-foreground">
{healthData?.process_info?.open_files || 0} open files
</p>
</div>
</div>
</div>
{/* System Metrics */}
<div>
<h3 className="text-lg font-semibold text-foreground mb-4">System Resources</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
title="CPU Usage"
value={healthData?.system?.cpu_percent?.toFixed(1) || '0'}
unit="%"
status={getStatusFromValue(healthData?.system?.cpu_percent || 0, 70, 90)}
icon={Cpu}
subtitle={`${healthData?.system?.cpu_count || 0} cores`}
color="blue"
/>
<MetricCard
title="Memory Usage"
value={healthData?.system?.memory_percent?.toFixed(1) || '0'}
unit="%"
status={getStatusFromValue(healthData?.system?.memory_percent || 0, 80, 95)}
icon={Activity}
subtitle={`${formatBytes(healthData?.system?.memory_used || 0)} / ${formatBytes(healthData?.system?.memory_total || 0)}`}
color="purple"
/>
<MetricCard
title="Disk Usage"
value={healthData?.system?.disk_percent?.toFixed(1) || '0'}
unit="%"
status={getStatusFromValue(healthData?.system?.disk_percent || 0, 80, 95)}
icon={HardDrive}
subtitle={`${formatBytes(healthData?.system?.disk_free || 0)} free`}
color="orange"
/>
<MetricCard
title="Uptime"
value={healthData?.system?.uptime_hours?.toFixed(0) || '0'}
unit="hours"
status="healthy"
icon={Clock}
subtitle="System running"
color="green"
/>
</div>
</div>
{/* Download Activity */}
{healthData?.download_activity && (
<div>
<h3 className="text-lg font-semibold text-foreground mb-4">Download Activity</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="card-glass-hover rounded-xl p-4">
<div className="text-sm text-muted-foreground">Last 24 Hours</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{healthData.download_activity.last_24h}
</div>
</div>
<div className="card-glass-hover rounded-xl p-4">
<div className="text-sm text-muted-foreground">Last 7 Days</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{healthData.download_activity.last_7d}
</div>
</div>
<div className="card-glass-hover rounded-xl p-4">
<div className="text-sm text-muted-foreground">Last 30 Days</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{healthData.download_activity.last_30d}
</div>
</div>
</div>
</div>
)}
{/* Dependencies */}
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center space-x-2">
<Package className="w-5 h-5" />
<span>Dependencies</span>
</h3>
<button
onClick={() => checkDepsMutation.mutate()}
disabled={checkingDeps || checkDepsMutation.isPending}
className="inline-flex items-center px-4 py-2 bg-primary hover:bg-primary/90 text-primary-foreground rounded-xl disabled:opacity-50 transition-colors btn-hover-lift"
title="Check for updates and install if available"
>
<RefreshCw className={`w-4 h-4 mr-2 ${checkingDeps ? 'animate-spin' : ''}`} />
{checkingDeps ? 'Updating...' : 'Check & Update'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{['flaresolverr', 'playwright', 'yt_dlp', 'python_packages'].map((component) => {
const componentData = depsStatus?.components?.[component]
const componentNames: Record<string, string> = {
flaresolverr: 'FlareSolverr',
playwright: 'Playwright',
yt_dlp: 'yt-dlp',
python_packages: 'Python Packages'
}
const status = componentData?.status || 'unknown'
const statusColors = {
updated: 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400',
current: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400',
unknown: 'bg-secondary text-muted-foreground'
}
return (
<div key={component} className="card-glass-hover rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<div className="font-medium text-foreground">
{componentNames[component]}
</div>
<span className={`px-2 py-1 text-xs font-medium rounded-lg ${statusColors[status as keyof typeof statusColors] || statusColors.unknown}`}>
{status}
</span>
</div>
{componentData?.last_update && (
<div className="text-xs text-muted-foreground">
Updated: {formatRelativeTime(componentData.last_update)}
</div>
)}
{componentData?.last_check && !componentData?.last_update && (
<div className="text-xs text-muted-foreground">
Checked: {formatRelativeTime(componentData.last_check)}
</div>
)}
{component === 'python_packages' && (componentData as any)?.updated_packages?.length > 0 && (
<div className="text-xs text-muted-foreground mt-1">
{(componentData as any).updated_packages.slice(0, 3).join(', ')}
{(componentData as any).updated_packages.length > 3 && ` +${(componentData as any).updated_packages.length - 3} more`}
</div>
)}
</div>
)
})}
</div>
{depsStatus?.last_check && (
<div className="mt-3 text-sm text-muted-foreground">
Last checked: {formatRelativeTime(depsStatus.last_check)}
</div>
)}
</div>
{/* Database Maintenance */}
<div>
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center space-x-2">
<Database className="w-5 h-5" />
<span>Database Maintenance</span>
</h3>
<div className="card-glass-hover rounded-xl p-6">
{/* Info Banner */}
<div className="mb-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="text-blue-900 dark:text-blue-100 font-medium mb-1">
Automatic Cleanup Schedule
</p>
<p className="text-blue-700 dark:text-blue-300">
Database cleanup runs automatically every night at 3:00 AM to remove references to deleted files.
You can also run a manual scan below.
</p>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-3 mb-4">
<button
onClick={() => handleDatabaseCleanup(true)}
disabled={runningCleanup}
className="inline-flex items-center px-4 py-2 bg-secondary hover:bg-secondary/80 text-foreground rounded-xl disabled:opacity-50 transition-colors btn-hover-lift"
>
<RefreshCw className={`w-4 h-4 mr-2 ${runningCleanup ? 'animate-spin' : ''}`} />
{runningCleanup ? 'Scanning...' : 'Scan (Dry Run)'}
</button>
<button
onClick={() => handleDatabaseCleanup(false)}
disabled={runningCleanup}
className="inline-flex items-center px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl disabled:opacity-50 transition-colors btn-hover-lift"
>
<Database className={`w-4 h-4 mr-2 ${runningCleanup ? 'animate-spin' : ''}`} />
{runningCleanup ? 'Cleaning...' : 'Clean Now'}
</button>
</div>
{/* Results Display */}
{cleanupResult && (
<div className="border border-border rounded-lg p-4 bg-secondary/50">
<h4 className="font-medium text-foreground mb-3">Last Scan Results</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Status:</span>
<span className="font-medium text-foreground capitalize">{cleanupResult.status}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Total Missing:</span>
<span className="font-medium text-foreground">{cleanupResult.total_missing}</span>
</div>
{cleanupResult.total_removed !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Total Removed:</span>
<span className="font-medium text-red-600 dark:text-red-400">{cleanupResult.total_removed}</span>
</div>
)}
{cleanupResult.tables && Object.keys(cleanupResult.tables).length > 0 && (
<div className="mt-3 pt-3 border-t border-border">
<div className="text-muted-foreground mb-2">By Table:</div>
<div className="space-y-1">
{Object.entries(cleanupResult.tables).map(([table, count]: [string, any]) => (
<div key={table} className="flex justify-between items-center pl-2">
<span className="text-xs text-muted-foreground">{table}:</span>
<span className="text-xs font-medium text-foreground">{count}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* Recent Errors */}
{healthData?.recent_errors && healthData.recent_errors.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center space-x-2">
<AlertCircle className="w-5 h-5 text-red-500" />
<span>Recent Errors</span>
</h3>
<div className="card-glass-hover rounded-xl overflow-hidden">
<div className="divide-y divide-border">
{healthData.recent_errors.map((error: any, index: number) => (
<div key={index} className="p-4 hover:bg-secondary/50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-foreground">{error.message}</p>
{error.details && (
<p className="mt-1 text-sm text-muted-foreground">{error.details}</p>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(error.timestamp)}
</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,766 @@
import { useState, useEffect, useMemo, forwardRef, useImperativeHandle } from 'react'
import {
Plus,
X,
Search,
ChevronDown,
ChevronRight,
AlertCircle,
Loader2,
ClipboardPaste,
} from 'lucide-react'
import { api } from '../lib/api'
import { notificationManager } from '../lib/notificationManager'
interface Account {
username: string
posts: boolean
stories: boolean
reels: boolean
tagged: boolean
}
interface ContentTypeConfig {
enabled: boolean
days_back: number
destination_path: string
}
interface PhraseSearch {
enabled: boolean
download_all: boolean
phrases: string[]
case_sensitive: boolean
match_all: boolean
}
interface UnifiedConfig {
enabled: boolean
check_interval_hours: number
run_at_start: boolean
user_delay_seconds: number
scraper_assignment: Record<string, string>
content_types: Record<string, ContentTypeConfig>
accounts: Account[]
phrase_search: PhraseSearch
scraper_settings: Record<string, Record<string, any>>
}
const CONTENT_TYPES = ['posts', 'stories', 'reels', 'tagged'] as const
type ContentType = typeof CONTENT_TYPES[number]
const CONTENT_TYPE_LABELS: Record<string, string> = {
posts: 'Posts',
stories: 'Stories',
reels: 'Reels',
tagged: 'Tagged',
}
export interface InstagramUnifiedRef {
save: () => Promise<void>
isSaving: boolean
}
const InstagramUnified = forwardRef<InstagramUnifiedRef>(function InstagramUnified(_props, ref) {
const [config, setConfig] = useState<UnifiedConfig | null>(null)
const [hiddenModules, setHiddenModules] = useState<string[]>([])
const [scraperCapabilities, setScraperCapabilities] = useState<Record<string, Record<string, boolean>>>({})
const [scraperLabels, setScraperLabels] = useState<Record<string, string>>({})
const [isMigrated, setIsMigrated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [showPasteModal, setShowPasteModal] = useState(false)
const [pasteText, setPasteText] = useState('')
const [phraseSearchOpen, setPhraseSearchOpen] = useState(false)
const [scraperSettingsOpen, setScraperSettingsOpen] = useState(false)
const [newUsername, setNewUsername] = useState('')
useImperativeHandle(ref, () => ({
save: saveConfig,
isSaving,
}), [config, isSaving])
useEffect(() => {
loadConfig()
}, [])
const loadConfig = async () => {
try {
setIsLoading(true)
const data = await api.getInstagramUnifiedConfig()
setConfig(data.config as unknown as UnifiedConfig)
setHiddenModules(data.hidden_modules)
setScraperCapabilities(data.scraper_capabilities)
setScraperLabels(data.scraper_labels)
setIsMigrated(data.migrated)
} catch (err) {
console.error('Failed to load config:', err)
} finally {
setIsLoading(false)
}
}
const saveConfig = async () => {
if (!config) return
setIsSaving(true)
try {
await api.updateInstagramUnifiedConfig(config as unknown as Record<string, any>)
notificationManager.settingsSaved('Instagram')
setIsMigrated(false)
} catch (err: any) {
notificationManager.settingsSaveError(err, 'Instagram')
} finally {
setIsSaving(false)
}
}
const updateConfig = (updates: Partial<UnifiedConfig>) => {
if (!config) return
setConfig({ ...config, ...updates })
}
const updateContentType = (ct: string, updates: Partial<ContentTypeConfig>) => {
if (!config) return
setConfig({
...config,
content_types: {
...config.content_types,
[ct]: { ...config.content_types[ct], ...updates }
}
})
}
const updateScraperAssignment = (ct: string, scraper: string) => {
if (!config) return
setConfig({
...config,
scraper_assignment: { ...config.scraper_assignment, [ct]: scraper }
})
}
const updateAccount = (index: number, updates: Partial<Account>) => {
if (!config) return
const newAccounts = [...config.accounts]
newAccounts[index] = { ...newAccounts[index], ...updates }
setConfig({ ...config, accounts: newAccounts })
}
const removeAccount = (index: number) => {
if (!config) return
const newAccounts = config.accounts.filter((_, i) => i !== index)
setConfig({ ...config, accounts: newAccounts })
}
const addAccount = (username: string) => {
if (!config || !username.trim()) return
const trimmed = username.trim().toLowerCase().replace(/^@/, '')
if (config.accounts.some(a => a.username === trimmed)) return
setConfig({
...config,
accounts: [...config.accounts, { username: trimmed, posts: true, stories: true, reels: true, tagged: false }]
})
}
const handleAddUsername = () => {
if (newUsername.trim()) {
addAccount(newUsername)
setNewUsername('')
}
}
const handlePaste = () => {
const usernames = pasteText
.split(/[\n,;]+/)
.map(u => u.trim().toLowerCase().replace(/^@/, ''))
.filter(u => u && !config?.accounts.some(a => a.username === u))
if (config && usernames.length > 0) {
const newAccounts = usernames.map(username => ({
username,
posts: true,
stories: true,
reels: true,
tagged: false,
}))
setConfig({ ...config, accounts: [...config.accounts, ...newAccounts] })
}
setPasteText('')
setShowPasteModal(false)
}
const updatePhraseSearch = (updates: Partial<PhraseSearch>) => {
if (!config) return
setConfig({
...config,
phrase_search: { ...config.phrase_search, ...updates }
})
}
const updateScraperSettings = (scraper: string, updates: Record<string, any>) => {
if (!config) return
setConfig({
...config,
scraper_settings: {
...config.scraper_settings,
[scraper]: { ...config.scraper_settings[scraper], ...updates }
}
})
}
// Get available scrapers for a content type (supports it + not hidden)
const getAvailableScrapers = (ct: string): string[] => {
return Object.entries(scraperCapabilities)
.filter(([key, caps]) => caps[ct] && !hiddenModules.includes(key))
.map(([key]) => key)
}
// Filtered accounts based on search
const filteredAccounts = useMemo(() => {
if (!config) return []
if (!searchQuery) return config.accounts
const q = searchQuery.toLowerCase()
return config.accounts.filter(a => a.username.toLowerCase().includes(q))
}, [config?.accounts, searchQuery])
// Filtered indices for select-all operations
const filteredIndices = useMemo(() => {
if (!config) return []
if (!searchQuery) return config.accounts.map((_, i) => i)
const q = searchQuery.toLowerCase()
return config.accounts.reduce<number[]>((acc, a, i) => {
if (a.username.toLowerCase().includes(q)) acc.push(i)
return acc
}, [])
}, [config?.accounts, searchQuery])
// Column select-all state
const getColumnState = (ct: ContentType): 'all' | 'none' | 'some' => {
if (filteredAccounts.length === 0) return 'none'
const checked = filteredAccounts.filter(a => a[ct]).length
if (checked === filteredAccounts.length) return 'all'
if (checked === 0) return 'none'
return 'some'
}
const toggleColumn = (ct: ContentType) => {
if (!config) return
const state = getColumnState(ct)
const newValue = state !== 'all'
const newAccounts = [...config.accounts]
filteredIndices.forEach(i => {
newAccounts[i] = { ...newAccounts[i], [ct]: newValue }
})
setConfig({ ...config, accounts: newAccounts })
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-3" />
<span className="text-slate-600 dark:text-slate-400">Loading Instagram configuration...</span>
</div>
)
}
if (!config) {
return (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
Failed to load configuration
</div>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="card-glass rounded-xl overflow-hidden">
<div className="bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 border-b border-slate-200 dark:border-slate-700 px-5 py-4 flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-purple-600 to-pink-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">IG</span>
</div>
<div>
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Instagram Configuration</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">Unified scraper management</p>
</div>
</div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={config.enabled}
onChange={e => updateConfig({ enabled: e.target.checked })}
className="w-4 h-4 rounded border-slate-300"
/>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Enabled</span>
</label>
</div>
{/* Migration banner */}
{isMigrated && (
<div className="px-5 py-3 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800">
<div className="flex items-center space-x-2 text-sm text-amber-700 dark:text-amber-300">
<AlertCircle className="w-4 h-4" />
<span>Configuration migrated from legacy scrapers. Review and save to apply.</span>
</div>
</div>
)}
{/* Global settings */}
<div className="p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Check Interval (hours)</label>
<input
type="number"
min="1"
value={config.check_interval_hours}
onChange={e => updateConfig({ check_interval_hours: parseInt(e.target.value) || 8 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">User Delay (seconds)</label>
<input
type="number"
min="0"
value={config.user_delay_seconds}
onChange={e => updateConfig({ user_delay_seconds: parseInt(e.target.value) || 20 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-purple-500"
/>
</div>
<div className="flex items-end">
<label className="flex items-center space-x-2 cursor-pointer pb-2">
<input
type="checkbox"
checked={config.run_at_start}
onChange={e => updateConfig({ run_at_start: e.target.checked })}
className="w-4 h-4 rounded border-slate-300"
/>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Run at Start</span>
</label>
</div>
</div>
</div>
</div>
{/* Scraper Assignment */}
<div className="card-glass rounded-xl overflow-hidden">
<div className="px-5 py-3 border-b border-slate-200 dark:border-slate-700">
<h4 className="font-medium text-slate-900 dark:text-slate-100 text-sm uppercase tracking-wide">Scraper Assignment</h4>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Choose which scraper handles each content type</p>
</div>
<div className="p-5">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{CONTENT_TYPES.map(ct => {
const available = getAvailableScrapers(ct)
const currentScraper = config.scraper_assignment[ct]
return (
<div key={ct}>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{CONTENT_TYPE_LABELS[ct]}
</label>
<select
value={currentScraper || ''}
onChange={e => updateScraperAssignment(ct, e.target.value)}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-purple-500 text-sm"
>
{available.map(s => (
<option key={s} value={s}>{scraperLabels[s] || s}</option>
))}
{/* Show current even if hidden */}
{currentScraper && !available.includes(currentScraper) && (
<option value={currentScraper}>{scraperLabels[currentScraper] || currentScraper} (hidden)</option>
)}
</select>
</div>
)
})}
</div>
</div>
</div>
{/* Content Settings */}
<div className="card-glass rounded-xl overflow-hidden">
<div className="px-5 py-3 border-b border-slate-200 dark:border-slate-700">
<h4 className="font-medium text-slate-900 dark:text-slate-100 text-sm uppercase tracking-wide">Content Settings</h4>
</div>
<div className="p-5 space-y-3">
{CONTENT_TYPES.map(ct => {
const ctConfig = config.content_types[ct] || { enabled: false, days_back: 7, destination_path: '' }
return (
<div key={ct} className="flex flex-wrap items-center gap-3 p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50">
<label className="flex items-center space-x-2 cursor-pointer min-w-[100px]">
<input
type="checkbox"
checked={ctConfig.enabled}
onChange={e => updateContentType(ct, { enabled: e.target.checked })}
className="w-4 h-4 rounded border-slate-300"
/>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{CONTENT_TYPE_LABELS[ct]}</span>
</label>
<div className="flex items-center space-x-2">
<label className="text-xs text-slate-500 dark:text-slate-400">Days:</label>
<input
type="number"
min="1"
value={ctConfig.days_back}
onChange={e => updateContentType(ct, { days_back: parseInt(e.target.value) || 7 })}
className="w-16 px-2 py-1 border border-slate-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
<div className="flex-1 min-w-[200px]">
<input
type="text"
value={ctConfig.destination_path}
onChange={e => updateContentType(ct, { destination_path: e.target.value })}
className="w-full px-2 py-1 border border-slate-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 font-mono text-xs focus:ring-2 focus:ring-purple-500"
placeholder={`/opt/immich/md/social media/instagram/${ct}`}
/>
</div>
</div>
)
})}
</div>
</div>
{/* Accounts */}
<div className="card-glass rounded-xl overflow-hidden">
<div className="px-5 py-3 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-2">
<div>
<h4 className="font-medium text-slate-900 dark:text-slate-100 text-sm uppercase tracking-wide">
Accounts ({config.accounts.length})
</h4>
</div>
<div className="flex items-center space-x-2">
<div className="relative">
<Search className="w-4 h-4 absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search..."
className="w-40 pl-8 pr-3 py-1.5 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
<div className="flex items-center space-x-1">
<input
type="text"
value={newUsername}
onChange={e => setNewUsername(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddUsername() }}
placeholder="Add username"
className="w-32 px-2 py-1.5 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 text-sm focus:ring-2 focus:ring-purple-500"
/>
<button
onClick={handleAddUsername}
className="p-1.5 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
title="Add account"
>
<Plus className="w-4 h-4" />
</button>
</div>
<button
onClick={() => setShowPasteModal(true)}
className="flex items-center space-x-1 px-2 py-1.5 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 rounded-lg text-sm transition-colors"
title="Paste list of usernames"
>
<ClipboardPaste className="w-4 h-4" />
<span className="hidden sm:inline">Paste</span>
</button>
</div>
</div>
{/* Column select-all */}
<div className="px-5 py-2 border-b border-slate-200 dark:border-slate-700 flex items-center gap-2 bg-slate-50 dark:bg-slate-800/50">
<span className="text-xs text-slate-500 dark:text-slate-400 mr-2">Toggle all:</span>
{CONTENT_TYPES.map(ct => {
const state = getColumnState(ct)
return (
<button
key={ct}
onClick={() => toggleColumn(ct)}
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
state === 'all'
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
: state === 'some'
? 'bg-purple-50 dark:bg-purple-900/15 text-purple-500 dark:text-purple-400'
: 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400'
}`}
>
{state === 'all' ? '\u2611' : state === 'some' ? '\u25A3' : '\u2610'} {CONTENT_TYPE_LABELS[ct]}
</button>
)
})}
</div>
{/* Account list */}
<div className="max-h-[500px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<tr>
<th className="text-left px-5 py-2 text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Username</th>
{CONTENT_TYPES.map(ct => (
<th key={ct} className="text-center px-2 py-2 text-xs font-medium text-slate-500 dark:text-slate-400 uppercase w-16">{CONTENT_TYPE_LABELS[ct]}</th>
))}
<th className="text-center px-2 py-2 w-10"></th>
</tr>
</thead>
<tbody>
{filteredAccounts.map((account) => {
const realIndex = config.accounts.indexOf(account)
return (
<tr key={account.username} className="border-b border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-800/30">
<td className="px-5 py-1.5 font-mono text-slate-900 dark:text-slate-100">{account.username}</td>
{CONTENT_TYPES.map(ct => (
<td key={ct} className="text-center px-2 py-1.5">
<input
type="checkbox"
checked={account[ct]}
onChange={e => updateAccount(realIndex, { [ct]: e.target.checked })}
className="w-4 h-4 rounded border-slate-300 text-purple-600 focus:ring-purple-500"
/>
</td>
))}
<td className="text-center px-2 py-1.5">
<button
onClick={() => removeAccount(realIndex)}
className="p-0.5 text-slate-400 hover:text-red-500 transition-colors"
title="Remove account"
>
<X className="w-4 h-4" />
</button>
</td>
</tr>
)
})}
</tbody>
</table>
{filteredAccounts.length === 0 && searchQuery && (
<div className="text-center py-6 text-slate-500 dark:text-slate-400 text-sm">
No accounts matching "{searchQuery}"
</div>
)}
</div>
</div>
{/* Phrase Search (collapsible) */}
<div className="card-glass rounded-xl overflow-hidden">
<button
onClick={() => setPhraseSearchOpen(!phraseSearchOpen)}
className="w-full px-5 py-3 flex items-center justify-between hover:bg-slate-50 dark:hover:bg-slate-800/30 transition-colors"
>
<div className="flex items-center space-x-2">
{phraseSearchOpen ? <ChevronDown className="w-4 h-4 text-slate-400" /> : <ChevronRight className="w-4 h-4 text-slate-400" />}
<h4 className="font-medium text-slate-900 dark:text-slate-100 text-sm">Phrase Search</h4>
{config.phrase_search.enabled && (
<span className="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs rounded-full">Active</span>
)}
</div>
</button>
{phraseSearchOpen && (
<div className="p-5 border-t border-slate-200 dark:border-slate-700 space-y-4">
<div className="flex items-center space-x-4">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={config.phrase_search.enabled}
onChange={e => updatePhraseSearch({ enabled: e.target.checked })}
className="w-4 h-4 rounded border-slate-300"
/>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Enabled</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={config.phrase_search.download_all}
onChange={e => updatePhraseSearch({ download_all: e.target.checked })}
className="w-4 h-4 rounded border-slate-300"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">Download All Matches</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={config.phrase_search.case_sensitive}
onChange={e => updatePhraseSearch({ case_sensitive: e.target.checked })}
className="w-4 h-4 rounded border-slate-300"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">Case Sensitive</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={config.phrase_search.match_all}
onChange={e => updatePhraseSearch({ match_all: e.target.checked })}
className="w-4 h-4 rounded border-slate-300"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">Match All</span>
</label>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Search Phrases (one per line)</label>
<textarea
value={config.phrase_search.phrases.join('\n')}
onChange={e => updatePhraseSearch({ phrases: e.target.value.split('\n').filter(p => p.trim()) })}
rows={4}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 font-mono text-sm focus:ring-2 focus:ring-purple-500"
placeholder="@evalongoria&#10;eva longoria"
/>
</div>
</div>
)}
</div>
{/* Scraper Settings (collapsible) */}
<div className="card-glass rounded-xl overflow-hidden">
<button
onClick={() => setScraperSettingsOpen(!scraperSettingsOpen)}
className="w-full px-5 py-3 flex items-center justify-between hover:bg-slate-50 dark:hover:bg-slate-800/30 transition-colors"
>
<div className="flex items-center space-x-2">
{scraperSettingsOpen ? <ChevronDown className="w-4 h-4 text-slate-400" /> : <ChevronRight className="w-4 h-4 text-slate-400" />}
<h4 className="font-medium text-slate-900 dark:text-slate-100 text-sm">Scraper Settings</h4>
<span className="text-xs text-slate-500 dark:text-slate-400">(auth/credentials per scraper)</span>
</div>
</button>
{scraperSettingsOpen && (
<div className="p-5 border-t border-slate-200 dark:border-slate-700 space-y-6">
{/* ImgInn */}
<div>
<h5 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-2">ImgInn</h5>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Cookie File</label>
<input
type="text"
value={config.scraper_settings.imginn?.cookie_file || ''}
onChange={e => updateScraperSettings('imginn', { cookie_file: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 font-mono text-sm focus:ring-2 focus:ring-purple-500"
placeholder="cookies/imginn_cookies.json"
/>
</div>
</div>
{/* Toolzu */}
<div>
<h5 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-2">Toolzu</h5>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Email</label>
<input
type="text"
value={config.scraper_settings.toolzu?.email || ''}
onChange={e => updateScraperSettings('toolzu', { email: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Password</label>
<input
type="password"
value={config.scraper_settings.toolzu?.password || ''}
onChange={e => updateScraperSettings('toolzu', { password: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Cookie File</label>
<input
type="text"
value={config.scraper_settings.toolzu?.cookie_file || ''}
onChange={e => updateScraperSettings('toolzu', { cookie_file: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 font-mono text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
</div>
{/* InstaLoader */}
<div>
<h5 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-2">InstaLoader</h5>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Login Username</label>
<input
type="text"
value={config.scraper_settings.instagram?.username || ''}
onChange={e => updateScraperSettings('instagram', { username: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Password</label>
<input
type="password"
value={config.scraper_settings.instagram?.password || ''}
onChange={e => updateScraperSettings('instagram', { password: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">TOTP Secret</label>
<input
type="text"
value={config.scraper_settings.instagram?.totp_secret || ''}
onChange={e => updateScraperSettings('instagram', { totp_secret: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 font-mono text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">Session File</label>
<input
type="text"
value={config.scraper_settings.instagram?.session_file || ''}
onChange={e => updateScraperSettings('instagram', { session_file: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 font-mono text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
</div>
</div>
)}
</div>
{/* Paste Modal */}
{showPasteModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md mx-4 p-5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Paste Usernames</h3>
<button onClick={() => { setShowPasteModal(false); setPasteText('') }} className="text-slate-400 hover:text-slate-600">
<X className="w-5 h-5" />
</button>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400">
Paste usernames separated by commas, semicolons, or new lines.
</p>
<textarea
value={pasteText}
onChange={e => setPasteText(e.target.value)}
rows={8}
autoFocus
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 font-mono text-sm focus:ring-2 focus:ring-purple-500"
placeholder="username1&#10;username2&#10;username3"
/>
<div className="flex justify-end space-x-2">
<button
onClick={() => { setShowPasteModal(false); setPasteText('') }}
className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors"
>
Cancel
</button>
<button
onClick={handlePaste}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Add Usernames
</button>
</div>
</div>
</div>
)}
</div>
)
})
export default InstagramUnified

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Lock, Eye, EyeOff, AlertCircle } from 'lucide-react'
import { api } from '../lib/api'
export default function Login() {
const navigate = useNavigate()
// Login form state
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [rememberMe, setRememberMe] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const result: any = await api.login(username, password, rememberMe)
// Check if 2FA is required
if (result.require2FA) {
const availableMethods = result.availableMethods || []
// Navigate to separate 2FA page
navigate('/2fa', {
state: {
username: result.username || username,
availableMethods,
rememberMe
}
})
} else if (result.success) {
// No 2FA - complete login
api.setAuthToken(result.token, result.user, result.sessionId)
navigate('/')
} else {
setError(result.error || 'Login failed. Please try again.')
}
} catch (err: any) {
setError(err.message || 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 dark:from-gray-900 dark:via-slate-900 dark:to-gray-900 p-4">
<div className="w-full max-w-md bg-card/80 backdrop-blur-xl border border-white/20 dark:border-white/10 rounded-2xl shadow-2xl p-8">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-primary to-purple-600 rounded-full mx-auto mb-4 flex items-center justify-center shadow-lg shadow-primary/25">
<Lock className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
Media Downloader
</h2>
<p className="text-muted-foreground">
Sign in to your account
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<form onSubmit={handleLogin} className="space-y-6">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-2">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full px-4 py-3 border border-border rounded-xl focus:ring-2 focus:ring-primary bg-background text-foreground"
placeholder="Enter your username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-3 pr-12 border border-border rounded-xl focus:ring-2 focus:ring-primary bg-background text-foreground"
placeholder="Enter your password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div className="flex items-center">
<input
id="remember-me"
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 text-primary bg-background border-border rounded focus:ring-primary focus:ring-2"
/>
<label htmlFor="remember-me" className="ml-2 text-sm text-muted-foreground">
Remember me
</label>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-primary to-purple-600 hover:from-primary/90 hover:to-purple-600/90 disabled:from-primary/60 disabled:to-purple-600/60 text-primary-foreground font-medium rounded-xl transition-all shadow-lg hover:shadow-xl hover:shadow-primary/20"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="mt-6 text-center text-sm text-muted-foreground">
<p>v13.13.1</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,745 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import { Terminal, RefreshCw, Download, Filter, ChevronDown, Wifi, WifiOff, AlertTriangle, X, ScrollText } from 'lucide-react'
import { api } from '../lib/api'
export default function Logs() {
useBreadcrumb(breadcrumbConfig['/logs'])
const [searchParams, setSearchParams] = useSearchParams()
// Default components to merge
const defaultComponents = [
'activitystatus',
'appearances',
'cachemanager',
'celebritydiscover',
'celebritynightly',
'celebrityresolutionfetch',
'coppermine',
'dashboard',
'database',
'dateutils',
'dependencyupdater',
'discovery',
'downloadermonitor',
'downloadmanager',
'duomanager',
'easynewsclient',
'easynewsmonitor',
'embeddinggenerator',
'facerecognition',
'filesrouter',
'forum',
'forumadapter',
'immichface',
'instagram',
'instagramclient',
'mediaidentifier',
'maintenance',
'media_downloader',
'movemanager',
'notifier',
'paidcontent',
'passkeymanager',
'perceptual_duplicate_detector',
'plex',
'podchaser',
'privategallerycrypto',
'redditmonitor',
'scheduler',
'semanticsearch',
'servicehealthmonitor',
'settingsmanager',
'snapchatclient',
'snapchatdirect',
'taddy',
'thumbnailcachebuilder',
'tiktok',
'tmdb',
'totpmanager',
'tvdb',
'twofactorauth',
'universalvideodownloader',
'youtubemonitor',
'cloudbackup',
'cloudflare.fastdl',
'cloudflare.imginn',
'cloudflare.imginn-stories',
'taskcheckpoint'
]
// Fetch config for hidden modules
const { data: logConfig } = useQuery({
queryKey: ['config'],
queryFn: () => api.getConfig(),
staleTime: 30000,
})
const hiddenModules: string[] = Array.isArray(logConfig?.hidden_modules) ? logConfig.hidden_modules : []
// Map log component names to module IDs
const componentToModule: Record<string, string[]> = {
'instagram': ['instagram', 'instagram_client'],
'instagramclient': ['instagram_client'],
'snapchatdirect': ['snapchat'],
'snapchatclient': ['snapchat_client'],
'tiktok': ['tiktok'],
'forum': ['forums'],
'forumadapter': ['forums'],
'coppermine': ['coppermine'],
}
// Components to hide from the dropdown (test/debug logs + hidden modules)
const hiddenComponents = [
'test',
'tests',
'testing',
'test_websocket',
...Object.entries(componentToModule)
.filter(([_, modules]) => modules.every(m => hiddenModules.includes(m)))
.map(([comp]) => comp),
]
// URL parameters for filtering
const urlFilter = searchParams.get('filter')
const urlTime = searchParams.get('time')
const urlLevel = searchParams.get('level')
const [realTimeUpdates, setRealTimeUpdates] = useState(true)
const [selectedComponents, setSelectedComponents] = useState<string[]>(defaultComponents)
const [levelFilter, setLevelFilter] = useState<string[]>(() => {
// If URL has level=error, only show errors
if (urlLevel?.toLowerCase() === 'error') {
return ['ERROR']
}
return ['ERROR', 'WARNING', 'INFO', 'SUCCESS']
})
const [showFilters, setShowFilters] = useState(false)
const [showComponentSelector, setShowComponentSelector] = useState(false)
const [highlightTimestamp, setHighlightTimestamp] = useState<string | null>(urlTime)
const logsEndRef = useRef<HTMLDivElement>(null)
const logsContainerRef = useRef<HTMLDivElement>(null)
const componentSelectorRef = useRef<HTMLDivElement>(null)
const highlightedLogRef = useRef<HTMLDivElement>(null)
// Clear URL filter banner
const clearUrlFilter = () => {
setSearchParams({})
setHighlightTimestamp(null)
setLevelFilter(['ERROR', 'WARNING', 'INFO', 'SUCCESS'])
}
const { data: mergedLogs, refetch } = useQuery({
queryKey: ['mergedLogs', selectedComponents, urlTime],
queryFn: () => api.getMergedLogs(1000, selectedComponents, urlTime || undefined),
enabled: selectedComponents.length > 0,
refetchInterval: realTimeUpdates && !urlTime ? 2000 : false, // Poll every 2 seconds when enabled (but not when filtering by time)
})
// Scroll to highlighted log entry when timestamp is set
useEffect(() => {
if (highlightTimestamp && highlightedLogRef.current && logsContainerRef.current) {
// Small delay to ensure DOM is updated
setTimeout(() => {
highlightedLogRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 300)
}
}, [highlightTimestamp, mergedLogs])
// Track if we've scrolled to bottom on initial load
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false)
// Close component selector when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (componentSelectorRef.current && !componentSelectorRef.current.contains(event.target as Node)) {
setShowComponentSelector(false)
}
}
if (showComponentSelector) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [showComponentSelector])
const allLogs = mergedLogs?.logs || []
const availableComponents = mergedLogs?.available_components || []
const parseLogEntry = (log: string) => {
// Parse format: 2025-11-13 11:24:40.123456 [MediaDownloader.Component] [Module] [LEVEL] message
// Also handles: 2025-11-13 11:24:40 [MediaDownloader.Component] [Module] [LEVEL] message (without microseconds)
// Also handles: 2025-11-13 11:24:40 [MediaDownloader] [Module] [LEVEL] message
const matchWithComponent = log.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?) \[MediaDownloader\.([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/)
if (matchWithComponent) {
return {
timestamp: matchWithComponent[1],
component: matchWithComponent[2],
module: matchWithComponent[3],
level: matchWithComponent[4],
message: matchWithComponent[5],
}
}
// Try without component (just [MediaDownloader])
const matchWithoutComponent = log.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?) \[MediaDownloader\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/)
if (matchWithoutComponent) {
return {
timestamp: matchWithoutComponent[1],
component: 'Core',
module: matchWithoutComponent[2],
level: matchWithoutComponent[3],
message: matchWithoutComponent[4],
}
}
return null
}
// Strip microseconds from log line for display (keeps sorting accurate in backend)
const formatLogForDisplay = (log: string) => {
// Replace timestamp with microseconds (2025-12-07 11:30:42.123456) with just seconds
return log.replace(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.\d+/, '$1')
}
const getLogLevel = (log: string) => {
const parsed = parseLogEntry(log)
if (parsed) return parsed.level.toLowerCase()
if (log.includes('[ERROR]')) return 'error'
if (log.includes('[WARNING]')) return 'warning'
if (log.includes('[SUCCESS]')) return 'success'
if (log.includes('[DEBUG]')) return 'debug'
return 'info'
}
const getLogColor = (level: string) => {
const colors = {
error: 'text-red-500 dark:text-red-400 font-semibold',
critical: 'text-red-600 dark:text-red-300 font-bold',
warning: 'text-amber-500 dark:text-amber-300 font-medium',
success: 'text-emerald-400 dark:text-emerald-300',
debug: 'text-slate-400 dark:text-slate-500',
info: 'text-blue-300 dark:text-blue-200',
}
return colors[level as keyof typeof colors] || colors.info
}
const getLevelBadgeColor = (level: string) => {
const colors = {
error: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
critical: 'bg-red-200 dark:bg-red-900/50 text-red-900 dark:text-red-200',
warning: 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300',
success: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-300',
debug: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400',
info: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300',
}
return colors[level as keyof typeof colors] || colors.info
}
// Check if a log entry matches the URL filter (module/component name)
const matchesUrlFilter = (log: string) => {
if (!urlFilter) return true
const lowerFilter = urlFilter.toLowerCase()
const parsed = parseLogEntry(log)
if (parsed) {
return parsed.module.toLowerCase().includes(lowerFilter) ||
parsed.component.toLowerCase().includes(lowerFilter)
}
// Fallback: check if filter appears in the log line
return log.toLowerCase().includes(lowerFilter)
}
// Check if a log entry's timestamp is close to the URL time (within 2 minutes)
const isNearTimestamp = (log: string) => {
if (!highlightTimestamp) return false
const parsed = parseLogEntry(log)
if (!parsed) return false
try {
const logTime = new Date(parsed.timestamp.replace(' ', 'T'))
const targetTime = new Date(highlightTimestamp)
const diffMs = Math.abs(logTime.getTime() - targetTime.getTime())
// Within 2 minutes (120000ms)
return diffMs <= 120000
} catch {
return false
}
}
const filteredLogs = allLogs.filter(log => {
const level = getLogLevel(log).toUpperCase()
// Apply level filter
if (!levelFilter.includes(level)) return false
// Apply URL module filter if present
if (urlFilter && !matchesUrlFilter(log)) return false
return true
})
// Scroll to bottom (most recent logs) on initial load
useEffect(() => {
// Only auto-scroll on initial load, not on every update
// Don't scroll if there's a highlight timestamp (user came from error link)
if (!hasScrolledToBottom && !highlightTimestamp && filteredLogs.length > 0 && logsEndRef.current) {
setTimeout(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'auto' })
setHasScrolledToBottom(true)
}, 100)
}
}, [filteredLogs.length, hasScrolledToBottom, highlightTimestamp])
const toggleLevelFilter = (level: string) => {
setLevelFilter(prev =>
prev.includes(level)
? prev.filter(l => l !== level)
: [...prev, level]
)
}
const toggleComponent = (component: string) => {
setSelectedComponents(prev =>
prev.includes(component)
? prev.filter(c => c !== component)
: [...prev, component]
)
}
const downloadLogs = () => {
const blob = new Blob([filteredLogs.join('\n')], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const componentName = selectedComponents.length > 0 ? selectedComponents.join('-') : 'merged'
a.download = `media-downloader-${componentName}-${new Date().toISOString().split('T')[0]}.log`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const getComponentDisplayName = (comp: string) => {
const names: { [key: string]: string } = {
// Core Services
'api': 'API',
'scheduler': 'Scheduler',
'database': 'Database',
'dashboard': 'Dashboard',
'settingsmanager': 'Settings',
'activitystatus': 'Activity Status',
'cachemanager': 'Cache',
'logcleanup': 'Log Cleanup',
'maintenance': 'Maintenance',
// Download Core
'mediadownloader': 'Media Downloader',
'media_downloader': 'Media Downloader',
'downloader': 'Media Downloader',
'movemanager': 'Move Manager',
'thumbnailcachebuilder': 'Thumbnail Cache',
'downloadmanager': 'Download Manager',
'downloadermonitor': 'Download Monitor',
'universalvideodownloader': 'Video Downloader',
'filesrouter': 'File Manager',
'mediaidentifier': 'Media Identifier',
// Platform Downloaders
'instagram': 'Instagram',
'instagramclient': 'Instagram',
'tiktok': 'TikTok',
'snapchat': 'Snapchat (Legacy)',
'snapchatclient': 'Snapchat',
'snapchatdirect': 'Snapchat',
'forum': 'Forums',
'forumadapter': 'Forums',
'coppermine': 'Coppermine',
'youtubedownloader': 'YouTube',
'youtubemonitor': 'YouTube Monitor',
'easynewsclient': 'Easynews Client',
'easynewsmonitor': 'Easynews Monitor',
'redditmonitor': 'Reddit Monitor',
// Face Recognition
'facerecognition': 'Face Recognition',
'immichface': 'Immich Faces',
'perceptual_duplicate_detector': 'Duplicate Detector',
// Internet Discovery
'celebritydiscover': 'Discovery',
'celebritynightly': 'Discovery Nightly',
'celebrityresolutionfetch': 'Discovery Resolution',
'celebritydatefetch': 'Discovery Dates',
'celebrityenrichment': 'Discovery Enrichment',
'discovery': 'Discovery',
// Authentication
'duomanager': 'Duo 2FA',
'twofactorauth': '2FA',
'passkeymanager': 'Passkeys',
'totpmanager': 'TOTP',
// Search & AI
'semanticsearch': 'Semantic Search',
'embeddinggenerator': 'Embeddings',
'autotagger': 'Auto Tagger',
// Appearances & Media APIs
'appearances': 'Appearances',
'tmdb': 'TMDb',
'tvdb': 'TVDB',
'imdb': 'IMDb',
'paidcontent': 'Paid Content',
'privategallerycrypto': 'Private Gallery Crypto',
'podchaser': 'Podchaser',
'taddy': 'Taddy',
'plex': 'Plex',
// Cloud & Backup
'cloudbackup': 'Cloud Backup',
'cloud backup': 'Cloud Backup',
// Cloudflare
'cloudflare.fastdl': 'Cloudflare (FastDL)',
'cloudflare.imginn': 'Cloudflare (ImgInn)',
'cloudflare.imginn-stories': 'Cloudflare (ImgInn Stories)',
// Task System
'taskcheckpoint': 'Task Checkpoint',
// Utilities
'notifier': 'Notifications',
'servicehealthmonitor': 'Health Monitor',
'dependencyupdater': 'Dependencies',
'dateutils': 'Date Utils',
'querybuilder': 'Query Builder',
// Legacy
'web-api': 'API (Legacy)',
'web-frontend': 'Frontend (Legacy)',
'service': 'Service (Legacy)',
}
// Clean up any remaining hyphens/underscores in unmapped names
const displayName = names[comp.toLowerCase()]
if (displayName) {
return displayName
}
// Fallback: convert hyphens/underscores to spaces and capitalize
return comp
.replace(/[-_]/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
return (
<div className="space-y-4 md:space-y-6 pb-safe">
{/* URL Filter Banner */}
{(urlFilter || urlLevel) && (
<div className="card-glass rounded-xl bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/30 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400" />
<div>
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100">
Filtered View
</h3>
<p className="text-xs text-amber-700 dark:text-amber-300">
{urlFilter && `Showing logs from: ${urlFilter}`}
{urlFilter && urlLevel && ' • '}
{urlLevel && `Level: ${urlLevel.toUpperCase()}`}
{highlightTimestamp && ` • Around: ${new Date(highlightTimestamp).toLocaleString()}`}
</p>
</div>
</div>
<button
onClick={clearUrlFilter}
className="p-1.5 text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded-lg transition-colors"
title="Clear filter"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Header - Mobile Optimized */}
<div className="flex flex-col space-y-3 md:space-y-0 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<ScrollText className="w-8 h-8 text-slate-500" />
System Logs
{realTimeUpdates && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded-full">
<span className="live-dot"></span>
Live
</span>
)}
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Monitor application activity{realTimeUpdates ? ' - Updates every 2 seconds' : ''}
</p>
</div>
<div className="flex items-center space-x-2">
{/* Component Selector */}
{availableComponents && availableComponents.length > 0 && (
<div className="relative" ref={componentSelectorRef}>
<button
onClick={() => setShowComponentSelector(!showComponentSelector)}
className="px-3 md:px-4 py-2.5 md:py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors touch-manipulation flex items-center space-x-2"
>
<span>
{selectedComponents.length === 0
? 'Select Components'
: `${selectedComponents.length} selected`}
</span>
<ChevronDown className={`w-4 h-4 transition-transform ${showComponentSelector ? 'rotate-180' : ''}`} />
</button>
{showComponentSelector && (
<div className="absolute left-0 md:left-auto md:right-0 mt-2 w-64 bg-card border border-border rounded-xl shadow-lg z-10 max-h-96 overflow-y-auto">
<div className="p-3 border-b border-border">
<button
onClick={() => {
if (selectedComponents.length === availableComponents.length) {
setSelectedComponents([])
} else {
setSelectedComponents([...availableComponents])
}
}}
className="text-xs text-primary hover:underline"
>
{selectedComponents.length === availableComponents.length ? 'Deselect All' : 'Select All'}
</button>
</div>
<div className="p-2">
{availableComponents
.filter((comp) => !hiddenComponents.includes(comp.toLowerCase()))
.sort((a, b) => getComponentDisplayName(a).localeCompare(getComponentDisplayName(b)))
.map((comp) => (
<label
key={comp}
className="flex items-center space-x-2 px-2 py-2 hover:bg-secondary rounded-lg cursor-pointer touch-manipulation transition-colors"
>
<input
type="checkbox"
checked={selectedComponents.includes(comp)}
onChange={() => toggleComponent(comp)}
className="rounded border-border text-primary focus:ring-primary"
/>
<span className="text-sm text-foreground">
{getComponentDisplayName(comp)}
</span>
</label>
))}
</div>
</div>
)}
</div>
)}
<button
onClick={() => setRealTimeUpdates(!realTimeUpdates)}
className={`p-2.5 md:px-4 md:py-2 border rounded-xl transition-colors touch-manipulation ${
realTimeUpdates
? 'bg-green-600 border-green-600 text-white hover:bg-green-700'
: 'bg-card border-border text-foreground hover:bg-secondary'
}`}
aria-label={realTimeUpdates ? 'Disable real-time updates' : 'Enable real-time updates'}
title={realTimeUpdates ? 'Real-time updates ON' : 'Real-time updates OFF'}
>
{realTimeUpdates ? (
<Wifi className="w-5 h-5 md:w-4 md:h-4" />
) : (
<WifiOff className="w-5 h-5 md:w-4 md:h-4" />
)}
</button>
<button
onClick={() => refetch()}
className="p-2.5 md:px-4 md:py-2 bg-card border border-border rounded-xl hover:bg-secondary transition-colors touch-manipulation"
aria-label="Refresh logs"
>
<RefreshCw className="w-5 h-5 md:w-4 md:h-4" />
</button>
<button
onClick={downloadLogs}
className="p-2.5 md:px-4 md:py-2 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-colors touch-manipulation btn-hover-lift"
aria-label="Export logs"
>
<Download className="w-5 h-5 md:w-4 md:h-4" />
</button>
</div>
</div>
{/* Stats - Mobile Grid */}
<div className="grid grid-cols-3 md:grid-cols-5 gap-2 md:gap-4">
<div className="card-glass-hover rounded-xl p-3 md:p-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Total</div>
<div className="text-xl md:text-2xl font-bold text-foreground animate-count-up">
{allLogs.length}
</div>
</div>
<div className="card-glass-hover rounded-xl p-3 md:p-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Errors</div>
<div className="text-xl md:text-2xl font-bold text-red-600 dark:text-red-400">
{allLogs.filter((l) => getLogLevel(l).toUpperCase() === 'ERROR').length}
</div>
</div>
<div className="card-glass-hover rounded-xl p-3 md:p-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Warnings</div>
<div className="text-xl md:text-2xl font-bold text-amber-600 dark:text-amber-400">
{allLogs.filter((l) => getLogLevel(l).toUpperCase() === 'WARNING').length}
</div>
</div>
<div className="card-glass-hover rounded-xl p-3 md:p-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Success</div>
<div className="text-xl md:text-2xl font-bold text-emerald-600 dark:text-emerald-400">
{allLogs.filter((l) => getLogLevel(l).toUpperCase() === 'SUCCESS').length}
</div>
</div>
<div className="card-glass-hover rounded-xl p-3 md:p-4 col-span-3 md:col-span-1">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Filtered</div>
<div className="text-xl md:text-2xl font-bold text-blue-600 dark:text-blue-400">
{filteredLogs.length}
</div>
</div>
</div>
{/* Filter Bar - Collapsible on Mobile */}
<div className="card-glass-hover rounded-xl p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-700 dark:text-slate-300">
<Filter className="w-4 h-4" />
<span className="text-sm font-medium">Filter by Level</span>
</div>
<div className="flex items-center gap-2">
{levelFilter.length < 5 && (
<button
onClick={() => setLevelFilter(['ERROR', 'WARNING', 'INFO', 'SUCCESS', 'DEBUG'])}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
Reset all
</button>
)}
<button
onClick={() => setShowFilters(!showFilters)}
className="md:hidden p-1"
>
<ChevronDown className={`w-5 h-5 text-slate-500 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</button>
</div>
</div>
<div className={`${showFilters ? 'block' : 'hidden md:block'}`}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{['ERROR', 'WARNING', 'INFO', 'SUCCESS', 'DEBUG'].map(level => (
<button
key={level}
onClick={() => toggleLevelFilter(level)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-all touch-manipulation ${
levelFilter.includes(level)
? getLevelBadgeColor(level.toLowerCase())
: 'bg-slate-100 dark:bg-slate-700 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-600'
}`}
>
{level}
</button>
))}
</div>
</div>
{/* Active Filters Display */}
{levelFilter.length < 5 && levelFilter.length > 0 && (
<div className="flex items-center gap-2 flex-wrap pt-2 border-t border-slate-200 dark:border-slate-700">
<span className="text-xs text-slate-500 dark:text-slate-400">Active filters:</span>
{levelFilter.map(level => (
<span key={level} className={`text-xs px-2 py-1 rounded-md flex items-center gap-1 ${getLevelBadgeColor(level.toLowerCase())}`}>
{level}
<button onClick={() => toggleLevelFilter(level)} className="hover:opacity-70">×</button>
</span>
))}
</div>
)}
</div>
{/* Log Viewer - iOS Safe Area & Touch Optimized */}
<div className="card-glass-hover rounded-xl overflow-hidden">
<div className="bg-secondary/50 px-4 py-3 border-b border-border flex items-center justify-between">
<div className="flex items-center space-x-3 min-w-0 flex-1">
<Terminal className="w-5 h-5 text-muted-foreground flex-shrink-0" />
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">
{selectedComponents.length > 0 ? (
`Merged Logs (${selectedComponents.length} components)`
) : (
'Select components to view logs'
)}
</div>
{selectedComponents.length > 0 && (
<div className="text-xs text-muted-foreground font-mono truncate">
{selectedComponents.map(c => getComponentDisplayName(c)).join(', ')}
</div>
)}
</div>
</div>
</div>
<div
ref={logsContainerRef}
className="bg-gray-950 dark:bg-black text-gray-100 p-3 md:p-4 font-mono text-[11px] md:text-xs overflow-y-auto overflow-x-auto custom-scrollbar"
style={{
fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
height: 'calc(100vh - 32rem)',
minHeight: '400px',
maxHeight: '600px',
WebkitOverflowScrolling: 'touch'
}}
>
{filteredLogs.length > 0 ? (
filteredLogs.map((log, index) => {
const level = getLogLevel(log)
const color = getLogColor(level)
const isHighlighted = isNearTimestamp(log)
// Add background highlight for errors and warnings
let bgClass = 'hover:bg-gray-900 dark:hover:bg-gray-900/50'
if (isHighlighted) {
// Highlighted entry from URL filter - use a distinct yellow/gold highlight
bgClass = 'bg-yellow-500/30 hover:bg-yellow-500/40 border-l-4 border-yellow-400 pl-2 ring-1 ring-yellow-500/50'
} else if (level === 'error' || level === 'critical') {
bgClass = 'bg-red-950/30 hover:bg-red-950/40 border-l-4 border-red-500 pl-2'
} else if (level === 'warning') {
bgClass = 'bg-amber-950/20 hover:bg-amber-950/30 border-l-4 border-amber-500 pl-2'
} else if (level === 'success') {
bgClass = 'hover:bg-emerald-950/20'
}
return (
<div
key={index}
ref={isHighlighted ? highlightedLogRef : undefined}
className={`py-1 transition-colors whitespace-pre-wrap break-all md:break-normal ${color} ${bgClass}`}
>
{formatLogForDisplay(log)}
</div>
)
})
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-12">
<Terminal className="w-12 h-12 mb-3 opacity-50" />
<p className="text-sm text-center px-4">No logs match the current filters</p>
<button
onClick={() => setLevelFilter(['ERROR', 'WARNING', 'INFO', 'SUCCESS', 'DEBUG'])}
className="mt-3 px-4 py-2 text-sm text-primary hover:text-primary/80 touch-manipulation transition-colors"
>
Reset filters
</button>
</div>
)}
<div ref={logsEndRef} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,599 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import { Upload, FileImage, FileVideo, Check, X, AlertCircle, Trash2, Play, Calendar, User, FolderInput } from 'lucide-react'
import { api } from '../lib/api'
import { isVideoFile } from '../lib/utils'
import { notificationManager } from '../lib/notificationManager'
import ThumbnailProgressModal, { ThumbnailProgressItem } from '../components/ThumbnailProgressModal'
interface ImportService {
name: string
platform: string
content_type: string
filename_pattern: string
destination: string
enabled: boolean
parse_filename: boolean
use_ytdlp?: boolean
}
interface UploadedFile {
filename: string
temp_path: string
size: number
parsed: {
valid: boolean
username: string | null
datetime: string | null
media_id: string | null
error: string | null
}
// For manual override when parse_filename is false
manual_datetime?: string
manual_username?: string
}
interface ImportConfig {
enabled: boolean
temp_dir: string
services: ImportService[]
preset_patterns: Record<string, { name: string; pattern: string; example: string }>
}
export default function ManualImport() {
useBreadcrumb(breadcrumbConfig['/import'])
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null)
const [selectedService, setSelectedService] = useState<string>('')
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [showProgress, setShowProgress] = useState(false)
const [progressItems, setProgressItems] = useState<ThumbnailProgressItem[]>([])
const [currentJobId, setCurrentJobId] = useState<string | null>(null)
// Helper to get current datetime in datetime-local format
const getCurrentDatetime = () => {
const now = new Date()
// Format: YYYY-MM-DDTHH:MM
return now.toISOString().slice(0, 16)
}
const [globalDatetime, setGlobalDatetime] = useState<string>(getCurrentDatetime())
const [globalUsername, setGlobalUsername] = useState<string>('')
// Fetch import configuration
const { data: config, isLoading } = useQuery<ImportConfig>({
queryKey: ['manual-import-services'],
queryFn: async () => (await api.getManualImportServices()) as ImportConfig
})
const enabledServices = config?.services?.filter((s: ImportService) => s.enabled) || []
const currentService = enabledServices.find((s: ImportService) => s.name === selectedService)
// Helper: Check if service needs manual date entry (no parsing AND no yt-dlp)
const needsManualEntry = (service: ImportService | undefined) => {
if (!service) return false
return service.parse_filename === false && !service.use_ytdlp
}
// Upload mutation
const uploadMutation = useMutation({
mutationFn: async (files: File[]) => {
setIsUploading(true)
return api.uploadFilesForImport(files, selectedService)
},
onSuccess: (data: { session_id: string; files: UploadedFile[] }) => {
setUploadedFiles(data.files)
setIsUploading(false)
notificationManager.success('Upload Complete', `Uploaded ${data.files.length} files`)
},
onError: (error: Error) => {
setIsUploading(false)
notificationManager.error('Upload Failed', error.message)
}
})
// Process mutation - now returns a job ID for background processing
type ProcessResponse = { job_id: string; status: string; total_files: number; message: string }
const processMutation = useMutation({
mutationFn: async (): Promise<ProcessResponse> => {
const filesToProcess = uploadedFiles.map(f => ({
temp_path: f.temp_path,
filename: f.filename,
// Include manual overrides - use global values as fallback
manual_datetime: f.manual_datetime || globalDatetime || undefined,
manual_username: f.manual_username || globalUsername || undefined
}))
return await api.processImportedFiles(filesToProcess, selectedService)
},
onSuccess: (data: ProcessResponse) => {
// Start polling the job status
setCurrentJobId(data.job_id)
},
onError: (error: Error) => {
setShowProgress(false)
setCurrentJobId(null)
notificationManager.error('Processing Failed', error.message)
}
})
// Poll job status when we have a job ID
useEffect(() => {
if (!currentJobId) return
const pollInterval = setInterval(async () => {
try {
const status = await api.getImportJobStatus(currentJobId)
// Update progress items from job results
setProgressItems(prev => prev.map(item => {
const result = status.results.find(r => r.filename === item.filename)
if (result) {
return {
...item,
status: result.status === 'success' ? 'success' : result.status === 'error' ? 'error' : 'processing',
error: result.error
}
}
// If the file is being processed (current_file)
if (status.current_file === item.filename) {
return { ...item, status: 'processing' }
}
return item
}))
// Check if job is complete
if (status.status === 'completed') {
clearInterval(pollInterval)
setCurrentJobId(null)
setTimeout(() => {
setShowProgress(false)
setUploadedFiles([])
notificationManager.success('Processing Complete', `Processed ${status.success_count} files, ${status.failed_count} failed`)
queryClient.invalidateQueries({ queryKey: ['media'] })
queryClient.invalidateQueries({ queryKey: ['file-inventory'] })
}, 1000)
}
} catch (error) {
console.error('Error polling job status:', error)
// Don't stop polling on error, the job might still be running
}
}, 500) // Poll every 500ms
return () => clearInterval(pollInterval)
}, [currentJobId, queryClient])
// Handle file selection
const handleFiles = useCallback((files: FileList | null) => {
if (!files || files.length === 0) return
if (!selectedService) {
notificationManager.error('No Service Selected', 'Please select a service first')
return
}
const fileArray = Array.from(files)
uploadMutation.mutate(fileArray)
}, [selectedService, uploadMutation])
// Drag and drop handlers
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
}, [handleFiles])
// Process files
const handleProcess = () => {
if (uploadedFiles.length === 0) return
// Initialize progress items with thumbnails from temp paths
setProgressItems(uploadedFiles.map(f => {
const fileType = isVideoFile(f.filename) ? 'video' as const : 'image' as const
return {
id: f.temp_path,
filename: f.filename,
status: 'pending' as const,
fileType,
thumbnailUrl: api.getMediaThumbnailUrl(f.temp_path, fileType),
}
}))
setShowProgress(true)
// Mark all as processing
setProgressItems(prev => prev.map(item => ({ ...item, status: 'processing' as const })))
processMutation.mutate()
}
// Clear uploaded files
const handleClear = async () => {
try {
await api.clearImportTemp()
setUploadedFiles([])
notificationManager.success('Files Cleared', 'Cleared uploaded files')
} catch (error) {
notificationManager.error('Clear Failed', 'Failed to clear files')
}
}
// Remove single file
const removeFile = (index: number) => {
setUploadedFiles(prev => prev.filter((_, i) => i !== index))
}
// Update manual datetime for a file
const updateManualDatetime = (index: number, datetime: string) => {
setUploadedFiles(prev => prev.map((f, i) =>
i === index ? { ...f, manual_datetime: datetime } : f
))
}
// Update manual username for a file
const updateManualUsername = (index: number, username: string) => {
setUploadedFiles(prev => prev.map((f, i) =>
i === index ? { ...f, manual_username: username } : f
))
}
// Format file size
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)
}
if (!config?.enabled) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<FolderInput className="w-8 h-8 text-orange-500" />
Manual Import
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Upload and import media files from external sources
</p>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
<AlertCircle className="w-12 h-12 text-yellow-500 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-yellow-800 dark:text-yellow-200 mb-2">
Manual Import Not Configured
</h2>
<p className="text-yellow-700 dark:text-yellow-300 mb-4">
Please configure Manual Import services in the Configuration page first.
</p>
<a
href="/config"
className="inline-block px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700"
>
Go to Configuration
</a>
</div>
</div>
)
}
if (enabledServices.length === 0) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<FolderInput className="w-8 h-8 text-orange-500" />
Manual Import
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Upload and import media files from external sources
</p>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
<AlertCircle className="w-12 h-12 text-yellow-500 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-yellow-800 dark:text-yellow-200 mb-2">
No Services Enabled
</h2>
<p className="text-yellow-700 dark:text-yellow-300 mb-4">
Please enable at least one import service in the Configuration page.
</p>
<a
href="/config"
className="inline-block px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700"
>
Go to Configuration
</a>
</div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<FolderInput className="w-8 h-8 text-orange-500" />
Manual Import
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Upload and import media files from external sources
</p>
</div>
{/* Service Selection */}
<div className="card-glass-hover rounded-xl p-6">
<h2 className="text-lg font-semibold mb-4 text-foreground">Select Service</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{enabledServices.map((service: ImportService) => (
<button
key={service.name}
onClick={() => {
setSelectedService(service.name)
setUploadedFiles([])
}}
className={`p-4 rounded-xl border-2 text-left transition-all hover:scale-[1.02] ${
selectedService === service.name
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50 hover:bg-secondary/50'
}`}
>
<div className="font-medium text-foreground">{service.name}</div>
<div className="text-sm text-muted-foreground">{service.platform}</div>
<div className="text-xs text-muted-foreground/70 mt-1">
{service.use_ytdlp ? 'Auto-fetch from YouTube' : service.parse_filename !== false ? 'Auto-parse filename' : 'Manual date entry'}
</div>
</button>
))}
</div>
</div>
{/* Upload Zone */}
{selectedService && (
<div className="card-glass-hover rounded-xl p-6">
<h2 className="text-lg font-semibold mb-4 text-foreground">Upload Files</h2>
<div
ref={dropZoneRef}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all ${
isDragging
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50 hover:bg-secondary/30'
}`}
>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,video/*"
onChange={(e) => handleFiles(e.target.files)}
className="hidden"
/>
{isUploading ? (
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p className="text-muted-foreground">Uploading files...</p>
</div>
) : (
<>
<Upload className="w-12 h-12 text-muted-foreground/50 mx-auto mb-4" />
<p className="text-muted-foreground mb-2">
Drag and drop files here, or click to browse
</p>
<p className="text-sm text-muted-foreground/70">
Supports images and videos
</p>
</>
)}
</div>
{currentService && (
<div className="mt-4 p-4 bg-secondary/50 rounded-xl">
{!needsManualEntry(currentService) ? (
<>
<div className="text-sm text-muted-foreground">
{currentService.use_ytdlp ? (
<><strong>Mode:</strong> Auto-fetch metadata from YouTube</>
) : (
<><strong>Pattern:</strong> <code className="bg-secondary px-2 py-1 rounded">{currentService.filename_pattern}</code></>
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
<strong>Destination:</strong> {currentService.destination}
</div>
</>
) : (
<>
<div className="text-sm text-muted-foreground mb-3">
<strong>Manual Entry Mode</strong> - Set the date/time for uploaded files
</div>
<div className="flex flex-wrap gap-4 items-end">
<div>
<label className="block text-xs text-muted-foreground mb-1">Date/Time for all files</label>
<input
type="datetime-local"
value={globalDatetime}
onChange={(e) => setGlobalDatetime(e.target.value)}
className="px-3 py-2 border border-border rounded-lg bg-background text-foreground"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Username for all files (optional)</label>
<input
type="text"
value={globalUsername}
onChange={(e) => setGlobalUsername(e.target.value)}
placeholder="e.g., evalongoria"
className="px-3 py-2 border border-border rounded-lg bg-background text-foreground w-48"
/>
</div>
</div>
<div className="text-sm text-muted-foreground mt-3">
<strong>Destination:</strong> {currentService.destination}
</div>
</>
)}
</div>
)}
</div>
)}
{/* Uploaded Files Preview */}
{uploadedFiles.length > 0 && (
<div className="card-glass-hover rounded-xl p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-foreground">
Uploaded Files ({uploadedFiles.length})
</h2>
<div className="flex gap-2">
<button
onClick={handleClear}
className="px-4 py-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl flex items-center gap-2 transition-colors"
>
<Trash2 className="w-4 h-4" />
Clear All
</button>
<button
onClick={handleProcess}
disabled={processMutation.isPending}
className="px-4 py-2 bg-primary hover:bg-primary/90 text-primary-foreground rounded-xl disabled:opacity-50 flex items-center gap-2 transition-colors"
>
<Play className="w-4 h-4" />
Process All
</button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-sm text-muted-foreground border-b border-border">
<th className="pb-3 pr-4">File</th>
<th className="pb-3 pr-4">Size</th>
<th className="pb-3 pr-4">Username</th>
<th className="pb-3 pr-4">Date/Time</th>
<th className="pb-3 pr-4">Status</th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{uploadedFiles.map((file, index) => (
<tr key={index} className="border-b border-border table-row-hover-stripe">
<td className="py-3 pr-4">
<div className="flex items-center gap-2">
{isVideoFile(file.filename) ? (
<FileVideo className="w-5 h-5 text-purple-500" />
) : (
<FileImage className="w-5 h-5 text-blue-500" />
)}
<span className="text-sm text-foreground truncate max-w-xs" title={file.filename}>
{file.filename}
</span>
</div>
</td>
<td className="py-3 pr-4 text-sm text-muted-foreground">
{formatSize(file.size)}
</td>
<td className="py-3 pr-4">
{!needsManualEntry(currentService) ? (
<div className="flex items-center gap-1">
<User className="w-4 h-4 text-muted-foreground" />
<span className={`text-sm ${file.parsed.username ? 'text-foreground' : 'text-muted-foreground'}`}>
{file.parsed.username || (currentService?.use_ytdlp ? 'From YouTube' : 'Unknown')}
</span>
</div>
) : (
<input
type="text"
value={file.manual_username || ''}
onChange={(e) => updateManualUsername(index, e.target.value)}
placeholder="Enter username"
className="text-sm px-2 py-1 border border-border rounded-lg bg-background text-foreground w-32"
/>
)}
</td>
<td className="py-3 pr-4">
{!needsManualEntry(currentService) ? (
<div className="flex items-center gap-1">
<Calendar className="w-4 h-4 text-muted-foreground" />
<span className={`text-sm ${file.parsed.datetime ? 'text-foreground' : 'text-muted-foreground'}`}>
{file.parsed.datetime
? new Date(file.parsed.datetime).toLocaleString()
: (currentService?.use_ytdlp ? 'From YouTube' : 'Unknown')}
</span>
</div>
) : (
<input
type="datetime-local"
value={file.manual_datetime || ''}
onChange={(e) => updateManualDatetime(index, e.target.value)}
className="text-sm px-2 py-1 border border-border rounded-lg bg-background text-foreground"
/>
)}
</td>
<td className="py-3 pr-4">
{file.parsed.valid || needsManualEntry(currentService) || currentService?.use_ytdlp ? (
<span className="inline-flex items-center gap-1 text-green-600 text-sm">
<Check className="w-4 h-4" />
Ready
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-600 text-sm" title={file.parsed.error || ''}>
<X className="w-4 h-4" />
Parse Error
</span>
)}
</td>
<td className="py-3">
<button
onClick={() => removeFile(index)}
className="p-1 text-muted-foreground hover:text-red-600 transition-colors"
>
<X className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Progress Modal */}
<ThumbnailProgressModal
isOpen={showProgress}
title="Processing Files"
items={progressItems}
onClose={() => setShowProgress(false)}
/>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,350 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import {
Activity,
AlertCircle,
CheckCircle,
Clock,
TrendingUp,
RefreshCw,
Filter,
Gauge,
} from 'lucide-react'
import { api } from '../lib/api'
import { formatRelativeTime } from '../lib/utils'
export default function Monitoring() {
useBreadcrumb(breadcrumbConfig['/monitoring'])
const [timeWindow, setTimeWindow] = useState(24)
const [selectedDownloader, setSelectedDownloader] = useState<string | null>(null)
const [historyLimit, setHistoryLimit] = useState(100)
const { data: statusData, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
queryKey: ['monitoring', 'status', timeWindow],
queryFn: () => api.getMonitoringStatus(timeWindow),
refetchInterval: 30000, // Refresh every 30 seconds
})
const { data: historyData, isLoading: historyLoading, refetch: refetchHistory } = useQuery({
queryKey: ['monitoring', 'history', selectedDownloader, historyLimit],
queryFn: () => api.getMonitoringHistory(selectedDownloader, historyLimit),
refetchInterval: 60000, // Refresh every minute
})
const downloaders = statusData?.downloaders || []
const history = historyData?.history || []
// Calculate overall stats
const totalAttempts = downloaders.reduce((sum, d) => sum + d.total_attempts, 0)
const totalSuccessful = downloaders.reduce((sum, d) => sum + d.successful, 0)
const totalFailed = downloaders.reduce((sum, d) => sum + d.failed, 0)
const overallSuccessRate = totalAttempts > 0 ? (totalSuccessful / totalAttempts * 100).toFixed(1) : 0
// Get downloader display name
const getDownloaderName = (downloader: string) => {
const names: Record<string, string> = {
fastdl: 'FastDL',
imginn: 'ImgInn',
toolzu: 'Toolzu',
instagram: 'Instagram',
snapchat: 'Snapchat',
tiktok: 'TikTok',
forums: 'Forums',
coppermine: 'Coppermine'
}
return names[downloader] || downloader
}
// Get status color
const getStatusColor = (successRate: number) => {
if (successRate >= 80) return 'text-green-600 dark:text-green-400'
if (successRate >= 50) return 'text-yellow-600 dark:text-yellow-400'
return 'text-red-600 dark:text-red-400'
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<Gauge className="w-8 h-8 text-orange-500" />
Downloader Monitoring
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Track downloader health and performance
</p>
</div>
<div className="flex items-center space-x-3">
<select
value={timeWindow}
onChange={(e) => setTimeWindow(Number(e.target.value))}
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100"
>
<option value={1}>Last Hour</option>
<option value={6}>Last 6 Hours</option>
<option value={24}>Last 24 Hours</option>
<option value={72}>Last 3 Days</option>
<option value={168}>Last Week</option>
</select>
<button
onClick={() => {
refetchStatus()
refetchHistory()
}}
className="p-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors btn-hover-lift"
title="Refresh"
>
<RefreshCw className="w-5 h-5" />
</button>
</div>
</div>
{/* Overall Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card-glass-hover rounded-xl p-5 border stat-card-blue shadow-blue-glow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Attempts</p>
<p className="text-2xl font-bold text-foreground mt-1 animate-count-up">{totalAttempts}</p>
</div>
<div className="p-3 rounded-xl bg-blue-500/20">
<Activity className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
</div>
</div>
<div className="card-glass-hover rounded-xl p-5 border stat-card-green shadow-green-glow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Successful</p>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 mt-1">{totalSuccessful}</p>
</div>
<div className="p-3 rounded-xl bg-emerald-500/20">
<CheckCircle className="w-6 h-6 text-emerald-600 dark:text-emerald-400" />
</div>
</div>
</div>
<div className="card-glass-hover rounded-xl p-5 border stat-card-red shadow-red-glow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Failed</p>
<p className="text-2xl font-bold text-red-600 dark:text-red-400 mt-1">{totalFailed}</p>
</div>
<div className="p-3 rounded-xl bg-red-500/20">
<AlertCircle className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
</div>
</div>
<div className="card-glass-hover rounded-xl p-5 border stat-card-purple shadow-purple-glow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Success Rate</p>
<p className={`text-2xl font-bold mt-1 ${getStatusColor(Number(overallSuccessRate))}`}>
{overallSuccessRate}%
</p>
</div>
<div className="p-3 rounded-xl bg-violet-500/20">
<TrendingUp className={`w-6 h-6 ${getStatusColor(Number(overallSuccessRate))}`} />
</div>
</div>
</div>
</div>
{/* Downloader Status Cards */}
<div>
<h2 className="text-xl font-semibold text-foreground mb-4">Downloader Status</h2>
{statusLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading...</div>
) : downloaders.length === 0 ? (
<div className="card-glass-hover rounded-xl p-8 text-center">
<Activity className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">No monitoring data available yet</p>
<p className="text-sm text-muted-foreground mt-1">
Data will appear after downloaders run
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{downloaders.map((downloader) => (
<div
key={downloader.downloader}
className={`card-glass-hover rounded-xl p-5 cursor-pointer ${
selectedDownloader === downloader.downloader
? 'ring-2 ring-primary'
: ''
}`}
onClick={() => setSelectedDownloader(
selectedDownloader === downloader.downloader ? null : downloader.downloader
)}
>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-foreground">
{getDownloaderName(downloader.downloader)}
</h3>
<span className={`text-2xl font-bold ${getStatusColor(downloader.success_rate)}`}>
{downloader.success_rate}%
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Attempts</span>
<span className="font-medium text-foreground">{downloader.total_attempts}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Successful</span>
<span className="font-medium text-green-600 dark:text-green-400">{downloader.successful}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Failed</span>
<span className="font-medium text-red-600 dark:text-red-400">{downloader.failed}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Files</span>
<span className="font-medium text-foreground">{downloader.total_files}</span>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border">
<div className="flex items-center space-x-2 text-xs">
<Clock className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">
Last: {downloader.last_attempt ? formatRelativeTime(downloader.last_attempt) : 'Never'}
</span>
</div>
{downloader.last_success && (
<div className="flex items-center space-x-2 text-xs mt-1">
<CheckCircle className="w-3 h-3 text-green-600" />
<span className="text-muted-foreground">
Success: {formatRelativeTime(downloader.last_success)}
</span>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* History Table */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-foreground">Download History</h2>
<div className="flex items-center space-x-3">
{selectedDownloader && (
<button
onClick={() => setSelectedDownloader(null)}
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 flex items-center space-x-1"
>
<Filter className="w-4 h-4" />
<span>Clear Filter ({getDownloaderName(selectedDownloader)})</span>
</button>
)}
<select
value={historyLimit}
onChange={(e) => setHistoryLimit(Number(e.target.value))}
className="px-3 py-1 text-sm border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100"
>
<option value={50}>Last 50</option>
<option value={100}>Last 100</option>
<option value={200}>Last 200</option>
<option value={500}>Last 500</option>
</select>
</div>
</div>
<div className="card-glass-hover rounded-xl overflow-hidden">
{historyLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading history...</div>
) : history.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No history available
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary/50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Downloader
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Username
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Files
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Error
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Alert
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{history.map((entry) => (
<tr
key={entry.id}
className="hover:bg-secondary/50 transition-colors"
>
<td className="px-4 py-3 whitespace-nowrap text-sm text-foreground">
{formatRelativeTime(entry.timestamp)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span className="px-2 py-1 text-xs font-medium rounded-full bg-primary/10 text-primary">
{getDownloaderName(entry.downloader)}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-foreground">
{entry.username}
</td>
<td className="px-4 py-3 whitespace-nowrap">
{entry.success ? (
<span className="flex items-center space-x-1 text-green-600 dark:text-green-400">
<CheckCircle className="w-4 h-4" />
<span className="text-sm font-medium">Success</span>
</span>
) : (
<span className="flex items-center space-x-1 text-red-600 dark:text-red-400">
<AlertCircle className="w-4 h-4" />
<span className="text-sm font-medium">Failed</span>
</span>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-foreground">
{entry.file_count}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground max-w-xs truncate">
{entry.error_message || '-'}
</td>
<td className="px-4 py-3 whitespace-nowrap">
{entry.alert_sent && (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Alerted
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,476 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useEffect } from 'react'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import { Play, CheckCircle, XCircle, Loader, Video, ExternalLink, Layers, Settings, RefreshCw, StopCircle } from 'lucide-react'
import { api } from '../lib/api'
import { useNavigate } from 'react-router-dom'
interface YouTubeCheckProgress {
isRunning: boolean
currentChannel: string | null
channelsChecked: number
totalChannels: number
videosFound: number
}
export default function Platforms() {
useBreadcrumb(breadcrumbConfig['/platforms'])
const navigate = useNavigate()
const queryClient = useQueryClient()
const [runningPlatforms, setRunningPlatforms] = useState<Set<string>>(new Set())
const [ytCheckProgress, setYtCheckProgress] = useState<YouTubeCheckProgress>({
isRunning: false,
currentChannel: null,
channelsChecked: 0,
totalChannels: 0,
videosFound: 0
})
const { data: platforms, isLoading } = useQuery({
queryKey: ['platforms'],
queryFn: () => api.getPlatforms(),
})
const { data: youtubeMonitors, refetch: refetchMonitors } = useQuery({
queryKey: ['youtube-monitors'],
queryFn: () => api.getYouTubeMonitors(),
})
const { data: youtubeStats } = useQuery({
queryKey: ['youtube-monitor-stats'],
queryFn: async () => {
const response = await api.getYouTubeMonitorStatistics()
return response.statistics
}
})
// Load running platforms on mount
useEffect(() => {
api.getRunningPlatforms().then(response => {
if (response.platforms && response.platforms.length > 0) {
const running = new Set(response.platforms.map(p => p.platform))
setRunningPlatforms(running)
}
}).catch(err => {
console.error('Failed to load running platforms:', err)
})
}, [])
// Poll for YouTube monitor background task status
useEffect(() => {
if (!ytCheckProgress.isRunning) return
const pollInterval = setInterval(async () => {
try {
// Use the new background task API
const task = await api.getBackgroundTask('youtube_monitor')
if (task.active) {
// Get info from extra_data
const videosFound = task.extra_data?.videos_found || 0
const currentChannel = task.detailed_status || task.extra_data?.current_channel || 'Checking...'
setYtCheckProgress(prev => ({
...prev,
currentChannel: currentChannel,
channelsChecked: task.progress?.current || prev.channelsChecked,
totalChannels: task.progress?.total || prev.totalChannels,
videosFound: videosFound,
}))
} else if (!task.active && ytCheckProgress.isRunning) {
// Check finished
setYtCheckProgress(prev => ({
...prev,
isRunning: false,
currentChannel: null,
}))
// Refetch monitors to get updated counts
refetchMonitors()
queryClient.invalidateQueries({ queryKey: ['download-queue'] })
}
} catch (error) {
console.error('Error polling background task:', error)
}
}, 1000)
return () => clearInterval(pollInterval)
}, [ytCheckProgress.isRunning, refetchMonitors, queryClient])
// Mutation for triggering YouTube check
const ytCheckMutation = useMutation({
mutationFn: () => api.checkAllYouTubeChannels(),
onMutate: () => {
setYtCheckProgress({
isRunning: true,
currentChannel: 'Starting...',
channelsChecked: 0,
totalChannels: youtubeMonitors?.channels?.length || 0,
videosFound: 0
})
},
onSuccess: (data) => {
// The check runs in background, progress comes from polling
setYtCheckProgress(prev => ({
...prev,
totalChannels: data?.channels_count || prev.totalChannels
}))
},
onError: () => {
setYtCheckProgress(prev => ({
...prev,
isRunning: false,
currentChannel: null
}))
}
})
const triggerMutation = useMutation({
mutationFn: ({ platform, username }: { platform: string; username?: string }) =>
api.triggerDownload(platform, username),
onMutate: ({ platform }) => {
setRunningPlatforms((prev) => new Set([...prev, platform]))
},
})
const stopMutation = useMutation({
mutationFn: (platform: string) => api.stopPlatformDownload(platform),
onSuccess: (_, platform) => {
setRunningPlatforms((prev) => {
const next = new Set(prev)
next.delete(platform)
return next
})
},
})
const getPlatformIcon = (name: string) => {
const icons: Record<string, string> = {
instagram_unified: '📸',
snapchat: '👻',
tiktok: '🎵',
forums: '💬',
coppermine: '🖼️',
youtube_monitors: '📺',
}
return icons[name] || '📥'
}
const getPlatformColor = (name: string) => {
const colors: Record<string, string> = {
instagram_unified: 'from-purple-500 to-pink-500',
snapchat: 'from-yellow-500 to-yellow-600',
tiktok: 'from-pink-500 to-rose-500',
forums: 'from-indigo-500 to-purple-500',
coppermine: 'from-amber-500 to-orange-500',
youtube_monitors: 'from-red-500 to-red-600',
}
return colors[name] || 'from-gray-500 to-gray-600'
}
const enabledMonitorsCount = youtubeStats?.active || 0
const totalMonitorsCount = youtubeStats?.total || 0
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader className="w-8 h-8 animate-spin text-blue-500" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<Layers className="w-8 h-8 text-cyan-500" />
Platforms
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Manage download sources and manually trigger downloads
</p>
</div>
{/* Platforms Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* YouTube Monitors Card */}
<div className="card-glass-hover rounded-xl overflow-hidden">
<div className={`h-24 bg-gradient-to-br ${getPlatformColor('youtube_monitors')} flex items-center justify-center`}>
<span className="text-5xl">{getPlatformIcon('youtube_monitors')}</span>
</div>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground">
YouTube Monitors
</h3>
<div className="flex items-center space-x-2">
{enabledMonitorsCount > 0 ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<XCircle className="w-5 h-5 text-gray-400" />
)}
<span className={`text-xs font-medium ${enabledMonitorsCount > 0 ? 'text-green-600 dark:text-green-400' : 'text-gray-500'}`}>
{enabledMonitorsCount > 0 ? `${enabledMonitorsCount} Active` : 'None Active'}
</span>
</div>
</div>
{/* Progress Display */}
{ytCheckProgress.isRunning ? (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<Loader className="w-4 h-4 animate-spin text-red-600 dark:text-red-400" />
<span className="text-sm font-medium text-red-700 dark:text-red-300">
Checking Channels
</span>
</div>
<span className="text-sm font-bold text-red-700 dark:text-red-300">
{ytCheckProgress.channelsChecked} / {ytCheckProgress.totalChannels}
</span>
</div>
{/* Progress bar */}
<div className="w-full bg-red-200 dark:bg-red-900 rounded-full h-1.5 sm:h-2 mb-2">
<div
className="bg-red-600 dark:bg-red-500 h-1.5 sm:h-2 rounded-full transition-all duration-300"
style={{
width: `${ytCheckProgress.totalChannels > 0
? (ytCheckProgress.channelsChecked / ytCheckProgress.totalChannels) * 100
: 0}%`
}}
/>
</div>
{/* Current channel status */}
{ytCheckProgress.currentChannel && (
<div className={`text-xs p-2 rounded ${
ytCheckProgress.currentChannel.startsWith('Found')
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 font-medium'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
}`}>
{ytCheckProgress.currentChannel.startsWith('Found') ? (
<div className="flex items-center space-x-1">
<CheckCircle className="w-3 h-3 flex-shrink-0" />
<span className="truncate">{ytCheckProgress.currentChannel}</span>
</div>
) : (
<span className="truncate block">{ytCheckProgress.currentChannel}</span>
)}
</div>
)}
{/* Total videos found badge */}
{ytCheckProgress.videosFound > 0 && (
<div className="mt-2 flex items-center justify-center space-x-1.5 py-1.5 bg-green-100 dark:bg-green-900/40 rounded text-green-700 dark:text-green-300">
<Video className="w-4 h-4" />
<span className="text-sm font-semibold">{ytCheckProgress.videosFound} new videos queued</span>
</div>
)}
</div>
) : (
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Channels:</span>
<span className="font-medium text-foreground">{totalMonitorsCount}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Active:</span>
<span className="font-medium text-foreground">{enabledMonitorsCount}</span>
</div>
</div>
)}
{/* Action Buttons */}
<div className="space-y-2">
<button
onClick={() => ytCheckMutation.mutate()}
disabled={ytCheckProgress.isRunning || enabledMonitorsCount === 0}
className="w-full flex items-center justify-center space-x-2 px-4 py-2.5 bg-red-600 hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-xl transition-colors min-h-[44px]"
>
{ytCheckProgress.isRunning ? (
<>
<Loader className="w-4 h-4 animate-spin" />
<span>Checking...</span>
</>
) : (
<>
<RefreshCw className="w-4 h-4" />
<span>Check All Channels</span>
</>
)}
</button>
<button
onClick={() => navigate('/config?tab=platforms&platform=youtube_monitors')}
className="w-full flex items-center justify-center space-x-2 px-4 py-2.5 bg-secondary hover:bg-secondary/80 text-foreground rounded-xl transition-colors min-h-[44px]"
>
<Settings className="w-4 h-4" />
<span>Configure</span>
</button>
</div>
</div>
</div>
{platforms?.map((platform) => {
const isRunning = runningPlatforms.has(platform.name)
return (
<div
key={platform.name}
className="card-glass-hover rounded-xl overflow-hidden"
>
{/* Header with gradient */}
<div
className={`h-24 bg-gradient-to-br ${getPlatformColor(
platform.name
)} flex items-center justify-center`}
>
<span className="text-5xl">{getPlatformIcon(platform.name)}</span>
</div>
{/* Content */}
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground">
{platform.display_name}
</h3>
{platform.type === 'scheduled' && (
<div className="flex items-center space-x-2">
{platform.enabled ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<XCircle className="w-5 h-5 text-gray-400" />
)}
<span
className={`text-xs font-medium ${
platform.enabled
? 'text-green-600 dark:text-green-400'
: 'text-gray-500'
}`}
>
{platform.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
)}
{platform.type === 'manual' && (
<div className="flex items-center space-x-2">
<Video className="w-5 h-5 text-blue-500" />
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
Manual Tool
</span>
</div>
)}
</div>
{/* Platform Details */}
<div className="space-y-2 mb-4">
{platform.type === 'scheduled' ? (
<>
{platform.check_interval_hours && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Check Interval:</span>
<span className="font-medium text-foreground">
{platform.check_interval_hours}h
</span>
</div>
)}
{platform.account_count !== undefined && platform.account_count > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Accounts:</span>
<span className="font-medium text-foreground">
{platform.account_count}
</span>
</div>
)}
</>
) : (
<>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Type:</span>
<span className="font-medium text-foreground">
Manual Download Tool
</span>
</div>
{platform.download_count !== undefined && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Videos Downloaded:</span>
<span className="font-medium text-foreground">
{platform.download_count}
</span>
</div>
)}
</>
)}
</div>
{/* Actions */}
<div className="space-y-2">
{platform.type === 'scheduled' ? (
<>
{isRunning ? (
<button
onClick={() => stopMutation.mutate(platform.name)}
disabled={stopMutation.isPending}
className="w-full flex items-center justify-center space-x-2 px-4 py-2.5 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white rounded-xl transition-colors min-h-[44px]"
>
<StopCircle className="w-4 h-4" />
<span>{stopMutation.isPending ? 'Stopping...' : 'Stop Download'}</span>
</button>
) : (
<button
onClick={() => {
if (platform.enabled) {
triggerMutation.mutate({ platform: platform.name })
}
}}
disabled={!platform.enabled}
className="w-full flex items-center justify-center space-x-2 px-4 py-2.5 bg-slate-800 hover:bg-slate-700 disabled:bg-muted disabled:cursor-not-allowed text-white rounded-xl transition-colors min-h-[44px]"
>
<Play className="w-4 h-4" />
<span>Trigger Download</span>
</button>
)}
<button
onClick={() => navigate(
platform.name === 'instagram_unified'
? '/config?tab=instagram_unified'
: `/config?tab=platforms&platform=${platform.name}`
)}
className="w-full flex items-center justify-center space-x-2 px-4 py-2.5 bg-secondary hover:bg-secondary/80 text-foreground rounded-xl transition-colors min-h-[44px]"
>
<Settings className="w-4 h-4" />
<span>Configure</span>
</button>
</>
) : (
<button
onClick={() => navigate('/videos')}
className="w-full flex items-center justify-center space-x-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white rounded-xl transition-colors"
>
<Video className="w-4 h-4" />
<span>Open Video Downloader</span>
<ExternalLink className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
)
})}
</div>
{/* Info Box */}
<div className="glass-gradient-border rounded-xl p-4">
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-200 mb-2">
Manual Triggers
</h4>
<p className="text-sm text-blue-800 dark:text-blue-300">
Click "Trigger Download" to manually start a download for any enabled platform. The
download will run immediately and follow the configured settings for that platform.
You can monitor progress in the Logs tab.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,421 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import {
Newspaper, ExternalLink, Loader2, RefreshCcw, Trash2,
BookOpen, BookOpenCheck, Globe, Settings
} from 'lucide-react'
import { api } from '../lib/api'
import { notificationManager } from '../lib/notificationManager'
import { parseISO, formatDistanceToNow } from 'date-fns'
import { FilterBar } from '../components/FilterPopover'
import PressArticleModal from '../components/PressArticleModal'
interface PressArticle {
id: number
celebrity_id: number
celebrity_name: string
title: string
url: string
domain: string
published_date: string
image_url: string | null
language: string
country: string
snippet: string
article_content: string | null
fetched_at: string
read: number
}
interface PressStats {
total: number
unread: number
by_celebrity: Array<{ id: number; name: string; count: number }>
by_domain: Array<{ domain: string; count: number }>
}
export default function Press() {
useBreadcrumb(breadcrumbConfig['/press'])
const queryClient = useQueryClient()
const [modalArticleId, setModalArticleId] = useState<number | null>(null)
const [celebrityFilter, setCelebrityFilter] = useState<string>('all')
const [domainFilter, setDomainFilter] = useState<string>('all')
const [readFilter, setReadFilter] = useState<string>('unread')
const [searchText, setSearchText] = useState<string>('')
const [page, setPage] = useState(1)
// Fetch stats
const { data: statsData } = useQuery({
queryKey: ['press', 'stats'],
queryFn: () => api.get<{ success: boolean; stats: PressStats }>('/press/stats'),
refetchInterval: 30000,
})
// Fetch articles
const { data: articlesData, isLoading } = useQuery({
queryKey: ['press', 'articles', celebrityFilter, domainFilter, searchText, readFilter, page],
queryFn: () => {
const params = new URLSearchParams()
if (celebrityFilter !== 'all') params.set('celebrity_id', celebrityFilter)
if (domainFilter !== 'all') params.set('domain', domainFilter)
if (searchText) params.set('search', searchText)
if (readFilter === 'unread') params.set('read', 'false')
else if (readFilter === 'read') params.set('read', 'true')
params.set('page', String(page))
params.set('per_page', '50')
const qs = params.toString()
return api.get<{
success: boolean
articles: PressArticle[]
total: number
page: number
pages: number
}>(`/press/articles${qs ? '?' + qs : ''}`)
},
refetchInterval: 60000,
})
// Fetch status
const { data: fetchStatus } = useQuery({
queryKey: ['press', 'fetch', 'status'],
queryFn: () => api.get<{ success: boolean; is_running: boolean }>('/press/fetch/status'),
refetchInterval: 5000,
})
// Auto-refresh articles when background fetch completes
const prevRunning = useRef(false)
useEffect(() => {
const running = fetchStatus?.is_running ?? false
if (prevRunning.current && !running) {
// Fetch just completed - refresh everything
queryClient.invalidateQueries({ queryKey: ['press'] })
}
prevRunning.current = running
}, [fetchStatus?.is_running, queryClient])
// Mutations
const fetchMutation = useMutation({
mutationFn: () => api.post<{ success: boolean; message: string }>('/press/fetch', {}),
onSuccess: (data) => {
notificationManager.success('Press', data.message || 'Fetch started')
queryClient.invalidateQueries({ queryKey: ['press'] })
},
onError: () => notificationManager.error('Press', 'Failed to start fetch'),
})
const markReadMutation = useMutation({
mutationFn: ({ id, read }: { id: number; read: boolean }) =>
api.patch<{ success: boolean }>(`/press/articles/${id}/read`, { read }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['press'] })
},
})
const markAllReadMutation = useMutation({
mutationFn: () => api.post<{ success: boolean; message: string; count: number }>('/press/articles/mark-all-read', {}),
onSuccess: (data) => {
notificationManager.success('Press', data.message || 'All articles marked as read')
queryClient.invalidateQueries({ queryKey: ['press'] })
},
onError: () => notificationManager.error('Press', 'Failed to mark all as read'),
})
const deleteMutation = useMutation({
mutationFn: (id: number) =>
api.delete<{ success: boolean }>(`/press/articles/${id}`),
onSuccess: () => {
notificationManager.success('Press', 'Article deleted')
queryClient.invalidateQueries({ queryKey: ['press'] })
},
})
const stats = statsData?.stats
const articles = articlesData?.articles || []
const totalPages = articlesData?.pages || 1
const totalCount = articlesData?.total || 0
const isFetching = fetchStatus?.is_running || false
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<Newspaper className="w-6 h-6 sm:w-8 sm:h-8 text-blue-500" />
Press
</h1>
<p className="text-sm sm:text-base text-slate-600 dark:text-slate-400 mt-1">
News articles about your tracked celebrities
</p>
</div>
<div className="flex items-center gap-2">
<Link
to="/config?tab=press"
className="px-3 sm:px-4 py-2.5 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-600 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 min-h-[44px]"
title="Configure in Settings"
>
<Settings className="w-4 h-4" />
<span className="hidden sm:inline">Settings</span>
</Link>
{stats && stats.unread > 0 && (
<button
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
className="px-3 sm:px-4 py-2.5 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-600 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 min-h-[44px]"
>
<BookOpenCheck className="w-4 h-4" />
<span className="hidden sm:inline">{markAllReadMutation.isPending ? 'Marking...' : 'Mark All Read'}</span>
</button>
)}
<button
onClick={() => fetchMutation.mutate()}
disabled={isFetching || fetchMutation.isPending}
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2.5 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-medium text-sm flex items-center justify-center gap-2 hover:from-blue-700 hover:to-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all min-h-[44px]"
>
{isFetching ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCcw className="w-4 h-4" />
)}
<span className="hidden sm:inline">{isFetching ? 'Fetching...' : 'Fetch Articles'}</span>
<span className="sm:hidden">{isFetching ? '...' : 'Fetch'}</span>
</button>
</div>
</div>
{/* Filters */}
<div className="card-glass-hover rounded-xl p-4 relative z-30">
<FilterBar
searchValue={searchText}
onSearchChange={(v) => { setSearchText(v); setPage(1) }}
searchPlaceholder="Search articles..."
filterSections={[
{
id: 'celebrity',
label: 'Celebrity',
type: 'select',
options: [
{ value: 'all', label: 'All Celebrities' },
...(stats?.by_celebrity.map(c => ({ value: String(c.id), label: `${c.name} (${c.count})` })) || [])
],
value: celebrityFilter,
onChange: (v) => { setCelebrityFilter(v as string); setPage(1) }
},
{
id: 'domain',
label: 'Source',
type: 'select',
options: [
{ value: 'all', label: 'All Sources' },
...(stats?.by_domain.map(d => ({ value: d.domain, label: `${d.domain} (${d.count})` })) || [])
],
value: domainFilter,
onChange: (v) => { setDomainFilter(v as string); setPage(1) }
},
{
id: 'read',
label: 'Status',
type: 'select',
options: [
{ value: 'all', label: 'All Articles' },
{ value: 'unread', label: 'Unread Only' },
{ value: 'read', label: 'Read Only' }
],
value: readFilter,
onChange: (v) => { setReadFilter(v as string); setPage(1) }
}
]}
activeFilters={[
...(celebrityFilter !== 'all' ? [{
id: 'celebrity',
label: 'Celebrity',
value: celebrityFilter,
displayValue: stats?.by_celebrity.find(c => String(c.id) === celebrityFilter)?.name || celebrityFilter,
onRemove: () => { setCelebrityFilter('all'); setPage(1) }
}] : []),
...(domainFilter !== 'all' ? [{
id: 'domain',
label: 'Source',
value: domainFilter,
displayValue: domainFilter,
onRemove: () => { setDomainFilter('all'); setPage(1) }
}] : []),
...(readFilter !== 'all' ? [{
id: 'read',
label: 'Status',
value: readFilter,
displayValue: readFilter === 'unread' ? 'Unread Only' : 'Read Only',
onRemove: () => { setReadFilter('all'); setPage(1) }
}] : [])
]}
onClearAll={() => {
setSearchText('')
setCelebrityFilter('all')
setDomainFilter('all')
setReadFilter('all')
setPage(1)
}}
totalCount={totalCount}
countLabel="articles"
/>
</div>
{/* Articles List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
</div>
) : articles.length === 0 ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<Newspaper className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-lg">No articles found</p>
<p className="text-sm mt-1">Click "Fetch Articles" to search for news about your tracked celebrities</p>
</div>
) : (
<div className="space-y-3">
{articles.map((article) => (
<div
key={article.id}
className={`card-glass-hover rounded-xl overflow-hidden transition-colors cursor-pointer ${
article.read ? '' : 'ring-1 ring-blue-500/30'
}`}
onClick={() => {
setModalArticleId(article.id)
if (!article.read) {
markReadMutation.mutate({ id: article.id, read: true })
}
}}
>
<div className="flex gap-4 p-4">
{/* Thumbnail */}
{article.image_url && (
<div className="flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden bg-slate-100 dark:bg-slate-800">
<img
src={article.image_url}
alt=""
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
)}
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-slate-900 dark:text-slate-100 hover:text-blue-600 dark:hover:text-blue-400 font-medium line-clamp-2 transition-colors">
{article.title || 'Untitled Article'}
</span>
<div className="flex items-center gap-2 mt-1.5 text-xs text-slate-500 dark:text-slate-400">
<span className="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-slate-600 dark:text-slate-400">
{article.domain}
</span>
<span className="px-1.5 py-0.5 bg-purple-500/10 text-purple-600 dark:text-purple-400 rounded">
{article.celebrity_name}
</span>
{article.published_date && (
<span title={article.published_date}>
{(() => {
try {
return formatDistanceToNow(parseISO(article.published_date), { addSuffix: true })
} catch {
return article.published_date
}
})()}
</span>
)}
{article.language && article.language !== 'English' && (
<span className="flex items-center gap-1">
<Globe className="w-3 h-3" />
{article.language}
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => markReadMutation.mutate({ id: article.id, read: !article.read })}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
title={article.read ? 'Mark as unread' : 'Mark as read'}
>
{article.read ? <BookOpenCheck className="w-4 h-4" /> : <BookOpen className="w-4 h-4" />}
</button>
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
title="Open original"
>
<ExternalLink className="w-4 h-4" />
</a>
<button
onClick={() => {
if (confirm('Delete this article?')) {
deleteMutation.mutate(article.id)
}
}}
className="p-1.5 rounded-lg hover:bg-red-500/10 text-slate-400 dark:text-slate-500 hover:text-red-500 dark:hover:text-red-400 transition-colors"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Snippet */}
{article.snippet && (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-2 line-clamp-2">{article.snippet}</p>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1}
className="px-3 py-1.5 bg-slate-200 hover:bg-slate-300 dark:bg-slate-700 dark:hover:bg-slate-600 disabled:opacity-50 text-sm text-slate-700 dark:text-slate-300 rounded-lg transition-colors"
>
Previous
</button>
<span className="text-sm text-slate-500 dark:text-slate-400">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages}
className="px-3 py-1.5 bg-slate-200 hover:bg-slate-300 dark:bg-slate-700 dark:hover:bg-slate-600 disabled:opacity-50 text-sm text-slate-700 dark:text-slate-300 rounded-lg transition-colors"
>
Next
</button>
</div>
)}
{/* Article Reader Modal */}
{modalArticleId !== null && (
<PressArticleModal
articleId={modalArticleId}
onClose={() => setModalArticleId(null)}
onMarkRead={(id, read) => {
markReadMutation.mutate({ id, read })
}}
onDelete={(id) => {
deleteMutation.mutate(id)
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,971 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import { api, getErrorMessage } from '../lib/api'
import { notificationManager } from '../lib/notificationManager'
import { formatBytes, formatPlatformName, isVideoFile } from '../lib/utils'
import { invalidateAllFileCaches, optimisticRemoveFromRecycleBin } from '../lib/cacheInvalidation'
import { RotateCcw, Trash2 as TrashIcon, Play, Check, Trash2, Copy } from 'lucide-react'
import EnhancedLightbox from '../components/EnhancedLightbox'
import { FilterBar } from '../components/FilterPopover'
import { BatchProgressModal, BatchProgressItem } from '../components/BatchProgressModal'
import ThrottledImage from '../components/ThrottledImage'
import { CopyToGalleryModal } from '../components/private-gallery/CopyToGalleryModal'
import { useEnabledFeatures } from '../hooks/useEnabledFeatures'
interface RecycleBinItem {
id: string
original_path: string
recycle_path: string
original_filename: string
file_extension: string
file_size: number
original_mtime: number
deleted_from: 'downloads' | 'media' | 'review' | 'instagram_perceptual_duplicate_detection'
deleted_at: string
deleted_by: string
metadata: string
restore_count: number
width?: number
height?: number
platform?: string
source?: string
video_id?: string
face_recognition?: {
scanned: boolean
matched: boolean
matched_person?: string
confidence?: number
face_count?: number
}
}
interface RecycleBinStats {
total_count: number
total_size: number
by_source: {
[key: string]: {
count: number
size: number
}
}
}
export default function RecycleBin() {
useBreadcrumb(breadcrumbConfig['/recycle-bin'])
const { isFeatureEnabled } = useEnabledFeatures()
const [filter, setFilter] = useState<string | null>(null)
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())
const [selectedPaths, setSelectedPaths] = useState<Map<string, { recyclePath: string, originalFilename: string }>>(new Map())
const [selectMode, setSelectMode] = useState(false)
const [selectedMedia, setSelectedMedia] = useState<RecycleBinItem | null>(null)
const [showCopyToGalleryModal, setShowCopyToGalleryModal] = useState(false)
const [page, setPage] = useState(0)
const queryClient = useQueryClient()
// Search and filters
const [searchQuery, setSearchQuery] = useState<string>('')
const [platformFilter, setPlatformFilter] = useState<string>('')
const [sourceFilter, setSourceFilter] = useState<string>('')
const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video'>('all')
const [sortBy, setSortBy] = useState<string>('download_date')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
// Advanced filters
const [dateFrom, setDateFrom] = useState<string>('')
const [dateTo, setDateTo] = useState<string>('')
const [sizeMin, setSizeMin] = useState<string>('')
const [sizeMax, setSizeMax] = useState<string>('')
// Progress modal
const [showProgressModal, setShowProgressModal] = useState(false)
const [progressFiles, setProgressFiles] = useState<BatchProgressItem[]>([])
const [progressTitle, setProgressTitle] = useState('Processing Files')
const limit = 50
// Reset page when any filter changes
useEffect(() => {
setPage(0)
}, [filter, platformFilter, sourceFilter, searchQuery, typeFilter, dateFrom, dateTo, sizeMin, sizeMax, sortBy, sortOrder])
// Fetch recycle bin items
const { data: recycleBinData, isLoading } = useQuery({
queryKey: ['recycle-bin', filter, platformFilter, sourceFilter, searchQuery, typeFilter, dateFrom, dateTo, sizeMin, sizeMax, sortBy, sortOrder, page],
queryFn: async () => {
const params = new URLSearchParams()
if (filter) params.append('deleted_from', filter)
if (platformFilter) params.append('platform', platformFilter)
if (sourceFilter) params.append('source', sourceFilter)
if (searchQuery) params.append('search', searchQuery)
if (typeFilter !== 'all') params.append('media_type', typeFilter)
if (dateFrom) params.append('date_from', dateFrom)
if (dateTo) params.append('date_to', dateTo)
if (sizeMin) params.append('size_min', sizeMin)
if (sizeMax) params.append('size_max', sizeMax)
params.append('sort_by', sortBy)
params.append('sort_order', sortOrder)
params.append('limit', limit.toString())
params.append('offset', (page * limit).toString())
const response: any = await api.get(`/recycle/list?${params}`)
return response
},
refetchInterval: 30000,
})
// Fetch filter options
const { data: filters } = useQuery({
queryKey: ['recycle-filters', platformFilter],
queryFn: () => api.getRecycleFilters(platformFilter || undefined),
})
// Clear source filter when platform changes and source is not available
useEffect(() => {
if (filters && sourceFilter && !filters.sources.includes(sourceFilter)) {
setSourceFilter('')
}
}, [filters, sourceFilter])
// Fetch stats
const { data: statsData } = useQuery({
queryKey: ['recycle-bin-stats'],
queryFn: async () => {
const response: any = await api.get('/recycle/stats')
return response
},
refetchInterval: 30000,
})
const items: RecycleBinItem[] = recycleBinData?.items || []
const filteredTotal = recycleBinData?.total || 0 // Total count for current filter
const stats: RecycleBinStats = statsData?.stats || { total_count: 0, total_size: 0, by_source: {} }
// Items are now filtered and sorted server-side
const filteredItems = items
const totalPages = Math.ceil(filteredTotal / limit)
// Check if any client-side filters are active
const hasActiveFilters = searchQuery || typeFilter !== 'all' || platformFilter || sourceFilter || dateFrom || dateTo || sizeMin || sizeMax
// Fetch metadata for current lightbox item when it changes
useEffect(() => {
if (!selectedMedia?.id) return
// Skip if already has dimensions
if (selectedMedia.width && selectedMedia.height) return
const fetchMetadata = async () => {
try {
const response = await api.get(`/recycle/metadata/${selectedMedia.id}`) as any
if (response && (response.width || response.height || response.platform || response.source)) {
setSelectedMedia((prev: any) => prev?.id === selectedMedia.id ? {
...prev,
width: response.width,
height: response.height,
platform: response.platform || prev.platform,
source: response.source || prev.source
} : prev)
}
} catch (error) {
// Silently fail - dimensions are optional
}
}
fetchMetadata()
}, [selectedMedia?.id])
// Helper function to advance to next/previous item after delete/restore
const advanceToNextItem = () => {
if (!selectedMedia) return
const currentIndex = filteredItems.findIndex(item => item.id === selectedMedia.id)
if (currentIndex === -1) {
setSelectedMedia(null)
return
}
// Try next item first
if (currentIndex + 1 < filteredItems.length) {
setSelectedMedia(filteredItems[currentIndex + 1])
}
// If no next item, try previous
else if (currentIndex - 1 >= 0) {
setSelectedMedia(filteredItems[currentIndex - 1])
}
// If no items left, close lightbox
else {
setSelectedMedia(null)
}
}
// Restore mutation
const restoreMutation = useMutation({
mutationFn: (id: string) => api.post('/recycle/restore', { recycle_id: id }),
onSuccess: (_data, variables) => {
// Advance to next item before invalidating queries
advanceToNextItem()
optimisticRemoveFromRecycleBin(queryClient, new Set([variables]))
invalidateAllFileCaches(queryClient)
notificationManager.success('Restored', 'File restored successfully')
// Trigger Immich scan (don't await - fire and forget)
api.triggerImmichScan().catch(() => {})
},
onError: (err: unknown) => {
notificationManager.apiError('Restore Failed', err, 'Failed to restore file')
},
})
// Delete mutation
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/recycle/delete/${id}`),
onSuccess: (_data, variables) => {
// Advance to next item before invalidating queries
advanceToNextItem()
optimisticRemoveFromRecycleBin(queryClient, new Set([variables]))
invalidateAllFileCaches(queryClient)
notificationManager.deleted('File', 'File permanently deleted')
},
onError: (err: unknown) => {
notificationManager.deleteError('file', err)
},
})
// Batch restore
const handleBatchRestore = async () => {
const selectedIds = Array.from(selectedItems)
// Build progress items from selected IDs
const progressItems: BatchProgressItem[] = selectedIds.map(id => {
const item = items.find((i: RecycleBinItem) => i.id === id)
return {
id,
filename: item?.original_filename || id,
status: 'pending' as const
}
})
setProgressFiles(progressItems)
setProgressTitle('Restoring Files')
setShowProgressModal(true)
let succeeded = 0
let failed = 0
for (const id of selectedIds) {
// Mark as processing
setProgressFiles(prev => prev.map(f =>
f.id === id ? { ...f, status: 'processing' } : f
))
try {
await api.post('/recycle/restore', { recycle_id: id })
succeeded++
setProgressFiles(prev => prev.map(f =>
f.id === id ? { ...f, status: 'success' } : f
))
} catch (err: unknown) {
failed++
setProgressFiles(prev => prev.map(f =>
f.id === id ? { ...f, status: 'error', error: getErrorMessage(err) } : f
))
}
}
optimisticRemoveFromRecycleBin(queryClient, new Set(selectedIds))
invalidateAllFileCaches(queryClient)
setSelectedItems(new Set())
setSelectedPaths(new Map())
setSelectMode(false)
setTimeout(() => {
setShowProgressModal(false)
notificationManager.batchSuccess('Restore', succeeded, 'file')
}, 1500)
}
// Batch delete
const handleBatchDelete = async () => {
if (!confirm(`Permanently delete ${selectedItems.size} file(s)? This cannot be undone!`)) return
const selectedIds = Array.from(selectedItems)
// Build progress items from selected IDs
const progressItems: BatchProgressItem[] = selectedIds.map(id => {
const item = items.find((i: RecycleBinItem) => i.id === id)
return {
id,
filename: item?.original_filename || id,
status: 'pending' as const
}
})
setProgressFiles(progressItems)
setProgressTitle('Permanently Deleting Files')
setShowProgressModal(true)
let succeeded = 0
let failed = 0
for (const id of selectedIds) {
// Mark as processing
setProgressFiles(prev => prev.map(f =>
f.id === id ? { ...f, status: 'processing' } : f
))
try {
await api.delete(`/recycle/delete/${id}`)
succeeded++
setProgressFiles(prev => prev.map(f =>
f.id === id ? { ...f, status: 'success' } : f
))
} catch (err: unknown) {
failed++
setProgressFiles(prev => prev.map(f =>
f.id === id ? { ...f, status: 'error', error: getErrorMessage(err) } : f
))
}
}
optimisticRemoveFromRecycleBin(queryClient, new Set(selectedIds))
invalidateAllFileCaches(queryClient)
setSelectedItems(new Set())
setSelectedPaths(new Map())
setSelectMode(false)
setTimeout(() => {
setShowProgressModal(false)
notificationManager.batchSuccess('Delete', succeeded, 'file')
}, 1500)
}
// Empty all
const handleEmptyAll = async () => {
if (!confirm(`Permanently delete ALL ${stats.total_count} file(s) in recycle bin? This cannot be undone!`)) return
// Show progress for all items
const progressItems: BatchProgressItem[] = items.map((item: RecycleBinItem) => ({
id: item.id,
filename: item.original_filename,
status: 'processing' as const
}))
setProgressFiles(progressItems)
setProgressTitle('Emptying Recycle Bin')
setShowProgressModal(true)
try {
const result = await api.post('/recycle/empty', { older_than_days: undefined })
const deletedCount = (result as any)?.deleted_count || items.length
// Mark all as success
setProgressFiles(prev => prev.map(f => ({ ...f, status: 'success' as const })))
optimisticRemoveFromRecycleBin(queryClient, new Set(items.map((item: RecycleBinItem) => item.id)))
invalidateAllFileCaches(queryClient)
setTimeout(() => {
setShowProgressModal(false)
notificationManager.batchSuccess('Delete', deletedCount, 'file')
}, 1500)
} catch (err) {
setShowProgressModal(false)
notificationManager.batchError('Empty Recycle Bin', err)
}
}
// Get media thumbnail URL for recycle bin items
const getRecycleThumbnailUrl = (item: RecycleBinItem) => {
// For YouTube videos with video_id, use stored thumbnail from video_downloads
if (item.platform === 'youtube' && item.video_id) {
return `/api/video/thumbnail/${item.platform}/${item.video_id}?source=downloads`
}
const mediaType = isVideoFile(item.original_filename) ? 'video' : 'image'
// Security: Auth via httpOnly cookie only - no token in URL
return `/api/recycle/file/${item.id}?thumbnail=true&type=${mediaType}`
}
const getRecyclePreviewUrl = (recycleId: string) => {
// Security: Auth via httpOnly cookie only - no token in URL
return `/api/recycle/file/${recycleId}`
}
// Toggle select mode
const toggleSelectMode = () => {
setSelectMode(!selectMode)
setSelectedItems(new Set())
setSelectedPaths(new Map())
}
// Toggle item selection
const toggleItem = (id: string) => {
const newSet = new Set(selectedItems)
const newPaths = new Map(selectedPaths)
if (newSet.has(id)) {
newSet.delete(id)
newPaths.delete(id)
} else {
newSet.add(id)
const item = filteredItems.find(i => i.id === id)
if (item) newPaths.set(id, { recyclePath: item.recycle_path, originalFilename: item.original_filename })
}
setSelectedItems(newSet)
setSelectedPaths(newPaths)
}
// Select all
const selectAll = () => {
if (selectedItems.size === filteredItems.length) {
setSelectedItems(new Set())
setSelectedPaths(new Map())
} else {
setSelectedItems(new Set(filteredItems.map(item => item.id)))
setSelectedPaths(new Map(filteredItems.map(item => [item.id, { recyclePath: item.recycle_path, originalFilename: item.original_filename }])))
}
}
// Helper to get current index for lightbox
const getCurrentLightboxIndex = () => {
if (!selectedMedia) return -1
return filteredItems.findIndex((item) => item.id === selectedMedia.id)
}
// Get items for lightbox - use selectedMedia for current item (has fetched metadata)
const getLightboxItems = () => {
if (!selectedMedia) return filteredItems
return filteredItems.map(item =>
item.id === selectedMedia.id ? selectedMedia : item
)
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-2">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<Trash2 className="w-8 h-8 text-red-500" />
Recycle Bin
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Restore or permanently delete removed files
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={toggleSelectMode}
className="px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 min-h-[44px] transition-colors"
>
{selectMode ? 'Cancel Selection' : 'Select Multiple'}
</button>
<button
onClick={handleEmptyAll}
disabled={stats.total_count === 0}
className="px-4 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] transition-colors"
>
Empty Recycle Bin
</button>
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card-glass-hover rounded-xl p-4">
<div className="text-sm text-muted-foreground">Total Files</div>
<div className="text-2xl font-bold text-foreground animate-count-up">{stats.total_count}</div>
</div>
<div className="card-glass-hover rounded-xl p-4">
<div className="text-sm text-muted-foreground">Total Size</div>
<div className="text-2xl font-bold text-foreground animate-count-up">{formatBytes(stats.total_size)}</div>
</div>
<div className="card-glass-hover rounded-xl p-4">
<div className="text-sm text-muted-foreground">From Media</div>
<div className="text-2xl font-bold text-foreground animate-count-up">
{stats.by_source.media?.count || 0}
</div>
</div>
<div className="card-glass-hover rounded-xl p-4">
<div className="text-sm text-muted-foreground">From Review</div>
<div className="text-2xl font-bold text-foreground animate-count-up">
{stats.by_source.review?.count || 0}
</div>
</div>
</div>
{/* Filters */}
<div className="card-glass-hover rounded-xl p-4 relative z-30">
<FilterBar
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="Search filenames..."
filterSections={[
{
id: 'type',
label: 'Media Type',
type: 'select',
options: [
{ value: 'all', label: 'All Media' },
{ value: 'image', label: 'Images Only' },
{ value: 'video', label: 'Videos Only' },
],
value: typeFilter,
onChange: (v) => setTypeFilter(v as 'all' | 'image' | 'video'),
},
{
id: 'deletedFrom',
label: 'Deleted From',
type: 'select',
options: [
{ value: '', label: `All Deleted From (${stats.total_count})` },
{ value: 'downloads', label: `Downloads (${stats.by_source.downloads?.count || 0})` },
{ value: 'media', label: `Media (${stats.by_source.media?.count || 0})` },
{ value: 'review', label: `Review (${stats.by_source.review?.count || 0})` },
{ value: 'instagram_perceptual_duplicate_detection', label: `Perceptual Duplicates (${stats.by_source.instagram_perceptual_duplicate_detection?.count || 0})` },
],
value: filter || '',
onChange: (v) => setFilter(v as string || null),
},
{
id: 'platform',
label: 'Platform',
type: 'select',
options: [
{ value: '', label: 'All Platforms' },
...(filters?.platforms.map((platform) => ({
value: platform,
label: formatPlatformName(platform),
})) || []),
],
value: platformFilter,
onChange: (v) => { setPlatformFilter(v as string); setPage(0) },
},
{
id: 'source',
label: 'Source',
type: 'select',
options: [
{ value: '', label: 'All Sources' },
...(filters?.sources.map((source) => ({
value: source,
label: source,
})) || []),
],
value: sourceFilter,
onChange: (v) => { setSourceFilter(v as string); setPage(0) },
},
{
id: 'sortBy',
label: 'Sort By',
type: 'select',
options: [
{ value: 'download_date', label: 'Download Date' },
{ value: 'post_date', label: 'Post Date' },
{ value: 'deleted_at', label: 'Deleted Date' },
{ value: 'file_size', label: 'File Size' },
{ value: 'filename', label: 'Filename' },
{ value: 'deleted_from', label: 'Source' },
{ value: 'confidence', label: 'Face Confidence' },
],
value: sortBy,
onChange: (v) => setSortBy(v as string),
},
{
id: 'sortOrder',
label: 'Sort Order',
type: 'select',
options: [
{ value: 'desc', label: 'Newest First' },
{ value: 'asc', label: 'Oldest First' },
],
value: sortOrder,
onChange: (v) => setSortOrder(v as 'asc' | 'desc'),
},
]}
activeFilters={[
...(typeFilter !== 'all' ? [{
id: 'type',
label: 'Type',
value: typeFilter,
displayValue: typeFilter === 'image' ? 'Images' : 'Videos',
onRemove: () => setTypeFilter('all'),
}] : []),
...(filter ? [{
id: 'deletedFrom',
label: 'Deleted From',
value: filter,
displayValue: filter === 'instagram_perceptual_duplicate_detection' ? 'Perceptual Duplicates' : filter.charAt(0).toUpperCase() + filter.slice(1),
onRemove: () => setFilter(null),
}] : []),
...(platformFilter ? [{
id: 'platform',
label: 'Platform',
value: platformFilter,
displayValue: formatPlatformName(platformFilter),
onRemove: () => { setPlatformFilter(''); setPage(0) },
}] : []),
...(sourceFilter ? [{
id: 'source',
label: 'Source',
value: sourceFilter,
displayValue: sourceFilter,
onRemove: () => { setSourceFilter(''); setPage(0) },
}] : []),
...(searchQuery ? [{
id: 'search',
label: 'Search',
value: searchQuery,
displayValue: `"${searchQuery}"`,
onRemove: () => setSearchQuery(''),
}] : []),
...(dateFrom ? [{
id: 'dateFrom',
label: 'From',
value: dateFrom,
displayValue: dateFrom,
onRemove: () => setDateFrom(''),
}] : []),
...(dateTo ? [{
id: 'dateTo',
label: 'To',
value: dateTo,
displayValue: dateTo,
onRemove: () => setDateTo(''),
}] : []),
...(sizeMin ? [{
id: 'sizeMin',
label: 'Min Size',
value: sizeMin,
displayValue: `${sizeMin} bytes`,
onRemove: () => setSizeMin(''),
}] : []),
...(sizeMax ? [{
id: 'sizeMax',
label: 'Max Size',
value: sizeMax,
displayValue: `${sizeMax} bytes`,
onRemove: () => setSizeMax(''),
}] : []),
]}
onClearAll={() => {
setTypeFilter('all')
setSearchQuery('')
setFilter(null)
setPlatformFilter('')
setSourceFilter('')
setDateFrom('')
setDateTo('')
setSizeMin('')
setSizeMax('')
}}
advancedFilters={{
dateFrom: { value: dateFrom, onChange: setDateFrom, label: 'Deleted From' },
dateTo: { value: dateTo, onChange: setDateTo, label: 'Deleted To' },
sizeMin: { value: sizeMin, onChange: setSizeMin, placeholder: '1MB = 1048576' },
sizeMax: { value: sizeMax, onChange: setSizeMax, placeholder: '10MB = 10485760' },
}}
totalCount={filteredTotal}
countLabel="items"
/>
</div>
{/* Stats showing filtered count */}
<div className="flex items-center justify-between text-sm text-slate-600 dark:text-slate-400">
<div>
{hasActiveFilters ? (
<>Showing {filteredItems.length} of {filteredTotal} filtered items</>
) : (
<>Showing {filteredItems.length} of {filteredTotal} items</>
)}
</div>
{totalPages > 1 && (
<div>
Page {page + 1} of {totalPages}
</div>
)}
</div>
{/* Selection Bar */}
{selectMode && filteredItems.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<button
onClick={selectAll}
className="flex items-center space-x-2 px-3 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 text-sm min-h-[44px] transition-colors"
>
{selectedItems.size === filteredItems.length ? (
<Check className="w-5 h-5" />
) : (
<span className="w-5 h-5 border-2 border-slate-400 rounded" />
)}
<span>{selectedItems.size === filteredItems.length ? 'Deselect All' : 'Select All'}</span>
</button>
<span className="text-sm text-blue-700 dark:text-blue-400">
{selectedItems.size} item{selectedItems.size !== 1 ? 's' : ''} selected
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={handleBatchRestore}
disabled={selectedItems.size === 0}
className="flex items-center space-x-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] text-sm transition-colors"
>
<RotateCcw className="w-5 h-5" />
<span className="hidden sm:inline">Restore Selected</span>
<span className="sm:hidden">Restore</span>
</button>
{isFeatureEnabled('/private-gallery') && (
<button
onClick={() => setShowCopyToGalleryModal(true)}
disabled={selectedItems.size === 0}
className="flex items-center space-x-2 px-3 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] text-sm transition-colors"
>
<Copy className="w-4 h-4" />
<span className="hidden sm:inline">Copy to Private</span>
<span className="sm:hidden">Private</span>
</button>
)}
<button
onClick={handleBatchDelete}
disabled={selectedItems.size === 0}
className="flex items-center space-x-2 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] text-sm transition-colors"
>
<TrashIcon className="w-5 h-5" />
<span className="hidden sm:inline">Delete Forever</span>
<span className="sm:hidden">Delete</span>
</button>
</div>
</div>
</div>
)}
{/* Gallery Grid */}
{isLoading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{[...Array(20)].map((_, i) => (
<div key={i} className="aspect-square bg-slate-200 dark:bg-slate-800 rounded-lg animate-pulse" />
))}
</div>
) : filteredItems.length === 0 ? (
<div className="text-center py-20">
<p className="text-slate-500 dark:text-slate-400 text-lg">
{items.length === 0 ? 'Recycle bin is empty' : 'No items match the current filters'}
</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{filteredItems.map((item) => {
const isVideo = isVideoFile(item.original_filename)
return (
<div
key={item.id}
onClick={() => selectMode ? toggleItem(item.id) : setSelectedMedia(item)}
className={`group relative aspect-square bg-slate-100 dark:bg-slate-800 rounded-lg overflow-hidden cursor-pointer hover:ring-2 card-lift thumbnail-zoom ${
selectedItems.has(item.id)
? 'ring-2 ring-blue-500'
: 'hover:ring-blue-500'
}`}
>
{/* Thumbnail - show for all files since we support image and video */}
{item.original_filename ? (
<ThrottledImage
src={getRecycleThumbnailUrl(item)}
alt={item.original_filename}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<TrashIcon className="w-12 h-12 text-gray-400 dark:text-gray-500" />
</div>
)}
{/* Video indicator */}
{isVideo && (
<div className="absolute top-2 right-2 bg-black/70 rounded-full p-1.5">
<Play className="w-4 h-4 text-white fill-white" />
</div>
)}
{/* Face recognition badge */}
{item.face_recognition?.scanned && (
<div className={`absolute ${selectMode ? 'top-10' : 'top-2'} left-2 px-1.5 py-0.5 text-white text-xs font-medium rounded z-10 ${
item.face_recognition.matched
? (item.face_recognition.confidence ?? 0) >= 0.8
? 'bg-green-600'
: (item.face_recognition.confidence ?? 0) >= 0.5
? 'bg-yellow-600'
: 'bg-orange-600'
: item.face_recognition.confidence && item.face_recognition.confidence > 0
? 'bg-red-600'
: 'bg-gray-500'
}`}>
{item.face_recognition.confidence && item.face_recognition.confidence > 0 ? (
<>{Math.round(item.face_recognition.confidence * 100)}%</>
) : (
'No Match'
)}
</div>
)}
{/* Select checkbox */}
{selectMode && (
<div className="absolute top-2 left-2 z-10">
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-colors ${
selectedItems.has(item.id)
? 'bg-blue-600 border-blue-600'
: 'bg-white/90 border-slate-300'
}`}>
{selectedItems.has(item.id) && (
<Check className="w-4 h-4 text-white" />
)}
</div>
</div>
)}
{/* Hover overlay with actions */}
{!selectMode && (
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center space-y-2 p-2">
<button
onClick={(e) => {
e.stopPropagation()
restoreMutation.mutate(item.id)
}}
className="w-full px-3 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center space-x-2 text-sm min-h-[44px] transition-colors"
>
<RotateCcw className="w-4 h-4" />
<span>Restore</span>
</button>
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`Permanently delete ${item.original_filename}? This cannot be undone!`)) {
deleteMutation.mutate(item.id)
}
}}
className="w-full px-3 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 flex items-center justify-center space-x-2 text-sm min-h-[44px] transition-colors"
>
<TrashIcon className="w-4 h-4" />
<span>Delete</span>
</button>
</div>
)}
{/* Info overlay at bottom - only show on hover when not in select mode */}
{!selectMode && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-white text-xs truncate">{item.original_filename}</p>
<div className="flex items-center justify-between text-white/70 text-xs mt-1">
<span>{formatBytes(item.file_size)}</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
item.deleted_from === 'media' ? 'bg-blue-600' :
item.deleted_from === 'review' ? 'bg-yellow-600' :
item.deleted_from === 'instagram_perceptual_duplicate_detection' ? 'bg-red-600' :
'bg-purple-600'
}`}>
{item.deleted_from === 'instagram_perceptual_duplicate_detection' ? 'Duplicate' :
item.deleted_from.charAt(0).toUpperCase() + item.deleted_from.slice(1)}
</span>
</div>
</div>
)}
</div>
)
})}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center space-x-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-4 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg disabled:opacity-50 hover:bg-slate-50 dark:hover:bg-slate-800 min-h-[44px] transition-colors"
>
Previous
</button>
<span className="px-4 py-2 text-slate-600 dark:text-slate-400">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-4 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg disabled:opacity-50 hover:bg-slate-50 dark:hover:bg-slate-800 min-h-[44px] transition-colors"
>
Next
</button>
</div>
)}
{/* Lightbox */}
{selectedMedia && getCurrentLightboxIndex() >= 0 && (
<EnhancedLightbox
items={getLightboxItems()}
currentIndex={getCurrentLightboxIndex()}
onClose={() => setSelectedMedia(null)}
onNavigate={(index) => setSelectedMedia(filteredItems[index])}
onDelete={(item) => {
if (confirm(`Permanently delete ${item.original_filename}? This cannot be undone!`)) {
deleteMutation.mutate(item.id)
}
}}
getPreviewUrl={(item) => getRecyclePreviewUrl(item.id)}
getThumbnailUrl={(item: RecycleBinItem) => getRecycleThumbnailUrl(item)}
isVideo={(item) => isVideoFile(item.original_filename)}
renderActions={(item) => (
<>
<button
onClick={() => restoreMutation.mutate(item.id)}
disabled={restoreMutation.isPending}
className="flex-1 flex items-center justify-center gap-1.5 md:gap-2 px-3 py-1.5 md:px-4 md:py-3 bg-green-600 hover:bg-green-700 disabled:bg-green-800 disabled:opacity-50 text-white text-sm md:text-base rounded-lg transition-colors whitespace-nowrap"
>
<RotateCcw className="w-4 h-4 md:w-5 md:h-5" />
<span>{restoreMutation.isPending ? 'Restoring...' : 'Restore File'}</span>
</button>
<button
onClick={() => {
if (confirm(`Permanently delete ${item.original_filename}? This cannot be undone!`)) {
deleteMutation.mutate(item.id)
}
}}
disabled={deleteMutation.isPending}
className="flex-1 flex items-center justify-center gap-1.5 md:gap-2 px-3 py-1.5 md:px-4 md:py-3 bg-red-600 hover:bg-red-700 disabled:bg-red-800 disabled:opacity-50 text-white text-sm md:text-base rounded-lg transition-colors whitespace-nowrap"
>
<TrashIcon className="w-4 h-4 md:w-5 md:h-5" />
<span>{deleteMutation.isPending ? 'Deleting...' : 'Delete Forever'}</span>
</button>
</>
)}
/>
)}
{/* Progress Modal */}
<BatchProgressModal
isOpen={showProgressModal}
title={progressTitle}
items={progressFiles}
onClose={() => setShowProgressModal(false)}
/>
{/* Copy to Private Gallery Modal */}
<CopyToGalleryModal
open={showCopyToGalleryModal}
onClose={() => setShowCopyToGalleryModal(false)}
sourcePaths={Array.from(selectedPaths.values()).map(v => v.recyclePath)}
sourceType="recycle"
sourceNames={Object.fromEntries(
Array.from(selectedPaths.values()).map(v => [v.recyclePath, v.originalFilename])
)}
onSuccess={() => {
setShowCopyToGalleryModal(false)
setSelectedItems(new Set())
setSelectedPaths(new Map())
setSelectMode(false)
}}
/>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,541 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import { Clock, Play, Pause, Calendar, TrendingUp, Activity, Power, RefreshCw, CalendarClock, Timer, X } from 'lucide-react'
import { api } from '../lib/api'
import { formatRelativeTime } from '../lib/utils'
export default function Scheduler() {
useBreadcrumb(breadcrumbConfig['/scheduler'])
const queryClient = useQueryClient()
const { data: schedulerStatus, isLoading } = useQuery({
queryKey: ['scheduler-status'],
queryFn: () => api.getSchedulerStatus(),
refetchInterval: 10000, // Refresh every 10 seconds
})
const { data: serviceStatus } = useQuery({
queryKey: ['scheduler-service-status'],
queryFn: () => api.getSchedulerServiceStatus(),
refetchInterval: 10000, // Refresh every 10 seconds
})
const { data: platforms } = useQuery({
queryKey: ['platforms'],
queryFn: () => api.getPlatforms(),
})
const { data: config } = useQuery({
queryKey: ['config'],
queryFn: () => api.getConfig(),
})
// Filter out tasks for disabled and hidden platforms
const hiddenModules = (config?.hidden_modules || []) as string[]
const enabledTasks = (schedulerStatus?.tasks.filter((task) => {
const [platformKey] = task.task_id.split(':')
if (hiddenModules.includes(platformKey)) return false
const platform = platforms?.find((p) => p.name === platformKey)
return platform?.enabled !== false
}) || []).sort((a, b) => {
const parseRun = (s: string | null) => s ? new Date(s.replace(' ', 'T')).getTime() || Infinity : Infinity
return parseRun(a.next_run) - parseRun(b.next_run)
})
const pauseMutation = useMutation({
mutationFn: (taskId: string) => api.pauseSchedulerTask(taskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduler-status'] })
},
})
const resumeMutation = useMutation({
mutationFn: (taskId: string) => api.resumeSchedulerTask(taskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduler-status'] })
},
})
const startServiceMutation = useMutation({
mutationFn: () => api.startSchedulerService(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduler-service-status'] })
},
})
const stopServiceMutation = useMutation({
mutationFn: () => api.stopSchedulerService(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduler-service-status'] })
},
})
const restartServiceMutation = useMutation({
mutationFn: () => api.restartSchedulerService(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduler-service-status'] })
},
})
const handlePause = (taskId: string) => {
if (confirm(`Are you sure you want to pause task: ${taskId}?`)) {
pauseMutation.mutate(taskId)
}
}
const handleResume = (taskId: string) => {
resumeMutation.mutate(taskId)
}
const [rescheduleTask, setRescheduleTask] = useState<{ task_id: string; next_run: string } | null>(null)
const [rescheduleTime, setRescheduleTime] = useState('')
const rescheduleMutation = useMutation({
mutationFn: ({ taskId, nextRun }: { taskId: string; nextRun: string }) =>
api.rescheduleTask(taskId, nextRun),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduler-status'] })
setRescheduleTask(null)
},
})
const toLocalDatetimeString = (d: Date): string => {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
const adjustTime = (minutes: number) => {
const d = new Date(rescheduleTime)
d.setMinutes(d.getMinutes() + minutes)
setRescheduleTime(toLocalDatetimeString(d))
}
const getStatusColor = (status: string) => {
if (status === 'active') return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400'
if (status === 'paused') return 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400'
return 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-400'
}
const getTaskPlatform = (taskId: string) => {
const [platform] = taskId.split(':')
const platformNames: Record<string, string> = {
instagram_unified: 'Instagram',
instagram: 'InstaLoader',
fastdl: 'FastDL',
imginn: 'ImgInn',
imginn_api: 'ImgInn API',
instagram_client: 'Instagram',
toolzu: 'Toolzu',
snapchat: 'Snapchat',
snapchat_client: 'Snapchat',
tiktok: 'TikTok',
forum: 'Forum',
monitor: 'Monitor',
appearances: 'Appearances',
paid_content: 'Paid Content',
youtube_channel_monitor: 'YouTube Monitor',
youtube_monitor: 'YouTube Monitor',
youtube_paused_monitor: 'YouTube Monitor', // Legacy name (pre-rename)
easynews_monitor: 'Easynews',
reddit_monitor: 'Reddit Monitor',
press_monitor: 'Press',
tmdb_appearances_sync: 'TMDb Appearances', // Legacy name
appearance_reminders: 'Appearances' // Legacy name
}
return platformNames[platform] || platform
}
const getTaskAccount = (taskId: string) => {
const parts = taskId.split(':')
// Tasks without accounts (like youtube_channel_monitor) should show description, not "Unknown"
if (parts.length === 1) {
const taskDescriptions: Record<string, string> = {
youtube_channel_monitor: 'Channel phrase matching',
youtube_monitor: 'Channel phrase matching',
youtube_paused_monitor: 'Paused channel re-checks', // Legacy name (pre-rename)
easynews_monitor: 'Media Monitor',
reddit_monitor: 'Community Monitor',
press_monitor: 'GDELT News Monitor',
tmdb_appearances_sync: 'Celebrity TV sync', // Legacy name
appearance_reminders: 'Daily Reminders' // Legacy name
}
return taskDescriptions[parts[0]] || ''
}
// Special handling for youtube_monitor subtasks
if (parts[0] === 'youtube_monitor' && parts[1] === 'paused') {
return 'Paused channel re-checks'
}
// Appearance task descriptions
if (parts[0] === 'appearances') {
const appearanceDescriptions: Record<string, string> = {
'tmdb_sync': 'TMDb Sync',
'reminders': 'Daily Reminders'
}
return appearanceDescriptions[parts[1]] || parts[1]
}
// Paid content task descriptions
if (parts[0] === 'paid_content') {
const paidContentDescriptions: Record<string, string> = {
'sync': 'All Creators'
}
return paidContentDescriptions[parts[1]] || parts[1]
}
return parts[1]
}
if (isLoading) {
return (
<div className="space-y-6">
<div className="animate-pulse">
<div className="h-8 bg-slate-200 dark:bg-slate-700 rounded w-1/4 mb-2"></div>
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-1/2"></div>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-3">
<CalendarClock className="w-7 h-7 sm:w-8 sm:h-8 text-emerald-500" />
Scheduler
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1 text-sm sm:text-base">
Manage scheduled download tasks
</p>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${serviceStatus?.running ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{serviceStatus?.running ? 'Running' : 'Stopped'}
</span>
</div>
{serviceStatus?.running ? (
<>
<button
onClick={() => stopServiceMutation.mutate()}
disabled={stopServiceMutation.isPending}
className="inline-flex items-center justify-center p-2 sm:px-4 sm:py-2 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 transition-colors min-w-[44px] min-h-[44px]"
title="Stop"
>
<Pause className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Stop</span>
</button>
<button
onClick={() => restartServiceMutation.mutate()}
disabled={restartServiceMutation.isPending}
className="inline-flex items-center justify-center p-2 sm:px-4 sm:py-2 border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-400 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 disabled:opacity-50 transition-colors min-w-[44px] min-h-[44px]"
title="Restart"
>
<RefreshCw className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Restart</span>
</button>
</>
) : (
<button
onClick={() => startServiceMutation.mutate()}
disabled={startServiceMutation.isPending}
className="inline-flex items-center justify-center p-2 sm:px-4 sm:py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg disabled:opacity-50 transition-colors min-w-[44px] min-h-[44px]"
title="Start"
>
<Power className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Start</span>
</button>
)}
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="card-glass-hover rounded-xl p-6 border stat-card-blue shadow-blue-glow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Tasks</p>
<p className="mt-2 text-3xl font-bold text-foreground animate-count-up">
{enabledTasks.length}
</p>
</div>
<div className="p-3 rounded-xl bg-blue-500/20 text-blue-600 dark:text-blue-400">
<Clock className="w-6 h-6" />
</div>
</div>
</div>
<div className="card-glass-hover rounded-xl p-6 border stat-card-green shadow-green-glow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Active Tasks</p>
<p className="mt-2 text-3xl font-bold text-emerald-600 dark:text-emerald-400 animate-count-up">
{enabledTasks.filter(t => t.status === 'active').length}
</p>
</div>
<div className="p-3 rounded-xl bg-emerald-500/20 text-emerald-600 dark:text-emerald-400">
<Activity className="w-6 h-6" />
</div>
</div>
</div>
<div className="card-glass-hover rounded-xl p-6 border stat-card-orange shadow-orange-glow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Paused Tasks</p>
<p className="mt-2 text-3xl font-bold text-amber-600 dark:text-amber-400 animate-count-up">
{enabledTasks.filter(t => t.status === 'paused').length}
</p>
</div>
<div className="p-3 rounded-xl bg-amber-500/20 text-amber-600 dark:text-amber-400">
<Pause className="w-6 h-6" />
</div>
</div>
</div>
<div className="card-glass-hover rounded-xl p-6 border stat-card-purple shadow-purple-glow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Next Run</p>
<p className="mt-2 text-sm font-bold text-foreground">
{schedulerStatus?.next_run ? formatRelativeTime(schedulerStatus.next_run) : 'N/A'}
</p>
</div>
<div className="p-3 rounded-xl bg-violet-500/20 text-violet-600 dark:text-violet-400">
<Calendar className="w-6 h-6" />
</div>
</div>
</div>
</div>
{/* Tasks Table */}
<div className="card-glass-hover rounded-xl overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Scheduled Tasks
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-full">
<thead className="bg-slate-50 dark:bg-slate-800">
<tr>
<th className="px-3 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Task
</th>
<th className="hidden md:table-cell px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Status
</th>
<th className="hidden lg:table-cell px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Last Run
</th>
<th className="hidden sm:table-cell px-3 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Next Run
</th>
<th className="hidden lg:table-cell px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Runs
</th>
<th className="px-3 sm:px-6 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-800">
{enabledTasks.map((task) => (
<tr key={task.task_id} className="table-row-hover-stripe">
<td className="px-3 sm:px-6 py-3 sm:py-4">
<div className="flex items-center max-w-[180px] sm:max-w-none">
<TrendingUp className="w-4 h-4 sm:w-5 sm:h-5 mr-2 sm:mr-3 text-slate-400 flex-shrink-0" />
<div className="min-w-0 flex-1 overflow-hidden">
<div className="text-xs sm:text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
{getTaskPlatform(task.task_id)}
</div>
<div className="text-xs text-slate-600 dark:text-slate-400 truncate">
{getTaskAccount(task.task_id)}
</div>
{/* Mobile-only: Show status, last run, and next run */}
<div className="sm:hidden mt-1.5 space-y-1">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${getStatusColor(task.status)}`}>
{task.status}
</span>
<span className="text-[10px] text-slate-500 dark:text-slate-400">
{task.run_count} runs
</span>
</div>
<div className="text-[10px] text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1 mr-5">
<Calendar className="w-3 h-3 flex-shrink-0" />
{task.last_run ? formatRelativeTime(task.last_run) : 'Never'}
</span>
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3 flex-shrink-0" />
{task.next_run ? formatRelativeTime(task.next_run) : 'N/A'}
</span>
</div>
</div>
</div>
</div>
</td>
<td className="hidden md:table-cell px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 text-xs font-medium rounded-full ${getStatusColor(task.status)}`}>
{task.status}
</span>
</td>
<td className="hidden lg:table-cell px-6 py-4 whitespace-nowrap text-sm text-slate-600 dark:text-slate-400">
{task.last_run ? formatRelativeTime(task.last_run) : 'Never'}
</td>
<td className="hidden sm:table-cell px-3 sm:px-6 py-4 whitespace-nowrap text-xs sm:text-sm text-slate-600 dark:text-slate-400">
{task.next_run ? formatRelativeTime(task.next_run) : 'N/A'}
</td>
<td className="hidden lg:table-cell px-6 py-4 whitespace-nowrap text-sm text-slate-600 dark:text-slate-400">
{task.run_count}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4 whitespace-nowrap text-right text-sm">
<div className="inline-flex items-center gap-2">
{task.status === 'active' ? (
<button
onClick={() => handlePause(task.task_id)}
disabled={pauseMutation.isPending}
className="inline-flex items-center p-2 sm:px-3 sm:py-2 border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-400 rounded-lg hover:bg-amber-50 dark:hover:bg-amber-900/20 disabled:opacity-50 min-w-[44px] min-h-[44px] justify-center transition-colors"
>
<Pause className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Pause</span>
</button>
) : (
<button
onClick={() => handleResume(task.task_id)}
disabled={resumeMutation.isPending}
className="inline-flex items-center p-2 sm:px-3 sm:py-2 border border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-400 rounded-lg hover:bg-emerald-50 dark:hover:bg-emerald-900/20 disabled:opacity-50 min-w-[44px] min-h-[44px] justify-center transition-colors"
>
<Play className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Resume</span>
</button>
)}
<button
onClick={() => {
setRescheduleTask({ task_id: task.task_id, next_run: task.next_run || new Date().toISOString() })
const d = new Date(task.next_run || new Date())
setRescheduleTime(toLocalDatetimeString(d))
}}
className="inline-flex items-center p-2 sm:px-3 sm:py-2 border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-400 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 min-w-[44px] min-h-[44px] justify-center transition-colors"
title="Reschedule"
>
<Timer className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Info Box */}
<div className="glass-gradient-border rounded-xl p-4">
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-200 mb-2">
About the Scheduler
</h4>
<p className="text-sm text-blue-800 dark:text-blue-300">
The scheduler automatically runs download tasks at randomized intervals to avoid detection.
You can pause individual tasks here without affecting the main scheduler service.
Paused tasks will be skipped until resumed.
</p>
</div>
{/* Reschedule Modal */}
{rescheduleTask && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setRescheduleTask(null)} />
<div className="relative bg-card rounded-xl shadow-2xl border border-border w-full max-w-md">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-foreground">
Reschedule {getTaskPlatform(rescheduleTask.task_id)}
</h3>
<button
onClick={() => setRescheduleTask(null)}
className="p-1 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<p className="text-sm text-muted-foreground mb-1">Current next run</p>
<p className="text-sm font-medium text-foreground">
{new Date(rescheduleTask.next_run).toLocaleString()}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-2">Quick adjust</p>
<div className="flex gap-2">
<button
onClick={() => adjustTime(-60)}
className="flex-1 px-3 py-2 text-sm font-medium border border-border rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
-1h
</button>
<button
onClick={() => adjustTime(-15)}
className="flex-1 px-3 py-2 text-sm font-medium border border-border rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
-15m
</button>
<button
onClick={() => adjustTime(15)}
className="flex-1 px-3 py-2 text-sm font-medium border border-border rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
+15m
</button>
<button
onClick={() => adjustTime(60)}
className="flex-1 px-3 py-2 text-sm font-medium border border-border rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
+1h
</button>
</div>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-2">Custom time</label>
<input
type="datetime-local"
value={rescheduleTime}
onChange={(e) => setRescheduleTime(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-border">
<button
onClick={() => setRescheduleTask(null)}
className="px-4 py-2 text-sm font-medium border border-border rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
Cancel
</button>
<button
onClick={() => rescheduleMutation.mutate({
taskId: rescheduleTask.task_id,
nextRun: rescheduleTime
})}
disabled={rescheduleMutation.isPending}
className="px-4 py-2 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 transition-colors"
>
{rescheduleMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
import { useQuery } from '@tanstack/react-query'
import { useBreadcrumb } from '../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../config/breadcrumbConfig'
import { Activity, Loader2, Clock } from 'lucide-react'
import { api } from '../lib/api'
export default function ScrapingMonitor() {
useBreadcrumb(breadcrumbConfig['/scraping-monitor'])
const { data: activity } = useQuery({
queryKey: ['current-activity'],
queryFn: () => api.getCurrentActivity(),
refetchInterval: 3000,
})
return (
<div className="space-y-6">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<Activity className="w-8 h-8 text-red-500" />
Scraping Monitor
</h1>
</div>
<section className="card-glass-hover rounded-xl p-5 border">
<h2 className="text-lg font-semibold text-foreground mb-3 flex items-center gap-2">
<Activity className="w-5 h-5 text-blue-500" />
Current Activity
</h2>
{activity?.active ? (
<div className="flex items-start gap-4">
<div className="flex-shrink-0 mt-1">
<Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="inline-flex items-center px-2.5 py-1 rounded text-xs font-semibold border bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30">
{activity.platform || 'Unknown'}
</span>
{activity.account && (
<span className="text-sm font-medium text-foreground">{activity.account}</span>
)}
{activity.start_time && (
<span className="text-xs text-muted-foreground flex items-center gap-1 ml-auto">
<Clock className="w-3 h-3" />
Started {new Date(activity.start_time).toLocaleTimeString()}
</span>
)}
</div>
{activity.detailed_status && (
<p className="text-sm text-muted-foreground mt-1">{activity.detailed_status}</p>
)}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground py-3 text-center">
No active scraping task
</div>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,425 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { Shield, Smartphone, Fingerprint, ShieldCheck, ArrowLeft, AlertCircle } from 'lucide-react'
import { api, getErrorMessage } from '../lib/api'
interface TwoFactorAuthProps {
username: string
availableMethods: string[]
rememberMe: boolean
}
type Method = 'totp' | 'passkey' | 'duo'
export default function TwoFactorAuth() {
const navigate = useNavigate()
const location = useLocation()
const state = location.state as TwoFactorAuthProps
const [selectedMethod, setSelectedMethod] = useState<Method | null>(null)
const [totpCode, setTotpCode] = useState('')
const [useBackupCode, setUseBackupCode] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null)
// Redirect if no state
useEffect(() => {
if (!state || !state.username || !state.availableMethods) {
navigate('/login')
}
}, [state, navigate])
if (!state) {
return null
}
const { username, availableMethods, rememberMe } = state
const methodInfo = {
totp: {
icon: Smartphone,
title: 'Authenticator App',
desc: 'Use Google Authenticator, Authy, or similar',
color: 'blue'
},
passkey: {
icon: Fingerprint,
title: 'Passkey',
desc: 'Use Face ID, Touch ID, or security key',
color: 'green'
},
duo: {
icon: ShieldCheck,
title: 'Duo Security',
desc: 'Push notification or phone call',
color: 'purple'
}
}
const handleMethodSelect = (method: Method) => {
setSelectedMethod(method)
setError('')
setAttemptsRemaining(null)
// Auto-start Duo or Passkey flows
if (method === 'duo') {
handleDuoAuth()
} else if (method === 'passkey') {
handlePasskeyAuth()
}
}
const handleTotpSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const result: any = await api.verifyTOTPLogin(username, totpCode, useBackupCode, rememberMe)
if (result.success) {
// Store token and redirect
api.setAuthToken(result.token, result.user, result.sessionId)
navigate('/')
} else {
setError(result.message || 'Invalid verification code')
if (result.attemptsRemaining !== undefined) {
setAttemptsRemaining(result.attemptsRemaining)
}
setTotpCode('')
}
} catch (err: unknown) {
setError(getErrorMessage(err))
setTotpCode('')
} finally {
setLoading(false)
}
}
const handlePasskeyAuth = async () => {
setError('')
setLoading(true)
try {
// Get authentication options
const optionsResult: any = await api.getPasskeyAuthenticationOptions(username)
if (!optionsResult.success) {
throw new Error(optionsResult.message || 'Failed to start passkey authentication')
}
// Helper to convert base64url to Uint8Array
const base64urlToUint8Array = (base64url: string): Uint8Array => {
// Convert base64url to base64
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
// Decode base64 to binary string
const binaryString = atob(base64)
// Convert binary string to Uint8Array
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes
}
// Convert options to proper format for WebAuthn API
const options = optionsResult.options
const publicKeyOptions = {
...options,
challenge: base64urlToUint8Array(options.challenge),
allowCredentials: options.allowCredentials?.map((cred: any) => ({
...cred,
id: base64urlToUint8Array(cred.id)
}))
}
// Use WebAuthn API to authenticate
const credential = await navigator.credentials.get({
publicKey: publicKeyOptions
}) as any
if (!credential) {
throw new Error('Passkey authentication cancelled')
}
// Convert credential to base64 for transmission
const credentialData = {
id: credential.id,
rawId: arrayBufferToBase64(credential.rawId),
response: {
authenticatorData: arrayBufferToBase64(credential.response.authenticatorData),
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
signature: arrayBufferToBase64(credential.response.signature),
userHandle: credential.response.userHandle ? arrayBufferToBase64(credential.response.userHandle) : null
},
type: credential.type
}
// Verify the credential
const result: any = await api.post('/auth/2fa/passkey/verify-authentication', {
credential: credentialData,
username: username,
rememberMe: rememberMe
})
if (result.success) {
// Store token and redirect
api.setAuthToken(result.token, result.user, result.sessionId)
navigate('/')
} else {
setError(result.message || 'Passkey authentication failed')
}
} catch (err: unknown) {
console.error('Passkey auth error:', err)
setError(getErrorMessage(err))
} finally {
setLoading(false)
}
}
const handleDuoAuth = async () => {
setError('')
setLoading(true)
try {
const result: any = await api.createDuoAuth(username, rememberMe)
if (!result.success) {
throw new Error(result.message || 'Failed to initiate Duo authentication')
}
// Redirect to Duo auth URL
window.location.href = result.authUrl
} catch (err: unknown) {
setError(getErrorMessage(err))
setLoading(false)
}
}
const handleBackToLogin = () => {
navigate('/login')
}
const handleBackToMethods = () => {
setSelectedMethod(null)
setError('')
setTotpCode('')
setAttemptsRemaining(null)
}
// Helper function to convert ArrayBuffer to base64
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
// Show method selector if no method selected or multiple methods available
if (!selectedMethod && availableMethods.length > 1) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 dark:from-gray-900 dark:via-slate-900 dark:to-gray-900 p-4">
<div className="w-full max-w-md bg-card/80 backdrop-blur-xl border border-white/20 dark:border-white/10 rounded-2xl shadow-2xl p-8">
<div className="text-center mb-8">
<Shield className="w-16 h-16 mx-auto mb-4 text-primary" />
<h2 className="text-2xl font-bold text-foreground mb-2">
Two-Factor Authentication
</h2>
<p className="text-muted-foreground">
Choose your verification method
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div className="space-y-3">
{availableMethods.map((method) => {
const info = methodInfo[method as Method]
const Icon = info.icon
return (
<button
key={method}
onClick={() => handleMethodSelect(method as Method)}
className={`w-full p-4 border-2 rounded-xl transition-all hover:scale-[1.02] hover:shadow-lg flex items-center space-x-4 bg-secondary/30
${info.color === 'blue' ? 'border-primary/50 hover:bg-primary/10' : ''}
${info.color === 'green' ? 'border-green-500/50 hover:bg-green-500/10' : ''}
${info.color === 'purple' ? 'border-purple-500/50 hover:bg-purple-500/10' : ''}
`}
>
<Icon className={`w-8 h-8
${info.color === 'blue' ? 'text-primary' : ''}
${info.color === 'green' ? 'text-green-600 dark:text-green-400' : ''}
${info.color === 'purple' ? 'text-purple-600 dark:text-purple-400' : ''}
`} />
<div className="flex-1 text-left">
<div className="font-semibold text-foreground">{info.title}</div>
<div className="text-sm text-muted-foreground">{info.desc}</div>
</div>
<svg className="w-6 h-6 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)
})}
</div>
<button
onClick={handleBackToLogin}
className="mt-6 w-full flex items-center justify-center space-x-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>Back to login</span>
</button>
</div>
</div>
)
}
// Auto-select if only one method
const currentMethod = selectedMethod || (availableMethods.length === 1 ? availableMethods[0] as Method : null)
if (!currentMethod) {
return null
}
const info = methodInfo[currentMethod]
const Icon = info.icon
// TOTP Verification Form
if (currentMethod === 'totp') {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 dark:from-gray-900 dark:via-slate-900 dark:to-gray-900 p-4">
<div className="w-full max-w-md bg-card/80 backdrop-blur-xl border border-white/20 dark:border-white/10 rounded-2xl shadow-2xl p-8">
<div className="text-center mb-8">
<Icon className="w-16 h-16 mx-auto mb-4 text-primary" />
<h2 className="text-2xl font-bold text-foreground mb-2">
{info.title}
</h2>
<p className="text-muted-foreground">
{useBackupCode ? 'Enter your backup code' : 'Enter the 6-digit code from your app'}
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
{attemptsRemaining !== null && (
<p className="text-xs text-red-700 dark:text-red-300 mt-1">
{attemptsRemaining} attempts remaining
</p>
)}
</div>
</div>
)}
<form onSubmit={handleTotpSubmit} className="space-y-6">
<div>
<input
type="text"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/[^0-9-]/g, ''))}
placeholder={useBackupCode ? "XXXX-XXXX" : "000000"}
maxLength={useBackupCode ? 9 : 6}
className="w-full px-4 py-3 text-center text-2xl tracking-widest border-2 border-border rounded-xl focus:ring-2 focus:ring-primary bg-background text-foreground"
autoFocus
required
/>
</div>
<button
type="submit"
disabled={loading || (!useBackupCode && totpCode.length !== 6) || (useBackupCode && totpCode.length !== 9)}
className="w-full py-3 bg-primary hover:bg-primary/90 disabled:bg-primary/50 text-primary-foreground font-medium rounded-xl transition-colors"
>
{loading ? 'Verifying...' : 'Verify Code'}
</button>
<button
type="button"
onClick={() => {
setUseBackupCode(!useBackupCode)
setTotpCode('')
setError('')
}}
className="w-full text-sm text-primary hover:underline"
>
{useBackupCode ? 'Use authenticator code instead' : 'Use backup code instead'}
</button>
</form>
{availableMethods.length > 1 && (
<button
onClick={handleBackToMethods}
className="mt-6 w-full flex items-center justify-center space-x-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>Try another method</span>
</button>
)}
<button
onClick={handleBackToLogin}
className="mt-2 w-full flex items-center justify-center space-x-2 text-muted-foreground hover:text-foreground transition-colors text-sm"
>
<ArrowLeft className="w-4 h-4" />
<span>Back to login</span>
</button>
</div>
</div>
)
}
// Passkey or Duo - show loading state
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 dark:from-gray-900 dark:via-slate-900 dark:to-gray-900 p-4">
<div className="w-full max-w-md bg-card/80 backdrop-blur-xl border border-white/20 dark:border-white/10 rounded-2xl shadow-2xl p-8">
<div className="text-center mb-8">
<Icon className={`w-16 h-16 mx-auto mb-4
${info.color === 'green' ? 'text-green-600 dark:text-green-400' : ''}
${info.color === 'purple' ? 'text-purple-600 dark:text-purple-400' : ''}
`} />
<h2 className="text-2xl font-bold text-foreground mb-2">
{info.title}
</h2>
<p className="text-muted-foreground">
{currentMethod === 'passkey' ? 'Follow the prompts on your device' : 'Redirecting to Duo Security...'}
</p>
</div>
{loading && (
<div className="flex justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
)}
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
{availableMethods.length > 1 && (
<button
onClick={handleBackToMethods}
className="mt-6 w-full flex items-center justify-center space-x-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>Try another method</span>
</button>
)}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,309 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useBreadcrumb } from '../../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../../config/breadcrumbConfig'
import { BarChart3, TrendingUp, HardDrive, Users, ChevronUp, ChevronDown } from 'lucide-react'
import { api, PaidContentAnalyticsSummary } from '../../lib/api'
import { formatBytes } from '../../lib/utils'
import {
AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer
} from 'recharts'
const TIME_RANGES = [
{ label: '7d', days: 7 },
{ label: '30d', days: 30 },
{ label: '90d', days: 90 },
{ label: 'All Time', days: 365 },
]
const CHART_COLORS = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6b7280']
const CustomTooltip = ({ active, payload, label, formatter }: any) => {
if (!active || !payload?.length) return null
return (
<div className="bg-popover border border-border rounded-lg p-2 shadow-lg text-sm">
<p className="text-muted-foreground mb-1">{label}</p>
{payload.map((entry: any, i: number) => (
<p key={i} style={{ color: entry.color }}>
{entry.name}: {formatter ? formatter(entry.value) : entry.value}
</p>
))}
</div>
)
}
type SortField = 'total_bytes' | 'file_count' | 'post_count' | 'username' | 'video_count' | 'image_count'
export default function PaidContentAnalytics() {
useBreadcrumb(breadcrumbConfig['/paid-content/analytics'])
const [days, setDays] = useState(30)
const [sortField, setSortField] = useState<SortField>('total_bytes')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const { data, isLoading } = useQuery<PaidContentAnalyticsSummary>({
queryKey: ['paid-content-analytics', days],
queryFn: () => api.paidContent.getAnalyticsSummary(days),
})
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDir('desc')
}
}
const sortedScorecards = [...(data?.creator_scorecards || [])].sort((a: any, b: any) => {
const av = a[sortField]
const bv = b[sortField]
if (typeof av === 'string') return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av)
return sortDir === 'asc' ? av - bv : bv - av
})
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) return null
return sortDir === 'asc' ? <ChevronUp className="w-3 h-3 inline" /> : <ChevronDown className="w-3 h-3 inline" />
}
const totalStorage = data?.storage_growth?.length
? data.storage_growth[data.storage_growth.length - 1].cumulative_bytes
: 0
const totalDownloads = data?.downloads_per_period?.reduce((s, d) => s + d.count, 0) || 0
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<BarChart3 className="w-8 h-8 text-violet-500" />
Analytics
</h1>
<p className="text-muted-foreground mt-1">Storage, downloads, and content insights</p>
</div>
<div className="flex gap-1 bg-secondary rounded-lg p-1">
{TIME_RANGES.map(r => (
<button
key={r.days}
onClick={() => setDays(r.days)}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
days === r.days ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
{r.label}
</button>
))}
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
</div>
) : !data ? (
<div className="text-center py-12 text-muted-foreground">No analytics data available</div>
) : (
<>
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-card rounded-xl border border-border p-4">
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-1">
<HardDrive className="w-4 h-4" /> Total Storage
</div>
<div className="text-2xl font-bold">{formatBytes(totalStorage)}</div>
</div>
<div className="bg-card rounded-xl border border-border p-4">
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-1">
<TrendingUp className="w-4 h-4" /> Downloads ({days}d)
</div>
<div className="text-2xl font-bold">{totalDownloads.toLocaleString()}</div>
</div>
<div className="bg-card rounded-xl border border-border p-4">
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-1">
<Users className="w-4 h-4" /> Active Creators
</div>
<div className="text-2xl font-bold">{data.creator_scorecards?.length || 0}</div>
</div>
<div className="bg-card rounded-xl border border-border p-4">
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-1">
<BarChart3 className="w-4 h-4" /> Platforms
</div>
<div className="text-2xl font-bold">{data.platform_distribution?.length || 0}</div>
</div>
</div>
{/* Storage Growth Chart */}
{data.storage_growth?.length > 0 && (
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
<h2 className="text-lg font-semibold mb-4">Storage Growth</h2>
<div className="h-64 md:h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data.storage_growth}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} tickFormatter={v => v.slice(5)} />
<YAxis tick={{ fontSize: 12 }} tickFormatter={v => formatBytes(v)} />
<Tooltip content={<CustomTooltip formatter={(v: number) => formatBytes(v)} />} />
<Area type="monotone" dataKey="cumulative_bytes" name="Total Storage" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Two-column charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Downloads Per Day */}
{data.downloads_per_period?.length > 0 && (
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
<h2 className="text-lg font-semibold mb-4">Downloads Per Day</h2>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.downloads_per_period}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="period" tick={{ fontSize: 12 }} tickFormatter={v => v.slice(5)} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" name="Downloads" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Storage By Creator */}
{data.storage_by_creator?.length > 0 && (
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
<h2 className="text-lg font-semibold mb-4">Top Creators by Storage</h2>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.storage_by_creator.slice(0, 10)} layout="vertical">
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis type="number" tick={{ fontSize: 12 }} tickFormatter={v => formatBytes(v)} />
<YAxis type="category" dataKey="username" tick={{ fontSize: 12 }} width={100} />
<Tooltip content={<CustomTooltip formatter={(v: number) => formatBytes(v)} />} />
<Bar dataKey="total_bytes" name="Storage" fill="#10b981" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Platform Distribution */}
{data.platform_distribution?.length > 0 && (
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
<h2 className="text-lg font-semibold mb-4">Platform Distribution</h2>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data.platform_distribution}
dataKey="total_bytes"
nameKey="platform"
cx="50%" cy="50%"
innerRadius={50} outerRadius={90}
label={({ platform, percent }) => `${platform} (${(percent * 100).toFixed(0)}%)`}
labelLine={false}
>
{data.platform_distribution.map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v: number) => formatBytes(v)} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Content Type Distribution */}
{data.content_type_distribution?.length > 0 && (
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
<h2 className="text-lg font-semibold mb-4">Content Type Distribution</h2>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data.content_type_distribution}
dataKey="count"
nameKey="content_type"
cx="50%" cy="50%"
innerRadius={50} outerRadius={90}
label={({ content_type, percent }) => `${content_type} (${(percent * 100).toFixed(0)}%)`}
labelLine={false}
>
{data.content_type_distribution.map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
</div>
)}
</div>
{/* Creator Scorecards Table */}
{sortedScorecards.length > 0 && (
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
<h2 className="text-lg font-semibold mb-4">Creator Scorecards</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="pb-2 pr-4 cursor-pointer hover:text-foreground" onClick={() => handleSort('username')}>
Creator <SortIcon field="username" />
</th>
<th className="pb-2 pr-4">Platform</th>
<th className="pb-2 pr-4 cursor-pointer hover:text-foreground text-right" onClick={() => handleSort('post_count')}>
Posts <SortIcon field="post_count" />
</th>
<th className="pb-2 pr-4 cursor-pointer hover:text-foreground text-right" onClick={() => handleSort('file_count')}>
Files <SortIcon field="file_count" />
</th>
<th className="pb-2 pr-4 cursor-pointer hover:text-foreground text-right" onClick={() => handleSort('image_count')}>
Images <SortIcon field="image_count" />
</th>
<th className="pb-2 pr-4 cursor-pointer hover:text-foreground text-right" onClick={() => handleSort('video_count')}>
Videos <SortIcon field="video_count" />
</th>
<th className="pb-2 cursor-pointer hover:text-foreground text-right" onClick={() => handleSort('total_bytes')}>
Storage <SortIcon field="total_bytes" />
</th>
</tr>
</thead>
<tbody>
{sortedScorecards.map((c: any) => (
<tr key={c.id} className="border-b border-border/50 hover:bg-secondary/50">
<td className="py-2 pr-4">
<div className="flex items-center gap-2">
{c.profile_image_url && (
<img
src={`/api/paid-content/proxy/image?url=${encodeURIComponent(c.profile_image_url)}`}
className="w-6 h-6 rounded-full"
alt=""
/>
)}
<span className="font-medium">{c.display_name || c.username}</span>
</div>
</td>
<td className="py-2 pr-4 capitalize text-muted-foreground">{c.platform}</td>
<td className="py-2 pr-4 text-right">{c.post_count}</td>
<td className="py-2 pr-4 text-right">{c.file_count}</td>
<td className="py-2 pr-4 text-right">{c.image_count}</td>
<td className="py-2 pr-4 text-right">{c.video_count}</td>
<td className="py-2 text-right">{formatBytes(c.total_bytes)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,886 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useSearchParams } from 'react-router-dom'
import {
Trash2,
CheckSquare,
Square,
X,
ChevronDown,
ChevronRight,
Image as ImageIcon,
Video,
File,
Paperclip,
ArrowUpDown,
ScanFace,
Plus,
Filter,
} from 'lucide-react'
import { api, getErrorMessage, PaidContentPost, PaidContentAttachment, PaidContentCreator } from '../../lib/api'
import { formatRelativeTime } from '../../lib/utils'
import { notificationManager } from '../../lib/notificationManager'
const PAGE_SIZE = 50
const STORAGE_KEY = 'bulk-delete-selected'
function loadSavedSelections(creatorId: number): Set<number> {
try {
const data = localStorage.getItem(`${STORAGE_KEY}-${creatorId}`)
if (data) return new Set(JSON.parse(data) as number[])
} catch { /* ignore */ }
return new Set()
}
function saveSelections(creatorId: number | null, ids: Set<number>) {
if (!creatorId) return
try {
if (ids.size === 0) {
localStorage.removeItem(`${STORAGE_KEY}-${creatorId}`)
} else {
localStorage.setItem(`${STORAGE_KEY}-${creatorId}`, JSON.stringify([...ids]))
}
} catch { /* ignore */ }
}
function AttachmentThumbnail({
attachment,
addRefMode,
onAddReference,
}: {
attachment: PaidContentAttachment
addRefMode?: boolean
onAddReference?: (att: PaidContentAttachment) => void
}) {
const [error, setError] = useState(false)
const [hovered, setHovered] = useState(false)
const [popupPos, setPopupPos] = useState({ top: 0, left: 0 })
const thumbRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const isVideo = attachment.file_type === 'video'
const videoUrl = isVideo && attachment.local_path
? `/api/paid-content/files/serve?path=${encodeURIComponent(attachment.local_path)}`
: null
const handleMouseEnter = useCallback(() => {
if (thumbRef.current) {
const rect = thumbRef.current.getBoundingClientRect()
const previewSize = 280
let top = rect.top - previewSize - 8
let left = rect.left
if (top < 8) {
top = rect.bottom + 8
}
if (left + previewSize > window.innerWidth - 8) {
left = window.innerWidth - previewSize - 8
}
if (left < 8) left = 8
setPopupPos({ top, left })
}
setHovered(true)
}, [])
const handleMouseLeave = useCallback(() => {
setHovered(false)
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.currentTime = 0
}
}, [])
const handleClick = useCallback((e: React.MouseEvent) => {
if (addRefMode && onAddReference && attachment.local_path && attachment.file_type === 'image') {
e.stopPropagation()
onAddReference(attachment)
}
}, [addRefMode, onAddReference, attachment])
if (error) {
return (
<div className="w-16 h-16 rounded bg-secondary flex items-center justify-center">
<ImageIcon className="w-4 h-4 text-muted-foreground" />
</div>
)
}
return (
<div ref={thumbRef} className="relative">
<img
src={`/api/paid-content/files/thumbnail/${attachment.id}?size=small`}
alt=""
className={`w-16 h-16 rounded object-cover bg-secondary cursor-pointer ${
addRefMode && attachment.file_type === 'image' && attachment.local_path
? 'ring-2 ring-green-500 ring-offset-1'
: ''
}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
onError={() => setError(true)}
loading="lazy"
/>
{addRefMode && attachment.file_type === 'image' && attachment.local_path && (
<div className="absolute -top-1 -right-1 bg-green-500 rounded-full w-4 h-4 flex items-center justify-center pointer-events-none">
<Plus className="w-3 h-3 text-white" />
</div>
)}
{isVideo && (
<div className="absolute bottom-0.5 right-0.5 bg-black/70 rounded px-1 py-0.5 pointer-events-none">
<Video className="w-2.5 h-2.5 text-white" />
</div>
)}
{hovered && createPortal(
<div
className="fixed z-[9999] pointer-events-none"
style={{ top: popupPos.top, left: popupPos.left }}
>
{videoUrl ? (
<video
ref={videoRef}
src={videoUrl}
className="w-[280px] h-[280px] rounded-lg object-contain bg-black/90 shadow-2xl border border-border"
autoPlay
muted
loop
playsInline
/>
) : (
<img
src={`/api/paid-content/files/thumbnail/${attachment.id}?size=large`}
alt=""
className="w-[280px] h-[280px] rounded-lg object-contain bg-black/90 shadow-2xl border border-border"
/>
)}
</div>,
document.body
)}
</div>
)
}
function getPostDisplayTitle(post: PaidContentPost): string {
if (post.title && post.title.trim()) {
return post.title.trim()
}
if (post.content && post.content.trim()) {
const text = post.content.trim().replace(/\s+/g, ' ')
return text.length > 120 ? text.slice(0, 120) + '...' : text
}
// Build from metadata
const parts: string[] = []
if (post.published_at) {
const date = new Date(post.published_at)
parts.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }))
}
if (post.attachment_count > 0) {
const imageCount = post.attachments?.filter(a => a.file_type === 'image').length || 0
const videoCount = post.attachments?.filter(a => a.file_type === 'video').length || 0
if (videoCount > 0 && imageCount > 0) {
parts.push(`${imageCount} images, ${videoCount} videos`)
} else if (videoCount > 0) {
parts.push(`${videoCount} ${videoCount === 1 ? 'video' : 'videos'}`)
} else if (imageCount > 0) {
parts.push(`${imageCount} ${imageCount === 1 ? 'image' : 'images'}`)
} else {
parts.push(`${post.attachment_count} ${post.attachment_count === 1 ? 'file' : 'files'}`)
}
}
if (parts.length > 0) return parts.join(' — ')
return `Post ${post.post_id?.slice(-8) || post.id}`
}
function PostCard({
post,
isSelected,
onSelect,
addRefMode,
onAddReference,
}: {
post: PaidContentPost
isSelected: boolean
onSelect: () => void
addRefMode?: boolean
onAddReference?: (att: PaidContentAttachment) => void
}) {
const thumbnailAttachments = post.attachments?.slice(0, 4) || []
return (
<div
className={`p-4 border-b border-border last:border-b-0 ${
isSelected ? 'bg-primary/10' : 'hover:bg-secondary/50'
} transition-colors cursor-pointer`}
onClick={onSelect}
>
{/* Top row: checkbox + title + metadata */}
<div className="flex items-start gap-3">
<button onClick={(e) => { e.stopPropagation(); onSelect() }} className="flex-shrink-0 mt-0.5">
{isSelected ? (
<CheckSquare className="w-5 h-5 text-primary" />
) : (
<Square className="w-5 h-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-foreground">
{getPostDisplayTitle(post)}
</p>
{(post.tags?.length || (post.tagged_users?.length ?? 0) > 0) ? (
<div className="flex gap-1 flex-shrink-0 flex-wrap">
{(post.tags || []).map(tag => (
<span
key={tag.id}
className="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
style={{ backgroundColor: tag.color + '20', color: tag.color }}
>
{tag.name}
</span>
))}
{(post.tagged_users || []).map(username => (
<span
key={`tu-${username}`}
className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-cyan-500/15 text-cyan-600 dark:text-cyan-400"
>
@{username}
</span>
))}
</div>
) : null}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
{post.published_at && (
<span>{formatRelativeTime(post.published_at)}</span>
)}
<span className="flex items-center gap-1">
<Paperclip className="w-3 h-3" />
{post.attachment_count} attachment{post.attachment_count !== 1 ? 's' : ''}
</span>
{post.image_count !== undefined && post.image_count > 0 && (
<span className="flex items-center gap-1">
<ImageIcon className="w-3 h-3" />
{post.image_count}
</span>
)}
{post.video_count !== undefined && post.video_count > 0 && (
<span className="flex items-center gap-1">
<Video className="w-3 h-3" />
{post.video_count}
</span>
)}
</div>
</div>
</div>
{/* Thumbnails underneath */}
{thumbnailAttachments.length > 0 && (
<div className="flex gap-1.5 mt-3 ml-8" onClick={(e) => e.stopPropagation()}>
{thumbnailAttachments.map((att) => (
<AttachmentThumbnail
key={att.id}
attachment={att}
addRefMode={addRefMode}
onAddReference={onAddReference}
/>
))}
{post.attachment_count > 4 && (
<div className="w-16 h-16 rounded bg-secondary flex items-center justify-center text-xs text-muted-foreground font-medium">
+{post.attachment_count - 4}
</div>
)}
</div>
)}
</div>
)
}
export default function PaidContentBulkDelete() {
const queryClient = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const initialCreatorId = searchParams.get('creator_id')
const parsedCreatorId = initialCreatorId ? parseInt(initialCreatorId, 10) : null
const [creatorId, setCreatorId] = useState<number | null>(parsedCreatorId)
const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc')
const [selectedIds, setSelectedIds] = useState<Set<number>>(
() => parsedCreatorId ? loadSavedSelections(parsedCreatorId) : new Set()
)
const [offset, setOffset] = useState(0)
const [allPosts, setAllPosts] = useState<PaidContentPost[]>([])
const [showConfirm, setShowConfirm] = useState(false)
// Face recognition state
const [faceExpanded, setFaceExpanded] = useState(false)
const [addRefMode, setAddRefMode] = useState(false)
const [personName, setPersonName] = useState('India Reynolds')
const [faceFilter, setFaceFilter] = useState<'all' | 'not-match'>('all')
const [scanComplete, setScanComplete] = useState(false)
const [isPolling, setIsPolling] = useState(false)
// Persist selections to localStorage
useEffect(() => {
saveSelections(creatorId, selectedIds)
}, [creatorId, selectedIds])
// Fetch creators for dropdown
const { data: creators = [] } = useQuery<PaidContentCreator[]>({
queryKey: ['paid-content-creators-all'],
queryFn: () => api.paidContent.getCreators({}),
staleTime: 30000,
})
// Fetch posts for selected creator
const { data: feedData, isLoading, isFetching } = useQuery({
queryKey: ['paid-content-bulk-delete-feed', creatorId, sortOrder, offset],
queryFn: async () => {
const result = await api.paidContent.getFeed({
creator_id: creatorId!,
sort_by: 'published_at',
sort_order: sortOrder,
pinned_first: false,
skip_pinned: true,
limit: PAGE_SIZE,
offset,
})
// Append or replace posts based on offset
if (offset === 0) {
setAllPosts(result.posts)
} else {
setAllPosts((prev) => {
const existingIds = new Set(prev.map((p) => p.id))
const newPosts = result.posts.filter((p) => !existingIds.has(p.id))
return [...prev, ...newPosts]
})
}
return result
},
enabled: !!creatorId,
})
// Face references
const { data: faceRefs = [], refetch: refetchRefs } = useQuery({
queryKey: ['face-references'],
queryFn: () => api.paidContent.faceGetReferences(),
staleTime: 10000,
})
// Face scan status polling - uses refetchInterval for reliable polling
const { data: scanStatus } = useQuery({
queryKey: ['face-scan-status'],
queryFn: () => api.paidContent.faceScanStatus(),
enabled: faceExpanded || isPolling,
refetchInterval: isPolling ? 2000 : false,
staleTime: 0,
})
const isScanning = scanStatus?.status === 'initializing' || scanStatus?.status === 'scanning'
// Auto-start polling when we detect a running scan, stop when done
useEffect(() => {
if (isScanning && !isPolling) {
setIsPolling(true)
}
if (scanStatus && !isScanning && isPolling) {
setIsPolling(false)
}
}, [isScanning, isPolling, scanStatus])
// When scan completes, reload posts to reflect updated tagged users
useEffect(() => {
if (scanStatus?.status === 'completed' && !scanComplete) {
setScanComplete(true)
// Reload posts to get updated tagged users
setOffset(0)
setAllPosts([])
queryClient.invalidateQueries({ queryKey: ['paid-content-bulk-delete-feed'] })
}
}, [scanStatus?.status, scanComplete, queryClient])
const totalPosts = feedData?.total || 0
const hasMore = allPosts.length < totalPosts
// Apply face filter: show posts WITHOUT lovefromreyn tagged user (i.e. not matched)
const displayPosts = faceFilter === 'not-match'
? allPosts.filter(p => !(p.tagged_users || []).includes('lovefromreyn'))
: allPosts
const handleCreatorChange = useCallback((newCreatorId: number | null) => {
setCreatorId(newCreatorId)
setSelectedIds(newCreatorId ? loadSavedSelections(newCreatorId) : new Set())
setOffset(0)
setAllPosts([])
setFaceFilter('all')
setScanComplete(false)
if (newCreatorId) {
setSearchParams({ creator_id: newCreatorId.toString() })
} else {
setSearchParams({})
}
}, [setSearchParams])
const handleSortChange = useCallback((newSort: 'desc' | 'asc') => {
setSortOrder(newSort)
setOffset(0)
setAllPosts([])
}, [])
const handleSelect = useCallback((id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const handleSelectAllOnPage = useCallback(() => {
const postsToSelect = displayPosts
const allIds = postsToSelect.map((p) => p.id)
const allSelected = allIds.every((id) => selectedIds.has(id))
if (allSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(allIds))
}
}, [displayPosts, selectedIds])
const handleLoadMore = useCallback(() => {
setOffset((prev) => prev + PAGE_SIZE)
}, [])
// Face reference add mutation
const addRefMutation = useMutation({
mutationFn: (att: PaidContentAttachment) =>
api.paidContent.faceAddReference(personName, att.local_path!),
onSuccess: () => {
notificationManager.success('Reference Added', 'Face reference added successfully')
refetchRefs()
},
onError: (error: unknown) => {
notificationManager.error('Failed', getErrorMessage(error))
},
})
const deleteRefMutation = useMutation({
mutationFn: (id: number) => api.paidContent.faceDeleteReference(id),
onSuccess: () => {
refetchRefs()
},
})
const startScanMutation = useMutation({
mutationFn: () => api.paidContent.faceScanCreator(creatorId!, personName),
onSuccess: () => {
notificationManager.success('Scan Started', `Scanning for ${personName}...`)
setIsPolling(true)
},
onError: (error: unknown) => {
notificationManager.error('Scan Failed', getErrorMessage(error))
},
})
const handleAddReference = useCallback((att: PaidContentAttachment) => {
if (!att.local_path) return
addRefMutation.mutate(att)
}, [addRefMutation])
const handleSelectAllNotMatch = useCallback(() => {
const notMatchIds = allPosts
.filter(p => !(p.tagged_users || []).includes('lovefromreyn'))
.map(p => p.id)
setSelectedIds(new Set(notMatchIds))
}, [allPosts])
// Bulk delete mutation
const deleteMutation = useMutation({
mutationFn: (postIds: number[]) => api.paidContent.bulkDeletePosts(postIds),
onSuccess: (result, postIds) => {
notificationManager.success(
'Deleted',
`${result.deleted_count} post(s) deleted successfully`
)
// Remove deleted posts from local state
const deletedSet = new Set(postIds)
setAllPosts((prev) => prev.filter((p) => !deletedSet.has(p.id)))
setSelectedIds(new Set())
// Invalidate feed queries so other pages reflect changes
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
queryClient.invalidateQueries({ queryKey: ['paid-content-bulk-delete-feed'] })
setShowConfirm(false)
},
onError: (error: unknown) => {
notificationManager.error('Delete Failed', getErrorMessage(error))
setShowConfirm(false)
},
})
const handleDeleteSelected = useCallback(() => {
if (selectedIds.size === 0) return
setShowConfirm(true)
}, [selectedIds])
const confirmDelete = useCallback(() => {
deleteMutation.mutate(Array.from(selectedIds))
}, [deleteMutation, selectedIds])
const allOnPageSelected =
displayPosts.length > 0 && displayPosts.every((p) => selectedIds.has(p.id))
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<Trash2 className="w-8 h-8 text-red-500" />
Bulk Delete
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Select and delete multiple posts at once
</p>
</div>
{/* Controls */}
<div className="card-glass-hover rounded-xl p-4 flex flex-wrap items-center gap-4">
{/* Creator dropdown */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-foreground whitespace-nowrap">Creator:</label>
<div className="relative">
<select
value={creatorId ?? ''}
onChange={(e) => handleCreatorChange(e.target.value ? parseInt(e.target.value, 10) : null)}
className="input pr-8 min-w-[200px]"
>
<option value="">Select a creator...</option>
{creators.map((c) => (
<option key={c.id} value={c.id}>
{c.username} ({c.platform})
</option>
))}
</select>
<ChevronDown className="w-4 h-4 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
</div>
</div>
{/* Sort dropdown */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-foreground whitespace-nowrap">Sort:</label>
<div className="relative">
<select
value={sortOrder}
onChange={(e) => handleSortChange(e.target.value as 'desc' | 'asc')}
className="input pr-8"
>
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
<ArrowUpDown className="w-4 h-4 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
</div>
</div>
{/* Post count */}
{creatorId && feedData && (
<span className="text-sm text-muted-foreground ml-auto">
{allPosts.length} of {totalPosts} posts loaded
</span>
)}
</div>
{/* Face Recognition Panel */}
{creatorId && (
<div className="card-glass-hover rounded-xl border border-border overflow-hidden">
<button
onClick={() => setFaceExpanded(!faceExpanded)}
className="w-full p-4 flex items-center gap-3 text-left hover:bg-secondary/30 transition-colors"
>
{faceExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
<ScanFace className="w-5 h-5 text-blue-500" />
<span className="font-medium text-foreground">Face Recognition</span>
{faceRefs.length > 0 && (
<span className="text-xs text-muted-foreground">
{faceRefs.length} reference{faceRefs.length !== 1 ? 's' : ''}
</span>
)}
</button>
{faceExpanded && (
<div className="p-4 pt-0 space-y-4">
{/* Person name input */}
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-foreground whitespace-nowrap">Person:</label>
<input
type="text"
value={personName}
onChange={(e) => setPersonName(e.target.value)}
className="input max-w-[200px]"
placeholder="Person name..."
/>
<button
onClick={() => setAddRefMode(!addRefMode)}
className={`btn btn-sm ${addRefMode ? 'btn-primary' : 'btn-secondary'}`}
>
<Plus className="w-4 h-4 mr-1" />
{addRefMode ? 'Done Adding' : 'Add from Posts'}
</button>
</div>
{addRefMode && (
<div className="text-xs text-blue-500 bg-blue-500/10 rounded-lg px-3 py-2">
Click any image thumbnail below to add it as a face reference for "{personName}".
Only images with faces will be accepted.
</div>
)}
{/* Existing references */}
{faceRefs.length > 0 && (
<div className="flex flex-wrap gap-2">
{faceRefs.map((ref) => (
<div key={ref.id} className="relative group">
{ref.thumbnail ? (
<img
src={`data:image/jpeg;base64,${ref.thumbnail}`}
alt={ref.person_name}
className="w-14 h-14 rounded-lg object-cover border border-border"
/>
) : (
<div className="w-14 h-14 rounded-lg bg-secondary flex items-center justify-center border border-border">
<ScanFace className="w-5 h-5 text-muted-foreground" />
</div>
)}
<button
onClick={() => deleteRefMutation.mutate(ref.id)}
className="absolute -top-1.5 -right-1.5 bg-red-500 rounded-full w-4 h-4 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3 text-white" />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 rounded-b-lg text-[9px] text-white text-center truncate px-1">
{ref.person_name}
</div>
</div>
))}
</div>
)}
{/* Scan controls */}
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={() => startScanMutation.mutate()}
disabled={!creatorId || faceRefs.length === 0 || isScanning || !personName.trim()}
className="btn btn-sm btn-primary"
>
<ScanFace className="w-4 h-4 mr-1" />
{isScanning ? 'Scanning...' : `Scan for ${personName}`}
</button>
{/* Scan progress */}
{scanStatus && scanStatus.status !== 'idle' && (
<div className="text-sm text-muted-foreground">
{scanStatus.status === 'initializing' && 'Initializing model...'}
{scanStatus.status === 'scanning' && (
<>
{scanStatus.processed}/{scanStatus.total} posts
{' '} ({scanStatus.matched} match, {scanStatus.unmatched} no match)
{scanStatus.matched > 0 && scanStatus.avg_confidence != null && (
<span className="text-blue-400 ml-1">
avg {scanStatus.avg_confidence}% [{scanStatus.min_confidence}-{scanStatus.max_confidence}%]
</span>
)}
</>
)}
{scanStatus.status === 'completed' && (
<span className="text-green-500">
Done: {scanStatus.matched} matched, {scanStatus.unmatched} not matched
{scanStatus.errors > 0 && `, ${scanStatus.errors} errors`}
{scanStatus.matched > 0 && scanStatus.avg_confidence != null && (
<span className="text-blue-400 ml-1">
(avg {scanStatus.avg_confidence}%, range {scanStatus.min_confidence}-{scanStatus.max_confidence}%)
</span>
)}
</span>
)}
{scanStatus.status === 'error' && (
<span className="text-red-500">
Error: {scanStatus.error_message}
</span>
)}
</div>
)}
{/* Scan progress bar */}
{isScanning && scanStatus && scanStatus.total > 0 && (
<div className="flex-1 min-w-[100px]">
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${(scanStatus.processed / scanStatus.total) * 100}%` }}
/>
</div>
</div>
)}
</div>
{/* Filter by face result */}
{scanComplete && (
<div className="flex items-center gap-3 pt-2 border-t border-border">
<button
onClick={() => setFaceFilter(faceFilter === 'not-match' ? 'all' : 'not-match')}
className={`btn btn-sm ${faceFilter === 'not-match' ? 'btn-primary' : 'btn-secondary'}`}
>
<Filter className="w-4 h-4 mr-1" />
{faceFilter === 'not-match' ? `Showing "Not ${personName}" only` : `Filter: Not ${personName}`}
</button>
{faceFilter === 'not-match' && (
<>
<span className="text-sm text-muted-foreground">
{displayPosts.length} posts
</span>
<button
onClick={handleSelectAllNotMatch}
className="btn btn-sm btn-secondary"
>
<CheckSquare className="w-4 h-4 mr-1" />
Select All Not {personName}
</button>
</>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Selection bar */}
{selectedIds.size > 0 && (
<div className="sticky top-0 z-20 bg-primary/10 rounded-xl border border-primary/20 p-4 flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{selectedIds.size} post(s) selected
</span>
<div className="flex items-center gap-2">
<button onClick={handleSelectAllOnPage} className="btn btn-sm btn-secondary">
{allOnPageSelected ? 'Deselect All' : 'Select All on Page'}
</button>
<button
onClick={handleDeleteSelected}
disabled={deleteMutation.isPending}
className="btn btn-sm btn-danger"
>
<Trash2 className="w-4 h-4 mr-1" />
Delete Selected
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="p-2 rounded-lg hover:bg-secondary"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
</div>
)}
{/* Confirm dialog */}
{showConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card rounded-xl border border-border p-6 max-w-md w-full mx-4 shadow-xl">
<h3 className="text-lg font-semibold text-foreground mb-2">Confirm Deletion</h3>
<p className="text-muted-foreground mb-4">
Are you sure you want to delete {selectedIds.size} post(s)? Files will be removed from disk and posts will be soft-deleted.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowConfirm(false)}
disabled={deleteMutation.isPending}
className="btn btn-secondary"
>
Cancel
</button>
<button
onClick={confirmDelete}
disabled={deleteMutation.isPending}
className="btn btn-danger"
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
{/* Posts list */}
{!creatorId ? (
<div className="bg-card rounded-xl border border-border p-12 text-center">
<Trash2 className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-semibold text-foreground">Select a creator</h3>
<p className="text-muted-foreground mt-1">Choose a creator from the dropdown to load their posts</p>
</div>
) : isLoading && allPosts.length === 0 ? (
<div className="bg-card rounded-xl border border-border flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
</div>
) : displayPosts.length === 0 ? (
<div className="bg-card rounded-xl border border-border p-12 text-center">
<File className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-semibold text-foreground">No posts found</h3>
<p className="text-muted-foreground mt-1">
{faceFilter === 'not-match' ? 'No unmatched posts found' : 'This creator has no posts'}
</p>
</div>
) : (
<div className="bg-card rounded-xl border border-border overflow-hidden">
{/* Select All Header */}
<div className="p-4 border-b border-border bg-secondary/30 flex items-center gap-4">
<button onClick={handleSelectAllOnPage}>
{allOnPageSelected ? (
<CheckSquare className="w-5 h-5 text-primary" />
) : (
<Square className="w-5 h-5 text-muted-foreground" />
)}
</button>
<span className="text-sm font-medium text-foreground">
{allOnPageSelected ? 'Deselect all' : 'Select all'}
</span>
{faceFilter === 'not-match' && (
<span className="text-xs text-muted-foreground ml-auto">
Showing {displayPosts.length} of {allPosts.length} posts
</span>
)}
</div>
{/* Post rows */}
<div className="max-h-[calc(100vh-400px)] overflow-y-auto">
{displayPosts.map((post) => (
<PostCard
key={post.id}
post={post}
isSelected={selectedIds.has(post.id)}
onSelect={() => handleSelect(post.id)}
addRefMode={addRefMode}
onAddReference={handleAddReference}
/>
))}
</div>
{/* Load More */}
{hasMore && faceFilter === 'all' && (
<div className="p-4 border-t border-border text-center">
<button
onClick={handleLoadMore}
disabled={isFetching}
className="btn btn-secondary"
>
{isFetching ? 'Loading...' : `Load More (${totalPosts - allPosts.length} remaining)`}
</button>
</div>
)}
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,873 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useBreadcrumb } from '../../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../../config/breadcrumbConfig'
import {
Users,
User,
FileText,
HardDrive,
CheckCircle,
RefreshCw,
ArrowUpRight,
XCircle,
Download,
Gem,
AlertCircle,
Loader2,
Play,
Image as ImageIcon,
Clock,
MessageSquare,
Eye,
EyeOff,
Cookie,
Settings,
} from 'lucide-react'
import { api, getErrorMessage, PaidContentService, PaidContentActiveTask, PaidContentPost, PlatformCredential } from '../../lib/api'
import { formatBytes, formatRelativeTime, formatPlatformName, cleanCaption, THUMB_CACHE_V } from '../../lib/utils'
import { notificationManager } from '../../lib/notificationManager'
import { Link, useNavigate } from 'react-router-dom'
import BundleLightbox from '../../components/paid-content/BundleLightbox'
// Use shared types from api.ts
type ActiveTask = PaidContentActiveTask
function StatCard({
title,
value,
icon: Icon,
color = 'blue',
subtitle,
link,
}: {
title: string
value: string | number
icon: React.ElementType
color?: 'blue' | 'green' | 'purple' | 'orange' | 'red'
subtitle?: string
link?: string
}) {
const colorStyles: Record<string, { card: string; icon: string }> = {
blue: {
card: 'stat-card-blue shadow-blue-glow',
icon: 'bg-blue-500/20 text-blue-600 dark:text-blue-400',
},
green: {
card: 'stat-card-green shadow-green-glow',
icon: 'bg-emerald-500/20 text-emerald-600 dark:text-emerald-400',
},
purple: {
card: 'stat-card-purple shadow-purple-glow',
icon: 'bg-violet-500/20 text-violet-600 dark:text-violet-400',
},
orange: {
card: 'stat-card-orange shadow-orange-glow',
icon: 'bg-amber-500/20 text-amber-600 dark:text-amber-400',
},
red: {
card: 'bg-card border border-red-200 dark:border-red-800',
icon: 'bg-red-500/20 text-red-600 dark:text-red-400',
},
}
const styles = colorStyles[color]
const content = (
<div className={`p-4 md:p-6 rounded-xl ${styles.card} transition-all duration-200 hover:scale-[1.02] h-full flex flex-col`}>
<div className="flex items-start justify-between flex-1">
<div>
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-2xl font-bold text-foreground mt-1">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
</div>
<div className={`p-3 rounded-xl ${styles.icon}`}>
<Icon className="w-5 h-5" />
</div>
</div>
{link && (
<div className="mt-3 flex items-center text-xs text-primary">
<span>View details</span>
<ArrowUpRight className="w-3 h-3 ml-1" />
</div>
)}
</div>
)
return link ? <Link to={link} className="block h-full">{content}</Link> : content
}
function ServiceStatusCard({ service, credential }: { service: PaidContentService; credential?: PlatformCredential }) {
const queryClient = useQueryClient()
const healthCheckMutation = useMutation({
mutationFn: () => api.paidContent.checkServiceHealth(service.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-services'] })
notificationManager.success('Health Check', `${service.name} health check completed`)
},
onError: (error: unknown) => {
notificationManager.error('Health Check Failed', getErrorMessage(error))
},
})
const monitoringMutation = useMutation({
mutationFn: (enabled: boolean) => api.togglePlatformMonitoring(service.id, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['platform-credentials'] })
queryClient.invalidateQueries({ queryKey: ['cookie-health'] })
},
})
const statusStyles: Record<string, { bg: string; icon: React.ElementType }> = {
healthy: { bg: 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400', icon: CheckCircle },
degraded: { bg: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400', icon: AlertCircle },
down: { bg: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400', icon: XCircle },
unknown: { bg: 'bg-slate-100 dark:bg-slate-500/20 text-slate-700 dark:text-slate-400', icon: AlertCircle },
}
const status = statusStyles[service.health_status || 'unknown'] || statusStyles.unknown
const StatusIcon = status.icon
const monitoringEnabled = credential?.monitoring_enabled ?? false
const cookiesCount = credential?.cookies_count ?? 0
return (
<div className="bg-card rounded-xl border border-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`p-2 rounded-lg ${status.bg}`}>
<StatusIcon className="w-4 h-4" />
</div>
<div>
<h3 className="font-medium text-foreground">{service.name}</h3>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground capitalize">{service.health_status || 'unknown'}</span>
{cookiesCount > 0 && (
<span className="flex items-center space-x-1 text-xs text-blue-600 dark:text-blue-400" title={`${cookiesCount} credentials`}>
<Cookie className="w-3 h-3" />
<span>{cookiesCount}</span>
</span>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-1">
<button
onClick={() => monitoringMutation.mutate(!monitoringEnabled)}
className={`p-1.5 rounded-md transition-colors ${
monitoringEnabled
? 'text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20'
: 'text-slate-400 dark:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
title={monitoringEnabled ? 'Monitoring enabled' : 'Monitoring disabled'}
>
{monitoringEnabled ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
<button
onClick={() => healthCheckMutation.mutate()}
disabled={healthCheckMutation.isPending}
className="p-1.5 rounded-lg hover:bg-secondary transition-colors disabled:opacity-50"
title="Check health"
>
<RefreshCw className={`w-4 h-4 text-muted-foreground ${healthCheckMutation.isPending ? 'animate-spin' : ''}`} />
</button>
<Link
to="/scrapers"
className="p-1.5 rounded-lg hover:bg-secondary transition-colors"
title="Manage credentials"
>
<Settings className="w-4 h-4 text-muted-foreground" />
</Link>
</div>
</div>
{service.last_health_check && (
<p className="text-xs text-muted-foreground mt-2">
Last checked: {formatRelativeTime(service.last_health_check)}
</p>
)}
</div>
)
}
function ActiveTaskCard({ task }: { task: ActiveTask }) {
const getPhaseIcon = () => {
switch (task.phase) {
case 'fetching':
return <RefreshCw className="w-4 h-4 animate-spin text-blue-500" />
case 'processing':
return <Loader2 className="w-4 h-4 animate-spin text-amber-500" />
case 'downloading':
return <Download className="w-4 h-4 animate-pulse text-emerald-500" />
default:
return <Loader2 className="w-4 h-4 animate-spin text-primary" />
}
}
const getPhaseLabel = () => {
switch (task.phase) {
case 'fetching':
return 'Fetching posts'
case 'processing':
return 'Processing'
case 'downloading':
return 'Downloading'
default:
return 'Working'
}
}
const progressPercent = task.total_files
? Math.round(((task.downloaded || 0) / task.total_files) * 100)
: 0
return (
<div className="p-4 md:p-6 bg-secondary/50 rounded-lg border border-border">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4">
<div className="p-3 rounded-xl bg-primary/10">
{getPhaseIcon()}
</div>
<div>
<p className="text-lg font-semibold text-foreground">{task.username}</p>
<p className="text-sm text-muted-foreground">
{formatPlatformName(task.platform)} {formatPlatformName(task.service)}
</p>
</div>
</div>
<div className="text-right">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary/10 text-primary">
{getPhaseLabel()}
</span>
</div>
</div>
<div className="space-y-3">
<p className="text-foreground">{task.status}</p>
{task.phase === 'downloading' && task.total_files && (
<div className="bg-background/50 rounded-lg p-4 space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">Download Progress</span>
<span className="text-sm text-muted-foreground">
{task.downloaded || 0} / {task.total_files} files ({progressPercent}%)
</span>
</div>
<div className="h-2.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary to-emerald-500 rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
{/* Active Downloads */}
{task.active_downloads && task.active_downloads.length > 0 && (
<div className="pt-2 border-t border-border/50 space-y-2">
<span className="text-xs text-muted-foreground">
Currently downloading ({task.active_count || task.active_downloads.length}):
</span>
{task.active_downloads.slice(0, 3).map((dl, idx) => (
<div key={idx} className="text-xs">
<div className="flex justify-between items-center mb-1">
<span className="text-foreground truncate flex-1 mr-2" title={dl.name}>
{dl.name.length > 35 ? dl.name.slice(0, 35) + '...' : dl.name}
</span>
<span className="text-muted-foreground whitespace-nowrap">
{formatBytes(dl.progress)}{dl.size ? ` / ${formatBytes(dl.size)}` : ''}
</span>
</div>
{dl.size && (
<div className="h-1 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-200"
style={{ width: `${dl.size > 0 ? Math.min(100, (dl.progress / dl.size) * 100) : 0}%` }}
/>
</div>
)}
</div>
))}
{task.active_downloads.length > 3 && (
<span className="text-xs text-muted-foreground">
+{task.active_downloads.length - 3} more...
</span>
)}
</div>
)}
</div>
)}
{task.phase === 'fetching' && task.posts_fetched !== undefined && (
<div className="bg-background/50 rounded-lg p-4">
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{task.posts_fetched}</span> posts fetched so far
</p>
</div>
)}
</div>
</div>
)
}
function RecentPostsCard({ posts, onPostClick, onAttachmentClick }: {
posts: PaidContentPost[]
onPostClick: (post: PaidContentPost) => void
onAttachmentClick: (post: PaidContentPost, attachmentIndex: number) => void
}) {
if (!posts || posts.length === 0) {
return (
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-primary" />
Recent Posts
</h2>
<div className="text-center py-8 text-muted-foreground">
<FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No posts yet</p>
</div>
</div>
)
}
// Truncate text to ~2 lines
const truncateText = (text: string | null, maxLength: number = 80) => {
if (!text) return ''
// Decode HTML entities
const decoded = text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
if (decoded.length <= maxLength) return decoded
return decoded.slice(0, maxLength).trim() + '...'
}
return (
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Clock className="w-5 h-5 text-primary" />
Recent Posts
</h2>
<Link to="/paid-content/feed" className="text-xs text-primary hover:underline flex items-center gap-1">
View all <ArrowUpRight className="w-3 h-3" />
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{posts.map((post) => {
const completedAttachments = (post.attachments || []).filter(
(a) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')
)
const displayAttachments = completedAttachments.slice(0, 4)
const extraCount = completedAttachments.length - 4
return (
<div
key={post.id}
className="bg-secondary/30 rounded-lg overflow-hidden hover:bg-secondary/50 transition-colors"
>
{/* Post Header - Clickable */}
<div
className="p-3 cursor-pointer"
onClick={() => onPostClick(post)}
>
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-pink-500 to-violet-500 p-0.5 flex-shrink-0">
<div className="w-full h-full rounded-full overflow-hidden bg-card">
{post.profile_image_url ? (
<img
src={post.profile_image_url}
alt=""
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-secondary">
<User className="w-3 h-3 text-muted-foreground" />
</div>
)}
</div>
</div>
<div className="min-w-0 flex-1 leading-tight">
<span className="text-sm font-medium text-foreground truncate block">
{post.display_name || post.username || 'Unknown'}
</span>
<span className="text-xs text-muted-foreground -mt-0.5 block">@{post.username}</span>
</div>
</div>
<p className="text-xs text-muted-foreground line-clamp-2 min-h-[2.5rem]">
{truncateText(cleanCaption(post.content || post.title || ''), 100) || 'No description'}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{formatRelativeTime(post.published_at || post.added_at || '')}
</p>
</div>
{/* Attachments Grid - Fixed height row */}
{displayAttachments.length > 0 && (
<div className="flex gap-1 h-16 overflow-hidden px-3 pb-2">
{displayAttachments.map((att, idx) => {
const isVideo = att.file_type === 'video'
return (
<div
key={att.id}
className={`h-16 flex-shrink-0 bg-slate-800 relative cursor-pointer group rounded ${
isVideo ? 'w-28' : 'w-16'
}`}
onClick={(e) => {
e.stopPropagation()
onAttachmentClick(post, idx)
}}
>
<img
src={`/api/paid-content/files/thumbnail/${att.id}?size=small&${att.file_hash ? `v=${att.file_hash.slice(0, 8)}` : THUMB_CACHE_V}`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
{isVideo && (
<div className="absolute top-1 left-1 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] flex items-center gap-0.5">
<Play className="w-2.5 h-2.5" fill="currentColor" />
Video
</div>
)}
{idx === 3 && extraCount > 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
<span className="text-white text-xs font-medium">+{extraCount}</span>
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
</div>
)
})}
</div>
)}
{/* No attachments placeholder - only show for posts that should have media */}
{displayAttachments.length === 0 && post.has_attachments === 1 && (
<div className="h-16 bg-secondary/50 flex items-center justify-center mx-3 mb-2 rounded">
<ImageIcon className="w-6 h-6 text-muted-foreground/30" />
</div>
)}
</div>
)
})}
</div>
</div>
)
}
export default function PaidContentDashboard() {
useBreadcrumb(breadcrumbConfig['/paid-content'])
const navigate = useNavigate()
const queryClient = useQueryClient()
const [retryingIds, setRetryingIds] = useState<number[]>([])
// Lightbox state for recent posts
const [lightboxPost, setLightboxPost] = useState<PaidContentPost | null>(null)
const [lightboxIndex, setLightboxIndex] = useState(0)
// Poll for active sync tasks (like main dashboard does)
const { data: activeSyncs } = useQuery({
queryKey: ['paid-content-active-syncs'],
queryFn: async () => {
const syncs = await api.paidContent.getActiveSyncs()
if (syncs && syncs.length > 0) {
console.log('[PaidContent Dashboard] Active syncs:', syncs)
}
return syncs
},
refetchInterval: 2000, // Poll every 2 seconds for real-time updates
staleTime: 1000,
})
// Refetch stats more frequently when syncs are active
const hasActiveSyncs = activeSyncs && activeSyncs.length > 0
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['paid-content-stats'],
queryFn: () => api.paidContent.getDashboardStats(),
refetchInterval: hasActiveSyncs ? 3000 : 30000, // Poll every 3s during active syncs
})
const { data: services, isLoading: servicesLoading } = useQuery({
queryKey: ['paid-content-services'],
queryFn: () => api.paidContent.getServices(),
refetchInterval: 60000, // Refresh cached status every minute
})
const { data: credentialsData } = useQuery({
queryKey: ['platform-credentials'],
queryFn: () => api.getPlatformCredentials(),
refetchInterval: 60000,
})
// Map platform credentials by service id for quick lookup
const credentialsByService = (credentialsData?.platforms ?? []).reduce<Record<string, PlatformCredential>>(
(acc, p) => { acc[p.id] = p; return acc },
{}
)
const { data: failedDownloads, isLoading: failedLoading } = useQuery({
queryKey: ['paid-content-failed'],
queryFn: () => api.paidContent.getFailedDownloads(),
refetchInterval: hasActiveSyncs ? 5000 : 60000, // Poll every 5s during active syncs
})
// Fetch recent posts for the Recent Posts card
const { data: recentPostsData } = useQuery({
queryKey: ['paid-content-recent-posts'],
queryFn: () => api.paidContent.getFeed({
limit: 4,
sort_by: 'published_at',
sort_order: 'desc',
pinned_first: false, // Don't prioritize pinned posts in dashboard
skip_pinned: true, // Exclude pinned posts from recent posts
}),
refetchInterval: hasActiveSyncs ? 10000 : 60000, // Refresh more often during active syncs
})
const retryMutation = useMutation({
mutationFn: (ids: number[]) => api.paidContent.retryFailedDownloads(ids),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-failed'] })
notificationManager.success('Retry Queued', 'Failed downloads queued for retry')
setRetryingIds([])
},
onError: (error: unknown) => {
notificationManager.error('Retry Failed', getErrorMessage(error))
},
})
const handleRetry = (ids: number[]) => {
setRetryingIds(ids)
retryMutation.mutate(ids)
}
const syncAllMutation = useMutation({
mutationFn: () => api.paidContent.syncAllCreators(),
onSuccess: () => {
notificationManager.success('Sync Started', 'Syncing all creators in background')
},
onError: (error: unknown) => {
notificationManager.error('Sync Failed', getErrorMessage(error))
},
})
// Unread messages count
const { data: unreadMessagesData } = useQuery({
queryKey: ['paid-content-unread-messages-count'],
queryFn: () => api.paidContent.getUnreadMessagesCount(),
staleTime: 60000,
refetchInterval: 60000,
})
const unreadMessagesCount = unreadMessagesData?.count ?? 0
// Mark all messages read mutation
const markAllMessagesReadMutation = useMutation({
mutationFn: () => api.paidContent.markAllMessagesRead(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-unread-messages-count'] })
},
})
// Unviewed posts count
const { data: unviewedData } = useQuery({
queryKey: ['paid-content-unviewed-count'],
queryFn: () => api.paidContent.getUnviewedCount(),
staleTime: 60000,
refetchInterval: 60000,
})
const unviewedCount = unviewedData?.count ?? 0
// Mark all posts viewed mutation
const markAllViewedMutation = useMutation({
mutationFn: () => api.paidContent.markAllViewed(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-unviewed-count'] })
queryClient.invalidateQueries({ queryKey: ['paid-content-recent-posts'] })
},
})
const isLoading = statsLoading || servicesLoading || failedLoading
// Handlers for Recent Posts card
const handlePostClick = (post: PaidContentPost) => {
// Navigate to feed and open this specific post
navigate(`/paid-content/feed?post=${post.id}`)
}
const handleAttachmentClick = (post: PaidContentPost, attachmentIndex: number) => {
setLightboxPost(post)
setLightboxIndex(attachmentIndex)
}
// Get completed attachments for lightbox
const lightboxAttachments = lightboxPost?.attachments?.filter(
(a) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')
) || []
return (
<div className="space-y-6">
{/* Lightbox for Recent Posts attachments */}
{lightboxPost && lightboxAttachments.length > 0 && (
<BundleLightbox
post={lightboxPost}
attachments={lightboxAttachments}
currentIndex={lightboxIndex}
onClose={() => setLightboxPost(null)}
onNavigate={setLightboxIndex}
onViewPost={() => {
setLightboxPost(null)
navigate(`/paid-content/feed?post=${lightboxPost.id}`)
}}
/>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<Gem className="w-8 h-8 text-violet-500" />
Paid Content
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Track and download content from creators
</p>
</div>
<button
onClick={() => syncAllMutation.mutate()}
disabled={syncAllMutation.isPending}
className="btn btn-primary"
>
<RefreshCw className={`w-4 h-4 mr-2 ${syncAllMutation.isPending ? 'animate-spin' : ''}`} />
{syncAllMutation.isPending ? 'Syncing...' : 'Sync All Creators'}
</button>
</div>
{/* Unread messages banner */}
{unreadMessagesCount > 0 && (
<div className="flex items-center justify-between gap-3 bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20 rounded-lg shadow-sm border border-violet-200 dark:border-violet-800 px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold shrink-0">
{unreadMessagesCount}
</div>
<span className="text-sm font-medium text-violet-700 dark:text-violet-300">
{unreadMessagesCount === 1 ? '1 unread message' : `${unreadMessagesCount} unread messages`}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<Link
to="/paid-content/messages"
className="px-3 py-1.5 rounded-lg bg-violet-600 text-white text-sm font-medium hover:bg-violet-700 transition-colors flex items-center gap-1.5"
>
<MessageSquare className="w-3.5 h-3.5" />
View messages
</Link>
<button
onClick={() => markAllMessagesReadMutation.mutate()}
disabled={markAllMessagesReadMutation.isPending}
className="px-3 py-1.5 rounded-md text-violet-700 dark:text-violet-300 text-sm hover:bg-violet-100 dark:hover:bg-violet-900/30 transition-colors"
>
{markAllMessagesReadMutation.isPending ? 'Marking...' : 'Mark all read'}
</button>
</div>
</div>
)}
{/* Unviewed posts banner */}
{unviewedCount > 0 && (
<div className="flex items-center justify-between gap-3 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-lg shadow-sm border border-blue-200 dark:border-blue-800 px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-600 text-white text-sm font-bold shrink-0">
{unviewedCount}
</div>
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{unviewedCount === 1 ? '1 unviewed post' : `${unviewedCount} unviewed posts`}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<Link
to="/paid-content/feed?unviewed=true"
className="px-3 py-1.5 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors flex items-center gap-1.5"
>
<Eye className="w-3.5 h-3.5" />
View unviewed
</Link>
<button
onClick={() => markAllViewedMutation.mutate()}
disabled={markAllViewedMutation.isPending}
className="px-3 py-1.5 rounded-md text-blue-700 dark:text-blue-300 text-sm hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
>
{markAllViewedMutation.isPending ? 'Marking...' : 'Mark all viewed'}
</button>
</div>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
</div>
) : (
<>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Tracked Creators"
value={stats?.total_creators || 0}
icon={Users}
color="blue"
link="/paid-content/creators"
/>
<StatCard
title="Total Posts"
value={stats?.total_posts || 0}
icon={FileText}
color="purple"
subtitle={`${stats?.downloaded_posts || 0} downloaded`}
link="/paid-content/feed"
/>
<StatCard
title="Total Files"
value={stats?.total_files || 0}
icon={Download}
color="green"
/>
<StatCard
title="Storage Used"
value={formatBytes(stats?.total_size_bytes || 0)}
icon={HardDrive}
color="orange"
/>
</div>
{/* Recent Posts */}
<RecentPostsCard
posts={recentPostsData?.posts || []}
onPostClick={handlePostClick}
onAttachmentClick={handleAttachmentClick}
/>
{/* Active Tasks */}
{activeSyncs && activeSyncs.length > 0 && (
<div className="bg-card rounded-xl border border-primary/30 p-4 md:p-6 shadow-glow">
<div className="flex items-center space-x-2 mb-4">
<Loader2 className="w-5 h-5 text-primary animate-spin" />
<h2 className="text-lg font-semibold text-foreground">
Active Tasks ({activeSyncs.length})
</h2>
</div>
<div className="space-y-4">
{activeSyncs.map((task) => (
<ActiveTaskCard key={task.creator_id} task={task} />
))}
</div>
</div>
)}
{/* Services Status */}
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">Service Status</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{services?.map((service) => (
<ServiceStatusCard key={service.id} service={service} credential={credentialsByService[service.id]} />
))}
</div>
</div>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Failed Downloads */}
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground">Failed Downloads</h2>
{failedDownloads && failedDownloads.length > 0 && (
<button
onClick={() => handleRetry(failedDownloads.map((f) => f.id))}
disabled={retryMutation.isPending}
className="btn btn-sm btn-secondary"
>
<RefreshCw className={`w-4 h-4 mr-1 ${retryMutation.isPending ? 'animate-spin' : ''}`} />
Retry All
</button>
)}
</div>
{!failedDownloads || failedDownloads.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<CheckCircle className="w-12 h-12 mx-auto mb-2 text-emerald-500" />
<p>No failed downloads</p>
</div>
) : (
<div className="space-y-3 max-h-80 overflow-y-auto">
{failedDownloads.map((download) => (
<div
key={download.id}
className="flex items-center justify-between p-3 bg-secondary/50 rounded-lg"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{download.name}</p>
<p className="text-xs text-muted-foreground">
{download.extension || 'unknown'} file
</p>
<p className="text-xs text-red-500 mt-1">{download.error_message}</p>
</div>
<button
onClick={() => handleRetry([download.id])}
disabled={retryingIds.includes(download.id)}
className="ml-2 p-2 rounded-lg hover:bg-secondary transition-colors"
title="Retry download"
>
<RefreshCw
className={`w-4 h-4 text-muted-foreground ${
retryingIds.includes(download.id) ? 'animate-spin' : ''
}`}
/>
</button>
</div>
))}
</div>
)}
</div>
{/* Storage by Creator */}
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">Storage by Creator</h2>
{!stats?.storage_by_creator || stats.storage_by_creator.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<HardDrive className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No storage data yet</p>
</div>
) : (
<div className="space-y-3 max-h-80 overflow-y-auto">
{stats.storage_by_creator.map((creator, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-500 to-violet-500 p-0.5">
<div className="w-full h-full rounded-full overflow-hidden bg-card flex items-center justify-center">
{creator.profile_image_url ? (
<img src={creator.profile_image_url} alt="" className="w-full h-full object-cover" />
) : (
<User className="w-4 h-4 text-muted-foreground" />
)}
</div>
</div>
<div>
<p className="text-sm font-medium text-foreground">{creator.display_name || creator.username}</p>
<p className="text-xs text-muted-foreground">@{creator.username}</p>
</div>
</div>
<span className="text-sm font-medium text-foreground">
{formatBytes(creator.total_size)}
</span>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
import { useSearchParams } from 'react-router-dom'
import { useBreadcrumb } from '../../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../../config/breadcrumbConfig'
import GalleryGroupLanding from '../../components/paid-content/GalleryGroupLanding'
import GalleryTimeline from '../../components/paid-content/GalleryTimeline'
export default function Gallery() {
useBreadcrumb(breadcrumbConfig['/paid-content/gallery'])
const [searchParams, setSearchParams] = useSearchParams()
const groupParam = searchParams.get('group')
const groupId = groupParam !== null ? parseInt(groupParam, 10) : null
const handleSelectGroup = (id: number) => {
setSearchParams({ group: String(id) })
}
const handleBack = () => {
setSearchParams({})
}
if (groupId !== null && !isNaN(groupId)) {
return <GalleryTimeline groupId={groupId} onBack={handleBack} />
}
return <GalleryGroupLanding onSelectGroup={handleSelectGroup} />
}

View File

@@ -0,0 +1,609 @@
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useBreadcrumb } from '../../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../../config/breadcrumbConfig'
import {
MessageCircle,
Search,
RefreshCw,
Loader2,
Image as ImageIcon,
Video,
Play,
ChevronUp,
ChevronLeft,
Lock,
DollarSign,
User,
} from 'lucide-react'
import { api, PaidContentMessage, PaidContentAttachment, PaidContentPost } from '../../lib/api'
import { formatRelativeTime, formatPlatformName, THUMB_CACHE_V } from '../../lib/utils'
import BundleLightbox from '../../components/paid-content/BundleLightbox'
export default function Messages() {
useBreadcrumb(breadcrumbConfig['/paid-content/messages'])
const { creatorId } = useParams<{ creatorId: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [selectedCreatorId, setSelectedCreatorId] = useState<number | null>(
creatorId ? parseInt(creatorId) : null
)
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxAttachments, setLightboxAttachments] = useState<PaidContentAttachment[]>([])
const [lightboxIndex, setLightboxIndex] = useState(0)
const [lightboxPost, setLightboxPost] = useState<PaidContentPost | null>(null)
// Mobile: track whether to show chat thread (true) or conversation list (false)
const [showMobileChat, setShowMobileChat] = useState(!!creatorId)
// Update URL when creator is selected
useEffect(() => {
if (selectedCreatorId && !creatorId) {
navigate(`/paid-content/messages/${selectedCreatorId}`, { replace: true })
}
}, [selectedCreatorId, creatorId, navigate])
// Conversations list
const { data: conversations = [], isLoading: loadingConversations } = useQuery({
queryKey: ['paid-content-conversations', search],
queryFn: () => api.paidContent.getConversations({ search: search || undefined }),
})
// Auto-select most recent conversation when none is selected (desktop only)
useEffect(() => {
if (!selectedCreatorId && conversations.length > 0) {
const first = conversations[0].creator_id
setSelectedCreatorId(first)
navigate(`/paid-content/messages/${first}`, { replace: true })
}
}, [conversations]) // eslint-disable-line react-hooks/exhaustive-deps
// Messages for selected conversation
const {
data: messagesData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: loadingMessages,
} = useInfiniteQuery({
queryKey: ['paid-content-messages', selectedCreatorId],
queryFn: ({ pageParam }) =>
api.paidContent.getMessages(selectedCreatorId!, {
before: pageParam as string | undefined,
limit: 50,
}),
getNextPageParam: (lastPage) => {
const msgs = lastPage.messages
if (msgs.length < 50) return undefined
return msgs[msgs.length - 1]?.sent_at
},
initialPageParam: undefined as string | undefined,
enabled: !!selectedCreatorId,
})
// Flatten and reverse messages (API returns newest first, we want oldest first)
const allMessages = (messagesData?.pages ?? [])
.flatMap((page) => page.messages)
.reverse()
const totalMessages = messagesData?.pages?.[0]?.total ?? 0
// Find the first unread message from creator
const firstUnreadId = useMemo(() => {
const unread = allMessages.find((m) => m.is_read === 0 && m.is_from_creator === 1)
return unread?.id ?? null
}, [allMessages])
// Scroll to first unread message, or bottom if all read
const scrolledForCreator = useRef<number | null>(null)
useEffect(() => {
if (allMessages.length > 0 && !isFetchingNextPage && selectedCreatorId !== scrolledForCreator.current) {
scrolledForCreator.current = selectedCreatorId
if (firstUnreadId) {
// Small delay to let DOM render
requestAnimationFrame(() => {
const el = document.getElementById(`msg-${firstUnreadId}`)
if (el) {
el.scrollIntoView({ behavior: 'auto', block: 'center' })
return
}
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
})
} else {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
}
}
}, [selectedCreatorId, allMessages.length > 0, isFetchingNextPage]) // eslint-disable-line react-hooks/exhaustive-deps
// Mark messages as read when they scroll into view (IntersectionObserver)
const pendingMarkRead = useRef<Set<number>>(new Set())
const markReadTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const flushMarkRead = useCallback(() => {
if (!selectedCreatorId || pendingMarkRead.current.size === 0) return
const ids = Array.from(pendingMarkRead.current)
pendingMarkRead.current.clear()
api.paidContent.markMessagesRead(selectedCreatorId, ids).then(() => {
queryClient.invalidateQueries({ queryKey: ['paid-content-conversations'] })
}).catch(() => {})
}, [selectedCreatorId, queryClient])
const onMessageVisible = useCallback((messageId: number) => {
pendingMarkRead.current.add(messageId)
if (markReadTimer.current) clearTimeout(markReadTimer.current)
markReadTimer.current = setTimeout(flushMarkRead, 500)
}, [flushMarkRead])
// Flush on unmount or conversation change
useEffect(() => {
return () => {
if (markReadTimer.current) clearTimeout(markReadTimer.current)
flushMarkRead()
}
}, [selectedCreatorId]) // eslint-disable-line react-hooks/exhaustive-deps
// Sync messages mutation
const syncMutation = useMutation({
mutationFn: (cId: number) => api.paidContent.syncMessages(cId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-conversations'] })
queryClient.invalidateQueries({ queryKey: ['paid-content-messages', selectedCreatorId] })
},
})
const handleSelectConversation = (cId: number) => {
setSelectedCreatorId(cId)
setShowMobileChat(true)
navigate(`/paid-content/messages/${cId}`, { replace: true })
}
const handleBackToList = () => {
setShowMobileChat(false)
}
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
const openLightbox = (attachments: PaidContentAttachment[], index: number, message?: PaidContentMessage) => {
setLightboxAttachments(attachments)
setLightboxIndex(index)
// Build a fake post object for BundleLightbox from conversation + message data
const conv = selectedConversation
setLightboxPost({
id: message?.id ?? 0,
creator_id: conv?.creator_id ?? 0,
post_id: message?.message_id ?? '',
title: null,
content: message?.text ?? null,
published_at: message?.sent_at ?? null,
added_at: message?.created_at ?? null,
has_attachments: attachments.length > 0 ? 1 : 0,
attachment_count: attachments.length,
downloaded: 0,
download_date: null,
is_favorited: 0,
is_viewed: 0,
is_pinned: 0,
pinned_at: null,
username: conv?.username ?? '',
platform: conv?.platform ?? '',
service_id: conv?.service_id ?? '',
display_name: conv?.display_name ?? null,
profile_image_url: conv?.profile_image_url ?? null,
identity_id: null,
attachments,
} as PaidContentPost)
setLightboxOpen(true)
}
const selectedConversation = conversations.find((c) => c.creator_id === selectedCreatorId)
// ---- Conversation List Panel ----
const conversationList = (
<div className={`flex flex-col bg-card h-full
${/* Desktop: fixed-width sidebar, always visible */''}
max-md:w-full md:w-80 md:flex-shrink-0 md:border-r md:border-border
${/* Mobile: hidden when chat is open */''}
${showMobileChat ? 'max-md:hidden' : ''}
`}>
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search conversations..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-input bg-background"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loadingConversations ? (
<div className="flex justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : conversations.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
No conversations yet. Sync a creator to fetch messages.
</div>
) : (
conversations.map((conv) => (
<button
key={conv.creator_id}
onClick={() => handleSelectConversation(conv.creator_id)}
className={`w-full text-left px-3 py-2.5 border-b border-border/50 hover:bg-accent/50 transition-colors ${
selectedCreatorId === conv.creator_id ? 'bg-violet-500/10 border-l-2 border-l-violet-500' : ''
}`}
>
<div className="flex items-center gap-2.5">
{conv.profile_image_url ? (
<img
src={conv.profile_image_url.startsWith('/api/') ? conv.profile_image_url : `/api/paid-content/proxy/image?url=${encodeURIComponent(conv.profile_image_url)}`}
alt=""
className="w-9 h-9 rounded-full object-cover flex-shrink-0"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = 'none'
}}
/>
) : (
<div className="w-9 h-9 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium text-sm truncate">
{conv.display_name || conv.username}
</span>
{conv.last_message_at && (
<span className="text-[10px] text-muted-foreground flex-shrink-0 ml-1">
{formatRelativeTime(conv.last_message_at)}
</span>
)}
</div>
<div className="flex items-center justify-between mt-0.5">
<p className="text-xs text-muted-foreground truncate">
{conv.last_message_from_creator
? conv.last_message_text || 'Sent media'
: `You: ${conv.last_message_text || 'Sent media'}`}
</p>
{conv.unread_count > 0 && (
<span className="ml-1 flex-shrink-0 bg-primary text-primary-foreground text-[10px] font-bold px-1.5 py-0.5 rounded-full">
{conv.unread_count}
</span>
)}
</div>
<div className="flex items-center gap-1 mt-0.5">
<span className="text-[10px] text-muted-foreground">
{formatPlatformName(conv.platform)}
</span>
<span className="text-[10px] text-muted-foreground">
&middot; {conv.message_count} msgs
</span>
</div>
</div>
</div>
</button>
))
)}
</div>
</div>
)
// ---- Chat Thread Panel ----
const chatThread = (
<div className={`flex-1 flex flex-col min-w-0 h-full
${/* Mobile: hidden when list is showing */''}
${!showMobileChat ? 'max-md:hidden' : ''}
`}>
{!selectedCreatorId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<MessageCircle className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">Select a conversation to view messages</p>
</div>
</div>
) : (
<>
{/* Chat header */}
<div className="flex items-center justify-between px-3 sm:px-4 py-2.5 border-b border-border bg-card">
<div className="flex items-center gap-2">
{/* Back button - mobile only */}
<button
onClick={handleBackToList}
className="md:hidden p-1 -ml-1 rounded-md hover:bg-accent transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
{selectedConversation?.profile_image_url ? (
<img
src={selectedConversation.profile_image_url.startsWith('/api/') ? selectedConversation.profile_image_url : `/api/paid-content/proxy/image?url=${encodeURIComponent(selectedConversation.profile_image_url)}`}
alt=""
className="w-8 h-8 rounded-full object-cover"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = 'none'
}}
/>
) : (
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
)}
<div>
<p className="font-medium text-sm">
{selectedConversation?.display_name || selectedConversation?.username}
</p>
<p className="text-[10px] text-muted-foreground">
{totalMessages} messages &middot; {formatPlatformName(selectedConversation?.platform)}
</p>
</div>
</div>
<button
onClick={() => syncMutation.mutate(selectedCreatorId)}
disabled={syncMutation.isPending}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">Sync</span>
</button>
</div>
{/* Messages area */}
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto px-3 sm:px-4 py-3 space-y-2">
{/* Load more button */}
{hasNextPage && (
<div className="text-center py-2">
<button
onClick={handleLoadMore}
disabled={isFetchingNextPage}
className="inline-flex items-center gap-1.5 px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors disabled:opacity-50"
>
{isFetchingNextPage ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<ChevronUp className="w-3 h-3" />
)}
Load older messages
</button>
</div>
)}
{loadingMessages ? (
<div className="flex justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : allMessages.length === 0 ? (
<div className="text-center py-12 text-muted-foreground text-sm">
No messages yet
</div>
) : (
allMessages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
onOpenLightbox={openLightbox}
onVisible={msg.is_read === 0 && msg.is_from_creator === 1 ? onMessageVisible : undefined}
/>
))
)}
<div ref={messagesEndRef} />
</div>
</>
)}
</div>
)
return (
<div className="space-y-2 sm:space-y-4">
{/* Header - hidden on mobile when viewing a chat */}
<div className={`flex items-center justify-between ${showMobileChat ? 'max-md:hidden' : ''}`}>
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<MessageCircle className="w-8 h-8 text-violet-500" />
Messages
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1 hidden sm:block">
View chat messages from creators
</p>
</div>
</div>
{/* Chat Layout */}
<div className="flex h-[calc(100vh-8rem)] sm:h-[calc(100vh-10rem)] overflow-hidden rounded-xl border border-border">
{conversationList}
{chatThread}
</div>
{/* Lightbox */}
{lightboxOpen && lightboxPost && lightboxAttachments.length > 0 && (
<BundleLightbox
post={lightboxPost}
attachments={lightboxAttachments}
currentIndex={lightboxIndex}
wasPlaying={true}
onClose={() => setLightboxOpen(false)}
onNavigate={setLightboxIndex}
/>
)}
</div>
)
}
// ============ Message Bubble Component ============
function MessageBubble({
message,
onOpenLightbox,
onVisible,
}: {
message: PaidContentMessage
onOpenLightbox: (attachments: PaidContentAttachment[], index: number, message: PaidContentMessage) => void
onVisible?: (messageId: number) => void
}) {
const isCreator = message.is_from_creator === 1
const viewableAttachments = message.attachments.filter((a) => a.status === 'completed' || a.status === 'duplicate')
const hasLockedContent = message.price && !message.is_purchased && message.is_free === 0
const bubbleRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!onVisible || !bubbleRef.current) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
onVisible(message.id)
observer.disconnect()
}
},
{ threshold: 0.5 }
)
observer.observe(bubbleRef.current)
return () => observer.disconnect()
}, [onVisible, message.id])
return (
<div id={`msg-${message.id}`} ref={bubbleRef} className={`flex ${isCreator ? 'justify-start' : 'justify-end'}`}>
<div
className={`max-w-[85%] sm:max-w-[75%] rounded-xl px-3 py-2 ${
isCreator
? 'bg-secondary text-secondary-foreground rounded-tl-sm'
: 'bg-primary text-primary-foreground rounded-tr-sm'
}`}
>
{/* PPV badge */}
{hasLockedContent && (
<div className="flex items-center gap-1 mb-1">
<Lock className="w-3 h-3" />
<span className="text-[10px] font-medium">
PPV {message.price ? `$${message.price.toFixed(2)}` : ''}
</span>
</div>
)}
{/* Tip badge */}
{message.is_tip === 1 && (
<div className="flex items-center gap-1 mb-1">
<DollarSign className="w-3 h-3" />
<span className="text-[10px] font-medium">
Tip {message.tip_amount ? `$${message.tip_amount.toFixed(2)}` : ''}
</span>
</div>
)}
{/* Message text */}
{message.text && (
<p className="text-sm whitespace-pre-wrap break-words">{message.text}</p>
)}
{/* Media attachments - post-style grid */}
{message.attachments.length > 0 && (() => {
const completed = message.attachments.filter((a) => a.status === 'completed' || a.status === 'duplicate')
const pending = message.attachments.filter((a) => a.status !== 'completed' && a.status !== 'duplicate')
return (
<div className={message.text ? 'mt-2' : ''}>
{/* Completed media grid */}
{completed.length > 0 && (() => {
const isSingle = completed.length === 1
// For single items, use the attachment's own aspect ratio
// For multi-item grids, compute a unified ratio from the average so rows align
const ratios = completed
.filter((a) => a.width && a.height)
.map((a) => a.width! / a.height!)
const avgRatio = ratios.length > 0
? ratios.reduce((s, r) => s + r, 0) / ratios.length
: 16 / 9
// Clamp between 3:4 portrait and 16:9 landscape
const gridRatio = Math.max(3 / 4, Math.min(16 / 9, avgRatio))
return (
<div className={`grid gap-1 ${isSingle ? 'grid-cols-1' : 'grid-cols-2'}`}>
{completed.map((att) => {
const isVideo = att.file_type === 'video'
const thumbUrl = `/api/paid-content/files/thumbnail/${att.id}?size=large&${att.file_hash ? `v=${att.file_hash.slice(0, 8)}` : THUMB_CACHE_V}`
const hasOwnRatio = att.width && att.height
// Single: use own ratio; multi: use unified grid ratio
const ratio = isSingle
? (hasOwnRatio ? `${att.width} / ${att.height}` : (isVideo ? '16 / 9' : '1'))
: `${gridRatio}`
return (
<button
key={att.id}
type="button"
className="relative rounded-lg overflow-hidden bg-black cursor-pointer hover:opacity-90 transition-opacity"
style={{ aspectRatio: ratio }}
onClick={() => {
const idx = viewableAttachments.findIndex((a) => a.id === att.id)
onOpenLightbox(viewableAttachments, idx >= 0 ? idx : 0, message)
}}
>
<img
src={thumbUrl}
alt=""
className={`w-full h-full ${isSingle ? 'object-contain' : 'object-cover'}`}
loading="lazy"
/>
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-10 h-10 rounded-full bg-black/50 flex items-center justify-center">
<Play className="w-5 h-5 text-white ml-0.5" fill="white" />
</div>
</div>
)}
</button>
)
})}
</div>
)
})()}
{/* Pending/unavailable attachments */}
{pending.length > 0 && (
<div className={`${completed.length > 0 ? 'mt-1' : ''} flex flex-wrap gap-1`}>
{pending.map((att) => (
<div
key={att.id}
className="relative w-16 h-16 rounded-md overflow-hidden bg-muted/30 flex flex-col items-center justify-center gap-0.5"
>
{att.file_type === 'video' ? (
<Video className="w-4 h-4 text-muted-foreground/50" />
) : (
<ImageIcon className="w-4 h-4 text-muted-foreground/50" />
)}
<span className="text-[8px] text-muted-foreground/40">
{att.status === 'pending' ? 'Pending' : att.status}
</span>
</div>
))}
</div>
)}
</div>
)
})()}
{/* Timestamp */}
<p
className={`text-[10px] mt-1 ${
isCreator ? 'text-muted-foreground/70' : 'text-primary-foreground/70'
}`}
>
{message.sent_at
? new Date(message.sent_at).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: ''}
</p>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More