Refactor code structure for improved readability and maintainability
This commit is contained in:
34
app/frontend/src/lib/auth.ts
Normal file
34
app/frontend/src/lib/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
import { pb } from './pocketbase'
|
||||
import type { PBUser } from '@/types'
|
||||
|
||||
const COOKIE_TOKEN = 'pb_token'
|
||||
const COOKIE_ROLE = 'pb_role'
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 14 // 14 days
|
||||
|
||||
export function getAuthUser(): PBUser | null {
|
||||
if (!pb.authStore.isValid) return null
|
||||
return pb.authStore.model as PBUser | null
|
||||
}
|
||||
|
||||
export function syncAuthCookies(): void {
|
||||
if (pb.authStore.isValid && pb.authStore.token) {
|
||||
const user = pb.authStore.model as PBUser | null
|
||||
const role = user?.role ?? ''
|
||||
document.cookie = `${COOKIE_TOKEN}=${pb.authStore.token}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`
|
||||
document.cookie = `${COOKIE_ROLE}=${role}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`
|
||||
} else {
|
||||
clearAuthCookies()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuthCookies(): void {
|
||||
document.cookie = `${COOKIE_TOKEN}=; path=/; max-age=0`
|
||||
document.cookie = `${COOKIE_ROLE}=; path=/; max-age=0`
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
pb.authStore.clear()
|
||||
clearAuthCookies()
|
||||
window.location.href = '/auth'
|
||||
}
|
||||
69
app/frontend/src/lib/hooks/useIngestionStatus.ts
Normal file
69
app/frontend/src/lib/hooks/useIngestionStatus.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { getIngestionStatus } from '@/lib/services'
|
||||
import type { IngestionJobStatus } from '@/types'
|
||||
|
||||
const TERMINAL_STATES = new Set<IngestionJobStatus['status']>(['done', 'failed'])
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
|
||||
interface UseIngestionStatusResult {
|
||||
status: IngestionJobStatus | null
|
||||
progress: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function calcProgress(s: IngestionJobStatus): number {
|
||||
const stageMap: Record<IngestionJobStatus['status'], number> = {
|
||||
queued: 5,
|
||||
extracting: 15,
|
||||
chunking: 30,
|
||||
structuring: 50,
|
||||
writing: 65,
|
||||
embedding:
|
||||
s.chunksTotal > 0 ? 65 + Math.round((s.chunksEmbedded / s.chunksTotal) * 30) : 70,
|
||||
done: 100,
|
||||
failed: 0,
|
||||
}
|
||||
return stageMap[s.status] ?? 0
|
||||
}
|
||||
|
||||
export function useIngestionStatus(jobId: string | null): UseIngestionStatusResult {
|
||||
const [status, setStatus] = useState<IngestionJobStatus | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function poll() {
|
||||
if (cancelled) return
|
||||
try {
|
||||
const result = await getIngestionStatus(jobId!)
|
||||
if (cancelled) return
|
||||
setStatus(result)
|
||||
setError(null)
|
||||
if (!TERMINAL_STATES.has(result.status)) {
|
||||
timerRef.current = setTimeout(poll, POLL_INTERVAL_MS)
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [jobId])
|
||||
|
||||
return {
|
||||
status,
|
||||
progress: status ? calcProgress(status) : 0,
|
||||
error,
|
||||
}
|
||||
}
|
||||
57
app/frontend/src/lib/hooks/usePBCollection.ts
Normal file
57
app/frontend/src/lib/hooks/usePBCollection.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
|
||||
interface UsePBCollectionResult<T> {
|
||||
items: T[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
refresh: () => void
|
||||
}
|
||||
|
||||
export function usePBCollection<T>(
|
||||
collection: string,
|
||||
options?: Record<string, unknown>,
|
||||
): UsePBCollectionResult<T> {
|
||||
const [items, setItems] = useState<T[]>([])
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [tick, setTick] = useState<number>(0)
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setTick((t) => t + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await pb.collection(collection).getFullList<T>({
|
||||
...(options ?? {}),
|
||||
})
|
||||
if (!cancelled) {
|
||||
setItems(result)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [collection, tick])
|
||||
|
||||
return { items, loading, error, refresh }
|
||||
}
|
||||
4
app/frontend/src/lib/pocketbase.ts
Normal file
4
app/frontend/src/lib/pocketbase.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
'use client'
|
||||
import PocketBase from 'pocketbase'
|
||||
|
||||
export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL ?? 'http://localhost:8090')
|
||||
89
app/frontend/src/lib/services.ts
Normal file
89
app/frontend/src/lib/services.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type {
|
||||
IngestionJobStatus,
|
||||
CompletePayload,
|
||||
CompleteResponse,
|
||||
LeaderboardEntry,
|
||||
FeedEntry,
|
||||
} from '@/types'
|
||||
|
||||
const INGESTION_URL = process.env.NEXT_PUBLIC_INGESTION_URL ?? 'http://localhost:3001'
|
||||
const GENERATION_URL = process.env.NEXT_PUBLIC_GENERATION_URL ?? 'http://localhost:3002'
|
||||
const CURRICULUM_URL = process.env.NEXT_PUBLIC_CURRICULUM_URL ?? 'http://localhost:3003'
|
||||
const PROGRESS_URL = process.env.NEXT_PUBLIC_PROGRESS_URL ?? 'http://localhost:3005'
|
||||
|
||||
async function handleResponse<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText)
|
||||
throw new Error(`HTTP ${res.status}: ${text}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function postIngest(formData: FormData): Promise<{ jobId: string }> {
|
||||
const res = await fetch(`${INGESTION_URL}/ingest`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
return handleResponse<{ jobId: string }>(res)
|
||||
}
|
||||
|
||||
export async function getIngestionStatus(jobId: string): Promise<IngestionJobStatus> {
|
||||
const res = await fetch(`${INGESTION_URL}/ingest/${encodeURIComponent(jobId)}/status`)
|
||||
return handleResponse<IngestionJobStatus>(res)
|
||||
}
|
||||
|
||||
export async function postGenerateAll(themeId: string): Promise<{ queued: number }> {
|
||||
const res = await fetch(`${GENERATION_URL}/generate/theme/${encodeURIComponent(themeId)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
return handleResponse<{ queued: number }>(res)
|
||||
}
|
||||
|
||||
export async function postComplete(payload: CompletePayload): Promise<CompleteResponse> {
|
||||
const res = await fetch(`${PROGRESS_URL}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return handleResponse<CompleteResponse>(res)
|
||||
}
|
||||
|
||||
export async function getLeaderboard(): Promise<LeaderboardEntry[]> {
|
||||
const res = await fetch(`${PROGRESS_URL}/leaderboard`)
|
||||
return handleResponse<LeaderboardEntry[]>(res)
|
||||
}
|
||||
|
||||
export async function getFeed(): Promise<FeedEntry[]> {
|
||||
const res = await fetch(`${PROGRESS_URL}/feed`)
|
||||
return handleResponse<FeedEntry[]>(res)
|
||||
}
|
||||
|
||||
export async function postCurriculumConfirm(versionId: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${CURRICULUM_URL}/curriculum/versions/${encodeURIComponent(versionId)}/confirm`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText)
|
||||
throw new Error(`HTTP ${res.status}: ${text}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchCurriculumWeekOrder(weekId: string, position: number): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${CURRICULUM_URL}/curriculum/weeks/${encodeURIComponent(weekId)}/order`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ position }),
|
||||
},
|
||||
)
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText)
|
||||
throw new Error(`HTTP ${res.status}: ${text}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user