diff --git a/.env.example b/.env.example index e6326efa..2d5e7fe6 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,12 @@ BETTER_AUTH_URL=http://localhost:3000 ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=change-me-strong-admin-password +# --- Storage ------------------------------------------------------------------ +# Directory the app reads/writes blobs (photo originals + derivatives) under. +# In docker-compose this is a mounted named volume (magstacker-uploads); for +# local tooling outside Docker, point it at any writable local directory. +UPLOAD_DIR=/data/uploads + # --- App -------------------------------------------------------------------- APP_HOST_PORT=3000 diff --git a/Dockerfile b/Dockerfile index a3af7db0..7c741efc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,14 @@ COPY --from=builder /app/auth.ts ./auth.ts COPY --from=builder /app/src ./src COPY --from=builder /app/scripts ./scripts +# Create the upload root owned by the unprivileged runtime user BEFORE dropping +# privileges. docker-compose mounts a NAMED volume at /data/uploads (UPLOAD_DIR); +# Docker seeds an empty named volume from the image directory's contents AND +# ownership on first use, so creating it bun-owned here makes the mounted volume +# writable by uid `bun`. Without this the fresh volume mounts root-owned and the +# first upload fails EACCES. +RUN mkdir -p /data/uploads && chown bun:bun /data/uploads + # Run as the unprivileged user shipped in the bun image. USER bun diff --git a/app/(app)/firearms/[id]/firearm-photos.tsx b/app/(app)/firearms/[id]/firearm-photos.tsx new file mode 100644 index 00000000..066231b7 --- /dev/null +++ b/app/(app)/firearms/[id]/firearm-photos.tsx @@ -0,0 +1,539 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import type { ChangeEvent } from "react"; +import { useId, useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { Badge, Callout } from "@/components/ui/feedback"; +import { Field } from "@/components/ui/field"; +import { Textarea } from "@/components/ui/input"; +import { Card } from "@/components/ui/surface"; +import { useToast } from "@/components/ui/toast"; +import { + PREVIEW_MAX_EDGE, + THUMB_MAX_EDGE, +} from "@/src/domain/firearm-photos/constants"; +import type { CreatePhotoErrorCode } from "@/src/domain/firearm-photos/service"; +import { photoVariantUrl } from "@/src/domain/firearm-photos/urls"; +import { + deletePhotoAction, + reorderPhotosAction, + setPrimaryPhotoAction, + updatePhotoCaptionAction, + uploadPhotosAction, +} from "../photo-actions"; + +/** + * Detail-view photo gallery + upload surface (#9 U7, R15, R17, R21-R25). + * + * The subset of a `firearm_photo` row this section needs to render — kept + * narrow (mirrors `MountedAccessoryRow`) rather than importing the full + * `FirearmPhoto` DB row type, since `listPhotos`'s return structurally + * satisfies this shape and no reshaping is needed at the call site. + */ +export interface FirearmPhotoRow { + id: string; + caption: string; + isPrimary: boolean; + width: number; + height: number; +} + +interface FirearmPhotosProps { + firearmId: string; + /** Used only for the accessible-name fallback (R24) — never for authz. */ + firearmName: string; + /** Server-loaded, already ordered by `sortOrder` ascending — this + * component never clones it into local state (reactCompiler-safe): every + * mutation calls `router.refresh()` so the parent Server Component + * re-fetches `listPhotos` and passes a fresh array back down. */ + photos: FirearmPhotoRow[]; + /** True when the actor owns or has edit rights on the firearm (R14). Read + * access (the gallery itself) does not depend on this — only the + * mutation affordances (upload/primary/reorder/caption/delete) do. */ + canEdit: boolean; +} + +interface UploadFailure { + filename: string; + message: string; +} + +/** Per-file/batch failure codes → human-readable reasons (R21). Kept local + * rather than added to `src/domain/validation-messages.ts` since that file + * is a domain module this unit doesn't touch. */ +const UPLOAD_FAILURE_MESSAGES: Record = { + disallowedMimeType: "unsupported file type (JPEG, PNG, WEBP, or AVIF only)", + fileTooLarge: "file exceeds the 15 MB size limit", + processingFailed: "could not process this image", + tooManyFiles: "too many files in one upload (10 max)", + photoQuotaExceeded: "this firearm already has the maximum number of photos", +}; + +function uploadFailureMessage(code: string): string { + return ( + UPLOAD_FAILURE_MESSAGES[code as keyof typeof UPLOAD_FAILURE_MESSAGES] ?? + "could not upload this file" + ); +} + +/** Mirrors sharp's `fit: "inside", withoutEnlargement: true` (`pipeline.ts`) + * so the reserved `` box matches the derivative's real pixel size + * exactly — the box never changes size once the image loads (R17). */ +function containDimensions( + width: number, + height: number, + maxEdge: number, +): { width: number; height: number } { + const scale = Math.min(1, maxEdge / width, maxEdge / height); + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }; +} + +/** Caption serves as the accessible name; an uncaptioned photo falls back to + * the firearm name plus its 1-indexed gallery position (R24). */ +function photoAccessibleName( + photo: FirearmPhotoRow, + position: number, + firearmName: string, +): string { + return photo.caption.trim() !== "" + ? photo.caption + : `${firearmName} — photo ${position}`; +} + +export function FirearmPhotos({ + firearmId, + firearmName, + photos, + canEdit, +}: FirearmPhotosProps) { + const router = useRouter(); + const { toast } = useToast(); + const uploadInputId = useId(); + + const [uploading, startUpload] = useTransition(); + const [mutating, startMutate] = useTransition(); + const [deleting, startDelete] = useTransition(); + + const [pendingCount, setPendingCount] = useState(0); + const [failures, setFailures] = useState([]); + + const [editingCaptionId, setEditingCaptionId] = useState(null); + const [captionDraft, setCaptionDraft] = useState(""); + + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + + const primaryIndex = photos.findIndex((p) => p.isPrimary); + const primary = primaryIndex >= 0 ? photos[primaryIndex] : null; + + function handleFilesSelected(event: ChangeEvent) { + // Capture synchronously — `event.currentTarget` nulls out once the + // handler returns, so it must not be read after an `await`. + const input = event.currentTarget; + const selected = Array.from(input.files ?? []); + input.value = ""; // allow re-selecting the same file(s) later + if (selected.length === 0) return; + + setFailures([]); + setPendingCount(selected.length); + + startUpload(async () => { + const formData = new FormData(); + for (const file of selected) formData.append("files", file); + + const result = await uploadPhotosAction(firearmId, formData); + + if (!result.ok) { + const message = + result.codes && result.codes.length > 0 + ? result.codes.map(uploadFailureMessage).join("; ") + : (result.error ?? "Upload failed."); + toast({ message, tone: "destructive" }); + return; + } + + const newFailures: UploadFailure[] = []; + let succeeded = 0; + const results = result.data?.results ?? []; + for (const [i, fileResult] of results.entries()) { + if (fileResult.ok) { + succeeded += 1; + } else { + newFailures.push({ + filename: selected[i]?.name ?? `file ${i + 1}`, + message: fileResult.codes.map(uploadFailureMessage).join("; "), + }); + } + } + setFailures(newFailures); + + if (succeeded > 0) { + toast({ + message: `${succeeded} photo${succeeded === 1 ? "" : "s"} uploaded`, + tone: "ok", + }); + } + if (newFailures.length > 0 && succeeded === 0) { + toast({ + message: `${newFailures.length} file${newFailures.length === 1 ? "" : "s"} could not be uploaded`, + tone: "destructive", + }); + } + + router.refresh(); + }); + } + + function handleSetPrimary(photoId: string) { + startMutate(async () => { + const result = await setPrimaryPhotoAction(photoId); + if (!result.ok) { + toast({ + message: result.error ?? "Could not set primary photo.", + tone: "destructive", + }); + return; + } + router.refresh(); + }); + } + + function handleMove(photoId: string, direction: "up" | "down") { + const index = photos.findIndex((p) => p.id === photoId); + if (index === -1) return; + const swapWith = direction === "up" ? index - 1 : index + 1; + if (swapWith < 0 || swapWith >= photos.length) return; + + // Build the new order on a fresh copy — `photos` (the prop) is never + // mutated in place. + const reordered = [...photos]; + const moved = reordered[index]; + const other = reordered[swapWith]; + if (!moved || !other) return; + reordered[index] = other; + reordered[swapWith] = moved; + const orderedIds = reordered.map((p) => p.id); + + startMutate(async () => { + const result = await reorderPhotosAction(firearmId, orderedIds); + if (!result.ok) { + toast({ + message: result.error ?? "Could not reorder photos.", + tone: "destructive", + }); + return; + } + router.refresh(); + }); + } + + function startCaptionEdit(photo: FirearmPhotoRow) { + setEditingCaptionId(photo.id); + setCaptionDraft(photo.caption); + } + + function cancelCaptionEdit() { + setEditingCaptionId(null); + setCaptionDraft(""); + } + + function saveCaption(photoId: string) { + const value = captionDraft.trim(); + startMutate(async () => { + const result = await updatePhotoCaptionAction(photoId, value); + if (!result.ok) { + toast({ + message: result.error ?? "Could not save caption.", + tone: "destructive", + }); + return; + } + setEditingCaptionId(null); + setCaptionDraft(""); + router.refresh(); + }); + } + + function handleRemoveCaption(photoId: string) { + startMutate(async () => { + const result = await updatePhotoCaptionAction(photoId, ""); + if (!result.ok) { + toast({ + message: result.error ?? "Could not remove caption.", + tone: "destructive", + }); + return; + } + router.refresh(); + }); + } + + function confirmDelete() { + const target = deleteTarget; + if (!target) return; + startDelete(async () => { + const result = await deletePhotoAction(target.id); + setDeleteTarget(null); + if (!result.ok) { + toast({ + message: result.error ?? "Could not delete photo.", + tone: "destructive", + }); + return; + } + toast({ message: "Photo deleted", tone: "neutral" }); + router.refresh(); + }); + } + + const primaryDims = primary + ? containDimensions(primary.width, primary.height, PREVIEW_MAX_EDGE) + : null; + const rowBusy = mutating || deleting; + + return ( + +
+
+

Photos

+

+ {photos.length} photo{photos.length === 1 ? "" : "s"} +

+
+
+ + {canEdit ? ( +
+ + + + {/* Always present so assistive tech gets the pending announcement + * without a landmark appearing/disappearing (R25). */} +

+ {uploading + ? `Uploading ${pendingCount} photo${pendingCount === 1 ? "" : "s"}…` + : ""} +

+ {failures.length > 0 ? ( +
+ +

+ {failures.length} file{failures.length === 1 ? "" : "s"} not + uploaded: +

+
    + {failures.map((f) => ( +
  • + {f.filename}: {f.message} +
  • + ))} +
+
+
+ ) : null} +
+ ) : null} + +
+

+ Primary photo +

+ {primary && primaryDims ? ( +
+ {photoAccessibleName(primary, + {primary.caption.trim() !== "" ? ( +
+ {primary.caption} +
+ ) : null} +
+ ) : ( + // Fixed-size neutral placeholder (R22): no photos (or no primary) + // renders this instead of a broken image or a reflow. +
+ No photos yet +
+ )} +
+ +
    + {photos.map((photo, index) => { + const position = index + 1; + const dims = containDimensions( + photo.width, + photo.height, + THUMB_MAX_EDGE, + ); + const name = photoAccessibleName(photo, position, firearmName); + const editingThis = editingCaptionId === photo.id; + const hasCaption = photo.caption.trim() !== ""; + + return ( +
  • +
    + {name} + {photo.isPrimary ? ( + + Primary + + ) : null} +
    + + {editingThis ? ( +
    +