1566 lines
42 KiB
Markdown
1566 lines
42 KiB
Markdown
# Officer.dev Frontend Architecture
|
|
|
|
## Executive Summary
|
|
|
|
Officer's frontend is a modern React 19 SPA (Single Page Application) built with:
|
|
- **React 19** with automatic compiler optimizations (no useCallback/useMemo needed)
|
|
- **React Router 7** for client-side navigation
|
|
- **React Query** for server state management
|
|
- **Tailwind CSS 4** with custom components for styling
|
|
- **Shadcn/ui** component library base
|
|
- **WebSockets** for real-time terminal and AI chat
|
|
- **Modular architecture** with shared component and hook libraries
|
|
|
|
This document covers the frontend architecture, component patterns, state management, and development practices.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Architecture Overview](#architecture-overview)
|
|
2. [Technology Stack](#technology-stack)
|
|
3. [Directory Structure](#directory-structure)
|
|
4. [Routing & Navigation](#routing--navigation)
|
|
5. [Component Architecture](#component-architecture)
|
|
6. [State Management](#state-management)
|
|
7. [API Communication](#api-communication)
|
|
8. [Real-Time Features](#real-time-features)
|
|
9. [Styling & Theming](#styling--theming)
|
|
10. [Performance & Optimization](#performance--optimization)
|
|
11. [Development Workflow](#development-workflow)
|
|
|
|
---
|
|
|
|
## Architecture Overview
|
|
|
|
### High-Level Architecture
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────────┐
|
|
│ Browser Application │
|
|
├──────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ App.tsx (Router Setup) │
|
|
│ ↓ │
|
|
│ BrowserRouter │
|
|
│ ├─ Authentication Routes (public) │
|
|
│ │ └─ Landing, SignIn, SignUp, ForgotPassword │
|
|
│ │ │
|
|
│ └─ Dashboard Routes (protected) │
|
|
│ ├─ HomeScreen │
|
|
│ ├─ SettingsPages (Profile, System, Resources) │
|
|
│ ├─ Automation │
|
|
│ ├─ SessionListPage (Chat with Pi) │
|
|
│ ├─ PlansScreen │
|
|
│ ├─ FilesScreen │
|
|
│ ├─ CodeEditorScreen │
|
|
│ ├─ SkillsScreen │
|
|
│ ├─ TasksScreen │
|
|
│ ├─ ProcessesScreen │
|
|
│ ├─ TaskLogsScreen │
|
|
│ ├─ WorkspacesScreen │
|
|
│ ├─ ProjectListScreen │
|
|
│ ├─ ProjectScreen │
|
|
│ └─ TerminalScreen │
|
|
│ │
|
|
│ ↓ Shared State/Context │
|
|
│ │
|
|
│ ├─ AuthContext (useAuth) │
|
|
│ ├─ GlobalState (useGlobalState) │
|
|
│ ├─ ServerSettings (useServerSettings) │
|
|
│ └─ ReactQuery (useQuery, useMutation) │
|
|
│ │
|
|
│ ↓ WebSocket Connections │
|
|
│ │
|
|
│ ├─ Terminal WebSocket │
|
|
│ │ └─ Interactive shell in xterm.js │
|
|
│ │ │
|
|
│ └─ Pi Chat WebSocket │
|
|
│ └─ Real-time streaming chat with Claude │
|
|
│ │
|
|
└──────────────────────────────────────────────────────────┘
|
|
↓ HTTP/REST & WebSocket
|
|
┌────────────────┐
|
|
│ Officer API │
|
|
│ (port 5000) │
|
|
└────────────────┘
|
|
```
|
|
|
|
### Data Flow
|
|
|
|
```
|
|
User Interaction (Click, Type, etc.)
|
|
↓
|
|
Component Event Handler
|
|
↓
|
|
State Update (useState/Context/Query)
|
|
↓
|
|
API Call (HTTP or WebSocket)
|
|
↓
|
|
Server Processing
|
|
↓
|
|
Response
|
|
↓
|
|
Update Component State
|
|
↓
|
|
Re-render
|
|
↓
|
|
Updated UI
|
|
```
|
|
|
|
---
|
|
|
|
## Technology Stack
|
|
|
|
### Core Frontend Framework
|
|
- **React 19** - Latest with automatic compiler optimizations
|
|
- **React DOM 19** - DOM rendering
|
|
- **React Router 7** - Client-side routing (file-based patterns support)
|
|
- **TypeScript 5.9** - Strict mode enabled
|
|
|
|
### State Management
|
|
- **React Context** - Component tree data sharing
|
|
- **React Query (TanStack)** - Server state, caching, synchronization
|
|
- **Custom hooks** - Encapsulated business logic
|
|
- **Zustand** - Optional lightweight state (if used)
|
|
|
|
### UI & Styling
|
|
- **Tailwind CSS 4** - Utility-first CSS framework
|
|
- **Shadcn/ui** - Accessible component library base
|
|
- **Radix UI** - Headless components for accessibility
|
|
- **Lucide React** - Icon library
|
|
- **Tabler Icons** - Additional icons
|
|
- **Class Variance Authority (CVA)** - Component style variations
|
|
|
|
### Real-Time Communication
|
|
- **WebSocket API** - Native browser WebSocket
|
|
- **Json RPC** - Message protocol over WebSocket
|
|
|
|
### Code Editor & Terminal
|
|
- **Monaco Editor** (@monaco-editor/react) - VS Code-like editor
|
|
- **xterm.js** (@xterm/xterm) - Terminal emulator
|
|
- **@uiw/react-textarea-code-editor** - Simple code editing
|
|
|
|
### Data Visualization & Rich Content
|
|
- **Recharts** - Chart library
|
|
- **React Markdown** - Markdown rendering
|
|
- **Shiki** - Syntax highlighting
|
|
- **HTML2Canvas** - Screenshot capture
|
|
- **React Three Fiber** - 3D rendering (if used)
|
|
|
|
### Form & Validation
|
|
- **React Hook Form** - Form state management
|
|
- **Zod** - Schema validation
|
|
- **@hookform/resolvers** - Form validation resolvers
|
|
|
|
### UI Components & Utilities
|
|
- **Sonner** - Toast notifications
|
|
- **Vaul** - Drawer component
|
|
- **React Resizable Panels** - Resizable layout panels
|
|
- **React Virtual** - Virtual scrolling
|
|
- **Input OTP** - OTP input component
|
|
- **React Spinners** - Loading animations
|
|
- **React CountUp** - Animated numbers
|
|
|
|
### Authentication
|
|
- **@simplewebauthn/browser** - WebAuthn/passkey support
|
|
- **@react-oauth/google** - Google OAuth integration
|
|
- **JWT Decode** - Token decoding
|
|
|
|
### External Services
|
|
- **Googleapis** - Google API integration
|
|
- **Nodemailer** (backend) - Email sending
|
|
|
|
### Development & Build Tools
|
|
- **Bun** - Runtime and package manager
|
|
- **Vite** - Build tool and dev server (if used)
|
|
- **Playwright** - E2E testing
|
|
- **Testing Library** - Component testing utilities
|
|
- **Happy DOM** - DOM testing
|
|
|
|
---
|
|
|
|
## Directory Structure
|
|
|
|
### Frontend Layout
|
|
|
|
```
|
|
src/
|
|
├── apps/
|
|
│ └── officer-web/ # Main dashboard app
|
|
│ ├── App.tsx # Root component & routing
|
|
│ ├── frontend.tsx # Vite entry point (if applicable)
|
|
│ ├── index.html # HTML template
|
|
│ │
|
|
│ ├── Screens/ # Page-level components
|
|
│ │ ├── Authentication/ # Auth screens (public)
|
|
│ │ │ ├── LandingPage.tsx
|
|
│ │ │ ├── VerifyScreen.tsx
|
|
│ │ │ ├── SignInScreen.tsx
|
|
│ │ │ ├── SignUpScreen.tsx
|
|
│ │ │ ├── ForgotPassword.tsx
|
|
│ │ │ ├── ResetPassword.tsx
|
|
│ │ │ └── SignoutScreen.tsx
|
|
│ │ │
|
|
│ │ └── Dashboard/ # Dashboard screens (protected)
|
|
│ │ ├── HomeScreen.tsx
|
|
│ │ ├── SettingsPages/
|
|
│ │ │ ├── ProfileSettings.tsx
|
|
│ │ │ ├── SystemSettings.tsx
|
|
│ │ │ └── ResourceSettings.tsx
|
|
│ │ ├── Automation.tsx
|
|
│ │ ├── SessionListPage.tsx (Pi Chat)
|
|
│ │ ├── PlansScreen.tsx
|
|
│ │ ├── FilesScreen.tsx
|
|
│ │ ├── CodeEditorScreen.tsx
|
|
│ │ ├── SkillsScreen.tsx
|
|
│ │ ├── TasksScreen.tsx
|
|
│ │ ├── ProcessesScreen.tsx
|
|
│ │ ├── TaskLogsScreen.tsx
|
|
│ │ ├── WorkspacesScreen.tsx
|
|
│ │ ├── WorkspaceScreen.tsx
|
|
│ │ ├── ProjectListScreen.tsx
|
|
│ │ ├── ProjectScreen.tsx
|
|
│ │ └── TerminalScreen.tsx
|
|
│ │
|
|
│ ├── state/ # Component state
|
|
│ │ ├── useAuth.ts # Authentication state
|
|
│ │ ├── useInitialData.ts # Initial data loading
|
|
│ │ └── useServerSettings.ts # Server configuration
|
|
│ │
|
|
│ ├── lib/ # Client utilities
|
|
│ │ ├── api.ts # API client
|
|
│ │ └── websocket.ts # WebSocket utilities
|
|
│ │
|
|
│ ├── styles/ # Global styles
|
|
│ │ ├── global.css
|
|
│ │ └── tailwind.css
|
|
│ │
|
|
│ └── locales/ # Translations
|
|
│ ├── en.json
|
|
│ └── ...
|
|
│
|
|
└── workspaces/ # Shared libraries
|
|
├── components/ # Reusable React components
|
|
│ ├── ui/ # Basic UI components
|
|
│ │ ├── button.tsx
|
|
│ │ ├── input.tsx
|
|
│ │ ├── dialog.tsx
|
|
│ │ ├── dropdown-menu.tsx
|
|
│ │ ├── select.tsx
|
|
│ │ ├── tabs.tsx
|
|
│ │ ├── card.tsx
|
|
│ │ └── ...more
|
|
│ │
|
|
│ ├── Complex/ # Feature components
|
|
│ │ ├── DataTable/
|
|
│ │ ├── MarkdownEditor.tsx
|
|
│ │ ├── ColorPicker.tsx
|
|
│ │ ├── CommandBlock.tsx
|
|
│ │ └── ...
|
|
│ │
|
|
│ ├── Workspace/ # Workspace-specific
|
|
│ ├── Dialogs/ # Dialog components
|
|
│ ├── ErrorDialogs/ # Error handling UI
|
|
│ ├── Logos/ # Logo variants
|
|
│ └── package.json
|
|
│
|
|
├── hooks/ # Custom React hooks
|
|
│ ├── useAuth.ts # Auth state hook
|
|
│ ├── useClient.ts # API client hook
|
|
│ ├── useQuery.ts # Data fetching
|
|
│ ├── useMutation.ts # Data mutation
|
|
│ └── ...
|
|
│
|
|
├── helpers/ # Utility functions
|
|
│ ├── formatters.ts # Date, number formatting
|
|
│ ├── validators.ts # Input validation
|
|
│ ├── converters.ts # Type conversions
|
|
│ └── ...
|
|
│
|
|
├── state/ # Global state
|
|
│ ├── useGlobalState.ts # Global state hook
|
|
│ └── ...
|
|
│
|
|
├── types/ # Type definitions
|
|
│ └── index.ts # Export all types
|
|
│
|
|
├── config/ # Configuration
|
|
│ ├── constants.ts
|
|
│ └── env.ts
|
|
│
|
|
├── definitions/ # Enums and constants
|
|
│ ├── roles.ts
|
|
│ ├── statuses.ts
|
|
│ └── ...
|
|
│
|
|
├── widgets/ # Complex UI widgets
|
|
│ └── ...
|
|
│
|
|
└── i18n/ # Internationalization
|
|
└── ...
|
|
```
|
|
|
|
---
|
|
|
|
## Routing & Navigation
|
|
|
|
### React Router Setup: `App.tsx`
|
|
|
|
```typescript
|
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
|
|
import { useAuth } from 'hooks/useAuth';
|
|
import { useServerSettings } from 'state/useServerSettings';
|
|
|
|
export function App() {
|
|
const { isLoading, isAuthenticated } = useAuth();
|
|
const { onboardingComplete, isLoading: isServerSettingsLoading } = useServerSettings();
|
|
|
|
// Show nothing while loading auth state
|
|
if (isLoading || isServerSettingsLoading) return null;
|
|
|
|
return (
|
|
<BrowserRouter>
|
|
{!isAuthenticated && (
|
|
<Authentication.AuthenticationLayout>
|
|
<Routes>
|
|
<Route path="/" element={<Authentication.LandingPage />} />
|
|
<Route path="/auth/verify" element={<Authentication.VerifyScreen />} />
|
|
<Route path="/auth/forgot-password" element={<Authentication.ForgotPassword />} />
|
|
<Route path="/auth/reset-password" element={<Authentication.ResetPassword />} />
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
</Authentication.AuthenticationLayout>
|
|
)}
|
|
|
|
{isAuthenticated && onboardingComplete && (
|
|
<Dashboard.DashboardLayout>
|
|
<Routes>
|
|
{/* Home & Settings */}
|
|
<Route path="/" element={<Dashboard.HomeScreen />} />
|
|
<Route path="/settings/profile" element={<Dashboard.ProfileSettings />} />
|
|
<Route path="/settings/system" element={<Dashboard.SystemSettings />} />
|
|
<Route path="/settings/resources" element={<Dashboard.ResourceSettings />} />
|
|
|
|
{/* Features */}
|
|
<Route path="/automation" element={<Dashboard.Automation />} />
|
|
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
|
|
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
|
|
<Route path="/plans" element={<Dashboard.Plans />} />
|
|
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
|
<Route path="/code-editor" element={<Dashboard.CodeEditor />} />
|
|
<Route path="/skills" element={<Dashboard.Skills />} />
|
|
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
|
<Route path="/processes" element={<Dashboard.Processes />} />
|
|
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
|
|
|
{/* Workspaces & Projects */}
|
|
<Route path="/workspaces" element={<Dashboard.WorkspacesScreen />} />
|
|
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
|
|
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
|
|
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
|
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
|
|
|
{/* Development */}
|
|
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
|
|
|
{/* Auth */}
|
|
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
|
|
|
{/* Fallback */}
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
</Dashboard.DashboardLayout>
|
|
)}
|
|
</BrowserRouter>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Layout Components
|
|
|
|
**AuthenticationLayout** - Wrapper for public pages
|
|
```typescript
|
|
export const AuthenticationLayout = ({ children }: Props) => {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800">
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
**DashboardLayout** - Wrapper for protected pages
|
|
```typescript
|
|
export const DashboardLayout = ({ children }: Props) => {
|
|
return (
|
|
<div className="flex h-screen">
|
|
<Sidebar />
|
|
<main className="flex-1 flex flex-col">
|
|
<Header />
|
|
<div className="flex-1 overflow-auto">
|
|
{children}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Component Architecture
|
|
|
|
### Component Patterns
|
|
|
|
#### 1. Functional Components with Props
|
|
|
|
All components are functional components using React 19:
|
|
|
|
```typescript
|
|
type ButtonProps = {
|
|
children: React.ReactNode;
|
|
onClick: () => void;
|
|
variant?: 'primary' | 'secondary' | 'outline';
|
|
size?: 'sm' | 'md' | 'lg';
|
|
disabled?: boolean;
|
|
};
|
|
|
|
export const Button = ({
|
|
children,
|
|
onClick,
|
|
variant = 'primary',
|
|
size = 'md',
|
|
disabled = false,
|
|
}: ButtonProps) => {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className={cn(
|
|
'font-medium rounded-lg transition-colors',
|
|
{
|
|
'bg-blue-600 text-white hover:bg-blue-700': variant === 'primary',
|
|
'bg-slate-200 text-slate-900 hover:bg-slate-300': variant === 'secondary',
|
|
'border-2 border-blue-600 text-blue-600 hover:bg-blue-50': variant === 'outline',
|
|
},
|
|
{
|
|
'px-3 py-1 text-sm': size === 'sm',
|
|
'px-4 py-2 text-base': size === 'md',
|
|
'px-6 py-3 text-lg': size === 'lg',
|
|
},
|
|
disabled && 'opacity-50 cursor-not-allowed',
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
};
|
|
```
|
|
|
|
#### 2. Component Organization
|
|
|
|
Complex components follow a structured pattern:
|
|
|
|
```typescript
|
|
// Directory structure
|
|
ComponentName/
|
|
├── index.tsx # Exports the component
|
|
├── ComponentName.tsx # Main implementation
|
|
├── hooks/ # Local hooks
|
|
│ └── useComponentState.ts
|
|
├── types.ts # Component types
|
|
└── utils.ts # Helper functions
|
|
|
|
// index.tsx
|
|
export { ComponentName } from './ComponentName';
|
|
export type { ComponentNameProps } from './types';
|
|
|
|
// ComponentName.tsx
|
|
import { useComponentState } from './hooks/useComponentState';
|
|
import type { ComponentNameProps } from './types';
|
|
|
|
export const ComponentName = ({ prop1, prop2 }: ComponentNameProps) => {
|
|
const { state, actions } = useComponentState();
|
|
|
|
return (
|
|
<div>
|
|
{/* Render */}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
#### 3. React 19 Patterns
|
|
|
|
**NO useCallback - React 19's compiler handles optimization:**
|
|
|
|
```typescript
|
|
// ❌ BAD - Unnecessary useCallback
|
|
export const Form = () => {
|
|
const handleSubmit = useCallback((data) => {
|
|
api.post('/data', data);
|
|
}, []);
|
|
|
|
return <FormInput onSubmit={handleSubmit} />;
|
|
};
|
|
|
|
// ✅ GOOD - Plain function
|
|
export const Form = () => {
|
|
const handleSubmit = (data) => {
|
|
api.post('/data', data);
|
|
};
|
|
|
|
return <FormInput onSubmit={handleSubmit} />;
|
|
};
|
|
```
|
|
|
|
**NO useMemo - Compiler handles memoization:**
|
|
|
|
```typescript
|
|
// ❌ BAD - Unnecessary useMemo
|
|
export const List = ({ items, filter }) => {
|
|
const filtered = useMemo(
|
|
() => items.filter(i => i.type === filter),
|
|
[items, filter]
|
|
);
|
|
|
|
return <ul>{filtered.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
|
|
};
|
|
|
|
// ✅ GOOD - Direct calculation
|
|
export const List = ({ items, filter }) => {
|
|
const filtered = items.filter(i => i.type === filter);
|
|
|
|
return <ul>{filtered.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
|
|
};
|
|
```
|
|
|
|
**Avoid stale closures - access object properties directly:**
|
|
|
|
```typescript
|
|
// ❌ BAD - Destructuring creates stale references
|
|
export const Editor = ({ state }) => {
|
|
const { content, save } = state;
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
save(content); // 'content' is stale!
|
|
}, 1000);
|
|
return () => clearTimeout(timer);
|
|
}, [content, save]);
|
|
};
|
|
|
|
// ✅ GOOD - Direct property access
|
|
export const Editor = ({ state }) => {
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
state.save(state.content); // Always fresh
|
|
}, 1000);
|
|
return () => clearTimeout(timer);
|
|
}, [state]);
|
|
};
|
|
```
|
|
|
|
#### 4. Keyboard Event Handling
|
|
|
|
Always prevent default for game/editor controls:
|
|
|
|
```typescript
|
|
export const CodeEditor = () => {
|
|
const handleKeyDown = (ev: KeyboardEvent) => {
|
|
// Prevent Space from scrolling page
|
|
if (ev.code === 'Space') {
|
|
ev.preventDefault();
|
|
// Handle space key
|
|
}
|
|
|
|
// Prevent Escape from closing dialogs
|
|
if (ev.key === 'Escape') {
|
|
ev.preventDefault();
|
|
// Handle escape
|
|
}
|
|
};
|
|
|
|
return <div onKeyDown={handleKeyDown}>{/* ... */}</div>;
|
|
};
|
|
```
|
|
|
|
#### 5. Button Focus Management
|
|
|
|
Buttons retain focus after clicking, interfering with keyboard shortcuts:
|
|
|
|
```typescript
|
|
export const Toolbar = () => {
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={(e) => {
|
|
handleSave();
|
|
e.currentTarget.blur(); // Remove focus to prevent Space re-triggering
|
|
}}
|
|
>
|
|
Save
|
|
</button>
|
|
</>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## State Management
|
|
|
|
### Authentication State: `useAuth`
|
|
|
|
```typescript
|
|
// Location: workspaces/hooks/useAuth.ts
|
|
|
|
export const useAuth = () => {
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [token, setToken] = useState<string | null>(
|
|
() => localStorage.getItem('token')
|
|
);
|
|
|
|
// Load auth state on mount
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
if (!token) {
|
|
setIsAuthenticated(false);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Verify token validity
|
|
const response = await fetch('/api/auth/verify', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (response.ok) {
|
|
const user = await response.json();
|
|
setUser(user);
|
|
setIsAuthenticated(true);
|
|
} else {
|
|
setIsAuthenticated(false);
|
|
localStorage.removeItem('token');
|
|
}
|
|
} catch {
|
|
setIsAuthenticated(false);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
load();
|
|
}, [token]);
|
|
|
|
const login = async (email: string, password: string) => {
|
|
const response = await fetch('/api/auth/signin', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const { token, user } = await response.json();
|
|
localStorage.setItem('token', token);
|
|
setToken(token);
|
|
setUser(user);
|
|
setIsAuthenticated(true);
|
|
return { success: true };
|
|
}
|
|
|
|
return { success: false, error: await response.text() };
|
|
};
|
|
|
|
const logout = async () => {
|
|
try {
|
|
await fetch('/api/auth/signout', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
} catch {
|
|
// Ignore errors during logout
|
|
} finally {
|
|
localStorage.removeItem('token');
|
|
setToken(null);
|
|
setUser(null);
|
|
setIsAuthenticated(false);
|
|
}
|
|
};
|
|
|
|
return {
|
|
isLoading,
|
|
isAuthenticated,
|
|
user,
|
|
token,
|
|
login,
|
|
logout,
|
|
};
|
|
};
|
|
|
|
// Usage in components
|
|
export const Dashboard = () => {
|
|
const { user, logout } = useAuth();
|
|
|
|
return (
|
|
<div>
|
|
<p>Welcome, {user?.email}</p>
|
|
<button onClick={logout}>Logout</button>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Server Settings State: `useServerSettings`
|
|
|
|
```typescript
|
|
export const useServerSettings = () => {
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [onboardingComplete, setOnboardingComplete] = useState(false);
|
|
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
|
const [settings, setSettings] = useState<ServerSettings | null>(null);
|
|
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
try {
|
|
const response = await fetch('/api/server-settings');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setOnboardingComplete(data.onboardingComplete);
|
|
setPlugins(data.plugins);
|
|
setSettings(data.settings);
|
|
}
|
|
} catch {
|
|
console.error('Failed to load server settings');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
load();
|
|
}, []);
|
|
|
|
return {
|
|
isLoading,
|
|
onboardingComplete,
|
|
plugins,
|
|
settings,
|
|
};
|
|
};
|
|
```
|
|
|
|
### Global State: `useGlobalState`
|
|
|
|
For component-tree-wide state, use Context:
|
|
|
|
```typescript
|
|
type GlobalContextType = {
|
|
theme: 'light' | 'dark';
|
|
setTheme: (theme: 'light' | 'dark') => void;
|
|
sidebarOpen: boolean;
|
|
setSidebarOpen: (open: boolean) => void;
|
|
};
|
|
|
|
const GlobalContext = createContext<GlobalContextType | null>(null);
|
|
|
|
export const GlobalProvider = ({ children }: { children: React.ReactNode }) => {
|
|
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
|
const saved = localStorage.getItem('theme');
|
|
return (saved as 'light' | 'dark') || 'dark';
|
|
});
|
|
|
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('theme', theme);
|
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
|
}, [theme]);
|
|
|
|
return (
|
|
<GlobalContext.Provider value={{ theme, setTheme, sidebarOpen, setSidebarOpen }}>
|
|
{children}
|
|
</GlobalContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useGlobalState = () => {
|
|
const context = useContext(GlobalContext);
|
|
if (!context) {
|
|
throw new Error('useGlobalState must be used within GlobalProvider');
|
|
}
|
|
return context;
|
|
};
|
|
```
|
|
|
|
### React Query for Server State
|
|
|
|
```typescript
|
|
// useQueryPlan.ts
|
|
export const useQueryPlan = (id: number) => {
|
|
return useQuery({
|
|
queryKey: ['plans', id],
|
|
queryFn: async () => {
|
|
const response = await fetch(`/api/plans/${id}`, {
|
|
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
|
});
|
|
if (!response.ok) throw new Error('Failed to load plan');
|
|
return response.json();
|
|
},
|
|
});
|
|
};
|
|
|
|
// useMutationCreatePlan.ts
|
|
export const useMutationCreatePlan = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (data: CreatePlanInput) => {
|
|
const response = await fetch('/api/plans', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
|
},
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!response.ok) throw new Error('Failed to create plan');
|
|
return response.json();
|
|
},
|
|
onSuccess: () => {
|
|
// Invalidate related queries
|
|
queryClient.invalidateQueries({ queryKey: ['plans'] });
|
|
},
|
|
});
|
|
};
|
|
|
|
// In component
|
|
export const CreatePlanForm = () => {
|
|
const { mutate, isPending } = useMutationCreatePlan();
|
|
|
|
const handleSubmit = (data: CreatePlanInput) => {
|
|
mutate(data);
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
{/* Form fields */}
|
|
<button type="submit" disabled={isPending}>
|
|
{isPending ? 'Creating...' : 'Create'}
|
|
</button>
|
|
</form>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## API Communication
|
|
|
|
### Client Initialization: `src/apps/officer-web/lib/api.ts`
|
|
|
|
```typescript
|
|
// Centralized API client
|
|
const API_BASE = import.meta.env.VITE_API_URL || '/api';
|
|
|
|
export const apiClient = {
|
|
async fetch(
|
|
endpoint: string,
|
|
options: RequestInit = {},
|
|
): Promise<Response> {
|
|
const token = localStorage.getItem('token');
|
|
|
|
return fetch(`${API_BASE}${endpoint}`, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(token && { Authorization: `Bearer ${token}` }),
|
|
...options.headers,
|
|
},
|
|
});
|
|
},
|
|
|
|
async get<T>(endpoint: string): Promise<T> {
|
|
const response = await this.fetch(endpoint);
|
|
if (!response.ok) throw new Error(`GET ${endpoint} failed`);
|
|
return response.json();
|
|
},
|
|
|
|
async post<T>(endpoint: string, body?: any): Promise<T> {
|
|
const response = await this.fetch(endpoint, {
|
|
method: 'POST',
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!response.ok) throw new Error(`POST ${endpoint} failed`);
|
|
return response.json();
|
|
},
|
|
|
|
async put<T>(endpoint: string, body?: any): Promise<T> {
|
|
const response = await this.fetch(endpoint, {
|
|
method: 'PUT',
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!response.ok) throw new Error(`PUT ${endpoint} failed`);
|
|
return response.json();
|
|
},
|
|
|
|
async delete<T>(endpoint: string): Promise<T> {
|
|
const response = await this.fetch(endpoint, { method: 'DELETE' });
|
|
if (!response.ok) throw new Error(`DELETE ${endpoint} failed`);
|
|
return response.json();
|
|
},
|
|
};
|
|
|
|
// Usage
|
|
const user = await apiClient.get<User>('/users/me');
|
|
await apiClient.post('/plans', { title: 'New Plan' });
|
|
```
|
|
|
|
---
|
|
|
|
## Real-Time Features
|
|
|
|
### Terminal WebSocket: `TerminalScreen.tsx`
|
|
|
|
```typescript
|
|
import { XTerm } from '@xterm/xterm';
|
|
import { FitAddon } from '@xterm/addon-fit';
|
|
import '@xterm/xterm/css/xterm.css';
|
|
|
|
export const TerminalScreen = () => {
|
|
const terminalRef = useRef<HTMLDivElement>(null);
|
|
const xtermRef = useRef<XTerm | null>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
|
|
useEffect(() => {
|
|
const term = new XTerm({
|
|
cols: 120,
|
|
rows: 40,
|
|
theme: { background: '#0f172a', foreground: '#e2e8f0' },
|
|
});
|
|
|
|
const fitAddon = new FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
|
|
// Mount terminal
|
|
if (terminalRef.current) {
|
|
term.open(terminalRef.current);
|
|
fitAddon.fit();
|
|
}
|
|
|
|
xtermRef.current = term;
|
|
|
|
// Connect WebSocket
|
|
const token = localStorage.getItem('token');
|
|
const cwd = '/home/user'; // Or from settings
|
|
|
|
const ws = new WebSocket(
|
|
`ws://localhost:5000/api/terminal/ws?token=${token}&cwd=${encodeURIComponent(cwd)}&cols=120&rows=40`
|
|
);
|
|
|
|
ws.onopen = () => {
|
|
console.log('Terminal connected');
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const message = JSON.parse(event.data);
|
|
|
|
if (message.type === 'output') {
|
|
term.write(message.data); // Display output
|
|
} else if (message.type === 'error') {
|
|
console.error('Terminal error:', message.error);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
term.write('\r\nConnection error\r\n');
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
term.write('\r\nDisconnected\r\n');
|
|
};
|
|
|
|
// Send input from terminal
|
|
term.onData((data) => {
|
|
ws.send(JSON.stringify({ type: 'input', data }));
|
|
});
|
|
|
|
wsRef.current = ws;
|
|
|
|
// Handle resize
|
|
const handleResize = () => {
|
|
fitAddon.fit();
|
|
const { cols, rows } = term;
|
|
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
// Cleanup
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
ws.close();
|
|
term.dispose();
|
|
};
|
|
}, []);
|
|
|
|
return <div ref={terminalRef} className="w-full h-full" />;
|
|
};
|
|
```
|
|
|
|
### Pi Chat WebSocket: `SessionListPage.tsx`
|
|
|
|
```typescript
|
|
export const SessionListPage = ({ sessionId, isNew }: Props) => {
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [input, setInput] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
|
|
// Connect to Pi chat WebSocket
|
|
useEffect(() => {
|
|
const token = localStorage.getItem('token');
|
|
const url = `ws://localhost:5000/api/pi/chat/ws?token=${token}&sessionId=${sessionId}`;
|
|
|
|
const ws = new WebSocket(url);
|
|
|
|
ws.onopen = () => {
|
|
console.log('Pi chat connected');
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const message = JSON.parse(event.data);
|
|
|
|
switch (message.type) {
|
|
case 'message_update':
|
|
// Stream response text
|
|
if (message.event.type === 'text_delta') {
|
|
setMessages(prev => {
|
|
const last = prev[prev.length - 1];
|
|
if (last && last.role === 'assistant') {
|
|
return [
|
|
...prev.slice(0, -1),
|
|
{ ...last, content: last.content + message.event.delta },
|
|
];
|
|
}
|
|
return prev;
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'tool_execution_start':
|
|
setMessages(prev => [
|
|
...prev,
|
|
{ role: 'tool', toolName: message.toolName, content: 'Executing...' },
|
|
]);
|
|
break;
|
|
|
|
case 'agent_end':
|
|
setIsLoading(false);
|
|
break;
|
|
}
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
console.error('Pi chat error:', error);
|
|
setIsLoading(false);
|
|
};
|
|
|
|
wsRef.current = ws;
|
|
|
|
return () => ws.close();
|
|
}, [sessionId]);
|
|
|
|
// Send message
|
|
const handleSendMessage = () => {
|
|
if (!input.trim() || !wsRef.current) return;
|
|
|
|
const userMessage = input;
|
|
setInput('');
|
|
setIsLoading(true);
|
|
|
|
// Add user message to UI
|
|
setMessages(prev => [
|
|
...prev,
|
|
{ role: 'user', content: userMessage },
|
|
]);
|
|
|
|
// Send to Pi
|
|
wsRef.current.send(JSON.stringify({
|
|
type: 'prompt',
|
|
text: userMessage,
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex-1 overflow-auto p-4">
|
|
{messages.map((msg, i) => (
|
|
<div key={i} className={cn('mb-4', msg.role === 'user' ? 'text-right' : 'text-left')}>
|
|
<div className={cn('inline-block p-2 rounded',
|
|
msg.role === 'user' ? 'bg-blue-600 text-white' : 'bg-slate-200'
|
|
)}>
|
|
{msg.content}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{isLoading && <div>Loading...</div>}
|
|
</div>
|
|
|
|
<div className="p-4 border-t">
|
|
<div className="flex gap-2">
|
|
<input
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
|
|
placeholder="Ask Pi anything..."
|
|
disabled={isLoading}
|
|
className="flex-1 px-4 py-2 border rounded"
|
|
/>
|
|
<button
|
|
onClick={handleSendMessage}
|
|
disabled={isLoading || !input.trim()}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
|
|
>
|
|
Send
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Styling & Theming
|
|
|
|
### Tailwind CSS Integration
|
|
|
|
Officer uses Tailwind CSS 4 with a custom configuration:
|
|
|
|
```javascript
|
|
// tailwind.config.js
|
|
export default {
|
|
content: [
|
|
'./src/**/*.{ts,tsx}',
|
|
],
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
// Custom color palette if needed
|
|
},
|
|
spacing: {
|
|
// Custom spacing
|
|
},
|
|
animation: {
|
|
// Custom animations
|
|
},
|
|
},
|
|
},
|
|
plugins: [
|
|
require('tailwindcss-animate'),
|
|
],
|
|
};
|
|
```
|
|
|
|
### Shadcn/ui Components
|
|
|
|
Reusable UI components from shadcn/ui:
|
|
|
|
```typescript
|
|
// Button component wrapper
|
|
import { Button as ShadcnButton } from '@/components/ui/button';
|
|
|
|
export const Button = (props) => (
|
|
<ShadcnButton {...props} />
|
|
);
|
|
|
|
// Usage
|
|
<Button variant="outline" size="lg">
|
|
Click me
|
|
</Button>
|
|
```
|
|
|
|
### Custom Theming
|
|
|
|
Support for light/dark themes:
|
|
|
|
```typescript
|
|
// Global theme management
|
|
export const useTheme = () => {
|
|
const { theme, setTheme } = useGlobalState();
|
|
|
|
useEffect(() => {
|
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
|
localStorage.setItem('theme', theme);
|
|
}, [theme]);
|
|
|
|
return { theme, setTheme };
|
|
};
|
|
|
|
// In component
|
|
export const ThemeToggle = () => {
|
|
const { theme, setTheme } = useTheme();
|
|
|
|
return (
|
|
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
|
{theme === 'dark' ? '☀️' : '🌙'}
|
|
</button>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Performance & Optimization
|
|
|
|
### Image Optimization
|
|
|
|
Use Next Image component or lazy loading:
|
|
|
|
```typescript
|
|
import { lazy, Suspense } from 'react';
|
|
|
|
// Lazy load heavy components
|
|
const CodeEditor = lazy(() => import('./CodeEditor'));
|
|
|
|
export const FeaturePage = () => {
|
|
return (
|
|
<Suspense fallback={<div>Loading...</div>}>
|
|
<CodeEditor />
|
|
</Suspense>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Virtual Scrolling for Large Lists
|
|
|
|
Use React Virtual for efficient rendering:
|
|
|
|
```typescript
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
|
|
export const VirtualList = ({ items }: { items: Item[] }) => {
|
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: items.length,
|
|
getScrollElement: () => parentRef.current,
|
|
estimateSize: () => 50,
|
|
});
|
|
|
|
return (
|
|
<div ref={parentRef} className="h-96 overflow-auto">
|
|
<div style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
|
{virtualizer.getVirtualItems().map(virtualItem => (
|
|
<div key={virtualItem.key} data-index={virtualItem.index}>
|
|
{items[virtualItem.index]?.name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Code Splitting with React Router
|
|
|
|
Routes are automatically code-split:
|
|
|
|
```typescript
|
|
// Lazy load screen components
|
|
const HomeScreen = lazy(() => import('./HomeScreen'));
|
|
const SettingsScreen = lazy(() => import('./SettingsScreen'));
|
|
|
|
<Suspense fallback={<LoadingSpinner />}>
|
|
<Routes>
|
|
<Route path="/" element={<HomeScreen />} />
|
|
<Route path="/settings" element={<SettingsScreen />} />
|
|
</Routes>
|
|
</Suspense>
|
|
```
|
|
|
|
### React Query Caching
|
|
|
|
Automatic caching and background updates:
|
|
|
|
```typescript
|
|
// Data is cached per queryKey
|
|
const { data: plans } = useQuery({
|
|
queryKey: ['plans'], // Cached
|
|
queryFn: fetchPlans,
|
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
gcTime: 1000 * 60 * 10, // 10 minutes (was cacheTime)
|
|
});
|
|
|
|
// Background refetch when window regains focus
|
|
useQuery({
|
|
queryKey: ['plans'],
|
|
queryFn: fetchPlans,
|
|
refetchOnWindowFocus: true,
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Development Workflow
|
|
|
|
### Development Server
|
|
|
|
```bash
|
|
# Start Bun dev server with hot reload
|
|
bun dev
|
|
|
|
# Runs on http://localhost:5000/
|
|
# Frontend and backend both reload on file changes
|
|
```
|
|
|
|
### Environment Variables
|
|
|
|
```bash
|
|
# .env (client-side, public)
|
|
VITE_API_URL=http://localhost:5000/api
|
|
```
|
|
|
|
### TypeScript Type Checking
|
|
|
|
```bash
|
|
# Run TypeScript compiler
|
|
tsc --noEmit
|
|
|
|
# Included in build process
|
|
bun run build
|
|
```
|
|
|
|
### Code Formatting
|
|
|
|
```bash
|
|
# Format all files
|
|
bun format
|
|
|
|
# Format specific files
|
|
bun format:check
|
|
|
|
# Prettier config in .prettierrc
|
|
{
|
|
"semi": true,
|
|
"singleQuote": true,
|
|
"trailingComma": "all",
|
|
"printWidth": 120
|
|
}
|
|
```
|
|
|
|
### Component Development
|
|
|
|
When creating new features:
|
|
|
|
1. **Create component structure**
|
|
```
|
|
src/apps/officer-web/Screens/MyFeature/
|
|
├── index.tsx # Exports
|
|
├── MyFeatureScreen.tsx # Component
|
|
├── hooks/
|
|
│ └── useMyFeature.ts
|
|
├── types.ts
|
|
└── utils.ts
|
|
```
|
|
|
|
2. **Define types**
|
|
```typescript
|
|
// types.ts
|
|
export type MyFeatureProps = {
|
|
onSubmit: (data: FormData) => void;
|
|
disabled?: boolean;
|
|
};
|
|
```
|
|
|
|
3. **Implement component**
|
|
```typescript
|
|
// MyFeatureScreen.tsx
|
|
import { useMyFeature } from './hooks/useMyFeature';
|
|
import type { MyFeatureProps } from './types';
|
|
|
|
export const MyFeatureScreen = ({ onSubmit, disabled }: MyFeatureProps) => {
|
|
const { state, actions } = useMyFeature();
|
|
|
|
return (/* JSX */);
|
|
};
|
|
```
|
|
|
|
4. **Export**
|
|
```typescript
|
|
// index.tsx
|
|
export { MyFeatureScreen } from './MyFeatureScreen';
|
|
export type { MyFeatureProps } from './types';
|
|
```
|
|
|
|
---
|
|
|
|
## Integration Points
|
|
|
|
### API Endpoints Used
|
|
|
|
- **Auth**: `/api/auth/*` (signin, signup, verify, etc.)
|
|
- **Plans**: `/api/plans` (GET, POST, PUT, DELETE)
|
|
- **Skills**: `/api/skills` (GET, POST)
|
|
- **Tasks**: `/api/tasks` (CRUD operations)
|
|
- **Files**: `/api/file-browser` (navigation, upload)
|
|
- **Settings**: `/api/user` (preferences)
|
|
- **Terminal WS**: `/api/terminal/ws` (real-time shell)
|
|
- **Pi Chat WS**: `/api/pi/chat/ws` (real-time AI assistant)
|
|
|
|
### State Flow from Server to UI
|
|
|
|
```
|
|
Database
|
|
↓
|
|
API Response
|
|
↓
|
|
React Query Cache
|
|
↓
|
|
Component State
|
|
↓
|
|
Rendered UI
|
|
```
|
|
|
|
---
|
|
|
|
## Debugging
|
|
|
|
### Browser DevTools
|
|
|
|
```javascript
|
|
// Log component props
|
|
console.log('Props:', props);
|
|
|
|
// Log state updates
|
|
console.log('State changed:', newState);
|
|
|
|
// React DevTools browser extension
|
|
// Inspect component tree and props
|
|
```
|
|
|
|
### React Query Devtools
|
|
|
|
```typescript
|
|
// Installed: @tanstack/react-query-devtools
|
|
|
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
|
|
|
export function App() {
|
|
return (
|
|
<>
|
|
{/* App content */}
|
|
<ReactQueryDevtools initialIsOpen={false} />
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Common Patterns
|
|
|
|
### Loading States
|
|
|
|
```typescript
|
|
export const DataComponent = () => {
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['data'],
|
|
queryFn: fetchData,
|
|
});
|
|
|
|
if (isLoading) return <LoadingSpinner />;
|
|
if (error) return <ErrorMessage error={error} />;
|
|
if (!data) return <EmptyState />;
|
|
|
|
return <DataView data={data} />;
|
|
};
|
|
```
|
|
|
|
### Form Handling with React Hook Form
|
|
|
|
```typescript
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
|
|
const schema = z.object({
|
|
email: z.string().email('Invalid email'),
|
|
password: z.string().min(8, 'Min 8 chars'),
|
|
});
|
|
|
|
type FormData = z.infer<typeof schema>;
|
|
|
|
export const LoginForm = () => {
|
|
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
|
resolver: zodResolver(schema),
|
|
});
|
|
|
|
const onSubmit = async (data: FormData) => {
|
|
await apiClient.post('/auth/signin', data);
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit)}>
|
|
<input {...register('email')} placeholder="Email" />
|
|
{errors.email && <span>{errors.email.message}</span>}
|
|
|
|
<input {...register('password')} type="password" />
|
|
{errors.password && <span>{errors.password.message}</span>}
|
|
|
|
<button type="submit">Login</button>
|
|
</form>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Toast Notifications
|
|
|
|
```typescript
|
|
import { toast } from 'sonner';
|
|
|
|
// Success
|
|
toast.success('Operation completed!');
|
|
|
|
// Error
|
|
toast.error('Something went wrong', {
|
|
description: 'Please try again later',
|
|
});
|
|
|
|
// Custom
|
|
toast.custom((t) => (
|
|
<div>Custom notification</div>
|
|
));
|
|
```
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- **Backend Architecture**: See `OFFICERDEV_BACKEND.md`
|
|
- **CONVENTIONS.md**: Detailed code patterns and style
|
|
- **CLAUDE.md**: Project overview
|
|
- **apps/officer-web/CLAUDE.md**: Frontend-specific patterns
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
Officer's frontend is a modern React 19 SPA featuring:
|
|
- Type-safe component architecture
|
|
- Real-time WebSocket communication
|
|
- Server state management with React Query
|
|
- Beautiful UI with Tailwind CSS and shadcn/ui
|
|
- Strong React patterns without useCallback/useMemo
|
|
- Comprehensive authentication and authorization
|
|
- Responsive design for all devices
|
|
- AI-powered development assistance via Pi
|
|
- Interactive terminal emulation
|
|
|
|
The architecture prioritizes developer experience, type safety, and performance while providing a rich, interactive user experience for life management and AI-assisted development.
|