Files
ApiCore/ApiCore-Frontend/src/components/photos/photo-capture-field.tsx
T
Nick SzaunigandClaude Opus 5.5 c01a38835a feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 17:57:54 +02:00

230 lines
7.4 KiB
TypeScript

"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 <img ref={showBlob} alt={alt} className={className} />;
}
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 <PhotoCapture {...props} />;
}
function PhotoCapture({ step, sessionId, date, onChange }: PhotoCaptureFieldProps) {
const queryClient = useQueryClient();
const ownerUserId = useCurrentUserId();
const inputRef = useRef<HTMLInputElement>(null);
const [notice, setNotice] = useState<Notice>(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<number> {
await queryClient.invalidateQueries({ queryKey });
return queryClient.getQueryData<unknown[]>(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<HTMLInputElement>) {
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 (
<FormSection
step={step}
title="Fotos"
description={`Bis zu ${PHOTOS_PER_INSPECTION_MAX} Fotos. Standortdaten werden vor dem Speichern entfernt.`}
>
{photos.length > 0 ? (
<ul className="grid grid-cols-3 gap-3" aria-label="Fotos dieser Durchsicht">
{photos.map((photo, index) => {
const name = `Foto ${index + 1}`;
return (
<li key={photo.id} className="flex flex-col gap-1">
<PhotoThumbnail
blob={photo.blob}
alt={name}
className="aspect-square w-full rounded-lg border border-border/60 object-cover"
/>
<ConfirmDialog
triggerLabel="Entfernen"
triggerAriaLabel={`Entfernen: ${name}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${name} entfernen?`}
description="Das Foto wird von diesem Gerät gelöscht."
confirmLabel="Entfernen"
onConfirm={() => remove.mutate(photo.id)}
isPending={remove.isPending}
/>
</li>
);
})}
</ul>
) : null}
<Button
type="button"
variant="outline"
size="touch"
className="w-fit"
disabled={add.isPending}
onClick={openCamera}
>
<Camera aria-hidden />
{add.isPending ? "Foto wird gespeichert…" : "Foto aufnehmen"}
</Button>
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
tabIndex={-1}
aria-hidden
onChange={handleFile}
/>
<p
role="status"
aria-live="polite"
className={cn(
"text-sm empty:hidden",
notice.isError ? "font-medium text-destructive" : "text-muted-foreground",
)}
>
{notice.text}
</p>
</FormSection>
);
}