mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 14:51:11 +00:00
Merge branch 'dev' into feat/dataset-explorer
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, type KeyboardEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { ConfirmDialog } from '../../components'
|
||||
import type { DetectionClass, Aircraft, User } from '../../types'
|
||||
|
||||
type EditForm = { name: string; shortName: string; color: string; maxSizeM: number }
|
||||
type EditErrorKind = 'nameRequired' | 'maxSizeMustBePositive' | 'updateFailed'
|
||||
|
||||
export default function AdminPage() {
|
||||
const { t } = useTranslation()
|
||||
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||
@@ -12,43 +15,87 @@ export default function AdminPage() {
|
||||
const [newClass, setNewClass] = useState({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'Annotator' })
|
||||
const [deactivateId, setDeactivateId] = useState<string | null>(null)
|
||||
// AZ-512 — inline edit state. Single `editingId` (not per-row) so opening
|
||||
// one row's editor implicitly closes any other (Risk 3 mitigation).
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editForm, setEditForm] = useState<EditForm>({ name: '', shortName: '', color: '#FF0000', maxSizeM: 0 })
|
||||
const [editError, setEditError] = useState<EditErrorKind | null>(null)
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
|
||||
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 handleStartEdit = (c: DetectionClass) => {
|
||||
setEditingId(c.id)
|
||||
setEditForm({ name: c.name, shortName: c.shortName, color: c.color, maxSizeM: c.maxSizeM })
|
||||
setEditError(null)
|
||||
setEditSaving(false)
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null)
|
||||
setEditError(null)
|
||||
setEditSaving(false)
|
||||
}
|
||||
|
||||
const handleUpdateClass = async () => {
|
||||
if (editingId === null || editSaving) return
|
||||
if (!editForm.name.trim()) { setEditError('nameRequired'); return }
|
||||
if (!(editForm.maxSizeM > 0)) { setEditError('maxSizeMustBePositive'); return }
|
||||
setEditError(null)
|
||||
setEditSaving(true)
|
||||
try {
|
||||
// Risk 2 mitigation — always send the complete form so backend PATCH
|
||||
// semantics (full-replace vs partial-merge) don't matter.
|
||||
await api.patch(endpoints.admin.class(editingId), editForm)
|
||||
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||
setClasses(updated)
|
||||
setEditingId(null)
|
||||
} catch {
|
||||
setEditError('updateFailed')
|
||||
} finally {
|
||||
setEditSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); void handleUpdateClass() }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); handleCancelEdit() }
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -56,7 +103,7 @@ export default function AdminPage() {
|
||||
<div className="flex h-full overflow-y-auto p-4 gap-4">
|
||||
{/* Detection classes */}
|
||||
<div className="w-[340px] shrink-0">
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes')}</h2>
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes.title')}</h2>
|
||||
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
@@ -68,12 +115,75 @@ export default function AdminPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classes.map(c => (
|
||||
{classes.map(c => c.id === editingId ? (
|
||||
<tr key={c.id} className="border-b border-az-border text-az-text bg-az-bg/40" data-editing-row={c.id}>
|
||||
<td className="px-2 py-1 align-top">{c.id}</td>
|
||||
<td colSpan={3} className="px-2 py-1">
|
||||
<div className="flex flex-wrap gap-1 items-center" onKeyDown={handleEditKeyDown}>
|
||||
<input
|
||||
autoFocus
|
||||
data-field="name"
|
||||
value={editForm.name}
|
||||
onChange={e => setEditForm(p => ({ ...p, name: e.target.value }))}
|
||||
className="flex-1 min-w-[80px] bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
||||
/>
|
||||
<input
|
||||
data-field="shortName"
|
||||
value={editForm.shortName}
|
||||
onChange={e => setEditForm(p => ({ ...p, shortName: e.target.value }))}
|
||||
className="w-12 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
data-field="color"
|
||||
value={editForm.color}
|
||||
onChange={e => setEditForm(p => ({ ...p, color: e.target.value }))}
|
||||
className="w-7 h-6 border-0 bg-transparent cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
data-field="maxSizeM"
|
||||
value={editForm.maxSizeM}
|
||||
onChange={e => setEditForm(p => ({ ...p, maxSizeM: Number(e.target.value) }))}
|
||||
className="w-14 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void handleUpdateClass()}
|
||||
disabled={editSaving}
|
||||
className="bg-az-orange text-white px-2 py-0.5 rounded disabled:opacity-50"
|
||||
>
|
||||
{t('admin.classes.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
disabled={editSaving}
|
||||
className="bg-az-bg border border-az-border text-az-text px-2 py-0.5 rounded disabled:opacity-50"
|
||||
>
|
||||
{t('admin.classes.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{editError && (
|
||||
<div role="alert" className="mt-1 text-az-red">
|
||||
{t(`admin.classes.${editError}`)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={c.id} className="border-b border-az-border text-az-text">
|
||||
<td className="px-2 py-1">{c.id}</td>
|
||||
<td className="px-2 py-1">{c.name}</td>
|
||||
<td className="px-2 py-1 text-center"><span className="inline-block w-3 h-3 rounded-full" style={{ backgroundColor: c.color }} /></td>
|
||||
<td className="px-2 py-1"><button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button></td>
|
||||
<td className="px-2 py-1 text-right whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleStartEdit(c)}
|
||||
aria-label={t('admin.classes.edit')}
|
||||
className="text-az-muted hover:text-az-orange mr-1"
|
||||
>
|
||||
{'\u270E'}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AdminPage } from './AdminPage'
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
||||
import { api } from '../../api/client'
|
||||
import { useResizablePanel } from '../../hooks'
|
||||
import { api, endpoints } from '../../api'
|
||||
import MediaList from './MediaList'
|
||||
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
||||
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
||||
import AnnotationsSidebar from './AnnotationsSidebar'
|
||||
import DetectionClasses from '../../components/DetectionClasses'
|
||||
import { DetectionClasses, useFlight } from '../../components'
|
||||
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
||||
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors'
|
||||
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
|
||||
import { captureThumbnails } from './thumbnail'
|
||||
import type { Media, AnnotationListItem, Detection } from '../../types'
|
||||
|
||||
@@ -65,9 +64,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)
|
||||
pushToStore(`saved-${crypto.randomUUID()}`)
|
||||
@@ -127,7 +126,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,9 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FaDownload } from 'react-icons/fa'
|
||||
import { api } from '../../api/client'
|
||||
import { createSSE } from '../../api/sse'
|
||||
import { getClassColor } from './classColors'
|
||||
import { api, createSSE, endpoints } from '../../api'
|
||||
import { getClassColor } from '../../class-colors'
|
||||
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
||||
|
||||
interface Props {
|
||||
@@ -22,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(() => {})
|
||||
}
|
||||
})
|
||||
@@ -36,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,7 +1,8 @@
|
||||
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'
|
||||
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from '../../class-colors'
|
||||
|
||||
interface Props {
|
||||
media: Media
|
||||
@@ -77,11 +78,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
img.crossOrigin = 'anonymous'
|
||||
const isLocalPath = media.path.startsWith('blob:') || media.path.startsWith('data:')
|
||||
if (annotation && !isLocalPath) {
|
||||
img.src = `/api/annotations/annotations/${annotation.id}/image`
|
||||
img.src = endpoints.annotations.annotationImage(annotation.id)
|
||||
} else if (isLocalPath) {
|
||||
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
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import { api } from '../../api/client'
|
||||
import { useDebounce } from '../../hooks/useDebounce'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import { useFlight, ConfirmDialog } from '../../components'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { useDebounce } from '../../hooks'
|
||||
import { MediaType } from '../../types'
|
||||
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
|
||||
|
||||
@@ -28,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:'))
|
||||
@@ -56,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 {
|
||||
@@ -73,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()
|
||||
}
|
||||
@@ -88,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,24 +0,0 @@
|
||||
const CLASS_COLORS = [
|
||||
'#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
|
||||
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080',
|
||||
]
|
||||
|
||||
export const FALLBACK_CLASS_NAMES = [
|
||||
'Car', 'Person', 'Truck', 'Bicycle', 'Motorcycle', 'Bus',
|
||||
'Animal', 'Tree', 'Building', 'Sign', 'Boat', 'Plane',
|
||||
]
|
||||
|
||||
export function getClassColor(classNum: number): string {
|
||||
const base = classNum % 20
|
||||
return CLASS_COLORS[base % CLASS_COLORS.length]
|
||||
}
|
||||
|
||||
export function getPhotoModeSuffix(classNum: number): string {
|
||||
const mode = Math.floor(classNum / 20)
|
||||
return mode === 1 ? ' (winter)' : mode === 2 ? ' (night)' : ''
|
||||
}
|
||||
|
||||
export function getClassNameFallback(classNum: number): string {
|
||||
const base = classNum % 20
|
||||
return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}`
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as AnnotationsPage } from './AnnotationsPage'
|
||||
// CanvasEditor remains in the Public API while F2 (cross-feature edge to
|
||||
// 07_dataset) is open. Closing F2 will remove this re-export.
|
||||
export { default as CanvasEditor } from './CanvasEditor'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MediaType } from '../../types'
|
||||
import type { Detection, Media } from '../../types'
|
||||
import { getClassColor } from './classColors'
|
||||
import { getClassColor } from '../../class-colors'
|
||||
|
||||
const THUMB_MAX = 240
|
||||
const CROP_PAD = 0.15
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FaPen } from 'react-icons/fa'
|
||||
import { api } from '../../api/client'
|
||||
import { useDebounce } from '../../hooks/useDebounce'
|
||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { useDebounce, useResizablePanel } from '../../hooks'
|
||||
import { useFlight, DetectionClasses } from '../../components'
|
||||
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||
import DetectionClasses from '../../components/DetectionClasses'
|
||||
import CanvasEditor from '../annotations/CanvasEditor'
|
||||
import { recaptureThumbnails } from '../annotations/thumbnail'
|
||||
import type { SavedDetection } from '../../components/SavedAnnotationsContext'
|
||||
@@ -64,7 +62,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 {}
|
||||
@@ -103,7 +101,7 @@ export default function DatasetPage() {
|
||||
imageName: item.imageName,
|
||||
status: item.status,
|
||||
createdDate: item.createdDate,
|
||||
thumbnailUrl: `/api/annotations/annotations/${item.annotationId}/thumbnail`,
|
||||
thumbnailUrl: endpoints.annotations.annotationThumbnail(item.annotationId),
|
||||
isSeed: item.isSeed,
|
||||
isLocal: false,
|
||||
}))
|
||||
@@ -138,7 +136,7 @@ export default function DatasetPage() {
|
||||
setEditorFullFrame('')
|
||||
setEditorLocalGroupId(null)
|
||||
try {
|
||||
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${card.annotationId}`)
|
||||
const ann = await api.get<AnnotationListItem>(endpoints.annotations.datasetItem(card.annotationId))
|
||||
setEditorAnnotation(ann)
|
||||
setEditorDetections(ann.detections)
|
||||
setTab('editor')
|
||||
@@ -188,7 +186,7 @@ export default function DatasetPage() {
|
||||
|
||||
if (backendIds.length > 0) {
|
||||
try {
|
||||
await api.post('/api/annotations/dataset/bulk-status', {
|
||||
await api.post(endpoints.annotations.datasetBulkStatus(), {
|
||||
annotationIds: backendIds,
|
||||
status: AnnotationStatus.Validated,
|
||||
})
|
||||
@@ -203,7 +201,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 {}
|
||||
}, [])
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatasetPage } from './DatasetPage'
|
||||
@@ -8,7 +8,7 @@ import DrawControl from './DrawControl'
|
||||
import MapPoint from './MapPoint'
|
||||
import MiniMap from './MiniMap'
|
||||
import { defaultIcon } from './mapIcons'
|
||||
import { TILE_URLS } from './types'
|
||||
import { getTileUrl } from './types'
|
||||
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types'
|
||||
|
||||
interface MapEventsProps {
|
||||
@@ -86,7 +86,6 @@ export default function FlightMap({
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [mapType, setMapType] = useState<'classic' | 'satellite'>('satellite')
|
||||
const [movingPoint, setMovingPoint] = useState<MovingPointInfo | null>(null)
|
||||
const [draggablePoints, setDraggablePoints] = useState(points)
|
||||
const polylineClickRef = useRef(false)
|
||||
@@ -123,13 +122,14 @@ export default function FlightMap({
|
||||
<MapContainer center={currentPosition} zoom={15} className="h-full w-full">
|
||||
<ClickHandler />
|
||||
<TileLayer
|
||||
url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite}
|
||||
attribution={mapType === 'classic' ? '© <a href="https://www.openstreetmap.org/copyright">OSM</a>' : 'Satellite'}
|
||||
url={getTileUrl()}
|
||||
crossOrigin="use-credentials"
|
||||
attribution="Satellite"
|
||||
/>
|
||||
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
|
||||
<SetView center={currentPosition} />
|
||||
|
||||
{movingPoint && <MiniMap pointPosition={movingPoint} mapType={mapType} />}
|
||||
{movingPoint && <MiniMap pointPosition={movingPoint} />}
|
||||
|
||||
{draggablePoints.map((point, index) => (
|
||||
<MapPoint key={point.id}
|
||||
@@ -171,13 +171,6 @@ export default function FlightMap({
|
||||
Click and drag on the map to draw a {actionMode === 'workArea' ? 'work area' : 'no-go zone'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => setMapType(m => m === 'classic' ? 'satellite' : 'classic')}
|
||||
className={`absolute top-2 right-2 z-[400] px-2 py-1 text-xs rounded border ${
|
||||
mapType === 'satellite' ? 'bg-az-panel border-az-orange text-white' : 'bg-az-panel border-az-border text-az-text'
|
||||
}`}>
|
||||
{t('flights.planner.satellite')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import L from 'leaflet'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import { api } from '../../api/client'
|
||||
import { createSSE } from '../../api/sse'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import { useFlight, ConfirmDialog } from '../../components'
|
||||
import { api, createSSE, endpoints } from '../../api'
|
||||
import FlightListSidebar from './FlightListSidebar'
|
||||
import FlightParamsPanel from './FlightParamsPanel'
|
||||
import FlightMap from './FlightMap'
|
||||
@@ -40,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 }),
|
||||
@@ -50,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,
|
||||
@@ -64,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(() => {
|
||||
@@ -73,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()
|
||||
@@ -201,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,7 +1,7 @@
|
||||
import { MapContainer, TileLayer, CircleMarker, useMap } from 'react-leaflet'
|
||||
import { useEffect } from 'react'
|
||||
import type L from 'leaflet'
|
||||
import { TILE_URLS } from './types'
|
||||
import { getTileUrl } from './types'
|
||||
import type { MovingPointInfo } from './types'
|
||||
|
||||
function UpdateCenter({ latlng }: { latlng: L.LatLng }) {
|
||||
@@ -12,10 +12,9 @@ function UpdateCenter({ latlng }: { latlng: L.LatLng }) {
|
||||
|
||||
interface Props {
|
||||
pointPosition: MovingPointInfo
|
||||
mapType: 'classic' | 'satellite'
|
||||
}
|
||||
|
||||
export default function MiniMap({ pointPosition, mapType }: Props) {
|
||||
export default function MiniMap({ pointPosition }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="absolute w-[240px] h-[180px] border border-az-border rounded shadow-lg z-[1000] overflow-hidden pointer-events-none"
|
||||
@@ -23,7 +22,7 @@ export default function MiniMap({ pointPosition, mapType }: Props) {
|
||||
>
|
||||
<MapContainer center={pointPosition.latlng} zoom={18} zoomControl={false}
|
||||
className="w-full h-full" attributionControl={false}>
|
||||
<TileLayer url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite} />
|
||||
<TileLayer url={getTileUrl()} crossOrigin="use-credentials" />
|
||||
<CircleMarker center={pointPosition.latlng} radius={3} color="#fa5252" />
|
||||
<UpdateCenter latlng={pointPosition.latlng} />
|
||||
</MapContainer>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import type L from 'leaflet'
|
||||
import { renderWithProviders, screen } from '../../../../tests/helpers/render'
|
||||
|
||||
// AZ-498 — self-hosted satellite tiles + drop classic/satellite toggle.
|
||||
//
|
||||
// Colocated under src/features/flights/__tests__/ per module-layout's "Tests"
|
||||
// guidance: keeps the cross-component import surface clean (these tests
|
||||
// reach into 05_flights internals — `./FlightMap`, `./MiniMap`, `./types` —
|
||||
// which is intra-component access). Tests/ is reserved for cross-cutting
|
||||
// black-box suites whose imports must go through public-API barrels.
|
||||
//
|
||||
// Covers the spec's fast-profile ACs:
|
||||
// AC-1 — env-resolved getTileUrl() returns the env var verbatim.
|
||||
// AC-2 — when the env var is unset, getTileUrl() returns the dev default
|
||||
// `http://localhost:5100/tiles/{z}/{x}/{y}` (cycle-2 assumption).
|
||||
// AC-3 — every <TileLayer> the SPA renders sets crossOrigin="use-credentials"
|
||||
// so the browser attaches the satellite-provider auth cookie.
|
||||
// AC-4 — the classic/satellite toggle, the `mapType` state, and the
|
||||
// `MiniMap.Props.mapType` prop are all gone.
|
||||
//
|
||||
// Notes:
|
||||
// - AC-5 is statically enforced by tsc on the new ImportMetaEnv shape +
|
||||
// the `.env.example` audit; no runtime test needed.
|
||||
// - AC-6, AC-7 are e2e/contract; AC-8 in the original spec misattributed
|
||||
// `tile_split_zoom*` (image-annotation surface) — see implementation
|
||||
// report. AC-9 is enforced by STC-ARCH-01 / STC-ARCH-02.
|
||||
|
||||
interface TileLayerProps {
|
||||
url?: string
|
||||
crossOrigin?: string
|
||||
attribution?: string
|
||||
}
|
||||
|
||||
interface MapContainerProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children, className }: MapContainerProps) => (
|
||||
<div data-testid="map-container" className={className}>{children}</div>
|
||||
),
|
||||
TileLayer: (props: TileLayerProps) => (
|
||||
<img
|
||||
data-testid="tile-layer"
|
||||
data-tile-url={props.url ?? ''}
|
||||
data-cross-origin={props.crossOrigin ?? ''}
|
||||
data-attribution={props.attribution ?? ''}
|
||||
alt=""
|
||||
/>
|
||||
),
|
||||
Marker: () => null,
|
||||
Popup: () => null,
|
||||
Polyline: () => null,
|
||||
Rectangle: () => null,
|
||||
CircleMarker: () => null,
|
||||
useMap: () => ({
|
||||
on: () => undefined,
|
||||
off: () => undefined,
|
||||
setView: () => undefined,
|
||||
removeLayer: () => undefined,
|
||||
getCenter: () => ({ lat: 0, lng: 0 }),
|
||||
invalidateSize: () => undefined,
|
||||
}),
|
||||
useMapEvents: () => null,
|
||||
}))
|
||||
|
||||
// Leaflet itself is touched at import time by FlightMap (`L.polyline`,
|
||||
// `L.Symbol.arrowHead`). Mock the bits the component reaches for so the
|
||||
// import doesn't blow up under jsdom.
|
||||
vi.mock('leaflet', () => {
|
||||
const Lstub = {
|
||||
polyline: () => ({ addTo: () => Lstub.polyline(), on: () => undefined }),
|
||||
polylineDecorator: () => ({ addTo: () => undefined }),
|
||||
Symbol: { arrowHead: () => ({}) },
|
||||
Icon: { Default: class { mergeOptions() {} } },
|
||||
Marker: class {},
|
||||
Layer: class {},
|
||||
LatLngBounds: class {},
|
||||
}
|
||||
return { default: Lstub }
|
||||
})
|
||||
vi.mock('leaflet/dist/leaflet.css', () => ({}))
|
||||
vi.mock('leaflet-polylinedecorator', () => ({}))
|
||||
vi.mock('../DrawControl', () => ({ default: () => null }))
|
||||
vi.mock('../MapPoint', () => ({ default: () => null }))
|
||||
vi.mock('../mapIcons', () => ({ defaultIcon: {} }))
|
||||
|
||||
import FlightMap from '../FlightMap'
|
||||
import MiniMap from '../MiniMap'
|
||||
import { getTileUrl, DEFAULT_SATELLITE_TILE_URL } from '../types'
|
||||
|
||||
const stubLatLng = { lat: 0, lng: 0 } as unknown as L.LatLng
|
||||
const fixedPosition = { lat: 50, lng: 30 }
|
||||
|
||||
const baseFlightMapProps = {
|
||||
points: [],
|
||||
calculatedPointInfo: [],
|
||||
currentPosition: fixedPosition,
|
||||
rectangles: [],
|
||||
setRectangles: () => undefined,
|
||||
rectangleColor: 'red',
|
||||
actionMode: 'points' as const,
|
||||
onAddPoint: () => undefined,
|
||||
onUpdatePoint: () => undefined,
|
||||
onRemovePoint: () => undefined,
|
||||
onAltitudeChange: () => undefined,
|
||||
onMetaChange: () => undefined,
|
||||
onPolylineClick: () => undefined,
|
||||
onPositionChange: () => undefined,
|
||||
onMapMove: () => undefined,
|
||||
}
|
||||
|
||||
describe('AZ-498 — getTileUrl() env resolution', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('AC-1: returns the env-set VITE_SATELLITE_TILE_URL verbatim', () => {
|
||||
// Arrange
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', 'http://satellite-provider:5100/tiles/{z}/{x}/{y}')
|
||||
|
||||
// Assert
|
||||
expect(getTileUrl()).toBe('http://satellite-provider:5100/tiles/{z}/{x}/{y}')
|
||||
})
|
||||
|
||||
it('AC-2: returns the dev default when VITE_SATELLITE_TILE_URL is unset', () => {
|
||||
// Arrange
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
|
||||
|
||||
// Assert
|
||||
expect(getTileUrl()).toBe(DEFAULT_SATELLITE_TILE_URL)
|
||||
expect(DEFAULT_SATELLITE_TILE_URL).toBe('http://localhost:5100/tiles/{z}/{x}/{y}')
|
||||
})
|
||||
|
||||
it('AC-2: strips trailing slashes off the env-set URL', () => {
|
||||
// Arrange
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', 'http://satellite-provider:5100/tiles/{z}/{x}/{y}/')
|
||||
|
||||
// Assert
|
||||
expect(getTileUrl()).toBe('http://satellite-provider:5100/tiles/{z}/{x}/{y}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AZ-498 — FlightMap satellite-only TileLayer', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('AC-3: <TileLayer> declares crossOrigin="use-credentials"', () => {
|
||||
// Act
|
||||
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
|
||||
|
||||
// Assert
|
||||
const tile = screen.getByTestId('tile-layer')
|
||||
expect(tile.getAttribute('data-cross-origin')).toBe('use-credentials')
|
||||
})
|
||||
|
||||
it('AC-3: <TileLayer> renders the dev-default URL when env is unset', () => {
|
||||
// Act
|
||||
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
|
||||
|
||||
// Assert
|
||||
const tile = screen.getByTestId('tile-layer')
|
||||
expect(tile.getAttribute('data-tile-url')).toBe(DEFAULT_SATELLITE_TILE_URL)
|
||||
})
|
||||
|
||||
it('AC-4: the classic/satellite toggle button is gone', () => {
|
||||
// Act
|
||||
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('button', { name: /satellite|classic/i })).toBeNull()
|
||||
// Only one <TileLayer> is mounted (no per-mode branching).
|
||||
expect(screen.getAllByTestId('tile-layer')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AZ-498 — MiniMap satellite-only TileLayer', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('AC-3: MiniMap <TileLayer> declares crossOrigin="use-credentials"', () => {
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<MiniMap pointPosition={{ x: 0, y: 0, latlng: stubLatLng }} />,
|
||||
{ withoutAuth: true },
|
||||
)
|
||||
|
||||
// Assert
|
||||
const tile = screen.getByTestId('tile-layer')
|
||||
expect(tile.getAttribute('data-cross-origin')).toBe('use-credentials')
|
||||
})
|
||||
|
||||
it('AC-4: MiniMap mounts with only `pointPosition` prop (no `mapType`)', () => {
|
||||
// Act — explicitly omit mapType; if MiniMap still required it, TS would
|
||||
// error at compile time. The runtime render also confirms the component
|
||||
// mounts with just the position prop.
|
||||
renderWithProviders(
|
||||
<MiniMap pointPosition={{ x: 0, y: 0, latlng: stubLatLng }} />,
|
||||
{ withoutAuth: true },
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('tile-layer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -56,9 +56,18 @@ export function calculateDistance(
|
||||
return ascentVertical + horizontalDistance + descentVertical
|
||||
}
|
||||
|
||||
const DEFAULT_OWM_BASE_URL = 'https://api.openweathermap.org/data/2.5'
|
||||
|
||||
function getOwmBaseUrl(): string {
|
||||
const raw = import.meta.env.VITE_OWM_BASE_URL
|
||||
if (!raw) return DEFAULT_OWM_BASE_URL
|
||||
return raw.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export async function getWeatherData(lat: number, lon: number) {
|
||||
const apiKey = '335799082893fad97fa36118b131f919'
|
||||
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`
|
||||
const apiKey = import.meta.env.VITE_OWM_API_KEY
|
||||
if (!apiKey) return null
|
||||
const url = `${getOwmBaseUrl()}/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as FlightsPage } from './FlightsPage'
|
||||
@@ -1,4 +1,5 @@
|
||||
import L from 'leaflet'
|
||||
import markerIcon from 'leaflet/dist/images/marker-icon.png'
|
||||
|
||||
function pinIcon(color: string) {
|
||||
return L.divIcon({
|
||||
@@ -15,7 +16,7 @@ export const pointIconBlue = pinIcon('#228be6')
|
||||
export const pointIconRed = pinIcon('#fa5252')
|
||||
|
||||
export const defaultIcon = new L.Icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png',
|
||||
iconUrl: markerIcon,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
|
||||
@@ -52,7 +52,18 @@ export const PURPOSES = [
|
||||
|
||||
export const COORDINATE_PRECISION = 8
|
||||
|
||||
export const TILE_URLS = {
|
||||
classic: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
} as const
|
||||
// AZ-498 — single self-hosted satellite tile URL. The previous classic/satellite
|
||||
// pair (OSM + Esri) was retired so the SPA only consumes the suite's own
|
||||
// satellite-provider service. Production builds MUST set VITE_SATELLITE_TILE_URL
|
||||
// to the same-origin nginx path (e.g. `/tiles/{z}/{x}/{y}`); the dev default
|
||||
// targets the satellite-provider container on its conventional dev port.
|
||||
//
|
||||
// Read via a function (mirrors `getOwmBaseUrl` in flightPlanUtils.ts) so tests
|
||||
// can stub `import.meta.env` per-case without module-reload tricks.
|
||||
export const DEFAULT_SATELLITE_TILE_URL = 'http://localhost:5100/tiles/{z}/{x}/{y}'
|
||||
|
||||
export function getTileUrl(): string {
|
||||
const raw = import.meta.env.VITE_SATELLITE_TILE_URL
|
||||
if (!raw) return DEFAULT_SATELLITE_TILE_URL
|
||||
return raw.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../../auth/AuthContext'
|
||||
import { useAuth } from '../../auth'
|
||||
|
||||
type UnlockStep = 'idle' | 'authenticating' | 'downloadingKey' | 'decrypting' | 'startingServices' | 'ready'
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as LoginPage } from './LoginPage'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SettingsPage } from './SettingsPage'
|
||||
Reference in New Issue
Block a user