From 17b428336db0b8f9f9d4762a1b37f9152291fa3f Mon Sep 17 00:00:00 2001 From: bellabuks Date: Sun, 26 Apr 2026 01:00:30 +0100 Subject: [PATCH] feat: add memoization, unified search hook, pagination, and SEO meta tags - Create unified useSearch hook with debounce and cursor-based pagination (closes #133) - Add useDebounce hook and useMemo to useDashboardData for memoization (closes #131) - Add cursor-based pagination to courses API route and PaginatedResponse type (closes #167) - Add OpenGraph and Twitter metadata to home, dashboard, and profile pages (closes #170) --- src/app/api/courses/route.ts | 11 +- src/app/dashboard/layout.tsx | 20 ++ src/app/page.tsx | 16 ++ src/app/profile/layout.tsx | 20 ++ src/hooks/useDashboardData.tsx | 427 +-------------------------------- src/hooks/useDebounce.tsx | 14 ++ src/hooks/useSearch.tsx | 79 ++++++ src/types/api.ts | 1 + 8 files changed, 165 insertions(+), 423 deletions(-) create mode 100644 src/app/dashboard/layout.tsx create mode 100644 src/app/profile/layout.tsx create mode 100644 src/hooks/useDebounce.tsx diff --git a/src/app/api/courses/route.ts b/src/app/api/courses/route.ts index 712b7286..31996afc 100644 --- a/src/app/api/courses/route.ts +++ b/src/app/api/courses/route.ts @@ -9,7 +9,8 @@ export async function GET(request: Request): Promise{children}; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index dae101c9..99826be5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,22 @@ +import type { Metadata } from 'next'; import CourseCard from './components/courses/CourseCard'; import Link from 'next/link'; +export const metadata: Metadata = { + title: 'TeachLink - Offline Learning Platform', + description: 'Learn anywhere, anytime with offline capabilities. Access courses, track progress, and study without an internet connection.', + openGraph: { + title: 'TeachLink - Offline Learning Platform', + description: 'Learn anywhere, anytime with offline capabilities.', + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: 'TeachLink - Offline Learning Platform', + description: 'Learn anywhere, anytime with offline capabilities.', + }, +}; + export default function Home() { return (
diff --git a/src/app/profile/layout.tsx b/src/app/profile/layout.tsx new file mode 100644 index 00000000..63bf098d --- /dev/null +++ b/src/app/profile/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Profile | TeachLink', + description: 'Manage your TeachLink profile, preferences, and account settings.', + openGraph: { + title: 'Profile | TeachLink', + description: 'Manage your TeachLink profile and account settings.', + type: 'profile', + }, + twitter: { + card: 'summary', + title: 'Profile | TeachLink', + description: 'Manage your TeachLink profile and account settings.', + }, +}; + +export default function ProfileLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/src/hooks/useDashboardData.tsx b/src/hooks/useDashboardData.tsx index b9a54039..6e229c06 100644 --- a/src/hooks/useDashboardData.tsx +++ b/src/hooks/useDashboardData.tsx @@ -1,10 +1,4 @@ -/** - * useDashboardData Hook - * Central state manager for the Advanced Data Visualization Dashboard. - * Manages panels, filters, drag-and-drop ordering, drill-down, and sharing. - */ - -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; import { ChartType, TimeRange, @@ -122,218 +116,15 @@ export const useDashboardData = (): UseDashboardDataReturn => { const [shareURL, setShareURL] = useState(null); - // Update filters and regenerate data for non-realtime panels - const setFilters = useCallback((partial: Partial) => { - setFiltersState((prev) => { - const next = { ...prev, ...partial }; - // Regenerate data if time range changed - if (partial.timeRange && partial.timeRange !== prev.timeRange) { - setPanels((prevPanels) => - prevPanels.map((panel) => - panel.id === 'realtime' - ? panel - : { - ...panel, - data: generateDashboardSampleData(panel.id, next.timeRange), - drillDownIndex: null, - }, - ), - ); - } - return next; - }); - }, []); - - const resetFilters = useCallback(() => { - setFiltersState(DEFAULT_FILTERS); - setPanels(buildDefaultPanels(DEFAULT_FILTERS.timeRange)); - setShareURL(null); - }, []); - - const setPanelChartType = useCallback((id: string, chartType: ChartType) => { - setPanels((prev) => prev.map((p) => (p.id === id ? { ...p, chartType } : p))); - }, []); - - const drillDown = useCallback((id: string, index: number) => { - setPanels((prev) => prev.map((p) => (p.id === id ? { ...p, drillDownIndex: index } : p))); - }, []); - - const clearDrillDown = useCallback((id: string) => { - setPanels((prev) => prev.map((p) => (p.id === id ? { ...p, drillDownIndex: null } : p))); - }, []); - - const reorderPanels = useCallback((fromIndex: number, toIndex: number) => { - setPanels((prev) => { - const sorted = [...prev].sort((a, b) => a.position - b.position); - const [moved] = sorted.splice(fromIndex, 1); - sorted.splice(toIndex, 0, moved); - return sorted.map((p, i) => ({ ...p, position: i })); - }); - }, []); - - const generateShareURLFn = useCallback((): string => { - const sortedPanels = [...panels].sort((a, b) => a.position - b.position); - const config: DashboardShareConfig = { - timeRange: filters.timeRange, - categories: filters.categories, - metric: filters.metric, - aggregation: filters.aggregation, - panelOrder: sortedPanels.map((p) => p.id), - }; - const url = generateShareableURL(config); - setShareURL(url); - return url; - }, [panels, filters]); - - const exportPanel = useCallback( - (id: string, format: 'csv' | 'json') => { - const panel = panels.find((p) => p.id === id); - if (!panel) return; - const filename = `${panel.title.replace(/\s+/g, '-').toLowerCase()}-${Date.now()}`; - if (format === 'csv') { - exportToCSV(panel.data, filename); - } else { - exportToJSON(panel.data, filename); - } - }, + const sortedPanels = useMemo( + () => [...panels].sort((a, b) => a.position - b.position), [panels], ); - return { - panels: [...panels].sort((a, b) => a.position - b.position), - filters, - shareURL, - isLoading, - setFilters, - resetFilters, - setPanelChartType, - drillDown, - clearDrillDown, - reorderPanels, - generateShareURL: generateShareURLFn, - exportPanel, - }; -};/** - * useDashboardData Hook - * Central state manager for the Advanced Data Visualization Dashboard. - * Manages panels, filters, drag-and-drop ordering, drill-down, and sharing. - */ - -import { useState, useCallback } from 'react'; -import { - ChartType, - TimeRange, - AggregationType, - exportToCSV, - exportToJSON, -} from '@/utils/visualizationUtils'; -import { - generateDashboardSampleData, - generateShareableURL, - parseDashboardURL, - getDrillDownData, - DashboardShareConfig, -} from '@/utils/chartUtils'; -import type { ChartData } from '@/utils/visualizationUtils'; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -export interface DashboardFiltersState { - timeRange: TimeRange; - categories: string[]; - metric: string; - aggregation: AggregationType; -} - -export interface DashboardPanel { - id: string; - title: string; - chartType: ChartType; - data: ChartData; - drillDownIndex: number | null; - position: number; -} - -export interface UseDashboardDataReturn { - panels: DashboardPanel[]; - filters: DashboardFiltersState; - shareURL: string | null; - setFilters: (filters: Partial) => void; - resetFilters: () => void; - setPanelChartType: (id: string, chartType: ChartType) => void; - drillDown: (id: string, index: number) => void; - clearDrillDown: (id: string) => void; - reorderPanels: (fromIndex: number, toIndex: number) => void; - generateShareURL: () => string; - exportPanel: (id: string, format: 'csv' | 'json') => void; -} - -// ─── Defaults ───────────────────────────────────────────────────────────────── - -const DEFAULT_FILTERS: DashboardFiltersState = { - timeRange: '30d', - categories: [], - metric: 'enrollments', - aggregation: 'sum', -}; - -const buildDefaultPanels = (timeRange: TimeRange): DashboardPanel[] => [ - { - id: 'enrollments', - title: 'Course Enrollments', - chartType: 'line', - data: generateDashboardSampleData('enrollments', timeRange), - drillDownIndex: null, - position: 0, - }, - { - id: 'revenue', - title: 'Revenue', - chartType: 'bar', - data: generateDashboardSampleData('revenue', timeRange), - drillDownIndex: null, - position: 1, - }, - { - id: 'completions', - title: 'Completions', - chartType: 'area', - data: generateDashboardSampleData('completions', timeRange), - drillDownIndex: null, - position: 2, - }, - { - id: 'realtime', - title: 'Live Activity', - chartType: 'line', - data: { labels: [], datasets: [] }, - drillDownIndex: null, - position: 3, - }, -]; - -// ─── Hook ───────────────────────────────────────────────────────────────────── - -export const useDashboardData = (): UseDashboardDataReturn => { - const [filters, setFiltersState] = useState(() => { - if (typeof window !== 'undefined') { - const parsed = parseDashboardURL(window.location.search); - return { ...DEFAULT_FILTERS, ...parsed }; - } - return DEFAULT_FILTERS; - }); - - const [panels, setPanels] = useState(() => - buildDefaultPanels(filters.timeRange), - ); - - const [shareURL, setShareURL] = useState(null); - // Update filters and regenerate data for non-realtime panels const setFilters = useCallback((partial: Partial) => { setFiltersState((prev) => { const next = { ...prev, ...partial }; - // Regenerate data if time range changed if (partial.timeRange && partial.timeRange !== prev.timeRange) { setPanels((prevPanels) => prevPanels.map((panel) => @@ -379,7 +170,6 @@ export const useDashboardData = (): UseDashboardDataReturn => { }, []); const generateShareURLFn = useCallback((): string => { - const sortedPanels = [...panels].sort((a, b) => a.position - b.position); const config: DashboardShareConfig = { timeRange: filters.timeRange, categories: filters.categories, @@ -390,7 +180,7 @@ export const useDashboardData = (): UseDashboardDataReturn => { const url = generateShareableURL(config); setShareURL(url); return url; - }, [panels, filters]); + }, [sortedPanels, filters]); const exportPanel = useCallback( (id: string, format: 'csv' | 'json') => { @@ -407,9 +197,10 @@ export const useDashboardData = (): UseDashboardDataReturn => { ); return { - panels: [...panels].sort((a, b) => a.position - b.position), + panels: sortedPanels, filters, shareURL, + isLoading, setFilters, resetFilters, setPanelChartType, @@ -419,210 +210,4 @@ export const useDashboardData = (): UseDashboardDataReturn => { generateShareURL: generateShareURLFn, exportPanel, }; -};/** - * useDashboardData Hook - * Central state manager for the Advanced Data Visualization Dashboard. - * Manages panels, filters, drag-and-drop ordering, drill-down, and sharing. - */ - -import { useState, useCallback } from 'react'; -import { - ChartType, - TimeRange, - AggregationType, - exportToCSV, - exportToJSON, -} from '@/utils/visualizationUtils'; -import { - generateDashboardSampleData, - generateShareableURL, - parseDashboardURL, - getDrillDownData, - DashboardShareConfig, -} from '@/utils/chartUtils'; -import type { ChartData } from '@/utils/visualizationUtils'; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -export interface DashboardFiltersState { - timeRange: TimeRange; - categories: string[]; - metric: string; - aggregation: AggregationType; -} - -export interface DashboardPanel { - id: string; - title: string; - chartType: ChartType; - data: ChartData; - drillDownIndex: number | null; - position: number; -} - -export interface UseDashboardDataReturn { - panels: DashboardPanel[]; - filters: DashboardFiltersState; - shareURL: string | null; - setFilters: (filters: Partial) => void; - resetFilters: () => void; - setPanelChartType: (id: string, chartType: ChartType) => void; - drillDown: (id: string, index: number) => void; - clearDrillDown: (id: string) => void; - reorderPanels: (fromIndex: number, toIndex: number) => void; - generateShareURL: () => string; - exportPanel: (id: string, format: 'csv' | 'json') => void; -} - -// ─── Defaults ───────────────────────────────────────────────────────────────── - -const DEFAULT_FILTERS: DashboardFiltersState = { - timeRange: '30d', - categories: [], - metric: 'enrollments', - aggregation: 'sum', }; - -const buildDefaultPanels = (timeRange: TimeRange): DashboardPanel[] => [ - { - id: 'enrollments', - title: 'Course Enrollments', - chartType: 'line', - data: generateDashboardSampleData('enrollments', timeRange), - drillDownIndex: null, - position: 0, - }, - { - id: 'revenue', - title: 'Revenue', - chartType: 'bar', - data: generateDashboardSampleData('revenue', timeRange), - drillDownIndex: null, - position: 1, - }, - { - id: 'completions', - title: 'Completions', - chartType: 'area', - data: generateDashboardSampleData('completions', timeRange), - drillDownIndex: null, - position: 2, - }, - { - id: 'realtime', - title: 'Live Activity', - chartType: 'line', - data: { labels: [], datasets: [] }, - drillDownIndex: null, - position: 3, - }, -]; - -// ─── Hook ───────────────────────────────────────────────────────────────────── - -export const useDashboardData = (): UseDashboardDataReturn => { - const [filters, setFiltersState] = useState(() => { - if (typeof window !== 'undefined') { - const parsed = parseDashboardURL(window.location.search); - return { ...DEFAULT_FILTERS, ...parsed }; - } - return DEFAULT_FILTERS; - }); - - const [panels, setPanels] = useState(() => - buildDefaultPanels(filters.timeRange), - ); - - const [shareURL, setShareURL] = useState(null); - - // Update filters and regenerate data for non-realtime panels - const setFilters = useCallback((partial: Partial) => { - setFiltersState((prev) => { - const next = { ...prev, ...partial }; - // Regenerate data if time range changed - if (partial.timeRange && partial.timeRange !== prev.timeRange) { - setPanels((prevPanels) => - prevPanels.map((panel) => - panel.id === 'realtime' - ? panel - : { - ...panel, - data: generateDashboardSampleData(panel.id, next.timeRange), - drillDownIndex: null, - }, - ), - ); - } - return next; - }); - }, []); - - const resetFilters = useCallback(() => { - setFiltersState(DEFAULT_FILTERS); - setPanels(buildDefaultPanels(DEFAULT_FILTERS.timeRange)); - setShareURL(null); - }, []); - - const setPanelChartType = useCallback((id: string, chartType: ChartType) => { - setPanels((prev) => prev.map((p) => (p.id === id ? { ...p, chartType } : p))); - }, []); - - const drillDown = useCallback((id: string, index: number) => { - setPanels((prev) => prev.map((p) => (p.id === id ? { ...p, drillDownIndex: index } : p))); - }, []); - - const clearDrillDown = useCallback((id: string) => { - setPanels((prev) => prev.map((p) => (p.id === id ? { ...p, drillDownIndex: null } : p))); - }, []); - - const reorderPanels = useCallback((fromIndex: number, toIndex: number) => { - setPanels((prev) => { - const sorted = [...prev].sort((a, b) => a.position - b.position); - const [moved] = sorted.splice(fromIndex, 1); - sorted.splice(toIndex, 0, moved); - return sorted.map((p, i) => ({ ...p, position: i })); - }); - }, []); - - const generateShareURLFn = useCallback((): string => { - const sortedPanels = [...panels].sort((a, b) => a.position - b.position); - const config: DashboardShareConfig = { - timeRange: filters.timeRange, - categories: filters.categories, - metric: filters.metric, - aggregation: filters.aggregation, - panelOrder: sortedPanels.map((p) => p.id), - }; - const url = generateShareableURL(config); - setShareURL(url); - return url; - }, [panels, filters]); - - const exportPanel = useCallback( - (id: string, format: 'csv' | 'json') => { - const panel = panels.find((p) => p.id === id); - if (!panel) return; - const filename = `${panel.title.replace(/\s+/g, '-').toLowerCase()}-${Date.now()}`; - if (format === 'csv') { - exportToCSV(panel.data, filename); - } else { - exportToJSON(panel.data, filename); - } - }, - [panels], - ); - - return { - panels: [...panels].sort((a, b) => a.position - b.position), - filters, - shareURL, - setFilters, - resetFilters, - setPanelChartType, - drillDown, - clearDrillDown, - reorderPanels, - generateShareURL: generateShareURLFn, - exportPanel, - }; -}; \ No newline at end of file diff --git a/src/hooks/useDebounce.tsx b/src/hooks/useDebounce.tsx new file mode 100644 index 00000000..a1704eb4 --- /dev/null +++ b/src/hooks/useDebounce.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delayMs = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} diff --git a/src/hooks/useSearch.tsx b/src/hooks/useSearch.tsx index e69de29b..bb16447b 100644 --- a/src/hooks/useSearch.tsx +++ b/src/hooks/useSearch.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; + +export interface SearchResult { + id: string; + title: string; + description?: string; + type: string; +} + +interface UseSearchOptions { + debounceMs?: number; +} + +export function useSearch( + fetchFn: (query: string, cursor?: string) => Promise<{ items: T[]; nextCursor?: string }>, + options: UseSearchOptions = {}, +) { + const { debounceMs = 300 } = options; + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [nextCursor, setNextCursor] = useState(undefined); + const [hasMore, setHasMore] = useState(false); + const debounceTimer = useRef | null>(null); + + const search = useCallback( + async (searchQuery: string, cursor?: string) => { + if (!searchQuery.trim()) { + setResults([]); + setNextCursor(undefined); + setHasMore(false); + return; + } + + setIsLoading(true); + setError(null); + + try { + const { items, nextCursor: next } = await fetchFn(searchQuery, cursor); + setResults((prev) => (cursor ? [...prev, ...items] : items)); + setNextCursor(next); + setHasMore(!!next); + } catch (err) { + setError(err instanceof Error ? err.message : 'Search failed'); + } finally { + setIsLoading(false); + } + }, + [fetchFn], + ); + + const updateQuery = useCallback( + (value: string) => { + setQuery(value); + if (debounceTimer.current) clearTimeout(debounceTimer.current); + debounceTimer.current = setTimeout(() => search(value), debounceMs); + }, + [search, debounceMs], + ); + + const loadMore = useCallback(() => { + if (hasMore && !isLoading && nextCursor) { + search(query, nextCursor); + } + }, [hasMore, isLoading, nextCursor, query, search]); + + const reset = useCallback(() => { + setQuery(''); + setResults([]); + setNextCursor(undefined); + setHasMore(false); + setError(null); + }, []); + + return { query, updateQuery, results, isLoading, error, hasMore, loadMore, reset }; +} diff --git a/src/types/api.ts b/src/types/api.ts index 13dc6273..6e71e4f8 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -16,6 +16,7 @@ export interface ApiResponse { export interface PaginatedResponse { data: T[]; total: number; + nextCursor?: string; } export interface SuccessResponse {