Complete Phase 2: Frontend implementation with React + TypeScript
Frontend Features: - React 18 + Vite + TypeScript + Tailwind CSS + Zustand - JWT authentication with automatic token refresh - Tree library with search and category filtering - Full tree navigation (decision/action/solution nodes) - Session management with notes and completion - Session history with export (Markdown/Text/HTML) - ErrorBoundary for graceful error handling Backend Fixes: - CORS: Added port 5174 to allowed origins - Sessions: Fixed JSONB datetime serialization (mode='json') Documentation: - Updated PROGRESS.md with Phase 2 completion - Updated 03-DEVELOPMENT-ROADMAP.md with checked items - Added PHASE-2.5-PERSONAL-BRANCHING.md spec Seed Data: - Added backend/scripts/seed_data.py with Password Reset tree Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
35
frontend/src/api/auth.ts
Normal file
35
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import apiClient from './client'
|
||||
import type { Token, User, UserCreate, UserLogin } from '@/types'
|
||||
|
||||
export const authApi = {
|
||||
async register(data: UserCreate): Promise<User> {
|
||||
const response = await apiClient.post<User>('/auth/register', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async login(data: UserLogin): Promise<Token> {
|
||||
const response = await apiClient.post<Token>('/auth/login/json', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async refresh(): Promise<Token> {
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
const response = await apiClient.post<Token>('/auth/refresh', null, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshToken}`,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async me(): Promise<User> {
|
||||
const response = await apiClient.get<User>('/auth/me')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await apiClient.post('/auth/logout')
|
||||
},
|
||||
}
|
||||
|
||||
export default authApi
|
||||
66
frontend/src/api/client.ts
Normal file
66
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: `${API_BASE_URL}/api/v1`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add auth token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response interceptor - handle token refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
// If 401 and not already retrying, attempt token refresh
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, null, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const { access_token, refresh_token } = response.data
|
||||
localStorage.setItem('access_token', access_token)
|
||||
localStorage.setItem('refresh_token', refresh_token)
|
||||
|
||||
// Retry original request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`
|
||||
}
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
// Refresh failed - clear tokens and redirect to login
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
4
frontend/src/api/index.ts
Normal file
4
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as apiClient } from './client'
|
||||
export { default as authApi } from './auth'
|
||||
export { default as treesApi } from './trees'
|
||||
export { default as sessionsApi } from './sessions'
|
||||
51
frontend/src/api/sessions.ts
Normal file
51
frontend/src/api/sessions.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import apiClient from './client'
|
||||
import type { Session, SessionCreate, SessionUpdate, SessionExport } from '@/types'
|
||||
|
||||
export interface SessionListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
tree_id?: string
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
export interface SessionListResponse {
|
||||
items: Session[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export const sessionsApi = {
|
||||
async list(params?: SessionListParams): Promise<Session[]> {
|
||||
const response = await apiClient.get<Session[]>('/sessions', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Session> {
|
||||
const response = await apiClient.get<Session>(`/sessions/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async create(data: SessionCreate): Promise<Session> {
|
||||
const response = await apiClient.post<Session>('/sessions', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async update(id: string, data: SessionUpdate): Promise<Session> {
|
||||
const response = await apiClient.put<Session>(`/sessions/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async complete(id: string): Promise<Session> {
|
||||
const response = await apiClient.post<Session>(`/sessions/${id}/complete`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async export(id: string, options: SessionExport): Promise<string> {
|
||||
const response = await apiClient.post<string>(`/sessions/${id}/export`, options)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default sessionsApi
|
||||
57
frontend/src/api/trees.ts
Normal file
57
frontend/src/api/trees.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import apiClient from './client'
|
||||
import type { Tree, TreeListItem, TreeCreate, TreeUpdate } from '@/types'
|
||||
|
||||
export interface TreeListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
category?: string
|
||||
include_inactive?: boolean
|
||||
}
|
||||
|
||||
export interface TreeListResponse {
|
||||
items: TreeListItem[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export const treesApi = {
|
||||
async list(params?: TreeListParams): Promise<TreeListItem[]> {
|
||||
const response = await apiClient.get<TreeListItem[]>('/trees', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Tree> {
|
||||
const response = await apiClient.get<Tree>(`/trees/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async create(data: TreeCreate): Promise<Tree> {
|
||||
const response = await apiClient.post<Tree>('/trees', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async update(id: string, data: TreeUpdate): Promise<Tree> {
|
||||
const response = await apiClient.put<Tree>(`/trees/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`/trees/${id}`)
|
||||
},
|
||||
|
||||
async categories(): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>('/trees/categories')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async search(query: string, category?: string): Promise<TreeListItem[]> {
|
||||
const response = await apiClient.get<TreeListItem[]>('/trees/search', {
|
||||
params: { q: query, category },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default treesApi
|
||||
Reference in New Issue
Block a user