feat: Implement Phase 3 optimizations

This commit is contained in:
gpt-engineer-app[bot]
2025-10-15 12:19:37 +00:00
parent c3533d0a82
commit 81a4b9ae31
10 changed files with 1582 additions and 7 deletions

View 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>
);
}

View 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';

View 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}</>;
}

View 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;
}

View 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>
);
}

View File

@@ -0,0 +1,4 @@
// Common reusable components barrel exports
export { LoadingGate } from './LoadingGate';
export { ProfileBadge } from './ProfileBadge';
export { SortControls } from './SortControls';

View File

@@ -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({