From a1a4b88a0d460b3264b977d18bf63f6e4f3fac77 Mon Sep 17 00:00:00 2001 From: noobydp Date: Sat, 11 Jul 2026 14:56:13 +0800 Subject: [PATCH] Add photo station management UI --- frontend/src/app/layout.tsx | 2 +- frontend/src/app/page.tsx | 249 +++++++++++++++++- .../src/app/stations/[id]/corners/page.tsx | 184 +++++++++++++ frontend/src/lib/api.ts | 37 ++- frontend/src/types/index.ts | 61 ++++- 5 files changed, 520 insertions(+), 13 deletions(-) create mode 100644 frontend/src/app/stations/[id]/corners/page.tsx diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 35a532e6..2bec1b97 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -21,7 +21,7 @@ export default function RootLayout({ const [showHelp, setShowHelp] = useState(false) const pathname = usePathname() - const isFullBleed = /^\/(trace|tools|bins)\//.test(pathname) + const isFullBleed = /^\/(trace|tools|bins|stations)\//.test(pathname) return ( diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index c43819ba..d646437c 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation' import { ImageUploader } from '@/components/ImageUploader' import { ConfirmModal } from '@/components/ConfirmModal' import { SectionHeader } from '@/components/SectionHeader' -import { uploadImage, listTools, listBins, listProjects, deleteTool, deleteBin, deleteProject, createBin, createProject, getImageUrl } from '@/lib/api' -import type { ToolSummary, BinSummary, BinPreviewTool, BinProjectSummary, Point, ToolImageContext, AffineMatrix, ProjectStatus } from '@/types' +import { uploadImage, listTools, listBins, listProjects, listPhotoStations, deleteTool, deleteBin, deleteProject, deletePhotoStation, updatePhotoStation, createBin, createProject, getImageUrl } from '@/lib/api' +import type { ToolSummary, BinSummary, BinPreviewTool, BinProjectSummary, PhotoStation, Point, ToolImageContext, AffineMatrix, ProjectStatus } from '@/types' import { polygonPathData } from '@/lib/svg' -import { Trash2, Package, Plus, Loader2, Grid3X3, Folder } from 'lucide-react' +import { Check, Pencil, Trash2, Package, Plus, Loader2, Grid3X3, Folder, X } from 'lucide-react' import { Alert } from '@/components/Alert' import { PhotoIllustration, CornersIllustration, TraceIllustration, OrganiseIllustration } from '@/components/OnboardingIllustrations' import { GRID_UNIT } from '@/lib/constants' @@ -147,6 +147,70 @@ function BinPreview({ gridX, gridY, tools }: { gridX: number; gridY: number; too ) } +function StationPaperPreview({ + station, + corners, +}: { + station: PhotoStation + corners: Point[] +}) { + const displayCorners = corners.length === 4 ? corners : station.corners + const imageWidth = Math.max(1, station.image_width) + const imageHeight = Math.max(1, station.image_height) + const imagePath = station.image_path + const imageUrl = imagePath ? getImageUrl(`/storage/${imagePath}`) : null + const handleRadius = Math.max(imageWidth, imageHeight) * 0.012 + + return ( + + + + + + + + {imageUrl ? ( + + ) : ( + + )} + + {displayCorners.length === 4 && ( + `${p.x},${p.y}`).join(' ')} + fill="rgba(255,255,255,0.08)" + stroke="rgb(90, 180, 222)" + strokeWidth={Math.max(imageWidth, imageHeight) * 0.004} + /> + )} + {displayCorners.map((corner, index) => ( + + + + ))} + + ) +} + function NameModal({ open, title = 'New bin', description = 'Give your bin a name.', placeholder = 'e.g. Screwdrivers tray', onConfirm, onCancel }: { open: boolean title?: string @@ -210,13 +274,14 @@ function NameModal({ open, title = 'New bin', description = 'Give your bin a nam } const SECTION_COLLAPSE_KEY = 'tracefinity.home.collapsedSections' -type MainSectionId = 'projects' | 'tools' | 'bins' | 'howItWorks' +type MainSectionId = 'projects' | 'tools' | 'bins' | 'stations' | 'howItWorks' type MainSectionCollapseState = Record const defaultSectionCollapse: MainSectionCollapseState = { projects: false, tools: false, bins: false, + stations: false, howItWorks: false, } @@ -238,18 +303,21 @@ export default function HomePage() { const [toolsList, setToolsList] = useState([]) const [binsList, setBinsList] = useState([]) const [projectsList, setProjectsList] = useState([]) + const [stationsList, setStationsList] = useState([]) const [loading, setLoading] = useState(true) - const { deleteTarget: deleteModal, requestDelete, clearDelete } = useDeleteConfirmation<{ type: 'tool' | 'bin' | 'project'; id: string }>() + const { deleteTarget: deleteModal, requestDelete, clearDelete } = useDeleteConfirmation<{ type: 'tool' | 'bin' | 'project' | 'station'; id: string }>() const [creatingBin, setCreatingBin] = useState(null) const [nameModal, setNameModal] = useState<{ toolIds?: string[] } | null>(null) const [projectModalOpen, setProjectModalOpen] = useState(false) + const [renamingStation, setRenamingStation] = useState<{ id: string; name: string } | null>(null) + const [savingStationNameId, setSavingStationNameId] = useState(null) const [projectSearch, setProjectSearch] = useState('') const [projectStatusFilter, setProjectStatusFilter] = useState('all') const [toolSearch, setToolSearch] = useState('') const [toolSort, setToolSort] = useState('date') const [collapsedSections, setCollapsedSections] = useState(loadSectionCollapseState) - const hasData = toolsList.length > 0 || binsList.length > 0 || projectsList.length > 0 + const hasData = toolsList.length > 0 || binsList.length > 0 || projectsList.length > 0 || stationsList.length > 0 const projectNameById = useMemo(() => projectNameMap(projectsList), [projectsList]) @@ -316,10 +384,11 @@ export default function HomePage() { async function loadData() { try { - const [t, b, p] = await Promise.all([listTools(), listBins(), listProjects()]) + const [t, b, p, stations] = await Promise.all([listTools(), listBins(), listProjects(), listPhotoStations()]) setToolsList(t) setBinsList(b) setProjectsList(p) + setStationsList(stations) } catch { // ignore } finally { @@ -369,6 +438,36 @@ export default function HomePage() { clearDelete() } + async function handleDeleteStation(id: string) { + try { + await deletePhotoStation(id) + setStationsList(prev => prev.filter(s => s.id !== id)) + setRenamingStation(prev => prev?.id === id ? null : prev) + } catch { /* ignore */ } + clearDelete() + } + + function startStationRename(station: PhotoStation) { + setRenamingStation({ id: station.id, name: station.name }) + } + + async function handleSaveStationName() { + if (!renamingStation) return + const name = renamingStation.name.trim() + if (!name) return + + setSavingStationNameId(renamingStation.id) + try { + const updated = await updatePhotoStation(renamingStation.id, { name }) + setStationsList(prev => prev.map(station => station.id === updated.id ? updated : station)) + setRenamingStation(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to rename station') + } finally { + setSavingStationNameId(null) + } + } + async function handleCreateBin(name: string, toolIds?: string[]) { const sourceToolId = toolIds?.[0] if (sourceToolId) setCreatingBin(sourceToolId) @@ -402,6 +501,13 @@ export default function HomePage() { }) } + function formatPaperSize(size: PhotoStation['paper_size']) { + if (size === 'a4') return 'A4' + if (size === 'a3') return 'A3' + if (size === 'tabloid') return 'Tabloid' + return 'Letter' + } + return (
{/* upload */} @@ -737,6 +843,126 @@ export default function HomePage() {
)} + {/* stations */} + {stationsList.length > 0 && ( +
+ setSectionCollapsed('stations', !collapsedSections.stations)} + /> + {!collapsedSections.stations && ( +
+ {stationsList.map(station => { + return ( +
+
+ +
+ + +
+
+
+
+ {renamingStation?.id === station.id ? ( +
+ setRenamingStation({ id: station.id, name: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveStationName() + if (e.key === 'Escape') setRenamingStation(null) + }} + className="min-w-0 flex-1 h-7 rounded border border-border-subtle bg-elevated px-2 text-[12px] text-text-primary focus:outline-none focus:border-accent" + autoFocus + /> + + +
+ ) : ( +
+

{station.name}

+ +
+ )} +

+ {station.image_width}x{station.image_height} · {formatPaperSize(station.paper_size)} +

+
+
+ + {station.last_used_at ? `Used ${formatDate(station.last_used_at)}` : `Saved ${formatDate(station.created_at)}`} + + {station.updated_at && ( + <> + · + Updated {formatDate(station.updated_at)} + + )} +
+ {!station.image_path && ( +

+ Photo unavailable. Edit uses a grid preview. +

+ )} +
+
+ ) + })} +
+ )} +
+ )} + {/* empty state onboarding */} {!loading && !hasData && (
@@ -775,14 +1001,18 @@ export default function HomePage() { ? 'Delete tool?' : deleteModal?.type === 'project' ? 'Delete project?' - : 'Delete bin?' + : deleteModal?.type === 'station' + ? 'Delete station?' + : 'Delete bin?' } message={ deleteModal?.type === 'tool' ? 'This will permanently delete the tool from your library.' : deleteModal?.type === 'project' ? 'This will remove the project. Tools and bins will stay in your library.' - : 'This will permanently delete the bin and all associated files.' + : deleteModal?.type === 'station' + ? 'This will remove the saved camera and paper alignment station.' + : 'This will permanently delete the bin and all associated files.' } confirmText="Delete" variant="danger" @@ -790,6 +1020,7 @@ export default function HomePage() { if (!deleteModal) return if (deleteModal.type === 'tool') handleDeleteTool(deleteModal.id) else if (deleteModal.type === 'project') handleDeleteProject(deleteModal.id) + else if (deleteModal.type === 'station') handleDeleteStation(deleteModal.id) else handleDeleteBin(deleteModal.id) }} onCancel={() => clearDelete()} diff --git a/frontend/src/app/stations/[id]/corners/page.tsx b/frontend/src/app/stations/[id]/corners/page.tsx new file mode 100644 index 00000000..751c1ea9 --- /dev/null +++ b/frontend/src/app/stations/[id]/corners/page.tsx @@ -0,0 +1,184 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { Loader2 } from 'lucide-react' +import { Alert } from '@/components/Alert' +import { CornersHint } from '@/components/OnboardingIllustrations' +import { PaperCornerEditor } from '@/components/PaperCornerEditor' +import { StepBar } from '@/components/StepBar' +import { getImageUrl, getPhotoStation, updatePhotoStation } from '@/lib/api' +import type { PaperSize, PhotoStation, Point } from '@/types' + +const STEPS = ['Stations', 'Corners'] +const PAPER_SIZE_OPTIONS: { value: PaperSize; label: string }[] = [ + { value: 'a4', label: 'A4' }, + { value: 'letter', label: 'Letter' }, + { value: 'a3', label: 'A3' }, + { value: 'tabloid', label: 'Tabloid' }, +] + +function fallbackStationImage(station: PhotoStation): string { + const width = Math.max(1, station.image_width) + const height = Math.max(1, station.image_height) + const gridW = width / 12 + const gridH = height / 12 + const stroke = Math.max(width, height) * 0.001 + const svg = ` + + + + + + + + + + ` + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` +} + +export default function StationCornersPage() { + const params = useParams() + const router = useRouter() + const stationId = params.id as string + const [station, setStation] = useState(null) + const [paperSize, setPaperSize] = useState('a4') + const [corners, setCorners] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + setLoading(true) + setError(null) + + getPhotoStation(stationId) + .then((found) => { + if (cancelled) return + setStation(found) + setPaperSize(found.paper_size) + setCorners(found.corners.map((point) => ({ ...point }))) + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : 'failed to load station') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { cancelled = true } + }, [stationId]) + + const imageUrl = useMemo(() => { + if (!station) return '' + const imagePath = station.image_path + return imagePath ? getImageUrl(`/storage/${imagePath}`) : fallbackStationImage(station) + }, [station]) + + async function handleSave() { + if (!station) return + setSaving(true) + setError(null) + try { + await updatePhotoStation(station.id, { + paper_size: paperSize, + corners, + }) + router.push('/') + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to update station') + } finally { + setSaving(false) + } + } + + return ( +
+ { + if (index === 0) router.push('/') + }} + /> + +
+
+
+
+

+ Adjust Corners +

+
+ +

+ Drag the corner handles to match the paper edges. +

+ +
+ Paper Size +
+ {PAPER_SIZE_OPTIONS.map((option) => ( + + ))} +
+
+ + {station && !station.image_path && ( +

+ Station photo unavailable; showing a grid. +

+ )} +
+
+ + {error && {error}} +
+ +
+ +
+
+ +
+ {loading ? ( +
+ +
+ ) : station && imageUrl ? ( + + ) : ( +
+ Station unavailable. +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8c80fdbf..dfa4693a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,6 +1,7 @@ import type { UploadResponse, CornersResponse, + PhotoStation, TraceResponse, GenerateResponse, Point, @@ -74,7 +75,7 @@ export async function uploadImage(file: File): Promise { export async function setCorners( sessionId: string, corners: Point[], - paperSize: PaperSize + paperSize: PaperSize, ): Promise { return fetchApi(`/api/sessions/${sessionId}/corners`, { method: 'POST', @@ -87,7 +88,12 @@ export interface TracerInfo { label: string } -export async function getAvailableKeys(): Promise<{ google: boolean; provider: string | null; provider_label: string | null; tracers: TracerInfo[] }> { +export async function getAvailableKeys(): Promise<{ + google: boolean + provider: string | null + provider_label: string | null + tracers: TracerInfo[] +}> { return fetchApi('/api/api-keys') } @@ -297,6 +303,33 @@ export async function getProjectHealth(projectId: string): Promise { + const res = await fetchApi<{ stations: PhotoStation[] }>('/api/photo-stations') + return res.stations +} + +export async function getPhotoStation(stationId: string): Promise { + return fetchApi(`/api/photo-stations/${stationId}`) +} + +export async function updatePhotoStation( + stationId: string, + updates: { + name?: string + paper_size?: PaperSize + corners?: Point[] + } +): Promise { + return fetchApi(`/api/photo-stations/${stationId}`, { + method: 'PATCH', + body: JSON.stringify(updates), + }) +} + +export async function deletePhotoStation(stationId: string): Promise { + await fetchApi(`/api/photo-stations/${stationId}`, { method: 'DELETE' }) +} + export async function repairProject(projectId: string): Promise { return fetchApi(`/api/bin-projects/${projectId}/repair`, { method: 'POST' }) } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index acb01d30..763cc1eb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -5,6 +5,13 @@ export interface Point { export type PaperSize = 'a4' | 'letter' | 'a3' | 'tabloid' +export interface CaptureCrop { + x: number + y: number + width: number + height: number +} + export interface FingerHole { id: string x: number @@ -51,6 +58,10 @@ export interface Session { tags: string[] created_at: string | null original_image_path: string | null + original_image_width: number | null + original_image_height: number | null + capture_crop: CaptureCrop | null + station_image_path: string | null corrected_image_path: string | null mask_image_path: string | null corners: Point[] | null @@ -76,11 +87,59 @@ export interface UploadResponse { session_id: string image_url: string detected_corners: Point[] | null + image_width: number | null + image_height: number | null + corner_source: 'detected' | 'station' | 'none' + station_id: string | null } export interface CornersResponse { corrected_image_url: string scale_factor: number + station: PhotoStation | null +} + +export interface RedetectCornersResponse { + corners: Point[] +} + +// --- photo stations --- + +export type PhotoStationMatchStatus = 'exact' | 'near' | 'far' + +export interface PhotoStation { + id: string + name: string + image_width: number + image_height: number + image_path: string | null + capture_crop: CaptureCrop | null + paper_size: PaperSize + corners: Point[] + created_at: string | null + updated_at: string | null + last_used_at: string | null +} + +export interface PhotoStationSuggestion { + station: PhotoStation + match_status: PhotoStationMatchStatus + width_delta_percent: number + height_delta_percent: number + max_corner_drift_px: number | null + max_corner_drift_percent: number | null + warnings: string[] +} + +export interface PhotoStationSuggestionsResponse { + suggestions: PhotoStationSuggestion[] + station_count: number +} + +export interface ReuseCornersResponse { + corners: Point[] + paper_size: PaperSize + suggestion: PhotoStationSuggestion } export interface TraceResponse { @@ -281,4 +340,4 @@ export interface BinSummary { grid_x: number grid_y: number preview_tools: BinPreviewTool[] -} \ No newline at end of file +}