Files
ApiCore/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx
T
Nick.RosenloecherandClaude Opus 5.5 ce68729233 feat(t5): Fokusring für alle, 44-px-Ziele bei Touch, 16-px-Schrift (T5a A5)
- globals.css: eine Basisregel :where(:focus-visible) mit --focus-width
  und --focus-offset (Sonne 3 px), Firefox-:-moz-focusring gleichgezogen;
  --text-xs 0.8125rem.
- Fokus-Codemod (scripts/codemod-t5-focus.mjs): eigene Ringe und
  outline-none raus, sm:h-/sm:size- zu pointer-fine:, Label-Zeilen mit
  focus-ring-within; Skript-Fokus (Lücken-Liste, „Gespeichert“) bleibt.
- Button/Input 44 px, kompakt nur mit Maus; Icon-Knöpfe mit unsichtbarer
  44-px-Trefferfläche bei Touch, Nachbarn mit genug Abstand.
- 16 px in Feldern, Labels, Auswahl, Leerzuständen, Listentiteln.
- .holo-stage auf allen Beute-Bühnen, .stage-card holt den Seitenring.
- Baukasten-Wächter (src/styles/baukasten-guard.spec.ts) mit den Regeln
  aus A3–A5, Ausnahmen mit Grund; Kalender-Auswahl als Inset-Ring.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 18:06:20 +02:00

539 lines
20 KiB
TypeScript

"use client";
import { useRef, type ComponentProps, type MouseEvent, type ReactNode } from "react";
import Link from "next/link";
import { AlertTriangle, Camera, Clock, Mic, RotateCw } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NavSheet } from "@/components/nav/nav-sheet";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
import { Button, buttonVariants } from "@/components/ui/button";
import {
discardQueueItem,
listQueue,
retryBulkWithout,
retryInspectionWithoutHiveChange,
retryInspectionWithoutVoice,
type QueueError,
type QueueItem,
} from "@/lib/offline/db";
import {
entriesSynced,
foreignEntriesWaiting,
photosAttached,
queueRejectionText,
VOICE_TOO_MANY_OPEN_HINT,
voiceRejectionText,
} from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { discardAllPendingVoice, discardPendingVoice } from "@/lib/voice/pending-voice";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
interface PendingQueueSheetProps {
userId: string;
/** Entries of another account on this device - shown only as a count. */
foreignCount: number;
open: boolean;
onOpenChange: (open: boolean) => void;
/** The `SheetTrigger` that opens the sheet, or null when opened elsewhere. */
trigger: ReactNode;
/** Where focus goes on close; by default back to the trigger. */
finalFocus?: ComponentProps<typeof NavSheet>["finalFocus"];
}
interface Named {
label: string;
queuedAt: string;
}
const PHOTO_LABEL = "Foto";
const VOICE_STORE = "pending-voice";
/** 75 seconds as "1:15". */
function durationLabel(totalSec: number): string {
const seconds = Math.round(totalSec);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
/** True for a plain left click; ctrl, meta, shift, alt and middle click open a link elsewhere. */
function isPlainLeftClick(event: MouseEvent): boolean {
return (
event.button === 0 &&
!event.defaultPrevented &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
/** The recording page, for the colony of a rejected note. */
function recordAgainHref(item: QueueItem): string {
return `/voice/new?${new URLSearchParams({ colonyId: item.voice?.colonyId ?? "" }).toString()}`;
}
function queuedAtLabel(queuedAt: string): string {
return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
}
/**
* „Durchsicht vom 13.10.26, 10:15:05" - a name per entry for its buttons and
* dialog. Seconds tell apart entries of the same minute; a running number
* „(2)" those queued in the same second.
*/
function entryNames(items: readonly Named[]): string[] {
const seen = new Map<string, number>();
return items.map((item) => {
const time = new Date(item.queuedAt).toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "medium",
});
const name = `${item.label} vom ${time}`;
const occurrence = (seen.get(name) ?? 0) + 1;
seen.set(name, occurrence);
return occurrence === 1 ? name : `${name} (${occurrence})`;
});
}
/** A queued inspection takes its photos along when it is discarded. */
function discardDescription(item: QueueItem): string {
if (!item.photoCount) {
return "Der Eintrag wird von diesem Gerät gelöscht und nicht mehr gesendet.";
}
const photos = item.photoCount === 1 ? "sein Foto" : `seine ${item.photoCount} Fotos`;
return `Der Eintrag und ${photos} werden von diesem Gerät gelöscht und nicht mehr gesendet.`;
}
/** Rejected with the server's reason, or waiting - in words, never by colour alone. */
function QueueStatus({
lastError,
changesHive = false,
isVoice = false,
}: {
lastError?: QueueError;
changesHive?: boolean;
isVoice?: boolean;
}) {
return lastError ? (
<p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>
Abgelehnt:{" "}
{isVoice ? voiceRejectionText(lastError) : queueRejectionText(lastError, changesHive)}
</span>
</p>
) : (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<Clock className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Wartet auf Senden</span>
</p>
);
}
/** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */
function ResendWithoutButton({
resend,
disabled,
onResend,
}: {
resend: NonNullable<QueueItem["resendWithout"]>;
disabled: boolean;
onResend: () => void;
}) {
const isNamed = resend.codes.length === resend.colonyIds.length;
const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker";
return (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={disabled}
onClick={onResend}
>
<RotateCw aria-hidden />
Ohne {without} erneut senden
</Button>
);
}
/**
* What this device still has to send for the signed-in user: each entry with
* its state, a rejected one with the server's reason, and a way to discard it.
* Photos whose inspection is already on the server are listed on their own;
* a rejected one can be sent again.
*/
export function PendingQueueSheet({
userId,
foreignCount,
open,
onOpenChange,
trigger,
finalFocus,
}: PendingQueueSheetProps) {
const queryClient = useQueryClient();
const { data: items = [], isSuccess } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "items"],
queryFn: () => listQueue(userId),
enabled: open,
// Reads this device's storage, never the network.
networkMode: "always",
});
const { data: photos = [] } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "photos"],
queryFn: () => listPendingPhotos(userId),
enabled: open,
networkMode: "always",
});
const discard = useMutation({
mutationFn: (item: QueueItem) => discardQueueItem(item),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Eintrag verworfen");
},
onError: () => toast.error("Eintrag konnte nicht verworfen werden."),
});
const voiceHeadingRef = useRef<HTMLHeadingElement>(null);
const discardVoice = useMutation({
mutationFn: (noteId: string) => discardPendingVoice(noteId, userId),
networkMode: "always",
onSuccess: () => {
// The discard button is gone: keep the focus inside the list.
voiceHeadingRef.current?.focus();
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Sprachnotiz verworfen");
},
onError: () => toast.error("Sprachnotiz konnte nicht verworfen werden."),
});
const discardAllVoice = useMutation({
mutationFn: () => discardAllPendingVoice(userId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Sprachnotizen verworfen");
},
onError: () => toast.error("Sprachnotizen konnten nicht verworfen werden."),
});
const discardPhoto = useMutation({
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Foto verworfen");
},
onError: () => toast.error("Foto konnte nicht verworfen werden."),
});
// „Erneut senden": clear the rejection, then a normal sync run sends it.
const retryPhoto = useMutation({
mutationFn: async (photoId: string) => {
await resetPhotoError(photoId);
// A sync already running may have passed this photo: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
// New files on the server: document lists may be stale now.
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Foto konnte nicht erneut gesendet werden."),
});
// „Ohne … erneut senden“: the refused colonies leave the bulk entry (same
// key, rejection cleared), then a normal run sends the rest.
const resendBulk = useMutation({
mutationFn: async ({ id, colonyIds }: { id: number; colonyIds: string[] }) => {
await retryBulkWithout(id, colonyIds);
// A sync already running may have passed this entry: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
// „Ohne Beute-Änderung senden“: the hive change leaves the inspection (same
// key, rejection cleared), then a normal run sends it.
const resendWithoutHive = useMutation({
mutationFn: async (id: number) => {
await retryInspectionWithoutHiveChange(id);
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
// „Ohne Sprachnotiz senden“: only after the server said the note is another colony's.
const resendWithoutVoice = useMutation({
mutationFn: async (id: number) => {
await retryInspectionWithoutVoice(id);
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
const records = items.filter((item) => item.store !== VOICE_STORE);
const voiceNotes = items.filter((item) => item.store === VOICE_STORE);
const names = entryNames(records);
const voiceNames = entryNames(voiceNotes);
const photoNames = entryNames(
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
);
return (
<NavSheet
open={open}
onOpenChange={onOpenChange}
trigger={trigger}
title="Wartende Einträge"
finalFocus={finalFocus}
>
<div className="flex flex-col gap-3 px-4 pb-4">
{isSuccess && items.length === 0 && photos.length === 0 ? (
<p className="text-sm text-muted-foreground">Von dir wartet nichts mehr.</p>
) : null}
<ul className="flex flex-col gap-2">
{records.map((item, index) => (
<li
key={`${item.store}-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Erfasst am {queuedAtLabel(item.queuedAt)}
</p>
{item.photoCount ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Camera className="size-4 shrink-0" aria-hidden />
{photosAttached(item.photoCount)}
</p>
) : null}
</div>
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${names[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${names[index]} verwerfen?`}
description={discardDescription(item)}
confirmLabel="Verwerfen"
onConfirm={() => discard.mutate(item)}
isPending={discard.isPending}
/>
</div>
<QueueStatus lastError={item.lastError} changesHive={item.hasHiveChange} />
{item.canResendWithoutHiveChange && typeof item.id === "number" ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={resendWithoutHive.isPending}
onClick={() => resendWithoutHive.mutate(item.id as number)}
>
<RotateCw aria-hidden />
Ohne Beute-Änderung senden
</Button>
) : null}
{item.canResendWithoutVoice && typeof item.id === "number" ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={resendWithoutVoice.isPending}
onClick={() => resendWithoutVoice.mutate(item.id as number)}
>
<RotateCw aria-hidden />
Ohne Sprachnotiz senden
</Button>
) : null}
{item.resendWithout && typeof item.id === "number" ? (
<ResendWithoutButton
resend={item.resendWithout}
disabled={resendBulk.isPending}
onResend={() =>
resendBulk.mutate({
id: item.id as number,
colonyIds: item.resendWithout?.colonyIds ?? [],
})
}
/>
) : null}
</li>
))}
{photos.map((photo, index) => (
<li
key={`photo-${photo.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<PhotoThumbnail
blob={photo.blob}
alt=""
className="size-14 shrink-0 rounded-lg border border-border/60 object-cover"
/>
<div className="min-w-0">
<p className="font-medium">Foto zur Durchsicht</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(photo.createdAt)}
</p>
</div>
</div>
<QueueStatus lastError={photo.lastError} />
<div className="flex flex-wrap gap-2">
{photo.lastError ? (
<Button
type="button"
variant="outline"
size="touch"
aria-label={`Erneut senden: ${photoNames[index]}`}
disabled={retryPhoto.isPending}
onClick={() => retryPhoto.mutate(photo.id)}
>
<RotateCw aria-hidden />
Erneut senden
</Button>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${photoNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${photoNames[index]} verwerfen?`}
description="Das Foto wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardPhoto.mutate(photo.id)}
isPending={discardPhoto.isPending}
/>
</div>
</li>
))}
</ul>
{voiceNotes.length > 0 ? (
<section aria-labelledby="queue-voice-heading" className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3
id="queue-voice-heading"
ref={voiceHeadingRef}
tabIndex={-1}
className="font-medium"
>
Sprachnotizen
</h3>
{voiceNotes.length > 1 ? (
<ConfirmDialog
triggerLabel="Alle Sprachnotizen verwerfen"
triggerVariant="ghost"
triggerSize="touch"
title="Alle Sprachnotizen verwerfen?"
description="Die Sprachnotizen werden von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Alle verwerfen"
onConfirm={() => discardAllVoice.mutate()}
isPending={discardAllVoice.isPending}
/>
) : null}
</div>
{voiceNotes.some((item) => item.voice?.blockedBy === "TOO_MANY_OPEN") ? (
<p role="status" className="text-sm text-muted-foreground">
{VOICE_TOO_MANY_OPEN_HINT}
</p>
) : null}
<ul className="flex flex-col gap-2">
{voiceNotes.map((item, index) => (
<li
key={`voice-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<Mic className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(item.queuedAt)}
{item.voice ? ` · Dauer ${durationLabel(item.voice.durationSec)}` : ""}
</p>
</div>
</div>
<QueueStatus lastError={item.lastError} isVoice />
<div className="flex flex-wrap gap-2">
{item.lastError ? (
<Link
href={recordAgainHref(item)}
className={buttonVariants({ variant: "outline", size: "touch" })}
aria-label={`Erneut aufnehmen: ${voiceNames[index]}`}
onClick={(event) => {
// Only a plain click stays in this tab; the refused audio is of no
// use any more and the new recording replaces it.
if (!isPlainLeftClick(event)) return;
discardVoice.mutate(String(item.id));
onOpenChange(false);
}}
>
<RotateCw aria-hidden />
Erneut aufnehmen
</Link>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${voiceNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${voiceNames[index]} verwerfen?`}
description="Die Sprachnotiz wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardVoice.mutate(String(item.id))}
isPending={discardVoice.isPending}
/>
</div>
</li>
))}
</ul>
</section>
) : null}
{foreignCount > 0 ? (
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
) : null}
</div>
</NavSheet>
);
}