feat(mechanic-pwa): photo upload infra (CameraCapture + PhotoSlot + AuthImg + presigned PUT)
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user