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]>
230 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|