"use client"; import { ChangeEvent, useCallback, useRef, useState } from "react"; import { Camera } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context"; import { todayLocalIsoDate } from "@/lib/dates"; import { PhotoMetadataError, PhotoTooLargeError, preparePhoto } from "@/lib/photos/reencode"; import { PHOTOS_PER_INSPECTION_MAX, addDraftPhoto, listSessionPhotos, removePhoto, } from "@/lib/photos/store"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { FormSection } from "@/components/form/form-section"; /** Prefix of the queries that read one form's photos from this device. */ export const PHOTO_SESSION_QUERY_KEY = ["photo-session"] as const; const LIMIT_MESSAGE = `Höchstens ${PHOTOS_PER_INSPECTION_MAX} Fotos je Durchsicht. Entferne eines, um ein neues aufzunehmen.`; interface Notice { text: string; isError: boolean; } const NO_NOTICE: Notice = { text: "", isError: false }; function captureError(error: unknown): Notice { if (error instanceof PhotoTooLargeError) { return { text: "Das Foto ist zu groß. Bitte noch einmal aufnehmen.", isError: true }; } if (error instanceof PhotoMetadataError) { return { text: "Foto wurde nicht gespeichert: Die Standortdaten ließen sich nicht entfernen.", isError: true, }; } return { text: "Foto konnte nicht gespeichert werden.", isError: true }; } interface PhotoThumbnailProps { blob: Blob; alt: string; className?: string; } /** A photo stored on this device, shown from an object URL. */ export function PhotoThumbnail({ blob, alt, className }: PhotoThumbnailProps) { // The URL lives exactly as long as the mounted image (ref cleanup), also // through StrictMode's unmount and remount. const showBlob = useCallback( (image: HTMLImageElement | null) => { if (!image) return; const url = URL.createObjectURL(blob); image.src = url; return () => URL.revokeObjectURL(url); }, [blob], ); // eslint-disable-next-line @next/next/no-img-element -- blob: object URL, next/image cannot handle this return {alt}; } interface PhotoCaptureFieldProps { /** Position of the section in its form. */ step?: number; /** The form's photo session (`photoSessionId` in its draft values). */ sessionId: string; /** The inspection date (yyyy-mm-dd), part of each file name. */ date: string; /** A photo was taken or removed: the form now has something to keep. */ onChange?: () => void; } /** * „Foto aufnehmen" in an inspection form. Each photo is re-encoded without * EXIF/GPS and stored on the device at once, so it is kept with the form * draft and works without a network. Uploaded after the inspection is saved. * Shown only to users who may upload documents. */ export function PhotoCaptureField(props: PhotoCaptureFieldProps) { const { hasPermission } = useAuth(); if (!hasPermission("documents:upload")) { return null; } return ; } function PhotoCapture({ step, sessionId, date, onChange }: PhotoCaptureFieldProps) { const queryClient = useQueryClient(); const ownerUserId = useCurrentUserId(); const inputRef = useRef(null); const [notice, setNotice] = useState(NO_NOTICE); const queryKey = [...PHOTO_SESSION_QUERY_KEY, sessionId]; const { data: photos = [] } = useQuery({ queryKey, queryFn: () => listSessionPhotos(sessionId), // Reads this device's storage, never the network. networkMode: "always", }); const isFull = photos.length >= PHOTOS_PER_INSPECTION_MAX; async function showStoredPhotos(): Promise { await queryClient.invalidateQueries({ queryKey }); return queryClient.getQueryData(queryKey)?.length ?? 0; } const add = useMutation({ networkMode: "always", mutationFn: async (file: File) => { // Never store a photo without an owner: it would follow the next user. if (!ownerUserId) { throw new Error("No signed-in user"); } const blob = await preparePhoto(file); return addDraftPhoto({ sessionId, ownerUserId, blob, date: date || todayLocalIsoDate() }); }, onSuccess: async () => { onChange?.(); const count = await showStoredPhotos(); setNotice({ text: `Foto ${count} gespeichert.`, isError: false }); }, onError: (error) => setNotice(captureError(error)), }); const remove = useMutation({ networkMode: "always", mutationFn: (id: string) => removePhoto(id), onSuccess: async () => { onChange?.(); await showStoredPhotos(); setNotice({ text: "Foto entfernt.", isError: false }); }, onError: () => setNotice({ text: "Foto konnte nicht entfernt werden.", isError: true }), }); function openCamera() { if (isFull) { setNotice({ text: LIMIT_MESSAGE, isError: true }); return; } setNotice(NO_NOTICE); inputRef.current?.click(); } function handleFile(event: ChangeEvent) { const file = event.target.files?.[0]; // Cleared, so the same picture can be chosen again after removing it. event.target.value = ""; if (!file) return; if (isFull) { setNotice({ text: LIMIT_MESSAGE, isError: true }); return; } add.mutate(file); } return ( {photos.length > 0 ? (
    {photos.map((photo, index) => { const name = `Foto ${index + 1}`; return (
  • remove.mutate(photo.id)} isPending={remove.isPending} />
  • ); })}
) : null}

{notice.text}

); }