feat(mechanic-pwa): photo upload infra (CameraCapture + PhotoSlot + AuthImg + presigned PUT)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-05-17 10:48:51 +10:00
co-authored by claude-flow
parent 537548d716
commit fc4060d4d9
4 changed files with 300 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
import { api } from "./client";
export interface PresignedUploadResponse {
photo_id: number;
presigned_url: string;
fields: Record<string, string>;
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<PresignedUploadResponse> {
return api
.post(`inspections/${inspectionId}/photos/upload-url`, { json: args })
.json<PresignedUploadResponse>();
}
/**
* 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<void> {
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<PhotoConfirmResponse> {
return api
.post(`inspections/${inspectionId}/photos/${photoId}/confirm`, { json: meta })
.json<PhotoConfirmResponse>();
}
/**
* 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<PhotoConfirmResponse> {
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,
});
}
@@ -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;
}
/**
* <img> wrapper that fetches the resource with the current Bearer token,
* turns it into a blob: URL, and renders an <img> pointing at the blob.
*
* Necessary because the mechanic photo endpoints are auth-guarded — plain
* <img src="/api/v1/mechanic/photos/123"> 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<string | null>(null);
const [error, setError] = useState<boolean>(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 (
<div className={className}>
{fallback ?? (
<span className="text-xs text-muted-foreground">нет фото</span>
)}
</div>
);
}
if (!blobUrl) {
return (
<div className={className}>
{fallback ?? (
<div className="bg-muted animate-pulse h-full w-full" />
)}
</div>
);
}
return <img src={blobUrl} alt={alt} className={className} />;
}
@@ -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<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(null);
// Cleanup preview blob URL on unmount or when preview changes
useEffect(() => {
return () => {
if (preview) URL.revokeObjectURL(preview);
};
}, [preview]);
return (
<div className="space-y-2">
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
aria-hidden="true"
onChange={(e) => {
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 ? (
<div className="space-y-2">
<img src={preview} alt="" className="w-full rounded-lg" />
<Button
variant="outline"
className="w-full"
onClick={() => inputRef.current?.click()}
>
Переснять
</Button>
</div>
) : (
<Button onClick={() => inputRef.current?.click()} className="w-full">
{buttonLabel}
</Button>
)}
</div>
);
}
@@ -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<number | undefined>(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 (
<div className="border rounded-lg p-3 bg-card space-y-2">
<div className="text-sm font-medium">{label}</div>
{photoId && (
<AuthImg
src={`photos/${photoId}/thumb`}
alt={label}
className="w-full aspect-[4/3] object-cover rounded bg-muted"
/>
)}
<CameraCapture
onCapture={handleCapture}
buttonLabel={photoId ? "Заменить" : "Снять"}
/>
{uploading && (
<div className="text-xs text-muted-foreground">Загружаю</div>
)}
</div>
);
}