From fc4060d4d9093e3adb1caf7dbceb5b950a6c7aa6 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Sun, 17 May 2026 10:48:51 +1000 Subject: [PATCH] feat(mechanic-pwa): photo upload infra (CameraCapture + PhotoSlot + AuthImg + presigned PUT) Co-Authored-By: claude-flow --- mechanic-pwa/frontend/src/api/photos.ts | 104 ++++++++++++++++++ .../frontend/src/components/AuthImg.tsx | 79 +++++++++++++ .../src/components/camera/CameraCapture.tsx | 57 ++++++++++ .../src/components/camera/PhotoSlot.tsx | 60 ++++++++++ 4 files changed, 300 insertions(+) create mode 100644 mechanic-pwa/frontend/src/api/photos.ts create mode 100644 mechanic-pwa/frontend/src/components/AuthImg.tsx create mode 100644 mechanic-pwa/frontend/src/components/camera/CameraCapture.tsx create mode 100644 mechanic-pwa/frontend/src/components/camera/PhotoSlot.tsx diff --git a/mechanic-pwa/frontend/src/api/photos.ts b/mechanic-pwa/frontend/src/api/photos.ts new file mode 100644 index 0000000..bf3b570 --- /dev/null +++ b/mechanic-pwa/frontend/src/api/photos.ts @@ -0,0 +1,104 @@ +import { api } from "./client"; + +export interface PresignedUploadResponse { + photo_id: number; + presigned_url: string; + fields: Record; + storage_key: string; + expires_at: string; +} + +export interface PhotoConfirmResponse { + id: number; + side: string; + slot_index: number; + storage_key: string; + thumb_key?: string | null; + width?: number | null; + height?: number | null; + status: string; + taken_at: string; +} + +export async function requestUploadUrl( + inspectionId: number, + args: { side: string; slot_index: number; content_type: string; size: number } +): Promise { + return api + .post(`inspections/${inspectionId}/photos/upload-url`, { json: args }) + .json(); +} + +/** + * PUT the file directly to MinIO via the presigned URL. + * Content-Type header MUST match the value the URL was signed with + * (we pass the same `args.content_type` to requestUploadUrl). + */ +export async function uploadToS3( + presigned: PresignedUploadResponse, + file: File, + contentType: string +): Promise { + const r = await fetch(presigned.presigned_url, { + method: "PUT", + headers: { "Content-Type": contentType }, + body: file, + }); + if (!r.ok) { + throw new Error(`S3 upload failed: ${r.status} ${r.statusText}`); + } +} + +export async function confirmPhoto( + inspectionId: number, + photoId: number, + meta: { width?: number; height?: number; actual_size?: number } +): Promise { + return api + .post(`inspections/${inspectionId}/photos/${photoId}/confirm`, { json: meta }) + .json(); +} + +/** + * Full upload flow: request URL → PUT to S3 → measure dims → confirm. + * Returns the confirmed PhotoConfirmResponse (frontend uses .id mostly). + */ +export async function uploadPhoto( + inspectionId: number, + side: string, + slotIndex: number, + file: File +): Promise { + const contentType = file.type || "image/jpeg"; + + const presign = await requestUploadUrl(inspectionId, { + side, + slot_index: slotIndex, + content_type: contentType, + size: file.size, + }); + + await uploadToS3(presign, file, contentType); + + // Measure dimensions from a local object URL (revoked once loaded). + const dim = await new Promise<{ w: number; h: number }>((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + const out = { w: img.naturalWidth, h: img.naturalHeight }; + URL.revokeObjectURL(url); + resolve(out); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Could not read image dimensions")); + }; + img.src = url; + }); + + return confirmPhoto(inspectionId, presign.photo_id, { + width: dim.w, + height: dim.h, + actual_size: file.size, + }); +} diff --git a/mechanic-pwa/frontend/src/components/AuthImg.tsx b/mechanic-pwa/frontend/src/components/AuthImg.tsx new file mode 100644 index 0000000..9e92440 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/AuthImg.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from "react"; +import { useAuth } from "@/store/auth"; + +interface Props { + /** Path relative to /api/v1/mechanic, e.g. "photos/123/thumb". */ + src: string; + alt?: string; + className?: string; + /** Optional placeholder while loading. */ + fallback?: React.ReactNode; +} + +/** + * wrapper that fetches the resource with the current Bearer token, + * turns it into a blob: URL, and renders an pointing at the blob. + * + * Necessary because the mechanic photo endpoints are auth-guarded — plain + * doesn't send the Authorization header. + * + * Cleans up the blob URL on unmount or when src changes. + */ +export function AuthImg({ src, alt = "", className, fallback }: Props) { + const token = useAuth((s) => s.token); + const [blobUrl, setBlobUrl] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + let createdUrl: string | null = null; + setError(false); + setBlobUrl(null); + + async function load() { + if (!token) { + setError(true); + return; + } + try { + const fullUrl = `/api/v1/mechanic/${src}`; + const res = await fetch(fullUrl, { + headers: { Authorization: `Bearer ${token}` }, + redirect: "follow", + }); + if (!res.ok) throw new Error(`${res.status}`); + const blob = await res.blob(); + createdUrl = URL.createObjectURL(blob); + if (!cancelled) setBlobUrl(createdUrl); + } catch { + if (!cancelled) setError(true); + } + } + + load(); + return () => { + cancelled = true; + if (createdUrl) URL.revokeObjectURL(createdUrl); + }; + }, [src, token]); + + if (error) { + return ( +
+ {fallback ?? ( + нет фото + )} +
+ ); + } + if (!blobUrl) { + return ( +
+ {fallback ?? ( +
+ )} +
+ ); + } + return {alt}; +} diff --git a/mechanic-pwa/frontend/src/components/camera/CameraCapture.tsx b/mechanic-pwa/frontend/src/components/camera/CameraCapture.tsx new file mode 100644 index 0000000..69d9984 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/camera/CameraCapture.tsx @@ -0,0 +1,57 @@ +import { useRef, useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; + +interface Props { + onCapture: (file: File) => void; + buttonLabel?: string; +} + +export function CameraCapture({ onCapture, buttonLabel = "Сделать фото" }: Props) { + const inputRef = useRef(null); + const [preview, setPreview] = useState(null); + + // Cleanup preview blob URL on unmount or when preview changes + useEffect(() => { + return () => { + if (preview) URL.revokeObjectURL(preview); + }; + }, [preview]); + + return ( +
+ { + const file = e.target.files?.[0]; + if (!file) return; + if (preview) URL.revokeObjectURL(preview); + setPreview(URL.createObjectURL(file)); + onCapture(file); + // Reset input value so capturing the same file twice works + e.target.value = ""; + }} + /> + {preview ? ( +
+ + +
+ ) : ( + + )} +
+ ); +} diff --git a/mechanic-pwa/frontend/src/components/camera/PhotoSlot.tsx b/mechanic-pwa/frontend/src/components/camera/PhotoSlot.tsx new file mode 100644 index 0000000..0af819b --- /dev/null +++ b/mechanic-pwa/frontend/src/components/camera/PhotoSlot.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { CameraCapture } from "./CameraCapture"; +import { AuthImg } from "@/components/AuthImg"; +import { uploadPhoto } from "@/api/photos"; +import { toast } from "sonner"; + +interface Props { + inspectionId: number; + side: string; + slotIndex: number; + label: string; + initialPhotoId?: number; + onUploaded?: (photoId: number) => void; +} + +export function PhotoSlot({ + inspectionId, + side, + slotIndex, + label, + initialPhotoId, + onUploaded, +}: Props) { + const [photoId, setPhotoId] = useState(initialPhotoId); + const [uploading, setUploading] = useState(false); + + async function handleCapture(file: File) { + setUploading(true); + try { + const result = await uploadPhoto(inspectionId, side, slotIndex, file); + setPhotoId(result.id); + onUploaded?.(result.id); + toast.success(`${label}: загружено`); + } catch { + toast.error(`${label}: ошибка загрузки`); + } finally { + setUploading(false); + } + } + + return ( +
+
{label}
+ {photoId && ( + + )} + + {uploading && ( +
Загружаю…
+ )} +
+ ); +}