mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:31:13 -05:00
659 lines
15 KiB
Markdown
659 lines
15 KiB
Markdown
# Frontend Architecture
|
|
|
|
Complete documentation of ThrillWiki's React frontend architecture, routing, state management, and UI patterns.
|
|
|
|
---
|
|
|
|
## Technology Stack
|
|
|
|
- **Framework**: React 18.3 with TypeScript
|
|
- **Build Tool**: Vite
|
|
- **Styling**: Tailwind CSS with custom design system
|
|
- **UI Components**: shadcn/ui (Radix UI primitives)
|
|
- **Routing**: React Router v6
|
|
- **Data Fetching**: TanStack Query (React Query)
|
|
- **Forms**: React Hook Form with Zod validation
|
|
- **State Machines**: Custom TypeScript state machines
|
|
- **Icons**: lucide-react
|
|
|
|
---
|
|
|
|
## Routing Structure
|
|
|
|
### URL Pattern Standards
|
|
|
|
**Parks:**
|
|
```
|
|
/parks/ → Global park list
|
|
/parks/{parkSlug}/ → Individual park detail
|
|
/parks/{parkSlug}/rides/ → Park's ride list
|
|
/operators/{operatorSlug}/parks/ → Operator's parks
|
|
/owners/{ownerSlug}/parks/ → Owner's parks
|
|
```
|
|
|
|
**Rides:**
|
|
```
|
|
/rides/ → Global ride list
|
|
/parks/{parkSlug}/rides/{rideSlug}/ → Ride detail (nested under park)
|
|
/manufacturers/{manufacturerSlug}/rides/ → Manufacturer's rides
|
|
/manufacturers/{manufacturerSlug}/models/ → Manufacturer's models
|
|
/designers/{designerSlug}/rides/ → Designer's rides
|
|
```
|
|
|
|
**Admin:**
|
|
```
|
|
/admin/ → Admin dashboard
|
|
/admin/moderation/ → Moderation queue
|
|
/admin/reports/ → Reports queue
|
|
/admin/users/ → User management
|
|
/admin/system-log/ → System activity log
|
|
/admin/blog/ → Blog management
|
|
/admin/settings/ → Admin settings
|
|
```
|
|
|
|
### Route Configuration
|
|
|
|
```typescript
|
|
// src/App.tsx
|
|
<BrowserRouter>
|
|
<Routes>
|
|
{/* Public routes */}
|
|
<Route path="/" element={<Index />} />
|
|
<Route path="/parks" element={<Parks />} />
|
|
<Route path="/parks/:slug" element={<ParkDetail />} />
|
|
<Route path="/parks/:parkSlug/rides" element={<ParkRides />} />
|
|
<Route path="/parks/:parkSlug/rides/:rideSlug" element={<RideDetail />} />
|
|
|
|
{/* Admin routes - auth guard applied in page component */}
|
|
<Route path="/admin" element={<AdminDashboard />} />
|
|
<Route path="/admin/moderation" element={<AdminModeration />} />
|
|
|
|
{/* Auth routes */}
|
|
<Route path="/auth" element={<Auth />} />
|
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
|
|
|
{/* 404 */}
|
|
<Route path="*" element={<NotFound />} />
|
|
</Routes>
|
|
</BrowserRouter>
|
|
```
|
|
|
|
---
|
|
|
|
## Component Architecture
|
|
|
|
### Component Organization
|
|
|
|
```
|
|
src/components/
|
|
├── admin/ # Admin forms and management tools
|
|
│ ├── ParkForm.tsx
|
|
│ ├── RideForm.tsx
|
|
│ ├── UserManagement.tsx
|
|
│ └── ...
|
|
├── auth/ # Authentication components
|
|
│ ├── AuthModal.tsx
|
|
│ ├── MFAChallenge.tsx
|
|
│ ├── TOTPSetup.tsx
|
|
│ └── ...
|
|
├── moderation/ # Moderation queue components
|
|
│ ├── ModerationQueue.tsx
|
|
│ ├── SubmissionReviewManager.tsx
|
|
│ ├── QueueFilters.tsx
|
|
│ └── ...
|
|
├── parks/ # Park display components
|
|
│ ├── ParkCard.tsx
|
|
│ ├── ParkGrid.tsx
|
|
│ ├── ParkFilters.tsx
|
|
│ └── ...
|
|
├── rides/ # Ride display components
|
|
├── upload/ # Image upload components
|
|
├── versioning/ # Version history components
|
|
├── layout/ # Layout components
|
|
│ ├── Header.tsx
|
|
│ ├── Footer.tsx
|
|
│ └── AdminLayout.tsx
|
|
├── common/ # Shared components
|
|
│ ├── LoadingGate.tsx
|
|
│ ├── ProfileBadge.tsx
|
|
│ └── SortControls.tsx
|
|
└── ui/ # shadcn/ui base components
|
|
├── button.tsx
|
|
├── dialog.tsx
|
|
└── ...
|
|
```
|
|
|
|
### Page Components
|
|
|
|
Top-level route components that:
|
|
- Handle auth guards (admin pages use `useAdminGuard`)
|
|
- Fetch initial data with React Query
|
|
- Implement layout structure
|
|
- Pass data to feature components
|
|
|
|
```typescript
|
|
// Example: AdminModeration.tsx
|
|
export function AdminModeration() {
|
|
const { loading } = useAdminGuard('moderator');
|
|
|
|
if (loading) return <LoadingGate isLoading />;
|
|
|
|
return (
|
|
<AdminLayout>
|
|
<AdminPageLayout
|
|
title="Moderation Queue"
|
|
description="Review and approve submissions"
|
|
>
|
|
<ModerationQueue />
|
|
</AdminPageLayout>
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Feature Components
|
|
|
|
Domain-specific components with business logic.
|
|
|
|
```typescript
|
|
// Example: ModerationQueue.tsx
|
|
export function ModerationQueue() {
|
|
const {
|
|
items,
|
|
isLoading,
|
|
filters,
|
|
pagination,
|
|
handleAction,
|
|
} = useModerationQueueManager();
|
|
|
|
return (
|
|
<div>
|
|
<QueueFilters {...filters} />
|
|
<QueueStats items={items} />
|
|
{items.map(item => (
|
|
<QueueItem
|
|
key={item.id}
|
|
item={item}
|
|
onAction={handleAction}
|
|
/>
|
|
))}
|
|
<QueuePagination {...pagination} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## State Management
|
|
|
|
### React Query (TanStack Query)
|
|
|
|
Used for ALL server state (data fetching, caching, mutations).
|
|
|
|
**Configuration:**
|
|
|
|
```typescript
|
|
// App.tsx
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
refetchOnWindowFocus: false,
|
|
refetchOnMount: true,
|
|
refetchOnReconnect: true,
|
|
retry: 1,
|
|
staleTime: 30000, // 30s fresh
|
|
gcTime: 5 * 60 * 1000, // 5min cache
|
|
},
|
|
},
|
|
});
|
|
|
|
<QueryClientProvider client={queryClient}>
|
|
<App />
|
|
</QueryClientProvider>
|
|
```
|
|
|
|
**Custom Hook Pattern:**
|
|
|
|
```typescript
|
|
// src/hooks/useEntityVersions.ts
|
|
export function useEntityVersions(entityType: EntityType, entityId: string) {
|
|
const query = useQuery({
|
|
queryKey: ['versions', entityType, entityId],
|
|
queryFn: () => fetchVersions(entityType, entityId),
|
|
enabled: !!entityId,
|
|
});
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: rollbackVersion,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries(['versions', entityType, entityId]);
|
|
},
|
|
});
|
|
|
|
return {
|
|
versions: query.data,
|
|
isLoading: query.isLoading,
|
|
rollback: mutation.mutate
|
|
};
|
|
}
|
|
```
|
|
|
|
### State Machines
|
|
|
|
Used for complex workflows with strict transitions.
|
|
|
|
**1. moderationStateMachine.ts** - Moderation workflow
|
|
|
|
```typescript
|
|
type ModerationState =
|
|
| { status: 'idle' }
|
|
| { status: 'claiming'; itemId: string }
|
|
| { status: 'locked'; itemId: string; lockExpires: string }
|
|
| { status: 'loading_data'; itemId: string; lockExpires: string }
|
|
| { status: 'reviewing'; itemId: string; lockExpires: string; reviewData: SubmissionItemWithDeps[] }
|
|
| { status: 'approving'; itemId: string }
|
|
| { status: 'rejecting'; itemId: string }
|
|
| { status: 'complete'; itemId: string; result: 'approved' | 'rejected' }
|
|
| { status: 'error'; itemId: string; error: string }
|
|
| { status: 'lock_expired'; itemId: string };
|
|
|
|
type ModerationAction =
|
|
| { type: 'CLAIM_ITEM'; payload: { itemId: string } }
|
|
| { type: 'LOCK_ACQUIRED'; payload: { lockExpires: string } }
|
|
| { type: 'LOAD_DATA' }
|
|
| { type: 'DATA_LOADED'; payload: { reviewData: SubmissionItemWithDeps[] } }
|
|
| { type: 'START_APPROVAL' }
|
|
| { type: 'START_REJECTION' }
|
|
| { type: 'COMPLETE'; payload: { result: 'approved' | 'rejected' } }
|
|
| { type: 'ERROR'; payload: { error: string } }
|
|
| { type: 'LOCK_EXPIRED' }
|
|
| { type: 'RESET' };
|
|
|
|
function moderationReducer(
|
|
state: ModerationState,
|
|
action: ModerationAction
|
|
): ModerationState {
|
|
// ... transition logic with guards
|
|
}
|
|
|
|
// Usage in SubmissionReviewManager.tsx
|
|
const [state, dispatch] = useReducer(moderationReducer, { status: 'idle' });
|
|
```
|
|
|
|
**2. deletionDialogMachine.ts** - Account deletion wizard
|
|
|
|
```typescript
|
|
type DeletionStep = 'warning' | 'confirm' | 'code';
|
|
|
|
type DeletionDialogState = {
|
|
step: DeletionStep;
|
|
confirmationCode: string;
|
|
codeReceived: boolean;
|
|
loading: boolean;
|
|
error: string | null;
|
|
};
|
|
|
|
// Usage in AccountDeletionDialog.tsx
|
|
```
|
|
|
|
### React Hook Form
|
|
|
|
Used for ALL forms with Zod validation.
|
|
|
|
```typescript
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
|
|
const parkFormSchema = z.object({
|
|
name: z.string().min(1, 'Name required').max(255),
|
|
slug: z.string().regex(/^[a-z0-9-]+$/),
|
|
park_type: z.enum(['theme_park', 'amusement_park', 'water_park']),
|
|
opening_date: z.string().optional(),
|
|
// ... all fields validated
|
|
});
|
|
|
|
export function ParkForm() {
|
|
const form = useForm({
|
|
resolver: zodResolver(parkFormSchema),
|
|
defaultValues: initialData,
|
|
});
|
|
|
|
const onSubmit = async (data: ParkFormData) => {
|
|
await submitParkCreation(data, user.id);
|
|
// Goes to moderation queue, NOT direct DB write
|
|
};
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
{/* form fields */}
|
|
</form>
|
|
</Form>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Context Providers
|
|
|
|
**1. AuthProvider** - Global auth state
|
|
|
|
```typescript
|
|
// src/hooks/useAuth.tsx
|
|
export function AuthProvider({ children }) {
|
|
const [user, setUser] = useState(null);
|
|
const [session, setSession] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
|
setSession(session);
|
|
setUser(session?.user ?? null);
|
|
setLoading(false);
|
|
});
|
|
|
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
|
(_event, session) => {
|
|
setSession(session);
|
|
setUser(session?.user ?? null);
|
|
}
|
|
);
|
|
|
|
return () => subscription.unsubscribe();
|
|
}, []);
|
|
|
|
return (
|
|
<AuthContext.Provider value={{ user, session, loading }}>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
);
|
|
}
|
|
```
|
|
|
|
**2. ThemeProvider** - Light/dark mode
|
|
|
|
```typescript
|
|
// src/components/theme/ThemeProvider.tsx
|
|
export function ThemeProvider({ children }) {
|
|
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
|
|
|
|
useEffect(() => {
|
|
const root = window.document.documentElement;
|
|
root.classList.remove('light', 'dark');
|
|
|
|
if (theme === 'system') {
|
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
? 'dark'
|
|
: 'light';
|
|
root.classList.add(systemTheme);
|
|
} else {
|
|
root.classList.add(theme);
|
|
}
|
|
}, [theme]);
|
|
|
|
return (
|
|
<ThemeContext.Provider value={{ theme, setTheme }}>
|
|
{children}
|
|
</ThemeContext.Provider>
|
|
);
|
|
}
|
|
```
|
|
|
|
**3. LocationAutoDetectProvider** - Auto-detect measurement system
|
|
|
|
```typescript
|
|
// src/components/providers/LocationAutoDetectProvider.tsx
|
|
export function LocationAutoDetectProvider() {
|
|
useEffect(() => {
|
|
const detectLocation = async () => {
|
|
const { data } = await supabase.functions.invoke('detect-location');
|
|
if (data?.country) {
|
|
const system = getMeasurementSystemFromCountry(data.country);
|
|
// Update user preferences
|
|
}
|
|
};
|
|
|
|
detectLocation();
|
|
}, []);
|
|
|
|
return null;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## UI/UX Patterns
|
|
|
|
### Design System
|
|
|
|
- **Colors**: HSL-based semantic tokens in `index.css`
|
|
- **Typography**: Custom font scale with Tailwind classes
|
|
- **Spacing**: Consistent spacing scale (4px, 8px, 16px, etc.)
|
|
- **Components**: shadcn/ui with custom variants
|
|
- **Responsive**: Mobile-first, breakpoints at sm, md, lg, xl
|
|
|
|
**Color Tokens (index.css):**
|
|
|
|
```css
|
|
:root {
|
|
--background: 0 0% 100%;
|
|
--foreground: 222.2 84% 4.9%;
|
|
--primary: 222.2 47.4% 11.2%;
|
|
--primary-foreground: 210 40% 98%;
|
|
--secondary: 210 40% 96.1%;
|
|
--muted: 210 40% 96.1%;
|
|
--accent: 210 40% 96.1%;
|
|
--destructive: 0 84.2% 60.2%;
|
|
--border: 214.3 31.8% 91.4%;
|
|
--input: 214.3 31.8% 91.4%;
|
|
--ring: 222.2 84% 4.9%;
|
|
--radius: 0.5rem;
|
|
}
|
|
|
|
.dark {
|
|
--background: 222.2 84% 4.9%;
|
|
--foreground: 210 40% 98%;
|
|
/* ... dark mode tokens */
|
|
}
|
|
```
|
|
|
|
### Reusable UI Components
|
|
|
|
**AdminPageLayout** - Wraps admin pages
|
|
|
|
```typescript
|
|
<AdminPageLayout
|
|
title="User Management"
|
|
description="Manage users"
|
|
onRefresh={handleRefresh}
|
|
refreshMode="auto"
|
|
pollInterval={30000}
|
|
>
|
|
{children}
|
|
</AdminPageLayout>
|
|
```
|
|
|
|
**LoadingGate** - Handles loading/error states
|
|
|
|
```typescript
|
|
<LoadingGate
|
|
isLoading={loading}
|
|
error={error}
|
|
variant="skeleton"
|
|
>
|
|
<Content />
|
|
</LoadingGate>
|
|
```
|
|
|
|
**ProfileBadge** - User display with role badges
|
|
|
|
```typescript
|
|
<ProfileBadge
|
|
username="john"
|
|
displayName="John Doe"
|
|
role="moderator"
|
|
showRole
|
|
size="md"
|
|
/>
|
|
```
|
|
|
|
---
|
|
|
|
## Form Patterns
|
|
|
|
### Entity Forms
|
|
|
|
All entity forms follow this structure:
|
|
|
|
1. **Schema Definition** (Zod)
|
|
2. **Form Setup** (React Hook Form)
|
|
3. **Image Upload** (EntityMultiImageUploader)
|
|
4. **Submit Handler** (entitySubmissionHelpers)
|
|
|
|
```typescript
|
|
// 1. Schema
|
|
const parkFormSchema = z.object({
|
|
name: z.string().min(1).max(255),
|
|
slug: z.string().regex(/^[a-z0-9-]+$/),
|
|
// ... all fields
|
|
});
|
|
|
|
// 2. Form
|
|
const form = useForm({
|
|
resolver: zodResolver(parkFormSchema),
|
|
defaultValues: initialData || defaultValues,
|
|
});
|
|
|
|
// 3. Submit
|
|
const onSubmit = async (data: ParkFormData) => {
|
|
if (isEditing) {
|
|
await submitParkUpdate(parkId, data, user.id);
|
|
} else {
|
|
await submitParkCreation(data, user.id);
|
|
}
|
|
toast({ title: "Submitted for review" });
|
|
};
|
|
```
|
|
|
|
### Image Upload
|
|
|
|
```typescript
|
|
const [imageAssignments, setImageAssignments] = useState<ImageAssignments>({
|
|
banner: null,
|
|
card: null,
|
|
uploaded: []
|
|
});
|
|
|
|
<EntityMultiImageUploader
|
|
value={imageAssignments}
|
|
onChange={setImageAssignments}
|
|
entityType="park"
|
|
maxImages={20}
|
|
/>
|
|
```
|
|
|
|
---
|
|
|
|
## Custom Hooks
|
|
|
|
### Data Fetching Hooks
|
|
|
|
```typescript
|
|
// src/hooks/useEntityVersions.ts
|
|
export function useEntityVersions(entityType, entityId)
|
|
|
|
// src/hooks/useModerationQueue.ts
|
|
export function useModerationQueue(filters, pagination)
|
|
|
|
// src/hooks/useProfile.tsx
|
|
export function useProfile(userId)
|
|
|
|
// src/hooks/useUserRole.ts
|
|
export function useUserRole()
|
|
```
|
|
|
|
### Utility Hooks
|
|
|
|
```typescript
|
|
// src/hooks/useDebounce.ts
|
|
export function useDebounce(value, delay)
|
|
|
|
// src/hooks/useMobile.tsx
|
|
export function useMobile()
|
|
|
|
// src/hooks/useUnitPreferences.ts
|
|
export function useUnitPreferences()
|
|
```
|
|
|
|
### Guard Hooks
|
|
|
|
```typescript
|
|
// src/hooks/useAdminGuard.ts
|
|
export function useAdminGuard(requiredRole: AppRole = 'moderator')
|
|
|
|
// src/hooks/useRequireMFA.ts
|
|
export function useRequireMFA()
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Optimizations
|
|
|
|
### React Query Caching
|
|
|
|
```typescript
|
|
// Aggressive caching for static data
|
|
const { data: parks } = useQuery({
|
|
queryKey: ['parks'],
|
|
queryFn: fetchParks,
|
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
|
});
|
|
|
|
// Shorter caching for dynamic data
|
|
const { data: queue } = useQuery({
|
|
queryKey: ['moderation', 'queue', filters],
|
|
queryFn: fetchQueue,
|
|
staleTime: 10000, // 10 seconds
|
|
gcTime: 60000, // 1 minute
|
|
refetchInterval: 30000, // Auto-refresh every 30s
|
|
});
|
|
```
|
|
|
|
### Code Splitting
|
|
|
|
```typescript
|
|
// Lazy load admin pages
|
|
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
|
|
const AdminModeration = lazy(() => import('./pages/AdminModeration'));
|
|
|
|
<Suspense fallback={<LoadingGate isLoading />}>
|
|
<Route path="/admin" element={<AdminDashboard />} />
|
|
</Suspense>
|
|
```
|
|
|
|
### Memoization
|
|
|
|
```typescript
|
|
// Expensive computations
|
|
const filteredItems = useMemo(() => {
|
|
return items.filter(item =>
|
|
item.status === statusFilter &&
|
|
item.entity_type === typeFilter
|
|
);
|
|
}, [items, statusFilter, typeFilter]);
|
|
|
|
// Callback stability
|
|
const handleAction = useCallback((itemId: string, action: string) => {
|
|
performAction(itemId, action);
|
|
}, [performAction]);
|
|
```
|
|
|
|
---
|
|
|
|
**See Also:**
|
|
- [DATABASE_ARCHITECTURE.md](./DATABASE_ARCHITECTURE.md) - Database schema
|
|
- [SUBMISSION_FLOW.md](./SUBMISSION_FLOW.md) - Submission workflow
|
|
- [AUTHENTICATION.md](./AUTHENTICATION.md) - Auth implementation
|