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