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]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
@@ -0,0 +1,229 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user