1
web/frontend/VERSION
Normal file
1
web/frontend/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
6.33.0
|
||||
34
web/frontend/index.html
Normal file
34
web/frontend/index.html
Normal 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
48
web/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
web/frontend/postcss.config.js
Normal file
6
web/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
web/frontend/public/apple-touch-icon.png
Normal file
BIN
web/frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
5
web/frontend/public/favicon.svg
Normal file
5
web/frontend/public/favicon.svg
Normal 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 |
BIN
web/frontend/public/icon-192.png
Normal file
BIN
web/frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
BIN
web/frontend/public/icon-512.png
Normal file
BIN
web/frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
30
web/frontend/public/manifest.json
Normal file
30
web/frontend/public/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
558
web/frontend/public/passkeys.js
Normal file
558
web/frontend/public/passkeys.js
Normal 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
177
web/frontend/public/sw.js
Normal 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
506
web/frontend/public/totp.js
Normal 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
1205
web/frontend/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
432
web/frontend/src/components/AppearanceDetailModal.tsx
Normal file
432
web/frontend/src/components/AppearanceDetailModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
199
web/frontend/src/components/BatchProgressModal.tsx
Normal file
199
web/frontend/src/components/BatchProgressModal.tsx
Normal 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
|
||||
46
web/frontend/src/components/Breadcrumb.tsx
Normal file
46
web/frontend/src/components/Breadcrumb.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
158
web/frontend/src/components/CookieHealthBanner.tsx
Normal file
158
web/frontend/src/components/CookieHealthBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1051
web/frontend/src/components/EnhancedLightbox.tsx
Normal file
1051
web/frontend/src/components/EnhancedLightbox.tsx
Normal file
File diff suppressed because it is too large
Load Diff
268
web/frontend/src/components/ErrorBanner.tsx
Normal file
268
web/frontend/src/components/ErrorBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
web/frontend/src/components/ErrorBoundary.tsx
Normal file
102
web/frontend/src/components/ErrorBoundary.tsx
Normal 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
|
||||
65
web/frontend/src/components/FeatureRoute.tsx
Normal file
65
web/frontend/src/components/FeatureRoute.tsx
Normal 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
|
||||
389
web/frontend/src/components/FilterBar.tsx
Normal file
389
web/frontend/src/components/FilterBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
465
web/frontend/src/components/FilterPopover.tsx
Normal file
465
web/frontend/src/components/FilterPopover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
169
web/frontend/src/components/FlareSolverrStatus.tsx
Normal file
169
web/frontend/src/components/FlareSolverrStatus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1099
web/frontend/src/components/GalleryLightbox.tsx
Normal file
1099
web/frontend/src/components/GalleryLightbox.tsx
Normal file
File diff suppressed because it is too large
Load Diff
93
web/frontend/src/components/LazyThumbnail.tsx
Normal file
93
web/frontend/src/components/LazyThumbnail.tsx
Normal 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 }
|
||||
208
web/frontend/src/components/MediaGrid.tsx
Normal file
208
web/frontend/src/components/MediaGrid.tsx
Normal 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 }
|
||||
94
web/frontend/src/components/NotificationToast.tsx
Normal file
94
web/frontend/src/components/NotificationToast.tsx
Normal 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
|
||||
288
web/frontend/src/components/PressArticleModal.tsx
Normal file
288
web/frontend/src/components/PressArticleModal.tsx
Normal 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, '<').replace(/>/g, '>')}</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>
|
||||
)
|
||||
}
|
||||
731
web/frontend/src/components/RecentItemsCard.tsx
Normal file
731
web/frontend/src/components/RecentItemsCard.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
101
web/frontend/src/components/SuspenseBoundary.tsx
Normal file
101
web/frontend/src/components/SuspenseBoundary.tsx
Normal 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 }
|
||||
117
web/frontend/src/components/ThrottledImage.tsx
Normal file
117
web/frontend/src/components/ThrottledImage.tsx
Normal 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)
|
||||
})
|
||||
}
|
||||
293
web/frontend/src/components/ThumbnailProgressModal.tsx
Normal file
293
web/frontend/src/components/ThumbnailProgressModal.tsx
Normal 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
|
||||
181
web/frontend/src/components/UpcomingAppearancesCard.tsx
Normal file
181
web/frontend/src/components/UpcomingAppearancesCard.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
193
web/frontend/src/components/UsernameListEditor.tsx
Normal file
193
web/frontend/src/components/UsernameListEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1505
web/frontend/src/components/paid-content/BundleLightbox.tsx
Normal file
1505
web/frontend/src/components/paid-content/BundleLightbox.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
657
web/frontend/src/components/paid-content/GalleryTimeline.tsx
Normal file
657
web/frontend/src/components/paid-content/GalleryTimeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2068
web/frontend/src/components/paid-content/PostDetailView.tsx
Normal file
2068
web/frontend/src/components/paid-content/PostDetailView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
173
web/frontend/src/components/paid-content/TimelineScrubber.tsx
Normal file
173
web/frontend/src/components/paid-content/TimelineScrubber.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
197
web/frontend/src/components/private-gallery/PersonSelector.tsx
Normal file
197
web/frontend/src/components/private-gallery/PersonSelector.tsx
Normal 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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
187
web/frontend/src/config/breadcrumbConfig.ts
Normal file
187
web/frontend/src/config/breadcrumbConfig.ts
Normal 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' }
|
||||
]
|
||||
}
|
||||
65
web/frontend/src/contexts/BreadcrumbContext.tsx
Normal file
65
web/frontend/src/contexts/BreadcrumbContext.tsx
Normal 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
|
||||
}
|
||||
30
web/frontend/src/hooks/useBreadcrumb.ts
Normal file
30
web/frontend/src/hooks/useBreadcrumb.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
92
web/frontend/src/hooks/useEnabledFeatures.ts
Normal file
92
web/frontend/src/hooks/useEnabledFeatures.ts
Normal 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
|
||||
225
web/frontend/src/hooks/useMediaFiltering.ts
Normal file
225
web/frontend/src/hooks/useMediaFiltering.ts
Normal 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
|
||||
189
web/frontend/src/hooks/usePrivateGalleryAuth.ts
Normal file
189
web/frontend/src/hooks/usePrivateGalleryAuth.ts
Normal 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
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
5282
web/frontend/src/lib/api.ts
Normal file
File diff suppressed because it is too large
Load Diff
96
web/frontend/src/lib/cacheInvalidation.ts
Normal file
96
web/frontend/src/lib/cacheInvalidation.ts
Normal 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)
|
||||
}
|
||||
409
web/frontend/src/lib/notificationManager.ts
Normal file
409
web/frontend/src/lib/notificationManager.ts
Normal 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()
|
||||
160
web/frontend/src/lib/taskManager.ts
Normal file
160
web/frontend/src/lib/taskManager.ts
Normal 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()
|
||||
57
web/frontend/src/lib/thumbnailQueue.ts
Normal file
57
web/frontend/src/lib/thumbnailQueue.ts
Normal 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
|
||||
}
|
||||
61
web/frontend/src/lib/useSwipeGestures.ts
Normal file
61
web/frontend/src/lib/useSwipeGestures.ts
Normal 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])
|
||||
}
|
||||
294
web/frontend/src/lib/utils.ts
Normal file
294
web/frontend/src/lib/utils.ts
Normal 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
66
web/frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
328
web/frontend/src/pages/Analytics.tsx
Normal file
328
web/frontend/src/pages/Analytics.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1147
web/frontend/src/pages/Appearances.tsx
Normal file
1147
web/frontend/src/pages/Appearances.tsx
Normal file
File diff suppressed because it is too large
Load Diff
242
web/frontend/src/pages/Changelog.tsx
Normal file
242
web/frontend/src/pages/Changelog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
970
web/frontend/src/pages/ChannelMonitors.tsx
Normal file
970
web/frontend/src/pages/ChannelMonitors.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9627
web/frontend/src/pages/Configuration.tsx
Normal file
9627
web/frontend/src/pages/Configuration.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2615
web/frontend/src/pages/Dashboard.tsx
Normal file
2615
web/frontend/src/pages/Dashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1464
web/frontend/src/pages/Discovery.tsx
Normal file
1464
web/frontend/src/pages/Discovery.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1408
web/frontend/src/pages/DownloadQueue.tsx
Normal file
1408
web/frontend/src/pages/DownloadQueue.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1506
web/frontend/src/pages/Downloads.tsx
Normal file
1506
web/frontend/src/pages/Downloads.tsx
Normal file
File diff suppressed because it is too large
Load Diff
346
web/frontend/src/pages/FaceRecognitionDashboard.tsx
Normal file
346
web/frontend/src/pages/FaceRecognitionDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
560
web/frontend/src/pages/Gallery.tsx
Normal file
560
web/frontend/src/pages/Gallery.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
618
web/frontend/src/pages/Health.tsx
Normal file
618
web/frontend/src/pages/Health.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
766
web/frontend/src/pages/InstagramUnified.tsx
Normal file
766
web/frontend/src/pages/InstagramUnified.tsx
Normal 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 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 username2 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
|
||||
2290
web/frontend/src/pages/InternetDiscovery.tsx
Normal file
2290
web/frontend/src/pages/InternetDiscovery.tsx
Normal file
File diff suppressed because it is too large
Load Diff
141
web/frontend/src/pages/Login.tsx
Normal file
141
web/frontend/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
745
web/frontend/src/pages/Logs.tsx
Normal file
745
web/frontend/src/pages/Logs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
599
web/frontend/src/pages/ManualImport.tsx
Normal file
599
web/frontend/src/pages/ManualImport.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1259
web/frontend/src/pages/Media.tsx
Normal file
1259
web/frontend/src/pages/Media.tsx
Normal file
File diff suppressed because it is too large
Load Diff
350
web/frontend/src/pages/Monitoring.tsx
Normal file
350
web/frontend/src/pages/Monitoring.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1172
web/frontend/src/pages/Notifications.tsx
Normal file
1172
web/frontend/src/pages/Notifications.tsx
Normal file
File diff suppressed because it is too large
Load Diff
476
web/frontend/src/pages/Platforms.tsx
Normal file
476
web/frontend/src/pages/Platforms.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
421
web/frontend/src/pages/Press.tsx
Normal file
421
web/frontend/src/pages/Press.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
971
web/frontend/src/pages/RecycleBin.tsx
Normal file
971
web/frontend/src/pages/RecycleBin.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1363
web/frontend/src/pages/Review.tsx
Normal file
1363
web/frontend/src/pages/Review.tsx
Normal file
File diff suppressed because it is too large
Load Diff
541
web/frontend/src/pages/Scheduler.tsx
Normal file
541
web/frontend/src/pages/Scheduler.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1576
web/frontend/src/pages/Scrapers.tsx
Normal file
1576
web/frontend/src/pages/Scrapers.tsx
Normal file
File diff suppressed because it is too large
Load Diff
63
web/frontend/src/pages/ScrapingMonitor.tsx
Normal file
63
web/frontend/src/pages/ScrapingMonitor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
425
web/frontend/src/pages/TwoFactorAuth.tsx
Normal file
425
web/frontend/src/pages/TwoFactorAuth.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1699
web/frontend/src/pages/VideoDownloader.tsx
Normal file
1699
web/frontend/src/pages/VideoDownloader.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1072
web/frontend/src/pages/paid-content/AddContent.tsx
Normal file
1072
web/frontend/src/pages/paid-content/AddContent.tsx
Normal file
File diff suppressed because it is too large
Load Diff
309
web/frontend/src/pages/paid-content/Analytics.tsx
Normal file
309
web/frontend/src/pages/paid-content/Analytics.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
886
web/frontend/src/pages/paid-content/BulkDelete.tsx
Normal file
886
web/frontend/src/pages/paid-content/BulkDelete.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1997
web/frontend/src/pages/paid-content/Creators.tsx
Normal file
1997
web/frontend/src/pages/paid-content/Creators.tsx
Normal file
File diff suppressed because it is too large
Load Diff
873
web/frontend/src/pages/paid-content/Dashboard.tsx
Normal file
873
web/frontend/src/pages/paid-content/Dashboard.tsx
Normal 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(/&/g, '&').replace(/</g, '<').replace(/>/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>
|
||||
)
|
||||
}
|
||||
4109
web/frontend/src/pages/paid-content/Feed.tsx
Normal file
4109
web/frontend/src/pages/paid-content/Feed.tsx
Normal file
File diff suppressed because it is too large
Load Diff
26
web/frontend/src/pages/paid-content/Gallery.tsx
Normal file
26
web/frontend/src/pages/paid-content/Gallery.tsx
Normal 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} />
|
||||
}
|
||||
609
web/frontend/src/pages/paid-content/Messages.tsx
Normal file
609
web/frontend/src/pages/paid-content/Messages.tsx
Normal 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">
|
||||
· {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 · {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
Reference in New Issue
Block a user