UNPKG

next-data-fetcher

Version:

A flexible data fetching system for Next.js applications with real-time updates and pagination

1 lines 80.7 kB
{"version":3,"sources":["../src/core/FetcherRegistry.ts","../src/core/realtime.ts","../src/core/BaseFetcher.ts","../src/hooks/useRealtimeUpdates.ts","../src/hocs/withClientFetching.tsx","../src/hocs/withServerFetching.tsx","../src/components/ListRenderers.tsx","../src/components/DynamicListRenderer.tsx","../src/components/Pagination.tsx","../src/components/DynamicDataDisplay.tsx","../src/components/Toggle.tsx","../src/utils/index.ts"],"sourcesContent":["import type { BaseFetcher, DataSourceType } from \"./BaseFetcher\"\n\nexport class FetcherRegistry {\n private static instance: FetcherRegistry\n private fetchers: Map<string, BaseFetcher<any>>\n private apiBasePath = \"/api/data\"\n private baseUrl: string =\n typeof process !== \"undefined\" && process.env.NEXT_PUBLIC_API_BASE_URL\n ? process.env.NEXT_PUBLIC_API_BASE_URL\n : typeof window !== \"undefined\"\n ? window.location.origin\n : \"http://localhost:3000\"\n\n private constructor() {\n this.fetchers = new Map()\n }\n\n public static getInstance(): FetcherRegistry {\n if (!FetcherRegistry.instance) {\n FetcherRegistry.instance = new FetcherRegistry()\n }\n return FetcherRegistry.instance\n }\n\n public register(componentId: string, fetcher: BaseFetcher<any>): void {\n this.fetchers.set(componentId, fetcher)\n }\n\n public getFetcher(componentId: string): BaseFetcher<any> | undefined {\n return this.fetchers.get(componentId)\n }\n\n public setApiBasePath(path: string): void {\n this.apiBasePath = path\n }\n\n public getApiBasePath(): string {\n return this.apiBasePath\n }\n\n public setBaseUrl(url: string): void {\n // Ensure the URL doesn't end with a slash\n this.baseUrl = url.endsWith(\"/\") ? url.slice(0, -1) : url\n }\n\n public getBaseUrl(): string {\n return this.baseUrl\n }\n\n // Method to handle URL construction\n public getDataUrl(componentId: string, dataSource: DataSourceType = \"json\", isServer = false): string {\n // For server-side fetching, use the full URL\n if (isServer) {\n return `${this.baseUrl}${this.apiBasePath}?component=${componentId}&dataSource=${dataSource}`\n }\n\n // For client-side, relative URL is fine\n return `${this.apiBasePath}?component=${componentId}&dataSource=${dataSource}`\n }\n}\n\n","import { EventEmitter } from \"events\"\n\n// Define event types\nexport type DataChangeEvent = {\n componentId: string\n action: \"create\" | \"update\" | \"delete\" | \"refresh\"\n data?: any\n id?: string | number\n}\n\n// Create a singleton event emitter for real-time updates\nclass RealtimeManager {\n private static instance: RealtimeManager\n private emitter: EventEmitter\n private sseClients: Set<{\n id: string\n send: (data: string) => void\n }>\n private isServerSide: boolean\n\n private constructor() {\n this.emitter = new EventEmitter()\n // Set high max listeners to avoid warnings\n this.emitter.setMaxListeners(100)\n this.sseClients = new Set()\n this.isServerSide = typeof window === \"undefined\"\n }\n\n public static getInstance(): RealtimeManager {\n if (!RealtimeManager.instance) {\n RealtimeManager.instance = new RealtimeManager()\n }\n return RealtimeManager.instance\n }\n\n // Subscribe to data changes for a specific component\n public subscribe(componentId: string, callback: (event: DataChangeEvent) => void): () => void {\n const eventName = `data-change:${componentId}`\n this.emitter.on(eventName, callback)\n\n // Return unsubscribe function\n return () => {\n this.emitter.off(eventName, callback)\n }\n }\n\n // Publish data changes\n public publish(event: DataChangeEvent): void {\n const eventName = `data-change:${event.componentId}`\n this.emitter.emit(eventName, event)\n\n // If on server, also send to all SSE clients\n if (this.isServerSide) {\n this.broadcastToSSEClients(event)\n }\n }\n\n // Register a new SSE client\n public registerSSEClient(id: string, send: (data: string) => void): void {\n this.sseClients.add({ id, send })\n }\n\n // Unregister an SSE client\n public unregisterSSEClient(id: string): void {\n for (const client of this.sseClients) {\n if (client.id === id) {\n this.sseClients.delete(client)\n break\n }\n }\n }\n\n // Broadcast to all SSE clients\n private broadcastToSSEClients(event: DataChangeEvent): void {\n const data = JSON.stringify(event)\n for (const client of this.sseClients) {\n try {\n client.send(data)\n } catch (error) {\n console.error(`Error sending to SSE client ${client.id}:`, error)\n this.sseClients.delete(client)\n }\n }\n }\n}\n\nexport const realtimeManager = RealtimeManager.getInstance()\n\n","import { FetcherRegistry } from \"./FetcherRegistry\"\nimport { realtimeManager, type DataChangeEvent } from \"./realtime\"\n\nexport type DataSourceType = \"json\" | \"csv\" | \"txt\" | \"api\"\n\nexport interface PaginationOptions {\n page: number\n limit: number\n enabled: boolean\n}\n\nexport interface FetcherOptions {\n dataSource?: DataSourceType\n componentId: string\n endpoint?: string\n pagination?: PaginationOptions\n}\n\nexport abstract class BaseFetcher<T> {\n protected options: FetcherOptions\n protected baseServerUrl: string =\n typeof process !== \"undefined\" && process.env.NEXT_PUBLIC_API_BASE_URL\n ? process.env.NEXT_PUBLIC_API_BASE_URL\n : typeof window !== \"undefined\"\n ? window.location.origin\n : \"http://localhost:3000\"\n\n // Add a static cache for server-side data\n private static serverCache = new Map<string, any[]>()\n\n // Add a cache for client-side data\n private clientCache = new Map<\n string,\n {\n data: T[]\n timestamp: number\n totalItems?: number\n totalPages?: number\n }\n >()\n\n // Cache expiration time in milliseconds (5 minutes)\n private cacheExpirationTime = 5 * 60 * 1000\n\n // Realtime subscription cleanup function\n private unsubscribe: (() => void) | null = null\n\n constructor(options: FetcherOptions) {\n this.options = {\n ...options,\n pagination: options.pagination || {\n page: 1,\n limit: 0, // 0 means no pagination\n enabled: false,\n },\n }\n\n // Subscribe to realtime updates for this component\n this.setupRealtimeSubscription()\n }\n\n // Set up realtime subscription\n private setupRealtimeSubscription() {\n // Clean up any existing subscription\n if (this.unsubscribe) {\n this.unsubscribe()\n }\n\n // Subscribe to data changes for this component\n this.unsubscribe = realtimeManager.subscribe(this.options.componentId, this.handleDataChange.bind(this))\n }\n\n // Handle data change events\n private handleDataChange(event: DataChangeEvent) {\n console.log(`Received data change for ${this.options.componentId}:`, event)\n\n // Clear cache on any data change\n this.invalidateCache()\n }\n\n // Invalidate the cache\n public invalidateCache() {\n // Clear client cache for this component\n const cacheKeys = Array.from(this.clientCache.keys())\n for (const key of cacheKeys) {\n if (key.includes(this.options.componentId)) {\n this.clientCache.delete(key)\n }\n }\n\n // Clear server cache if we're on the server\n if (typeof window === \"undefined\") {\n const serverCacheKeys = Array.from(BaseFetcher.serverCache.keys())\n for (const key of serverCacheKeys) {\n if (key.includes(this.options.componentId)) {\n BaseFetcher.serverCache.delete(key)\n }\n }\n }\n }\n\n abstract parseData(data: any): T[]\n\n private getUrl(isServer: boolean): string {\n const registry = FetcherRegistry.getInstance()\n\n // If using a specific endpoint (like external API)\n if (this.options.endpoint && this.options.dataSource === \"api\") {\n return this.options.endpoint\n }\n\n // Use the registry to get the correct URL format\n let url = registry.getDataUrl(this.options.componentId, this.options.dataSource, isServer)\n\n // Add pagination parameters if enabled\n if (this.options.pagination?.enabled && this.options.pagination.limit > 0) {\n url += `&page=${this.options.pagination.page}&limit=${this.options.pagination.limit}`\n }\n\n return url\n }\n\n async fetchJsonData(isServer: boolean): Promise<{ data: T[]; totalItems?: number; totalPages?: number }> {\n // Generate cache key based on component, data source, and pagination\n const paginationString = this.options.pagination?.enabled\n ? `_page${this.options.pagination.page}_limit${this.options.pagination.limit}`\n : \"\"\n const cacheKey = `json_${this.options.componentId}${paginationString}`\n\n // Check server cache first if on server\n if (isServer) {\n if (BaseFetcher.serverCache.has(cacheKey)) {\n console.log(`Using cached data for ${this.options.componentId} from server cache`)\n return { data: BaseFetcher.serverCache.get(cacheKey) as T[] }\n }\n } else {\n // Check client cache if on client\n const cachedData = this.clientCache.get(cacheKey)\n if (cachedData && Date.now() - cachedData.timestamp < this.cacheExpirationTime) {\n console.log(`Using cached data for ${this.options.componentId} from client cache`)\n return {\n data: cachedData.data,\n totalItems: cachedData.totalItems,\n totalPages: cachedData.totalPages,\n }\n }\n }\n\n const url = this.getUrl(isServer)\n try {\n const response = await fetch(url, {\n // Add cache: 'no-store' to prevent caching issues when switching sources\n cache: \"no-store\",\n })\n\n if (!response.ok) {\n throw new Error(`Failed to fetch data: ${response.statusText}`)\n }\n\n const responseData = await response.json()\n\n // Handle both simple array responses and paginated responses\n let parsedData: T[]\n let totalItems: number | undefined\n let totalPages: number | undefined\n\n if (responseData.data && Array.isArray(responseData.data)) {\n // Handle paginated response\n parsedData = this.parseData(responseData.data)\n totalItems = responseData.pagination?.totalItems\n totalPages = responseData.pagination?.totalPages\n } else if (Array.isArray(responseData)) {\n // Handle simple array response\n parsedData = this.parseData(responseData)\n } else {\n // Handle unexpected response format\n console.warn(\"Unexpected response format:\", responseData)\n parsedData = []\n }\n\n // Cache server-side results\n if (isServer) {\n BaseFetcher.serverCache.set(cacheKey, parsedData)\n } else {\n // Cache client-side results with timestamp\n this.clientCache.set(cacheKey, {\n data: parsedData,\n timestamp: Date.now(),\n totalItems,\n totalPages,\n })\n }\n\n return { data: parsedData, totalItems, totalPages }\n } catch (error) {\n console.error(\"Error fetching JSON data:\", error)\n throw error\n }\n }\n\n async fetchCsvData(isServer: boolean): Promise<{ data: T[] }> {\n // Generate cache key\n const cacheKey = `csv_${this.options.componentId}`\n\n // Check server cache first if on server\n if (isServer) {\n if (BaseFetcher.serverCache.has(cacheKey)) {\n console.log(`Using cached data for ${this.options.componentId} from server cache`)\n return { data: BaseFetcher.serverCache.get(cacheKey) as T[] }\n }\n } else {\n // Check client cache if on client\n const cachedData = this.clientCache.get(cacheKey)\n if (cachedData && Date.now() - cachedData.timestamp < this.cacheExpirationTime) {\n console.log(`Using cached data for ${this.options.componentId} from client cache`)\n return { data: cachedData.data }\n }\n }\n\n const url = this.getUrl(isServer)\n try {\n const response = await fetch(url, {\n cache: \"no-store\",\n })\n\n if (!response.ok) {\n throw new Error(`Failed to fetch CSV data: ${response.statusText}`)\n }\n\n const text = await response.text()\n const rows = text.split(\"\\n\")\n const headers = rows[0].split(\",\")\n\n const jsonData = rows\n .slice(1)\n .filter((row) => row.trim() !== \"\")\n .map((row) => {\n const values = row.split(\",\")\n return headers.reduce((obj, header, index) => {\n obj[header.trim()] = values[index]?.trim()\n return obj\n }, {} as any)\n })\n\n const parsedData = this.parseData(jsonData)\n\n // Cache results\n if (isServer) {\n BaseFetcher.serverCache.set(cacheKey, parsedData)\n } else {\n this.clientCache.set(cacheKey, {\n data: parsedData,\n timestamp: Date.now(),\n })\n }\n\n return { data: parsedData }\n } catch (error) {\n console.error(\"Error fetching CSV data:\", error)\n throw error\n }\n }\n\n async fetchTxtData(isServer: boolean): Promise<{ data: T[] }> {\n // Generate cache key\n const cacheKey = `txt_${this.options.componentId}`\n\n // Check cache first\n if (isServer) {\n if (BaseFetcher.serverCache.has(cacheKey)) {\n console.log(`Using cached data for ${this.options.componentId} from server cache`)\n return { data: BaseFetcher.serverCache.get(cacheKey) as T[] }\n }\n } else {\n const cachedData = this.clientCache.get(cacheKey)\n if (cachedData && Date.now() - cachedData.timestamp < this.cacheExpirationTime) {\n console.log(`Using cached data for ${this.options.componentId} from client cache`)\n return { data: cachedData.data }\n }\n }\n\n const url = this.getUrl(isServer)\n try {\n const response = await fetch(url, {\n cache: \"no-store\",\n })\n\n if (!response.ok) {\n throw new Error(`Failed to fetch TXT data: ${response.statusText}`)\n }\n\n const text = await response.text()\n const lines = text.split(\"\\n\")\n\n // Simple format: assume each line is a JSON object\n const jsonData = lines\n .filter((line) => line.trim() !== \"\")\n .map((line) => {\n try {\n return JSON.parse(line)\n } catch (e) {\n // Simple key-value format\n const pairs = line.split(\",\")\n return pairs.reduce((obj, pair) => {\n const [key, value] = pair.split(\":\").map((s) => s.trim())\n if (key && value) {\n obj[key] = value\n }\n return obj\n }, {} as any)\n }\n })\n\n const parsedData = this.parseData(jsonData)\n\n // Cache results\n if (isServer) {\n BaseFetcher.serverCache.set(cacheKey, parsedData)\n } else {\n this.clientCache.set(cacheKey, {\n data: parsedData,\n timestamp: Date.now(),\n })\n }\n\n return { data: parsedData }\n } catch (error) {\n console.error(\"Error fetching TXT data:\", error)\n throw error\n }\n }\n\n // Update the fetchApiData method to include retry logic and better error handling\n async fetchApiData(isServer: boolean): Promise<{ data: T[] }> {\n // Generate cache key\n const cacheKey = `api_${this.options.componentId}`\n\n // Check cache first\n if (isServer) {\n if (BaseFetcher.serverCache.has(cacheKey)) {\n console.log(`Using cached data for ${this.options.componentId} from server cache`)\n return { data: BaseFetcher.serverCache.get(cacheKey) as T[] }\n }\n } else {\n const cachedData = this.clientCache.get(cacheKey)\n if (cachedData && Date.now() - cachedData.timestamp < this.cacheExpirationTime) {\n console.log(`Using cached data for ${this.options.componentId} from client cache`)\n return { data: cachedData.data }\n }\n }\n\n // For external API calls (like RapidAPI or MockAPI)\n const url = this.options.endpoint || \"\"\n\n if (!url || url === \"\") {\n console.warn(`No endpoint provided for API data source in component ${this.options.componentId}`)\n return { data: [] }\n }\n\n // Maximum number of retry attempts\n const MAX_RETRIES = 3\n let retries = 0\n let lastError: any = null\n\n while (retries < MAX_RETRIES) {\n try {\n let headers: Record<string, string> = {}\n\n // Only add API keys if they're defined\n if (process.env.NEXT_PUBLIC_RAPIDAPI_KEY && process.env.NEXT_PUBLIC_RAPIDAPI_HOST) {\n headers = {\n \"x-rapidapi-key\": process.env.NEXT_PUBLIC_RAPIDAPI_KEY,\n \"x-rapidapi-host\": process.env.NEXT_PUBLIC_RAPIDAPI_HOST,\n }\n }\n\n console.log(`Fetching API data from ${url}, attempt ${retries + 1}`)\n\n const response = await fetch(url, {\n headers,\n cache: \"no-store\",\n })\n\n // Handle rate limiting specifically\n if (response.status === 429) {\n const retryAfter = response.headers.get(\"Retry-After\") || \"5\"\n const waitTime = Number.parseInt(retryAfter, 10) * 1000 || 2 ** retries * 1000\n console.warn(`Rate limited. Retrying after ${waitTime}ms...`)\n await new Promise((resolve) => setTimeout(resolve, waitTime))\n retries++\n continue\n }\n\n if (!response.ok) {\n throw new Error(`Failed to fetch API data: ${response.statusText}`)\n }\n\n const data = await response.json()\n const parsedData = this.parseData(data)\n\n // Cache results\n if (isServer) {\n BaseFetcher.serverCache.set(cacheKey, parsedData)\n } else {\n this.clientCache.set(cacheKey, {\n data: parsedData,\n timestamp: Date.now(),\n })\n }\n\n return { data: parsedData }\n } catch (error) {\n lastError = error\n console.error(`Error fetching API data from ${url} (attempt ${retries + 1}):`, error)\n\n // Exponential backoff\n const waitTime = 2 ** retries * 1000\n await new Promise((resolve) => setTimeout(resolve, waitTime))\n retries++\n }\n }\n\n console.error(`Failed to fetch API data after ${MAX_RETRIES} attempts. Using fallback data.`)\n\n // Try to use fallback data from local files\n try {\n // If we're on the client, try to get data from the local API endpoint\n if (!isServer) {\n const componentId = this.options.componentId\n const fallbackUrl = `/api/fallback?component=${componentId}`\n console.log(`Trying fallback data from: ${fallbackUrl}`)\n\n const response = await fetch(fallbackUrl)\n if (response.ok) {\n const data = await response.json()\n const parsedData = this.parseData(data)\n return { data: parsedData }\n }\n }\n } catch (fallbackError) {\n console.error(\"Error fetching fallback data:\", fallbackError)\n }\n\n // If all else fails, return mock data based on the component type\n return { data: this.getMockData() }\n }\n\n // Add a new method to provide mock data as a last resort\n getMockData(): T[] {\n // Determine what kind of mock data to return based on the component ID\n const componentId = this.options.componentId\n\n if (componentId.includes(\"User\")) {\n return [\n { id: 1, name: \"Mock User 1\", email: \"user1@example.com\" },\n { id: 2, name: \"Mock User 2\", email: \"user2@example.com\" },\n { id: 3, name: \"Mock User 3\", email: \"user3@example.com\" },\n ] as unknown as T[]\n } else if (componentId.includes(\"Product\")) {\n return [\n { id: 1, name: \"Mock Product 1\", price: 99.99, description: \"A mock product\" },\n { id: 2, name: \"Mock Product 2\", price: 199.99, description: \"Another mock product\" },\n { id: 3, name: \"Mock Product 3\", price: 299.99, description: \"Yet another mock product\" },\n ] as unknown as T[]\n }\n\n // Default empty array if component type is unknown\n return [] as T[]\n }\n\n // Update pagination settings\n setPagination(page: number, limit: number, enabled = true) {\n if (this.options.pagination) {\n this.options.pagination.page = page\n this.options.pagination.limit = limit\n this.options.pagination.enabled = enabled\n } else {\n this.options.pagination = { page, limit, enabled }\n }\n }\n\n // Publish a data change event\n publishDataChange(action: \"create\" | \"update\" | \"delete\" | \"refresh\", data?: any, id?: string | number) {\n realtimeManager.publish({\n componentId: this.options.componentId,\n action,\n data,\n id,\n })\n }\n\n async fetchData(isServer = false): Promise<{ data: T[]; totalItems?: number; totalPages?: number }> {\n console.log(`Fetching data for ${this.options.componentId} with isServer=${isServer}`)\n const dataSource = this.options.dataSource || \"json\"\n\n switch (dataSource) {\n case \"json\":\n return this.fetchJsonData(isServer)\n case \"csv\":\n return this.fetchCsvData(isServer)\n case \"txt\":\n return this.fetchTxtData(isServer)\n case \"api\":\n return this.fetchApiData(isServer)\n default:\n throw new Error(`Unsupported data source: ${dataSource}`)\n }\n }\n}\n\n","\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { realtimeManager } from \"../core/realtime\"\n\n// Hook to subscribe to realtime updates\nexport function useRealtimeUpdates(componentId: string, onUpdate: () => void) {\n const eventSourceRef = useRef<EventSource | null>(null)\n const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)\n const [connectionAttempts, setConnectionAttempts] = useState(0)\n const MAX_RECONNECT_ATTEMPTS = 5\n\n useEffect(() => {\n // Subscribe to local events (for same-client updates)\n const unsubscribe = realtimeManager.subscribe(componentId, () => {\n onUpdate()\n })\n\n // Function to create and set up the EventSource\n const setupEventSource = () => {\n // Don't try to reconnect if we've exceeded the maximum attempts\n if (connectionAttempts >= MAX_RECONNECT_ATTEMPTS) {\n console.warn(`Exceeded maximum reconnection attempts (${MAX_RECONNECT_ATTEMPTS}). Giving up.`)\n return\n }\n\n // Clean up any existing connection\n if (eventSourceRef.current) {\n eventSourceRef.current.close()\n }\n\n // Clear any pending reconnection timeout\n if (reconnectTimeoutRef.current) {\n clearTimeout(reconnectTimeoutRef.current)\n reconnectTimeoutRef.current = null\n }\n\n try {\n // Create a new EventSource with a timestamp to prevent caching\n const timestamp = new Date().getTime()\n const eventSource = new EventSource(`/api/sse?t=${timestamp}`)\n eventSourceRef.current = eventSource\n\n // Handle successful connection\n eventSource.onopen = () => {\n console.log(\"SSE connection established\")\n setConnectionAttempts(0) // Reset connection attempts on successful connection\n }\n\n // Handle messages\n eventSource.onmessage = (event) => {\n try {\n const data = JSON.parse(event.data)\n\n // Handle connected message\n if (data.type === \"connected\") {\n console.log(\"Connected to SSE stream with client ID:\", data.clientId)\n return\n }\n\n // Handle data change events\n if (data.componentId === componentId) {\n console.log(\"Received SSE update for component:\", componentId)\n onUpdate()\n }\n } catch (error) {\n console.error(\"Error parsing SSE message:\", error)\n }\n }\n\n // Handle errors with improved error handling\n eventSource.onerror = (event) => {\n // Don't log the full event object as it's not very helpful\n console.warn(\"SSE connection error. Attempting to reconnect...\")\n\n // Close the current connection\n eventSource.close()\n eventSourceRef.current = null\n\n // Increment connection attempts\n setConnectionAttempts((prev) => prev + 1)\n\n // Use exponential backoff for reconnection\n const backoffTime = Math.min(1000 * Math.pow(2, connectionAttempts), 30000) // Max 30 seconds\n\n console.log(`Reconnecting in ${backoffTime / 1000} seconds...`)\n\n // Schedule reconnection\n reconnectTimeoutRef.current = setTimeout(() => {\n setupEventSource()\n }, backoffTime)\n }\n } catch (error) {\n console.error(\"Error setting up SSE connection:\", error)\n }\n }\n\n // Connect to SSE for cross-client updates\n if (typeof window !== \"undefined\") {\n setupEventSource()\n }\n\n // Cleanup function\n return () => {\n unsubscribe()\n\n if (eventSourceRef.current) {\n eventSourceRef.current.close()\n eventSourceRef.current = null\n }\n\n if (reconnectTimeoutRef.current) {\n clearTimeout(reconnectTimeoutRef.current)\n reconnectTimeoutRef.current = null\n }\n }\n }, [componentId, onUpdate, connectionAttempts])\n}\n\n","\"use client\"\n\nimport type React from \"react\"\nimport { useEffect, useState, useCallback, useRef } from \"react\"\nimport { FetcherRegistry } from \"../core/FetcherRegistry\"\nimport type { DataSourceType } from \"../core/BaseFetcher\"\nimport { useRealtimeUpdates } from \"../hooks/useRealtimeUpdates\"\n\nexport interface WithClientFetchingOptions {\n dataSource?: DataSourceType\n enableRealtime?: boolean\n defaultItemsPerPage?: number\n loadingComponent?: React.ReactNode\n errorComponent?: React.ReactNode\n}\n\nexport function withClientFetching<T, P extends { data?: T[] }>(\n WrappedComponent: React.ComponentType<P>,\n componentId: string,\n options: WithClientFetchingOptions = {},\n) {\n // Use a more descriptive component name for better debugging\n const displayName = WrappedComponent.displayName || WrappedComponent.name || \"Component\"\n\n const {\n dataSource = \"json\",\n enableRealtime = false,\n defaultItemsPerPage = 10,\n loadingComponent = <div>Loading data...</div>,\n errorComponent = (error: string, retry: () => void) => (\n <div className=\"error-container p-4 border border-red-300 rounded-md bg-red-50\">\n <div className=\"text-red-500 mb-2\">Error: {error}</div>\n <button onClick={retry} className=\"px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 text-sm\">\n Retry\n </button>\n </div>\n ),\n } = options\n\n function ClientComponent(props: Omit<P, \"data\">) {\n const [data, setData] = useState<T[]>([])\n const [loading, setLoading] = useState<boolean>(true)\n const [error, setError] = useState<string | null>(null)\n const [retryCount, setRetryCount] = useState<number>(0)\n const [currentPage, setCurrentPage] = useState<number>(1)\n const [itemsPerPage, setItemsPerPage] = useState<number>(defaultItemsPerPage)\n const [totalItems, setTotalItems] = useState<number | undefined>(undefined)\n const [totalPages, setTotalPages] = useState<number | undefined>(undefined)\n\n // Use a ref to store the fetcher instance\n const fetcherRef = useRef<any>(null)\n\n // Add a cache key to prevent unnecessary refetches\n const cacheKey = `client_${componentId}_${dataSource}_page${currentPage}_limit${itemsPerPage}`\n\n // Subscribe to realtime updates if enabled\n useRealtimeUpdates(componentId, () => {\n // Invalidate cache and refetch data\n if (fetcherRef.current) {\n fetcherRef.current.invalidateCache()\n }\n setRetryCount((prev) => prev + 1)\n })\n\n // Function to handle retry\n const handleRetry = useCallback(() => {\n setLoading(true)\n setError(null)\n setRetryCount((prev) => prev + 1)\n }, [])\n\n // Function to handle page change\n const handlePageChange = useCallback((page: number) => {\n setCurrentPage(page)\n }, [])\n\n // Function to handle items per page change\n const handleItemsPerPageChange = useCallback((items: number) => {\n setItemsPerPage(items)\n setCurrentPage(1) // Reset to first page when changing items per page\n }, [])\n\n // Fetch data with pagination\n useEffect(() => {\n let isMounted = true\n\n const fetchData = async () => {\n if (!isMounted) return\n\n setLoading(true)\n setError(null)\n\n try {\n const registry = FetcherRegistry.getInstance()\n const fetcher = registry.getFetcher(componentId)\n\n if (!fetcher) {\n throw new Error(`No fetcher registered for component: ${componentId}`)\n }\n\n // Store fetcher in ref for later use\n fetcherRef.current = fetcher\n\n // Set pagination options\n fetcher.setPagination(currentPage, itemsPerPage, true)\n\n console.log(`Client-side fetching for: ${componentId} (page ${currentPage}, limit ${itemsPerPage})`)\n // Fetch data from the client\n const result = await fetcher.fetchData(false)\n\n if (isMounted) {\n if (result.data.length === 0 && currentPage > 1) {\n // If we got no data and we're not on the first page, go back to first page\n setCurrentPage(1)\n } else {\n setData(result.data)\n\n // Update pagination info if available\n if (result.totalItems !== undefined) {\n setTotalItems(result.totalItems)\n }\n if (result.totalPages !== undefined) {\n setTotalPages(result.totalPages)\n } else if (result.totalItems !== undefined) {\n // Calculate total pages if not provided\n setTotalPages(Math.ceil(result.totalItems / itemsPerPage))\n }\n\n if (result.data.length === 0) {\n setError(\"No data available. The API might be rate limited.\")\n }\n }\n }\n } catch (err: any) {\n if (isMounted) {\n console.error(\"Client fetching error:\", err)\n let errorMessage = err.message || \"Unknown error occurred\"\n\n // Provide more user-friendly error messages\n if (errorMessage.includes(\"Too Many Requests\") || errorMessage.includes(\"429\")) {\n errorMessage = \"The API is rate limited. Please try again later.\"\n } else if (errorMessage.includes(\"Failed to fetch\")) {\n errorMessage = \"Network error. Please check your connection.\"\n }\n\n setError(errorMessage)\n }\n } finally {\n if (isMounted) {\n setLoading(false)\n }\n }\n }\n\n fetchData()\n\n // Cleanup function to prevent state updates after unmount\n return () => {\n isMounted = false\n }\n }, [componentId, currentPage, itemsPerPage])\n\n // Render loading state\n if (loading) {\n return <>{loadingComponent}</>\n }\n\n // Render error state\n if (error) {\n return <>{typeof errorComponent === \"function\" ? errorComponent(error, handleRetry) : errorComponent}</>\n }\n\n // Add pagination props to the wrapped component\n const enhancedProps = {\n ...(props as P),\n data,\n pagination: {\n currentPage,\n totalPages: totalPages || Math.ceil(data.length / itemsPerPage),\n totalItems: totalItems || data.length,\n itemsPerPage,\n onPageChange: handlePageChange,\n onItemsPerPageChange: handleItemsPerPageChange,\n },\n }\n\n return <WrappedComponent {...enhancedProps} />\n }\n\n // Set a display name for better debugging\n ClientComponent.displayName = `withClientFetching(${displayName})`\n\n return ClientComponent\n}\n\n","import type React from \"react\"\nimport { FetcherRegistry } from \"../core/FetcherRegistry\"\n\nexport interface WithServerFetchingOptions {\n defaultItemsPerPage?: number\n loadingComponent?: React.ReactNode\n errorComponent?: React.ReactNode\n}\n\nexport function withServerFetching<T, P extends { data?: T[] }>(\n WrappedComponent: React.ComponentType<P>,\n componentId: string,\n options: WithServerFetchingOptions = {},\n) {\n // Use a more descriptive component name for better debugging\n const displayName = WrappedComponent.displayName || WrappedComponent.name || \"Component\"\n\n const {\n defaultItemsPerPage = 10,\n loadingComponent = <div>Loading data...</div>,\n errorComponent = (error: string) => <div>Error: {error}</div>,\n } = options\n\n async function ServerComponent(props: Omit<P, \"data\">) {\n console.log(`Server-side fetching for: ${componentId}`)\n\n try {\n const registry = FetcherRegistry.getInstance()\n const fetcher = registry.getFetcher(componentId)\n\n if (!fetcher) {\n throw new Error(`No fetcher registered for component: ${componentId}`)\n }\n\n // Set pagination for server-side (default values)\n fetcher.setPagination(1, defaultItemsPerPage, true)\n\n // Fetch data from the server\n const result = await fetcher.fetchData(true)\n\n // Add pagination props to the wrapped component\n const enhancedProps = {\n ...(props as P),\n data: result.data,\n pagination: {\n currentPage: 1,\n totalPages: result.totalPages || Math.ceil(result.data.length / defaultItemsPerPage),\n totalItems: result.totalItems || result.data.length,\n itemsPerPage: defaultItemsPerPage,\n },\n }\n\n return <WrappedComponent {...enhancedProps} />\n } catch (error: any) {\n console.error(\"Server fetching error:\", error)\n return (\n <>\n {typeof errorComponent === \"function\"\n ? errorComponent(error.message || \"Unknown error occurred\")\n : errorComponent}\n </>\n )\n }\n }\n\n // Set a display name for better debugging\n ServerComponent.displayName = `withServerFetching(${displayName})`\n\n return ServerComponent\n}\n\n","import type React from \"react\"\n\nexport interface ListRendererProps<T> {\n data: T[]\n renderItem: (item: T, index: number) => React.ReactNode\n title: string\n className?: string\n listClassName?: string\n itemClassName?: string\n}\n\nexport function ListRenderer<T>({\n data,\n renderItem,\n title,\n className = \"list-container\",\n listClassName = \"list\",\n itemClassName = \"list-item\",\n}: ListRendererProps<T>) {\n return (\n <div className={className}>\n <h2>{title}</h2>\n {data.length === 0 ? (\n <p>No data available</p>\n ) : (\n <ul className={listClassName}>\n {data.map((item, index) => (\n <li key={index} className={itemClassName}>\n {renderItem(item, index)}\n </li>\n ))}\n </ul>\n )}\n </div>\n )\n}\n\n","\"use client\"\n\nimport { useState, useEffect, useRef } from \"react\"\nimport { Pagination } from \"./Pagination\"\nimport { DynamicDataDisplay } from \"./DynamicDataDisplay\"\n\nexport interface DynamicListRendererProps<T> {\n data: T[]\n title: string\n priorityFields?: string[]\n excludeFields?: string[]\n itemsPerPage?: number\n virtualized?: boolean\n className?: string\n listClassName?: string\n itemClassName?: string\n}\n\nexport function DynamicListRenderer<T extends Record<string, any>>({\n data,\n title,\n priorityFields = [],\n excludeFields = [],\n itemsPerPage: defaultItemsPerPage = 10,\n virtualized = false,\n className = \"\",\n listClassName = \"\",\n itemClassName = \"\",\n}: DynamicListRendererProps<T>) {\n const [currentPage, setCurrentPage] = useState(1)\n const [itemsPerPage, setItemsPerPage] = useState(defaultItemsPerPage)\n const [visibleItems, setVisibleItems] = useState<T[]>([])\n const containerRef = useRef<HTMLDivElement>(null)\n\n // Calculate total pages\n const totalPages = Math.max(1, Math.ceil(data.length / itemsPerPage))\n\n // Handle page change\n const handlePageChange = (page: number) => {\n setCurrentPage(page)\n // Scroll to top of list when page changes\n if (containerRef.current) {\n containerRef.current.scrollTop = 0\n }\n }\n\n // Handle items per page change\n const handleItemsPerPageChange = (newItemsPerPage: number) => {\n setItemsPerPage(newItemsPerPage)\n // Reset to first page when changing items per page\n setCurrentPage(1)\n }\n\n // Update visible items when page or itemsPerPage changes\n useEffect(() => {\n const startIndex = (currentPage - 1) * itemsPerPage\n const endIndex = startIndex + itemsPerPage\n setVisibleItems(data.slice(startIndex, endIndex))\n }, [currentPage, itemsPerPage, data])\n\n // Virtualized list implementation\n const renderVirtualizedList = () => {\n return (\n <div ref={containerRef} className={`virtualized-list-container overflow-auto max-h-[500px] ${listClassName}`}>\n {visibleItems.map((item, index) => (\n <div\n key={index}\n className={`list-item p-4 border rounded-md mb-2 hover:shadow-md transition-shadow ${itemClassName}`}\n >\n <DynamicDataDisplay data={item} priorityFields={priorityFields} excludeFields={excludeFields} />\n </div>\n ))}\n </div>\n )\n }\n\n // Standard list implementation\n const renderStandardList = () => {\n return (\n <div className={`list-container ${listClassName}`}>\n <ul className=\"list\">\n {visibleItems.map((item, index) => (\n <li\n key={index}\n className={`list-item p-4 border rounded-md mb-2 hover:shadow-md transition-shadow ${itemClassName}`}\n >\n <DynamicDataDisplay data={item} priorityFields={priorityFields} excludeFields={excludeFields} />\n </li>\n ))}\n </ul>\n </div>\n )\n }\n\n return (\n <div className={`dynamic-list-renderer ${className}`}>\n <h2 className=\"text-xl font-bold mb-4\">{title}</h2>\n\n {data.length === 0 ? (\n <p className=\"text-gray-500\">No data available</p>\n ) : (\n <>\n {virtualized ? renderVirtualizedList() : renderStandardList()}\n\n <Pagination\n currentPage={currentPage}\n totalPages={totalPages}\n onPageChange={handlePageChange}\n itemsPerPage={itemsPerPage}\n onItemsPerPageChange={handleItemsPerPageChange}\n totalItems={data.length}\n />\n </>\n )}\n </div>\n )\n}\n\n","\"use client\"\n\nexport interface PaginationProps {\n currentPage: number\n totalPages: number\n onPageChange: (page: number) => void\n itemsPerPage: number\n onItemsPerPageChange?: (itemsPerPage: number) => void\n totalItems?: number\n showItemsPerPage?: boolean\n className?: string\n}\n\nexport function Pagination({\n currentPage,\n totalPages,\n onPageChange,\n itemsPerPage,\n onItemsPerPageChange,\n totalItems,\n showItemsPerPage = true,\n className = \"\",\n}: PaginationProps) {\n // Generate page numbers to display\n const getPageNumbers = () => {\n const pageNumbers = []\n const maxPagesToShow = 5\n\n if (totalPages <= maxPagesToShow) {\n // Show all pages if there are fewer than maxPagesToShow\n for (let i = 1; i <= totalPages; i++) {\n pageNumbers.push(i)\n }\n } else {\n // Always show first page\n pageNumbers.push(1)\n\n // Calculate start and end of page range around current page\n let start = Math.max(2, currentPage - 1)\n let end = Math.min(totalPages - 1, currentPage + 1)\n\n // Adjust if we're near the beginning\n if (currentPage <= 3) {\n end = Math.min(totalPages - 1, 4)\n }\n\n // Adjust if we're near the end\n if (currentPage >= totalPages - 2) {\n start = Math.max(2, totalPages - 3)\n }\n\n // Add ellipsis if needed before middle pages\n if (start > 2) {\n pageNumbers.push(\"...\")\n }\n\n // Add middle pages\n for (let i = start; i <= end; i++) {\n pageNumbers.push(i)\n }\n\n // Add ellipsis if needed after middle pages\n if (end < totalPages - 1) {\n pageNumbers.push(\"...\")\n }\n\n // Always show last page\n pageNumbers.push(totalPages)\n }\n\n return pageNumbers\n }\n\n return (\n <div\n className={`pagination-container flex flex-col sm:flex-row items-center justify-between gap-4 mt-4 w-full ${className}`}\n >\n <div className=\"flex items-center text-sm text-gray-500\">\n {totalItems !== undefined && (\n <span>\n Showing {Math.min((currentPage - 1) * itemsPerPage + 1, totalItems)} to{\" \"}\n {Math.min(currentPage * itemsPerPage, totalItems)} of {totalItems} items\n </span>\n )}\n </div>\n\n <div className=\"flex items-center gap-1\">\n <button\n onClick={() => onPageChange(1)}\n disabled={currentPage === 1}\n className=\"p-2 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none\"\n aria-label=\"First page\"\n >\n {\"<<\"}\n </button>\n <button\n onClick={() => onPageChange(currentPage - 1)}\n disabled={currentPage === 1}\n className=\"p-2 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none\"\n aria-label=\"Previous page\"\n >\n {\"<\"}\n </button>\n\n {getPageNumbers().map((page, index) =>\n typeof page === \"number\" ? (\n <button\n key={index}\n onClick={() => onPageChange(page)}\n className={`px-3 py-2 rounded-md ${\n currentPage === page ? \"bg-blue-500 text-white\" : \"hover:bg-gray-100\"\n }`}\n >\n {page}\n </button>\n ) : (\n <span key={index} className=\"px-3 py-2\">\n {page}\n </span>\n ),\n )}\n\n <button\n onClick={() => onPageChange(currentPage + 1)}\n disabled={currentPage === totalPages}\n className=\"p-2 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none\"\n aria-label=\"Next page\"\n >\n {\">\"}\n </button>\n <button\n onClick={() => onPageChange(totalPages)}\n disabled={currentPage === totalPages}\n className=\"p-2 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none\"\n aria-label=\"Last page\"\n >\n {\">>\"}\n </button>\n </div>\n\n {showItemsPerPage && onItemsPerPageChange && (\n <div className=\"flex items-center gap-2\">\n <span className=\"text-sm text-gray-500\">Items per page:</span>\n <select\n value={itemsPerPage}\n onChange={(e) => onItemsPerPageChange(Number(e.target.value))}\n className=\"p-1 border rounded-md text-sm bg-white\"\n >\n <option value=\"5\">5</option>\n <option value=\"10\">10</option>\n <option value=\"20\">20</option>\n <option value=\"50\">50</option>\n <option value=\"100\">100</option>\n </select>\n </div>\n )}\n </div>\n )\n}\n\n","\"use client\"\n\nimport { useState } from \"react\"\n\nexport interface DynamicDataDisplayProps {\n data: Record<string, any>\n excludeFields?: string[]\n priorityFields?: string[]\n className?: string\n}\n\nexport function DynamicDataDisplay({\n data,\n excludeFields = [],\n priorityFields = [],\n className = \"\",\n}: DynamicDataDisplayProps) {\n const [expanded, setExpanded] = useState(false)\n\n if (!data || typeof data !== \"object\") {\n return <div>No data available</div>\n }\n\n // Get all fields, excluding those in excludeFields\n const allFields = Object.keys(data).filter((key) => !excludeFields.includes(key))\n\n // Separate fields into priority and non-priority\n const priorityFieldsToShow = allFields.filter((field) => priorityFields.includes(field))\n const otherFields = allFields.filter((field) => !priorityFields.includes(field))\n\n // Format value based on type\n const formatValue = (value: any): string => {\n if (value === null || value === undefined) return \"N/A\"\n if (typeof value === \"object\") {\n if (Array.isArray(value)) {\n return value.length > 0 ? `[Array(${value.length})]` : \"[]\"\n }\n return \"[Object]\"\n }\n return String(value)\n }\n\n return (\n <div className={`dynamic-data-display ${className}`}>\n {/* Always show priority fields */}\n {priorityFieldsToShow.map((field) => (\n <div key={field} className=\"field-row mb-1\">\n <span className=\"field-name font-medium\">{field}:</span>{\" \"}\n <span className=\"field-value\">{formatValue(data[field])}</span>\n </div>\n ))}\n\n {/* Show a subset of other fields by default */}\n {otherFields.slice(0, expanded ? otherFields.length : 2).map((field) => (\n <div key={field} className=\"field-row mb-1\">\n <span className=\"field-name font-medium\">{field}:</span>{\" \"}\n <span className=\"field-value\">{formatValue(data[field])}</span>\n </div>\n ))}\n\n {/* Show expand/collapse button if there are more fields */}\n {otherFields.length > 2 && (\n <button\n onClick={() => setExpanded(!expanded)}\n className=\"text-sm flex items-center mt-1 text-blue-500 hover:underline\"\n >\n {expanded ? (\n <>\n <span className=\"mr-1\">▼</span> Show less\n </>\n ) : (\n <>\n <span className=\"mr-1\">▶</span> Show {otherFields.length - 2} more fields\n </>\n )}\n </button>\n )}\n </div>\n )\n}\n\n","\"use client\"\nimport type { DataSourceType } from \"../core/BaseFetcher\"\n\nexport interface ToggleProps {\n onToggleMode: (isServer: boolean) => void\n onChangeDataSource: (dataSource: DataSourceType) => void\n onRefresh?: () => void\n isServer: boolean\n dataSource: DataSourceType\n isRealtime?: boolean\n onToggleRealtime?: () => void\n className?: string\n}\n\nexport function Toggle({\n onToggleMode,\n onChangeDataSource,\n onRefresh,\n isServer,\n dataSource,\n isRealtime = false,\n onToggleRealtime,\n className = \"\",\n}: ToggleProps) {\n return (\n <div\n className={`toggle-container flex flex-col md:flex-row justify-between gap-4 p-4 bg-gray-100 rounded-lg ${className}`}\n >\n <div className=\"mode-toggle\">\n <h3 className=\"font-medium mb-2\">Fetch Mode:</h3>\n <div className=\"toggle-buttons flex gap-2\">\n <button\n className={`px-3 py-1 rounded-md ${isServer ? \"bg-blue-500 text-white\" : \"bg-gray-200\"}`}\n onClick={() => onToggleMode(true)}\n >\n Server-side\n </button>\n <button\n className={`px-3 py-1 rounded-md ${!isServer ? \"bg-blue-500 text-white\" : \"bg-gray-200\"}`}\n onClick={() => onToggleMode(false)}\n >\n Client-side\n </button>\n </div>\n </div>\n\n <div className=\"data-source-toggle\">\n <h3 className=\"font-medium mb-2\">Data Source:</h3>\n <select\n value={dataSource}\n onChange={(e) => onChangeDataSource(e.target.value as DataSourceType)}\n className=\"px-3 py-1 rounded-md bg-white border\"\n >\n <option value=\"json\">JSON</option>\n <option value=\"csv\">CSV</option>\n <option value=\"txt\">TXT</option>\n <option value=\"api\">API</option>\n </select>\n </div>\n\n {onToggleRealtime && (\n <div className=\"realtime-toggle\">\n <h3 className=\"font-medium mb-2\">Real-time Updates:</h3>\n <div className=\"toggle-buttons flex gap-2\">\n <button\n className={`px-3 py-1 rounded-md ${isRealtime ? \"bg-blue-500 text-white\" : \"bg-gray-200\"}`}\n onClick={onToggleRealtime}\n >\n {isRealtime ? \"Enabled\" : \"Disabled\"}\n </button>\n {onRefresh && (\n <button\n onClick={onRefresh}\n className=\"px-3 py-1 rounded-md bg-gray-200 flex items-center gap-1\"\n aria-label=\"Refresh data\"\n >\n <span className=\"refresh-icon\">↻</span>\n Refresh\n </button>\n )}\n </div>\n </div>\n )}\n </div>\n )\n}\n\n","// Utility functions for the package\n\n/**\n * Creates a simple API route handler for Next.js that reads data from files\n * @param dataDir Directory where data files are stored\n * @returns A handler function for Nex