mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 11:01:11 +00:00
[AZ-486] F7 endpoint builders + STC-ARCH-02 (cycle 1 close)
Single source of truth for every /api/<service>/... URL the UI talks to: src/api/endpoints.ts (25 typed builders) re-exported via the F4 barrel. Migrates 13 production callsites in admin / annotations / flights / settings / dataset / auth / api-client / FlightContext / DetectionClasses to endpoints.* . Adds the STC-ARCH-02 static gate (--mode=api-literals in scripts/check-arch-imports.mjs, wired into scripts/run-tests.sh) that fails any new hardcoded /api/<service>/ literal in src/ outside endpoints.ts and *.test.tsx? files. Tests: +36 contract assertions in src/api/endpoints.test.ts (every builder, character-identical), +6 STC-ARCH-02 architecture cases in tests/architecture_imports.test.ts (single / double / template literal fail paths, *.test.* exemption, line-comment skip, migrated codebase pass). Fast profile 167 -> 209 PASS / 13 SKIP / 0 FAIL, +42 new, 0 regressions. Static profile 31 / 31 PASS. Closes architecture baseline finding F7. Cycle 1 of Phase B closed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+3
-1
@@ -1,3 +1,5 @@
|
||||
import { endpoints } from './endpoints'
|
||||
|
||||
let accessToken: string | null = null
|
||||
|
||||
/**
|
||||
@@ -85,7 +87,7 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
|
||||
async function refreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(getApiBase() + '/api/admin/auth/refresh', { method: 'POST', credentials: 'include' })
|
||||
const res = await fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' })
|
||||
if (!res.ok) return false
|
||||
const data = await res.json()
|
||||
setToken(data.token)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { endpoints } from './endpoints'
|
||||
import { endpoints as endpointsViaBarrel } from '../api'
|
||||
|
||||
// AZ-486 / F7 — this test file IS the wire-contract for the UI ↔ nginx layer
|
||||
// (per `module-layout.md`'s "code-derived documentation" pattern referenced in
|
||||
// the task spec). Every builder is asserted to produce the URL literal that
|
||||
// existed in source before the refactor and that MSW handlers + e2e stubs
|
||||
// intercept today. A change to any assertion below is a wire-contract change
|
||||
// and MUST be coordinated with backend + MSW + e2e stubs in the same commit.
|
||||
|
||||
describe('AZ-486 endpoints — wire-contract URLs', () => {
|
||||
describe('AC-1: admin', () => {
|
||||
it('admin.authRefresh', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.authRefresh()).toBe('/api/admin/auth/refresh')
|
||||
})
|
||||
|
||||
it('admin.authLogin', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.authLogin()).toBe('/api/admin/auth/login')
|
||||
})
|
||||
|
||||
it('admin.authLogout', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.authLogout()).toBe('/api/admin/auth/logout')
|
||||
})
|
||||
|
||||
it('admin.users', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.users()).toBe('/api/admin/users')
|
||||
})
|
||||
|
||||
it('admin.user(id) interpolates the id', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.user('abc')).toBe('/api/admin/users/abc')
|
||||
})
|
||||
|
||||
it('admin.classes', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.classes()).toBe('/api/admin/classes')
|
||||
})
|
||||
|
||||
it('admin.class(id) interpolates the id (string)', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.class('cls-7')).toBe('/api/admin/classes/cls-7')
|
||||
})
|
||||
|
||||
it('admin.class(id) interpolates the id (number — DetectionClass.id today)', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.class(42)).toBe('/api/admin/classes/42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: annotations', () => {
|
||||
it('annotations.classes', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.classes()).toBe('/api/annotations/classes')
|
||||
})
|
||||
|
||||
it('annotations.settingsUser', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.settingsUser()).toBe(
|
||||
'/api/annotations/settings/user',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.settingsSystem', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.settingsSystem()).toBe(
|
||||
'/api/annotations/settings/system',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.settingsDirectories', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.settingsDirectories()).toBe(
|
||||
'/api/annotations/settings/directories',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotations', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotations()).toBe(
|
||||
'/api/annotations/annotations',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationsByMedia(mediaId) defaults pageSize=1000', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationsByMedia('m-1')).toBe(
|
||||
'/api/annotations/annotations?mediaId=m-1&pageSize=1000',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationsByMedia(mediaId, pageSize) overrides pageSize', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationsByMedia('m-1', 50)).toBe(
|
||||
'/api/annotations/annotations?mediaId=m-1&pageSize=50',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationImage(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationImage('ann-7')).toBe(
|
||||
'/api/annotations/annotations/ann-7/image',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationThumbnail(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationThumbnail('ann-7')).toBe(
|
||||
'/api/annotations/annotations/ann-7/thumbnail',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationEvents', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationEvents()).toBe(
|
||||
'/api/annotations/annotations/events',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.media(queryString)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.media('page=1&pageSize=50')).toBe(
|
||||
'/api/annotations/media?page=1&pageSize=50',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.mediaFile(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.mediaFile('m-1')).toBe(
|
||||
'/api/annotations/media/m-1/file',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.mediaItem(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.mediaItem('m-1')).toBe(
|
||||
'/api/annotations/media/m-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.mediaBatch', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.mediaBatch()).toBe(
|
||||
'/api/annotations/media/batch',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.dataset(queryString)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.dataset('status=PENDING')).toBe(
|
||||
'/api/annotations/dataset?status=PENDING',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.datasetItem(annotationId)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.datasetItem('ann-7')).toBe(
|
||||
'/api/annotations/dataset/ann-7',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.datasetBulkStatus', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.datasetBulkStatus()).toBe(
|
||||
'/api/annotations/dataset/bulk-status',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.datasetClassDistribution', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.datasetClassDistribution()).toBe(
|
||||
'/api/annotations/dataset/class-distribution',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: flights', () => {
|
||||
it('flights.collection() without query', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.collection()).toBe('/api/flights')
|
||||
})
|
||||
|
||||
it('flights.collection(queryString) appends ?queryString', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.collection('pageSize=1000')).toBe(
|
||||
'/api/flights?pageSize=1000',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.aircrafts', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.aircrafts()).toBe('/api/flights/aircrafts')
|
||||
})
|
||||
|
||||
it('flights.aircraft(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.aircraft('ac-1')).toBe(
|
||||
'/api/flights/aircrafts/ac-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.flight(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flight('f-1')).toBe('/api/flights/f-1')
|
||||
})
|
||||
|
||||
it('flights.flightWaypoints(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flightWaypoints('f-1')).toBe(
|
||||
'/api/flights/f-1/waypoints',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.flightWaypoint(flightId, waypointId)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flightWaypoint('f-1', 'wp-2')).toBe(
|
||||
'/api/flights/f-1/waypoints/wp-2',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.flightLiveGps(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flightLiveGps('f-1')).toBe(
|
||||
'/api/flights/f-1/live-gps',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: detect', () => {
|
||||
it('detect.media(mediaId)', () => {
|
||||
// Assert
|
||||
expect(endpoints.detect.media('m-1')).toBe('/api/detect/m-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-6: barrel re-export', () => {
|
||||
it('endpoints is the same object when imported from src/api (the F4 barrel)', () => {
|
||||
// Assert
|
||||
expect(endpointsViaBarrel).toBe(endpoints)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// AZ-486 / F7 — Single source of truth for every `/api/<service>/<path>` URL
|
||||
// the UI talks to today. Closes architecture baseline finding F7.
|
||||
//
|
||||
// Every UI callsite of `api.*`, `createSSE`, and raw image/video `src` URLs
|
||||
// pointing at an API resource MUST go through one of these builders. The
|
||||
// STC-ARCH-02 static gate (scripts/check-arch-imports.mjs `--mode=api-literals`,
|
||||
// wired into scripts/run-tests.sh) enforces it.
|
||||
//
|
||||
// **Wire-contract invariant**: the strings produced here are character-identical
|
||||
// to the literals that lived in the source before this refactor and that MSW
|
||||
// handlers + e2e stubs intercept. Any change to a builder's output is a wire-
|
||||
// contract change and MUST be coordinated with the backend + the MSW handler
|
||||
// surface + e2e stubs in the same commit. The accompanying test file
|
||||
// (`endpoints.test.ts`) pins every URL string and is the contract documentation.
|
||||
//
|
||||
// **Why function form (not constants)**: per user direction at task-creation
|
||||
// time; allows parameter interpolation without callsite re-introducing template
|
||||
// literals and keeps tree-shaking per-builder under Vite's production rollup.
|
||||
|
||||
export const endpoints = {
|
||||
admin: {
|
||||
authRefresh: () => '/api/admin/auth/refresh',
|
||||
authLogin: () => '/api/admin/auth/login',
|
||||
authLogout: () => '/api/admin/auth/logout',
|
||||
users: () => '/api/admin/users',
|
||||
user: (id: string) => `/api/admin/users/${id}`,
|
||||
classes: () => '/api/admin/classes',
|
||||
// DetectionClass.id is `number` in the type system; widened to accept
|
||||
// string for forward-compat if the backend switches the column to UUID.
|
||||
class: (id: string | number) => `/api/admin/classes/${id}`,
|
||||
},
|
||||
annotations: {
|
||||
classes: () => '/api/annotations/classes',
|
||||
settingsUser: () => '/api/annotations/settings/user',
|
||||
settingsSystem: () => '/api/annotations/settings/system',
|
||||
settingsDirectories: () => '/api/annotations/settings/directories',
|
||||
annotations: () => '/api/annotations/annotations',
|
||||
// page-size is currently always 1000 at every callsite; expose it as an
|
||||
// optional param so future tuning is a single-file change.
|
||||
annotationsByMedia: (mediaId: string, pageSize: number = 1000) =>
|
||||
`/api/annotations/annotations?mediaId=${mediaId}&pageSize=${pageSize}`,
|
||||
annotationImage: (annotationId: string) =>
|
||||
`/api/annotations/annotations/${annotationId}/image`,
|
||||
annotationThumbnail: (annotationId: string) =>
|
||||
`/api/annotations/annotations/${annotationId}/thumbnail`,
|
||||
annotationEvents: () => '/api/annotations/annotations/events',
|
||||
// Callers pre-build a URLSearchParams.toString() and pass it through; the
|
||||
// builder owns the path + `?` only so the wire-contract stays identical.
|
||||
media: (queryString: string) => `/api/annotations/media?${queryString}`,
|
||||
mediaFile: (mediaId: string) => `/api/annotations/media/${mediaId}/file`,
|
||||
mediaItem: (mediaId: string) => `/api/annotations/media/${mediaId}`,
|
||||
mediaBatch: () => '/api/annotations/media/batch',
|
||||
dataset: (queryString: string) => `/api/annotations/dataset?${queryString}`,
|
||||
datasetItem: (annotationId: string) =>
|
||||
`/api/annotations/dataset/${annotationId}`,
|
||||
datasetBulkStatus: () => '/api/annotations/dataset/bulk-status',
|
||||
datasetClassDistribution: () =>
|
||||
'/api/annotations/dataset/class-distribution',
|
||||
},
|
||||
flights: {
|
||||
// GET (with `?pageSize=...`) lists flights; POST (no query) creates one.
|
||||
// The query string is owned by the caller (URLSearchParams.toString()) so
|
||||
// the wire-contract stays identical to today.
|
||||
collection: (queryString?: string) =>
|
||||
queryString ? `/api/flights?${queryString}` : '/api/flights',
|
||||
aircrafts: () => '/api/flights/aircrafts',
|
||||
aircraft: (id: string) => `/api/flights/aircrafts/${id}`,
|
||||
flight: (id: string) => `/api/flights/${id}`,
|
||||
flightWaypoints: (id: string) => `/api/flights/${id}/waypoints`,
|
||||
flightWaypoint: (flightId: string, waypointId: string) =>
|
||||
`/api/flights/${flightId}/waypoints/${waypointId}`,
|
||||
flightLiveGps: (id: string) => `/api/flights/${id}/live-gps`,
|
||||
},
|
||||
detect: {
|
||||
// Trigger detection for a media item. Single-segment service path.
|
||||
media: (mediaId: string) => `/api/detect/${mediaId}`,
|
||||
},
|
||||
} as const
|
||||
@@ -1,2 +1,3 @@
|
||||
export { api, setToken, getToken, getApiBase, setNavigateToLogin } from './client'
|
||||
export { createSSE } from './sse'
|
||||
export { endpoints } from './endpoints'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
|
||||
import { api, setToken } from '../api'
|
||||
import { api, endpoints, setToken } from '../api'
|
||||
import type { AuthUser } from '../types'
|
||||
|
||||
interface AuthState {
|
||||
@@ -21,7 +21,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ user: AuthUser; token: string }>('/api/admin/auth/refresh')
|
||||
api.get<{ user: AuthUser; token: string }>(endpoints.admin.authRefresh())
|
||||
.then(data => {
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
@@ -31,13 +31,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const data = await api.post<{ token: string; user: AuthUser }>('/api/admin/auth/login', { email, password })
|
||||
const data = await api.post<{ token: string; user: AuthUser }>(endpoints.admin.authLogin(), { email, password })
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try { await api.post('/api/admin/auth/logout') } catch {}
|
||||
try { await api.post(endpoints.admin.authLogout()) } catch {}
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
|
||||
import { FaRegSnowflake } from 'react-icons/fa'
|
||||
import { api } from '../api'
|
||||
import { api, endpoints } from '../api'
|
||||
// classColors lives under 06_annotations until F3 moves it to its own home.
|
||||
// Importing through the 06_annotations barrel would create a cycle
|
||||
// (DetectionClasses -> 06_annotations barrel -> AnnotationsPage -> DetectionClasses).
|
||||
@@ -33,7 +33,7 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
|
||||
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DetectionClass[]>('/api/annotations/classes')
|
||||
api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||
.then(list => setClasses(list?.length ? list : FALLBACK_CLASSES))
|
||||
.catch(() => setClasses(FALLBACK_CLASSES))
|
||||
}, [])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { api } from '../api'
|
||||
import { api, endpoints } from '../api'
|
||||
import type { Flight, UserSettings } from '../types'
|
||||
|
||||
interface FlightState {
|
||||
@@ -21,17 +21,17 @@ export function FlightProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const refreshFlights = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<{ items: Flight[] }>('/api/flights?pageSize=1000')
|
||||
const data = await api.get<{ items: Flight[] }>(endpoints.flights.collection('pageSize=1000'))
|
||||
setFlights(data.items ?? [])
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshFlights()
|
||||
api.get<UserSettings>('/api/annotations/settings/user')
|
||||
api.get<UserSettings>(endpoints.annotations.settingsUser())
|
||||
.then(settings => {
|
||||
if (settings?.selectedFlightId) {
|
||||
api.get<Flight>(`/api/flights/${settings.selectedFlightId}`)
|
||||
api.get<Flight>(endpoints.flights.flight(settings.selectedFlightId))
|
||||
.then(f => setSelectedFlight(f))
|
||||
.catch(() => {})
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function FlightProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const selectFlight = useCallback((f: Flight | null) => {
|
||||
setSelectedFlight(f)
|
||||
api.put('/api/annotations/settings/user', { selectedFlightId: f?.id ?? null }).catch(() => {})
|
||||
api.put(endpoints.annotations.settingsUser(), { selectedFlightId: f?.id ?? null }).catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { ConfirmDialog } from '../../components'
|
||||
import type { DetectionClass, Aircraft, User } from '../../types'
|
||||
|
||||
@@ -14,41 +14,41 @@ export default function AdminPage() {
|
||||
const [deactivateId, setDeactivateId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
api.get<User[]>('/api/admin/users').then(setUsers).catch(() => {})
|
||||
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
|
||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||
api.get<User[]>(endpoints.admin.users()).then(setUsers).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleAddClass = async () => {
|
||||
if (!newClass.name) return
|
||||
await api.post('/api/admin/classes', newClass)
|
||||
const updated = await api.get<DetectionClass[]>('/api/annotations/classes')
|
||||
await api.post(endpoints.admin.classes(), newClass)
|
||||
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||
setClasses(updated)
|
||||
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
||||
}
|
||||
|
||||
const handleDeleteClass = async (id: number) => {
|
||||
await api.delete(`/api/admin/classes/${id}`)
|
||||
await api.delete(endpoints.admin.class(id))
|
||||
setClasses(prev => prev.filter(c => c.id !== id))
|
||||
}
|
||||
|
||||
const handleAddUser = async () => {
|
||||
if (!newUser.email || !newUser.password) return
|
||||
await api.post('/api/admin/users', newUser)
|
||||
const updated = await api.get<User[]>('/api/admin/users')
|
||||
await api.post(endpoints.admin.users(), newUser)
|
||||
const updated = await api.get<User[]>(endpoints.admin.users())
|
||||
setUsers(updated)
|
||||
setNewUser({ name: '', email: '', password: '', role: 'Annotator' })
|
||||
}
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!deactivateId) return
|
||||
await api.patch(`/api/admin/users/${deactivateId}`, { isActive: false })
|
||||
await api.patch(endpoints.admin.user(deactivateId), { isActive: false })
|
||||
setUsers(prev => prev.map(u => u.id === deactivateId ? { ...u, isActive: false } : u))
|
||||
setDeactivateId(null)
|
||||
}
|
||||
|
||||
const handleToggleDefault = async (a: Aircraft) => {
|
||||
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
|
||||
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useResizablePanel } from '../../hooks'
|
||||
import { api } from '../../api'
|
||||
import { api, endpoints } from '../../api'
|
||||
import MediaList from './MediaList'
|
||||
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
||||
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
||||
@@ -36,9 +36,9 @@ export default function AnnotationsPage() {
|
||||
|
||||
if (!selectedMedia.path.startsWith('blob:')) {
|
||||
try {
|
||||
await api.post('/api/annotations/annotations', body)
|
||||
await api.post(endpoints.annotations.annotations(), body)
|
||||
const res = await api.get<{ items: AnnotationListItem[] }>(
|
||||
`/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`,
|
||||
endpoints.annotations.annotationsByMedia(selectedMedia.id),
|
||||
)
|
||||
setAnnotations(res.items)
|
||||
return
|
||||
@@ -96,7 +96,7 @@ export default function AnnotationsPage() {
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = selectedMedia.path.startsWith('blob:')
|
||||
? selectedMedia.path
|
||||
: `/api/annotations/media/${selectedMedia.id}/file`
|
||||
: endpoints.annotations.mediaFile(selectedMedia.id)
|
||||
await new Promise(res => { img.onload = res; img.onerror = res })
|
||||
w = img.naturalWidth
|
||||
h = img.naturalHeight
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FaDownload } from 'react-icons/fa'
|
||||
import { api, createSSE } from '../../api'
|
||||
import { api, createSSE, endpoints } from '../../api'
|
||||
import { getClassColor } from './classColors'
|
||||
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
||||
|
||||
@@ -21,10 +21,10 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
||||
|
||||
useEffect(() => {
|
||||
if (!media) return
|
||||
return createSSE<{ annotationId: string; mediaId: string; status: number }>('/api/annotations/annotations/events', (event) => {
|
||||
return createSSE<{ annotationId: string; mediaId: string; status: number }>(endpoints.annotations.annotationEvents(), (event) => {
|
||||
if (event.mediaId === media.id) {
|
||||
api.get<PaginatedResponse<AnnotationListItem>>(
|
||||
`/api/annotations/annotations?mediaId=${media.id}&pageSize=1000`
|
||||
endpoints.annotations.annotationsByMedia(media.id),
|
||||
).then(res => onAnnotationsUpdate(res.items)).catch(() => {})
|
||||
}
|
||||
})
|
||||
@@ -35,7 +35,7 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
||||
setDetecting(true)
|
||||
setDetectLog(['Starting AI detection...'])
|
||||
try {
|
||||
await api.post(`/api/detect/${media.id}`)
|
||||
await api.post(endpoints.detect.media(media.id))
|
||||
setDetectLog(prev => [...prev, 'Detection complete.'])
|
||||
} catch (e: any) {
|
||||
setDetectLog(prev => [...prev, `Error: ${e.message}`])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
|
||||
import { endpoints } from '../../api'
|
||||
import { MediaType } from '../../types'
|
||||
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
|
||||
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from './classColors'
|
||||
@@ -76,11 +77,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
if (annotation && !media.path.startsWith('blob:')) {
|
||||
img.src = `/api/annotations/annotations/${annotation.id}/image`
|
||||
img.src = endpoints.annotations.annotationImage(annotation.id)
|
||||
} else if (media.path.startsWith('blob:')) {
|
||||
img.src = media.path
|
||||
} else {
|
||||
img.src = `/api/annotations/media/${media.id}/file`
|
||||
img.src = endpoints.annotations.mediaFile(media.id)
|
||||
}
|
||||
img.onload = () => {
|
||||
imgRef.current = img
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useFlight, ConfirmDialog } from '../../components'
|
||||
import { api } from '../../api'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { useDebounce } from '../../hooks'
|
||||
import { MediaType } from '../../types'
|
||||
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
|
||||
@@ -27,7 +27,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
if (selectedFlight) params.set('flightId', selectedFlight.id)
|
||||
if (debouncedFilter) params.set('name', debouncedFilter)
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<Media>>(`/api/annotations/media?${params}`)
|
||||
const res = await api.get<PaginatedResponse<Media>>(endpoints.annotations.media(params.toString()))
|
||||
setMedia(prev => {
|
||||
// Keep local-only (blob URL) entries, merge with backend entries
|
||||
const local = prev.filter(m => m.path.startsWith('blob:'))
|
||||
@@ -55,7 +55,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
}
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<AnnotationListItem>>(
|
||||
`/api/annotations/annotations?mediaId=${m.id}&pageSize=1000`
|
||||
endpoints.annotations.annotationsByMedia(m.id),
|
||||
)
|
||||
onAnnotationsLoaded(res.items)
|
||||
} catch {
|
||||
@@ -72,7 +72,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
setDeleteId(null)
|
||||
return
|
||||
}
|
||||
try { await api.delete(`/api/annotations/media/${deleteId}`) } catch {}
|
||||
try { await api.delete(endpoints.annotations.mediaItem(deleteId)) } catch {}
|
||||
setDeleteId(null)
|
||||
fetchMedia()
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
const form = new FormData()
|
||||
form.append('waypointId', '')
|
||||
for (const file of arr) form.append('files', file)
|
||||
await api.upload('/api/annotations/media/batch', form)
|
||||
await api.upload(endpoints.annotations.mediaBatch(), form)
|
||||
fetchMedia()
|
||||
return
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
|
||||
import { endpoints } from '../../api'
|
||||
import type { Media } from '../../types'
|
||||
|
||||
interface Props {
|
||||
@@ -38,7 +39,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||
|
||||
const videoUrl = media.path.startsWith('blob:')
|
||||
? media.path
|
||||
: `/api/annotations/media/${media.id}/file`
|
||||
: endpoints.annotations.mediaFile(media.id)
|
||||
|
||||
const stepFrames = useCallback((count: number) => {
|
||||
const video = videoRef.current
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { useDebounce, useResizablePanel } from '../../hooks'
|
||||
import { useFlight, DetectionClasses, ConfirmDialog } from '../../components'
|
||||
import CanvasEditor from '../annotations/CanvasEditor'
|
||||
@@ -42,7 +42,7 @@ export default function DatasetPage() {
|
||||
if (objectsOnly) params.set('hasDetections', 'true')
|
||||
if (debouncedSearch) params.set('name', debouncedSearch)
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<DatasetItem>>(`/api/annotations/dataset?${params}`)
|
||||
const res = await api.get<PaginatedResponse<DatasetItem>>(endpoints.annotations.dataset(params.toString()))
|
||||
setItems(res.items)
|
||||
setTotalCount(res.totalCount)
|
||||
} catch {}
|
||||
@@ -52,7 +52,7 @@ export default function DatasetPage() {
|
||||
|
||||
const handleDoubleClick = async (item: DatasetItem) => {
|
||||
try {
|
||||
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${item.annotationId}`)
|
||||
const ann = await api.get<AnnotationListItem>(endpoints.annotations.datasetItem(item.annotationId))
|
||||
setEditorAnnotation(ann)
|
||||
setEditorDetections(ann.detections)
|
||||
setTab('editor')
|
||||
@@ -61,7 +61,7 @@ export default function DatasetPage() {
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
await api.post('/api/annotations/dataset/bulk-status', {
|
||||
await api.post(endpoints.annotations.datasetBulkStatus(), {
|
||||
annotationIds: Array.from(selectedIds),
|
||||
status: AnnotationStatus.Validated,
|
||||
})
|
||||
@@ -71,7 +71,7 @@ export default function DatasetPage() {
|
||||
|
||||
const loadDistribution = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<ClassDistributionItem[]>('/api/annotations/dataset/class-distribution')
|
||||
const data = await api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
|
||||
setDistribution(data)
|
||||
} catch {}
|
||||
}, [])
|
||||
@@ -180,7 +180,7 @@ export default function DatasetPage() {
|
||||
} ${item.isSeed ? 'ring-2 ring-az-red' : ''}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/annotations/annotations/${item.annotationId}/thumbnail`}
|
||||
src={endpoints.annotations.annotationThumbnail(item.annotationId)}
|
||||
alt={item.imageName}
|
||||
className="w-full h-32 object-cover bg-az-bg"
|
||||
loading="lazy"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import L from 'leaflet'
|
||||
import { useFlight, ConfirmDialog } from '../../components'
|
||||
import { api, createSSE } from '../../api'
|
||||
import { api, createSSE, endpoints } from '../../api'
|
||||
import FlightListSidebar from './FlightListSidebar'
|
||||
import FlightParamsPanel from './FlightParamsPanel'
|
||||
import FlightMap from './FlightMap'
|
||||
@@ -38,7 +38,7 @@ export default function FlightsPage() {
|
||||
const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' })
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||
setAircraft(getMockAircraftParams())
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setCurrentPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||
@@ -48,7 +48,7 @@ export default function FlightsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFlight) { setPoints([]); return }
|
||||
api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`)
|
||||
api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id))
|
||||
.then(wps => {
|
||||
setPoints(wps.sort((a, b) => a.order - b.order).map(wp => ({
|
||||
id: wp.id,
|
||||
@@ -62,7 +62,7 @@ export default function FlightsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFlight || mode !== 'gps') return
|
||||
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(`/api/flights/${selectedFlight.id}/live-gps`, (data) => setLiveGps(data))
|
||||
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(endpoints.flights.flightLiveGps(selectedFlight.id), (data) => setLiveGps(data))
|
||||
}, [selectedFlight, mode])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -71,12 +71,12 @@ export default function FlightsPage() {
|
||||
}, [points, aircraft, initialAltitude])
|
||||
|
||||
const handleCreateFlight = async (name: string) => {
|
||||
await api.post('/api/flights', { name })
|
||||
await api.post(endpoints.flights.collection(), { name })
|
||||
refreshFlights()
|
||||
}
|
||||
const handleDeleteFlight = async () => {
|
||||
if (!deleteId) return
|
||||
await api.delete(`/api/flights/${deleteId}`)
|
||||
await api.delete(endpoints.flights.flight(deleteId))
|
||||
if (selectedFlight?.id === deleteId) selectFlight(null)
|
||||
setDeleteId(null)
|
||||
refreshFlights()
|
||||
@@ -199,12 +199,12 @@ export default function FlightsPage() {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedFlight) return
|
||||
const existing = await api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`).catch(() => [] as Waypoint[])
|
||||
const existing = await api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id)).catch(() => [] as Waypoint[])
|
||||
for (const wp of existing) {
|
||||
await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wp.id}`).catch(() => {})
|
||||
await api.delete(endpoints.flights.flightWaypoint(selectedFlight.id, wp.id)).catch(() => {})
|
||||
}
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
await api.post(`/api/flights/${selectedFlight.id}/waypoints`, {
|
||||
await api.post(endpoints.flights.flightWaypoints(selectedFlight.id), {
|
||||
name: `Point ${i + 1}`,
|
||||
latitude: points[i].position.lat,
|
||||
longitude: points[i].position.lng,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api'
|
||||
import { api, endpoints } from '../../api'
|
||||
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -11,27 +11,27 @@ export default function SettingsPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<SystemSettings>('/api/annotations/settings/system').then(setSystem).catch(() => {})
|
||||
api.get<DirectorySettings>('/api/annotations/settings/directories').then(setDirs).catch(() => {})
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {})
|
||||
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {})
|
||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const saveSystem = async () => {
|
||||
if (!system) return
|
||||
setSaving(true)
|
||||
await api.put('/api/annotations/settings/system', system)
|
||||
await api.put(endpoints.annotations.settingsSystem(), system)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const saveDirs = async () => {
|
||||
if (!dirs) return
|
||||
setSaving(true)
|
||||
await api.put('/api/annotations/settings/directories', dirs)
|
||||
await api.put(endpoints.annotations.settingsDirectories(), dirs)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleToggleDefault = async (a: Aircraft) => {
|
||||
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
|
||||
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user