This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
app/apps/web/src/components/PetPhotoDisplay.tsx
T
Scrubs McBarkley 1380848aea 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>
2026-03-22 00:07:48 +00:00

87 lines
2.3 KiB
TypeScript

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>
);
}