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'
|
||||
}
|
||||
Reference in New Issue
Block a user