import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useAuth } from '@clerk/clerk-react'; export interface ContentAsset { id: number; user_id: string; asset_type: 'text' | 'image' | 'video' | 'audio'; source_module: string; filename: string; file_url: string; file_path?: string; file_size?: number; mime_type?: string; title?: string; description?: string; prompt?: string; tags: string[]; asset_metadata: Record; provider?: string; model?: string; cost: number; generation_time?: number; is_favorite: boolean; download_count: number; share_count: number; created_at: string; updated_at: string; } export interface AssetFilters { asset_type?: 'text' | 'image' | 'video' | 'audio'; source_module?: string | string[]; // Support single or multiple source modules search?: string; tags?: string[]; favorites_only?: boolean; collection_id?: number; date_from?: string; date_to?: string; sort_by?: string; sort_order?: 'asc' | 'desc'; limit?: number; offset?: number; } export interface AssetListResponse { assets: ContentAsset[]; total: number; limit: number; offset: number; } const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8000'; export const useContentAssets = (filters: AssetFilters = {}) => { const { getToken } = useAuth(); const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [total, setTotal] = useState(0); const isFetchingRef = useRef(false); const abortControllerRef = useRef(null); // Memoize filters to create stable reference - only changes when actual values change const stableFilters = useMemo(() => { return { asset_type: filters.asset_type, source_module: filters.source_module, search: filters.search, tags: filters.tags ? [...filters.tags] : undefined, // Create new array to ensure stability favorites_only: filters.favorites_only, collection_id: filters.collection_id, date_from: filters.date_from, date_to: filters.date_to, sort_by: filters.sort_by, sort_order: filters.sort_order, limit: filters.limit, offset: filters.offset, }; }, [ filters.asset_type, filters.source_module, filters.search, // Use JSON.stringify for array comparison in dependency array filters.tags ? JSON.stringify(filters.tags) : undefined, filters.favorites_only, filters.collection_id, filters.date_from, filters.date_to, filters.sort_by, filters.sort_order, filters.limit, filters.offset, ]); // Create stable filter key for comparison const filterKey = useMemo(() => { return JSON.stringify(stableFilters); }, [stableFilters]); // Store latest filters in ref for use in fetch function const filtersRef = useRef(stableFilters); useEffect(() => { filtersRef.current = stableFilters; }, [stableFilters]); // Fetch function - exposed for manual retry, not called automatically on errors const fetchAssets = useCallback(async () => { // Prevent concurrent fetches if (isFetchingRef.current) { return; } // Cancel any pending request if (abortControllerRef.current) { abortControllerRef.current.abort(); } // Create new abort controller for this request const abortController = new AbortController(); abortControllerRef.current = abortController; try { isFetchingRef.current = true; setLoading(true); setError(null); const token = await getToken(); if (!token) { setLoading(false); isFetchingRef.current = false; return; } // Use ref to get latest filters const currentFilters = filtersRef.current; const params = new URLSearchParams(); if (currentFilters.asset_type) params.append('asset_type', currentFilters.asset_type); if (currentFilters.source_module) { // Handle both string and array cases if (Array.isArray(currentFilters.source_module)) { // For arrays, use the first value (backend doesn't support multiple yet) params.append('source_module', currentFilters.source_module[0]); } else { params.append('source_module', currentFilters.source_module); } } if (currentFilters.search) params.append('search', currentFilters.search); if (currentFilters.tags && currentFilters.tags.length > 0) params.append('tags', currentFilters.tags.join(',')); if (currentFilters.favorites_only) params.append('favorites_only', 'true'); if (currentFilters.collection_id) params.append('collection_id', String(currentFilters.collection_id)); if (currentFilters.date_from) params.append('date_from', currentFilters.date_from); if (currentFilters.date_to) params.append('date_to', currentFilters.date_to); if (currentFilters.sort_by) params.append('sort_by', currentFilters.sort_by); if (currentFilters.sort_order) params.append('sort_order', currentFilters.sort_order); params.append('limit', String(currentFilters.limit || 100)); params.append('offset', String(currentFilters.offset || 0)); const response = await fetch(`${API_BASE_URL}/api/content-assets/?${params.toString()}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, signal: abortController.signal, }); if (!response.ok) { if (response.status === 429) { setError('Rate limit exceeded. Please try again later.'); setAssets([]); setTotal(0); setLoading(false); isFetchingRef.current = false; return; } throw new Error(`Failed to fetch assets: ${response.statusText}`); } const data: AssetListResponse = await response.json(); setAssets(data.assets); setTotal(data.total); } catch (err) { // Don't set error for aborted requests if (err instanceof Error && err.name === 'AbortError') { return; } if (err instanceof TypeError && err.message.includes('fetch')) { setError('Network error. Please check your connection.'); } else { setError(err instanceof Error ? err.message : 'Failed to fetch assets'); } setAssets([]); } finally { setLoading(false); isFetchingRef.current = false; } }, [getToken]); // Only depend on getToken, use ref for filters // Fetch on mount and when filters change - but only once per filter change // NO automatic retry on errors - user must call refetch() manually useEffect(() => { fetchAssets(); // Cleanup: abort on unmount or filter change return () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } }; }, [filterKey, fetchAssets]); // Include fetchAssets but it's stable due to ref usage const toggleFavorite = useCallback(async (assetId: number) => { try { const token = await getToken(); if (!token) { throw new Error('Not authenticated'); } const response = await fetch(`${API_BASE_URL}/api/content-assets/${assetId}/favorite`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error('Failed to toggle favorite'); } const data = await response.json(); // Update local state setAssets(prev => prev.map(asset => asset.id === assetId ? { ...asset, is_favorite: data.is_favorite } : asset ) ); return data.is_favorite; } catch (err) { console.error('Error toggling favorite:', err); throw err; } }, [getToken]); const deleteAsset = useCallback(async (assetId: number) => { try { const token = await getToken(); if (!token) { throw new Error('Not authenticated'); } const response = await fetch(`${API_BASE_URL}/api/content-assets/${assetId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error('Failed to delete asset'); } // Remove from local state setAssets(prev => prev.filter(asset => asset.id !== assetId)); setTotal(prev => prev - 1); return true; } catch (err) { console.error('Error deleting asset:', err); throw err; } }, [getToken]); const trackUsage = useCallback(async (assetId: number, action: 'download' | 'share' | 'access') => { try { const token = await getToken(); if (!token) { return; } await fetch(`${API_BASE_URL}/api/content-assets/${assetId}/usage?action=${action}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, }); } catch (err) { console.error('Error tracking usage:', err); } }, [getToken]); const updateAsset = useCallback(async ( assetId: number, updates: { title?: string; description?: string; tags?: string[] } ) => { try { const token = await getToken(); if (!token) { throw new Error('Not authenticated'); } const body: any = {}; if (updates.title !== undefined) body.title = updates.title; if (updates.description !== undefined) body.description = updates.description; if (updates.tags !== undefined) body.tags = updates.tags; // Send as array, not comma-separated const response = await fetch(`${API_BASE_URL}/api/content-assets/${assetId}`, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (!response.ok) { throw new Error('Failed to update asset'); } const updatedAsset = await response.json(); // Update local state setAssets(prev => prev.map(asset => asset.id === assetId ? { ...asset, ...updatedAsset } : asset ) ); return updatedAsset; } catch (err) { console.error('Error updating asset:', err); throw err; } }, [getToken]); return { assets, loading, error, total, refetch: fetchAssets, toggleFavorite, deleteAsset, updateAsset, trackUsage, }; };