feat: pet photo upload via presigned S3 URLs (GH #93, GRO-123)
- DB migration 0012: add photo_key and photo_uploaded_at columns to pets table - S3 client utility (apps/api/src/lib/s3.ts): presigned PUT/GET, delete via Rook-Ceph RGW - API photo routes on petsRouter: - POST /:petId/photo/upload-url — returns presigned PUT URL + object key - POST /:petId/photo/confirm — records key in DB after successful upload - DELETE /:petId/photo — deletes from storage and clears DB - GET /:petId/photo — returns presigned GET URL - RBAC: all staff roles (manager, receptionist, groomer) may upload/delete photos; restructured index.ts guards so groomer-accessible photo paths don't overlap with the manager/receptionist-only general pets write guard - Frontend PetPhotoDisplay: responsive image with shimmer skeleton and paw placeholder - Frontend PetPhotoUpload: client-side resize to max 1200px, XHR with progress, presigned PUT flow — binary data never passes through the API server - Wired both components into Clients.tsx staff portal pet cards - Unit tests: 14 test cases covering all four routes (happy path + error cases) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
petId: string;
|
||||
/** Size of the photo avatar in pixels. Default: 64. */
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type PhotoState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; url: string }
|
||||
| { status: "none" }
|
||||
| { status: "error" };
|
||||
|
||||
/**
|
||||
* Fetches and displays a pet's photo from the API.
|
||||
* Shows a loading skeleton while fetching, a paw-print placeholder when no photo exists,
|
||||
* and gracefully falls back to the placeholder on error.
|
||||
*/
|
||||
export function PetPhotoDisplay({ petId, size = 64, className }: Props) {
|
||||
const [state, setState] = useState<PhotoState>({ status: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
setState({ status: "loading" });
|
||||
fetch(`/api/pets/${petId}/photo`)
|
||||
.then(async (res) => {
|
||||
if (res.status === 404) {
|
||||
setState({ status: "none" });
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { url: string };
|
||||
setState({ status: "loaded", url: data.url });
|
||||
})
|
||||
.catch(() => setState({ status: "error" }));
|
||||
}, [petId]);
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: Math.round(size * 0.2),
|
||||
overflow: "hidden",
|
||||
background: "#f0ebe4",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
...containerStyle,
|
||||
background: "linear-gradient(90deg, #f0ebe4 25%, #e8e0d8 50%, #f0ebe4 75%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer 1.5s infinite",
|
||||
}}
|
||||
aria-label="Loading photo…"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "loaded") {
|
||||
return (
|
||||
<div className={className} style={containerStyle}>
|
||||
<img
|
||||
src={state.url}
|
||||
alt="Pet photo"
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// no photo / error — paw placeholder
|
||||
return (
|
||||
<div className={className} style={containerStyle} aria-label="No photo">
|
||||
<span style={{ fontSize: Math.round(size * 0.45), userSelect: "none" }}>🐾</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user