mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 18:31:12 -05:00
feat: Implement Phase 3 optimizations
This commit is contained in:
133
src/components/admin/AdminPageLayout.tsx
Normal file
133
src/components/admin/AdminPageLayout.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { MFARequiredAlert } from '@/components/auth/MFARequiredAlert';
|
||||
import { QueueSkeleton } from '@/components/moderation/QueueSkeleton';
|
||||
import { useAdminGuard } from '@/hooks/useAdminGuard';
|
||||
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
||||
import { useModerationStats } from '@/hooks/useModerationStats';
|
||||
|
||||
interface AdminPageLayoutProps {
|
||||
/** Page title */
|
||||
title: string;
|
||||
|
||||
/** Page description */
|
||||
description: string;
|
||||
|
||||
/** Main content to render when authorized */
|
||||
children: ReactNode;
|
||||
|
||||
/** Optional refresh handler */
|
||||
onRefresh?: () => void;
|
||||
|
||||
/** Whether to require MFA (default: true) */
|
||||
requireMFA?: boolean;
|
||||
|
||||
/** Number of skeleton items to show while loading */
|
||||
skeletonCount?: number;
|
||||
|
||||
/** Whether to show refresh controls */
|
||||
showRefreshControls?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable admin page layout with auth guards and common UI
|
||||
*
|
||||
* Handles:
|
||||
* - Authentication & authorization checks
|
||||
* - MFA enforcement
|
||||
* - Loading states
|
||||
* - Refresh controls and stats
|
||||
* - Consistent header layout
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AdminPageLayout
|
||||
* title="User Management"
|
||||
* description="Manage user profiles and roles"
|
||||
* onRefresh={handleRefresh}
|
||||
* >
|
||||
* <UserManagement />
|
||||
* </AdminPageLayout>
|
||||
* ```
|
||||
*/
|
||||
export function AdminPageLayout({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
onRefresh,
|
||||
requireMFA = true,
|
||||
skeletonCount = 5,
|
||||
showRefreshControls = true,
|
||||
}: AdminPageLayoutProps) {
|
||||
const { isLoading, isAuthorized, needsMFA } = useAdminGuard(requireMFA);
|
||||
|
||||
const {
|
||||
getAdminPanelRefreshMode,
|
||||
getAdminPanelPollInterval,
|
||||
} = useAdminSettings();
|
||||
|
||||
const refreshMode = getAdminPanelRefreshMode();
|
||||
const pollInterval = getAdminPanelPollInterval();
|
||||
|
||||
const { lastUpdated } = useModerationStats({
|
||||
enabled: isAuthorized && showRefreshControls,
|
||||
pollingEnabled: refreshMode === 'auto',
|
||||
pollingInterval: pollInterval,
|
||||
});
|
||||
|
||||
const handleRefreshClick = useCallback(() => {
|
||||
onRefresh?.();
|
||||
}, [onRefresh]);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AdminLayout
|
||||
onRefresh={showRefreshControls ? handleRefreshClick : undefined}
|
||||
refreshMode={showRefreshControls ? refreshMode : undefined}
|
||||
pollInterval={showRefreshControls ? pollInterval : undefined}
|
||||
lastUpdated={showRefreshControls ? lastUpdated : undefined}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
<p className="text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
<QueueSkeleton count={skeletonCount} />
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Not authorized
|
||||
if (!isAuthorized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// MFA required
|
||||
if (needsMFA) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<MFARequiredAlert />
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Main content
|
||||
return (
|
||||
<AdminLayout
|
||||
onRefresh={showRefreshControls ? handleRefreshClick : undefined}
|
||||
refreshMode={showRefreshControls ? refreshMode : undefined}
|
||||
pollInterval={showRefreshControls ? pollInterval : undefined}
|
||||
lastUpdated={showRefreshControls ? lastUpdated : undefined}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
<p className="text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
15
src/components/admin/index.ts
Normal file
15
src/components/admin/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Admin components barrel exports
|
||||
export { AdminPageLayout } from './AdminPageLayout';
|
||||
export { DesignerForm } from './DesignerForm';
|
||||
export { LocationSearch } from './LocationSearch';
|
||||
export { ManufacturerForm } from './ManufacturerForm';
|
||||
export { NovuMigrationUtility } from './NovuMigrationUtility';
|
||||
export { OperatorForm } from './OperatorForm';
|
||||
export { ParkForm } from './ParkForm';
|
||||
export { ProfileAuditLog } from './ProfileAuditLog';
|
||||
export { PropertyOwnerForm } from './PropertyOwnerForm';
|
||||
export { RideForm } from './RideForm';
|
||||
export { RideModelForm } from './RideModelForm';
|
||||
export { SystemActivityLog } from './SystemActivityLog';
|
||||
export { TestDataGenerator } from './TestDataGenerator';
|
||||
export { UserManagement } from './UserManagement';
|
||||
114
src/components/common/LoadingGate.tsx
Normal file
114
src/components/common/LoadingGate.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface LoadingGateProps {
|
||||
/** Whether data is still loading */
|
||||
isLoading: boolean;
|
||||
|
||||
/** Optional error to display */
|
||||
error?: Error | null;
|
||||
|
||||
/** Content to render when loaded */
|
||||
children: ReactNode;
|
||||
|
||||
/** Loading variant */
|
||||
variant?: 'skeleton' | 'spinner' | 'card';
|
||||
|
||||
/** Number of skeleton items (for skeleton variant) */
|
||||
skeletonCount?: number;
|
||||
|
||||
/** Custom loading message */
|
||||
loadingMessage?: string;
|
||||
|
||||
/** Custom error message */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable loading and error state wrapper
|
||||
*
|
||||
* Handles common loading patterns:
|
||||
* - Skeleton loaders
|
||||
* - Spinner with message
|
||||
* - Card-based loading states
|
||||
* - Error display
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LoadingGate isLoading={loading} error={error} variant="skeleton" skeletonCount={3}>
|
||||
* <YourContent />
|
||||
* </LoadingGate>
|
||||
* ```
|
||||
*/
|
||||
export function LoadingGate({
|
||||
isLoading,
|
||||
error,
|
||||
children,
|
||||
variant = 'skeleton',
|
||||
skeletonCount = 3,
|
||||
loadingMessage = 'Loading...',
|
||||
errorMessage,
|
||||
}: LoadingGateProps) {
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
{errorMessage || error.message || 'An unexpected error occurred'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
switch (variant) {
|
||||
case 'spinner':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">{loadingMessage}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'card':
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-3">
|
||||
{Array.from({ length: skeletonCount }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 p-3 border rounded-lg">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
case 'skeleton':
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: skeletonCount }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Loaded state
|
||||
return <>{children}</>;
|
||||
}
|
||||
156
src/components/common/ProfileBadge.tsx
Normal file
156
src/components/common/ProfileBadge.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { User, Shield, ShieldCheck, Crown } from 'lucide-react';
|
||||
import { getRoleLabel } from '@/lib/moderation/constants';
|
||||
|
||||
interface ProfileBadgeProps {
|
||||
/** Username to display */
|
||||
username?: string;
|
||||
|
||||
/** Display name (fallback to username) */
|
||||
displayName?: string;
|
||||
|
||||
/** Avatar image URL */
|
||||
avatarUrl?: string;
|
||||
|
||||
/** User role */
|
||||
role?: 'admin' | 'moderator' | 'user' | 'superuser';
|
||||
|
||||
/** Show role badge */
|
||||
showRole?: boolean;
|
||||
|
||||
/** Size variant */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
|
||||
/** Whether to show as a link */
|
||||
clickable?: boolean;
|
||||
|
||||
/** Custom click handler */
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: {
|
||||
avatar: 'h-6 w-6',
|
||||
text: 'text-xs',
|
||||
badge: 'h-4 text-[10px] px-1',
|
||||
},
|
||||
md: {
|
||||
avatar: 'h-8 w-8',
|
||||
text: 'text-sm',
|
||||
badge: 'h-5 text-xs px-1.5',
|
||||
},
|
||||
lg: {
|
||||
avatar: 'h-10 w-10',
|
||||
text: 'text-base',
|
||||
badge: 'h-6 text-sm px-2',
|
||||
},
|
||||
};
|
||||
|
||||
const roleIcons = {
|
||||
superuser: Crown,
|
||||
admin: ShieldCheck,
|
||||
moderator: Shield,
|
||||
user: User,
|
||||
};
|
||||
|
||||
const roleColors = {
|
||||
superuser: 'bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/20',
|
||||
admin: 'bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20',
|
||||
moderator: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
|
||||
user: 'bg-muted text-muted-foreground border-border',
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable user profile badge component
|
||||
*
|
||||
* Displays user avatar, name, and optional role badge
|
||||
* Used consistently across moderation queue and admin panels
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ProfileBadge
|
||||
* username="johndoe"
|
||||
* displayName="John Doe"
|
||||
* avatarUrl="/avatars/john.jpg"
|
||||
* role="moderator"
|
||||
* showRole
|
||||
* size="md"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ProfileBadge({
|
||||
username,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
role = 'user',
|
||||
showRole = false,
|
||||
size = 'md',
|
||||
clickable = false,
|
||||
onClick,
|
||||
}: ProfileBadgeProps) {
|
||||
const sizes = sizeClasses[size];
|
||||
const name = displayName || username || 'Anonymous';
|
||||
const initials = name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
|
||||
const RoleIcon = roleIcons[role];
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`flex items-center gap-2 ${clickable ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Avatar className={sizes.avatar}>
|
||||
<AvatarImage src={avatarUrl} alt={name} />
|
||||
<AvatarFallback className="text-[10px]">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className={`font-medium truncate ${sizes.text}`}>
|
||||
{name}
|
||||
</span>
|
||||
{username && displayName && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
@{username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRole && role !== 'user' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${sizes.badge} ${roleColors[role]} flex items-center gap-1`}
|
||||
>
|
||||
<RoleIcon className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">{getRoleLabel(role)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (showRole && role !== 'user') {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{content}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{getRoleLabel(role)}
|
||||
{username && ` • @${username}`}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
122
src/components/common/SortControls.tsx
Normal file
122
src/components/common/SortControls.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { ArrowUp, ArrowDown, Loader2 } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface SortControlsProps<T extends string = string> {
|
||||
/** Current sort field */
|
||||
sortField: T;
|
||||
|
||||
/** Current sort direction */
|
||||
sortDirection: 'asc' | 'desc';
|
||||
|
||||
/** Available sort fields with labels */
|
||||
sortFields: Record<T, string>;
|
||||
|
||||
/** Handler for field change */
|
||||
onFieldChange: (field: T) => void;
|
||||
|
||||
/** Handler for direction toggle */
|
||||
onDirectionToggle: () => void;
|
||||
|
||||
/** Whether component is in mobile mode */
|
||||
isMobile?: boolean;
|
||||
|
||||
/** Whether data is loading */
|
||||
isLoading?: boolean;
|
||||
|
||||
/** Optional label for the sort selector */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic reusable sort controls component
|
||||
*
|
||||
* Provides consistent sorting UI across the application:
|
||||
* - Field selector with custom labels
|
||||
* - Direction toggle (asc/desc)
|
||||
* - Mobile-responsive layout
|
||||
* - Loading states
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SortControls
|
||||
* sortField={sortConfig.field}
|
||||
* sortDirection={sortConfig.direction}
|
||||
* sortFields={{
|
||||
* created_at: 'Date Created',
|
||||
* name: 'Name',
|
||||
* status: 'Status'
|
||||
* }}
|
||||
* onFieldChange={(field) => setSortConfig({ ...sortConfig, field })}
|
||||
* onDirectionToggle={() => setSortConfig({
|
||||
* ...sortConfig,
|
||||
* direction: sortConfig.direction === 'asc' ? 'desc' : 'asc'
|
||||
* })}
|
||||
* isMobile={isMobile}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function SortControls<T extends string = string>({
|
||||
sortField,
|
||||
sortDirection,
|
||||
sortFields,
|
||||
onFieldChange,
|
||||
onDirectionToggle,
|
||||
isMobile = false,
|
||||
isLoading = false,
|
||||
label = 'Sort By',
|
||||
}: SortControlsProps<T>) {
|
||||
const DirectionIcon = sortDirection === 'asc' ? ArrowUp : ArrowDown;
|
||||
|
||||
return (
|
||||
<div className={`flex gap-2 ${isMobile ? 'flex-col' : 'items-end'}`}>
|
||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[160px]'}`}>
|
||||
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'} flex items-center gap-2`}>
|
||||
{label}
|
||||
{isLoading && <Loader2 className="w-3 h-3 animate-spin text-primary" />}
|
||||
</Label>
|
||||
<Select
|
||||
value={sortField}
|
||||
onValueChange={onFieldChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className={isMobile ? "h-10" : ""} disabled={isLoading}>
|
||||
<SelectValue>
|
||||
{sortFields[sortField]}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(sortFields).map(([field, label]) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{label as string}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className={isMobile ? "" : "pb-[2px]"}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={isMobile ? "default" : "icon"}
|
||||
onClick={onDirectionToggle}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : 'h-10 w-10'}`}
|
||||
title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<DirectionIcon className="w-4 h-4" />
|
||||
)}
|
||||
{isMobile && (
|
||||
<span className="capitalize">
|
||||
{isLoading ? 'Loading...' : `${sortDirection}ending`}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/components/common/index.ts
Normal file
4
src/components/common/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Common reusable components barrel exports
|
||||
export { LoadingGate } from './LoadingGate';
|
||||
export { ProfileBadge } from './ProfileBadge';
|
||||
export { SortControls } from './SortControls';
|
||||
@@ -29,14 +29,21 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
const { isAdmin, isSuperuser } = useUserRole();
|
||||
const adminSettings = useAdminSettings();
|
||||
|
||||
// Memoize settings - call functions inside useMemo to avoid recreating on every render
|
||||
// Extract settings values to stable primitives for memoization
|
||||
const refreshMode = adminSettings.getAdminPanelRefreshMode();
|
||||
const pollInterval = adminSettings.getAdminPanelPollInterval();
|
||||
const refreshStrategy = adminSettings.getAutoRefreshStrategy();
|
||||
const preserveInteraction = adminSettings.getPreserveInteractionState();
|
||||
const useRealtimeQueue = adminSettings.getUseRealtimeQueue();
|
||||
|
||||
// Memoize settings object using stable primitive dependencies
|
||||
const settings = useMemo(() => ({
|
||||
refreshMode: adminSettings.getAdminPanelRefreshMode(),
|
||||
pollInterval: adminSettings.getAdminPanelPollInterval(),
|
||||
refreshStrategy: adminSettings.getAutoRefreshStrategy(),
|
||||
preserveInteraction: adminSettings.getPreserveInteractionState(),
|
||||
useRealtimeQueue: adminSettings.getUseRealtimeQueue(),
|
||||
}), [adminSettings]);
|
||||
refreshMode,
|
||||
pollInterval,
|
||||
refreshStrategy,
|
||||
preserveInteraction,
|
||||
useRealtimeQueue,
|
||||
}), [refreshMode, pollInterval, refreshStrategy, preserveInteraction, useRealtimeQueue]);
|
||||
|
||||
// Initialize queue manager (replaces all state management, fetchItems, effects)
|
||||
const queueManager = useModerationQueueManager({
|
||||
|
||||
Reference in New Issue
Block a user