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:
Michael Chihlas
2026-01-27 22:42:22 -05:00
parent 7d96807fb1
commit cd10ecd42c
51 changed files with 9014 additions and 111 deletions

35
frontend/src/api/auth.ts Normal file
View 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

View 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

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

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