- 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]>
539 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|