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