feat: Aufnahmeseite und Liste der Sprachnotizen (T7 L5 Task 19)
/voice/new: großer Aufnahmeknopf mit Zeit, Hinweis bei 150 s, Stopp bei 180 s; eine automatisch beendete Aufnahme liegt sofort in der Warteschlange des Geräts (Ruling 13); Abbrechen und Neu aufnehmen fragen nach; Hilfe bei gesperrtem Mikrofon; Fokus bleibt bei jedem Zustandswechsel an einem Bedienelement; Volk und Datum während der Aufnahme gesperrt, ?date= auf die letzten 7 Tage begrenzt. /voice: wartende Notizen des Geräts zuerst, dann die des Servers mit Status, „Prüfen“ nur bei fertigen, Verwerfen mit Rückfrage. Einstiege über Schnellanlage, Menü (nur mit voice-captures:use und inspections:create), Volksseite und Durchsicht. Bei ausgeschalteter Aufnahme verschwinden die Einstiege; jeder HTTP-Fehler der Konfiguration zählt als aus, nur fehlendes Netz als unbekannt. Fehlertexte gleich mit dem Backend (Test). Gates: Frontend lint, typecheck, 2979 Tests, build. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
462ac46d1e
commit
e0be687b7c
33 files changed
+3460
-19
No files matched your search
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { VOICE_TOO_MANY_OPEN_HINT, voiceRejectionText } from "@/lib/offline/queue-texts";
|
||||
import type { VoiceCaptureListItem } from "@/lib/voice/api";
|
||||
import type { StoredVoiceNote } from "@/lib/voice/pending-voice";
|
||||
import {
|
||||
VOICE_WAITING_FOR_NETWORK,
|
||||
describeVoiceCapture,
|
||||
type VoiceStatusIcon,
|
||||
type VoiceStatusText,
|
||||
} from "@/lib/voice/status-texts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { VoiceConfirmDialog } from "./voice-confirm-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const STATUS_ICONS: Record<VoiceStatusIcon, LucideIcon> = {
|
||||
wait: Clock,
|
||||
work: Loader2,
|
||||
ready: CheckCircle2,
|
||||
failed: AlertTriangle,
|
||||
};
|
||||
|
||||
const TIME_FORMAT = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
|
||||
function timeText(iso: string): string {
|
||||
return `${TIME_FORMAT.format(new Date(iso))} Uhr`.replace(", ", " · ");
|
||||
}
|
||||
|
||||
function StatusLine({ status }: { status: VoiceStatusText }) {
|
||||
const Icon = STATUS_ICONS[status.icon];
|
||||
return (
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<Icon
|
||||
className={`size-4 shrink-0${status.icon === "work" ? " motion-safe:animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{status.label}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface DiscardMenuProps {
|
||||
/** Names the menu for assistive tech, e.g. "B3, 14.10. · 09:12 Uhr". */
|
||||
subject: string;
|
||||
onDiscard(): void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** „Mehr“ with „Verwerfen“, which asks first: a discarded note cannot come back. */
|
||||
function DiscardMenu({ subject, onDiscard, disabled }: DiscardMenuProps) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
aria-label={`Mehr zu ${subject}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Mehr
|
||||
<ChevronDown aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto min-w-48">
|
||||
<DropdownMenuItem
|
||||
className="min-h-11 px-3"
|
||||
disabled={disabled}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
Verwerfen
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<VoiceConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Sprachnotiz verwerfen?"
|
||||
description="Die Sprachnotiz wird gelöscht. Das lässt sich nicht rückgängig machen."
|
||||
cancelLabel="Abbrechen"
|
||||
confirmLabel="Verwerfen"
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmOpen(false);
|
||||
onDiscard();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardShellProps {
|
||||
title: string;
|
||||
time: string;
|
||||
status: VoiceStatusText;
|
||||
detail?: string | null;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function CardShell({ title, time, status, detail, children }: CardShellProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||
<h2 className="font-heading text-lg font-semibold">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{time}</p>
|
||||
</div>
|
||||
<StatusLine status={status} />
|
||||
{detail ? <p className="text-sm text-muted-foreground">{detail}</p> : null}
|
||||
{children ? <div className="flex flex-wrap gap-2 [&>*]:flex-1 sm:[&>*]:flex-none">{children}</div> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface LocalVoiceCardProps {
|
||||
note: StoredVoiceNote;
|
||||
colonyName: string;
|
||||
/** Whether the device is online: a waiting note is then about to be sent. */
|
||||
online: boolean;
|
||||
onDiscard(): void;
|
||||
}
|
||||
|
||||
/** What a recording on this device is doing, in words. */
|
||||
function localStatus(note: StoredVoiceNote, online: boolean): { status: VoiceStatusText; detail: string | null } {
|
||||
if (note.lastError) {
|
||||
return {
|
||||
status: { label: "Fehlgeschlagen", icon: "failed" },
|
||||
detail: voiceRejectionText(note.lastError),
|
||||
};
|
||||
}
|
||||
if (note.blockedBy === "TOO_MANY_OPEN") {
|
||||
return {
|
||||
status: { label: "Wartet auf freien Platz", icon: "wait" },
|
||||
detail: VOICE_TOO_MANY_OPEN_HINT,
|
||||
};
|
||||
}
|
||||
if (note.state === "uploading" || online) {
|
||||
return { status: { label: "Wird gesendet", icon: "work" }, detail: null };
|
||||
}
|
||||
return { status: VOICE_WAITING_FOR_NETWORK, detail: null };
|
||||
}
|
||||
|
||||
/** A recording that still waits on this device. */
|
||||
export function LocalVoiceCard({ note, colonyName, online, onDiscard }: LocalVoiceCardProps) {
|
||||
const time = timeText(note.createdAt);
|
||||
const { status, detail } = localStatus(note, online);
|
||||
const again = new URLSearchParams({ colonyId: note.colonyId, date: note.inspectionDate });
|
||||
return (
|
||||
<CardShell title={colonyName} time={time} status={status} detail={detail}>
|
||||
{note.lastError ? (
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/voice/new?${again.toString()}`} />}
|
||||
>
|
||||
Neu aufnehmen
|
||||
</Button>
|
||||
) : null}
|
||||
<DiscardMenu subject={`${colonyName}, ${time}`} onDiscard={onDiscard} />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
interface VoiceCaptureCardProps {
|
||||
item: VoiceCaptureListItem;
|
||||
/** False when the operator switched the evaluation off. */
|
||||
extractionEnabled?: boolean;
|
||||
onRetry(): void;
|
||||
onDiscard(): void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
/** A note the server holds. */
|
||||
export function VoiceCaptureCard({
|
||||
item,
|
||||
extractionEnabled = true,
|
||||
onRetry,
|
||||
onDiscard,
|
||||
busy,
|
||||
}: VoiceCaptureCardProps) {
|
||||
const state = describeVoiceCapture(item, extractionEnabled);
|
||||
const time = timeText(item.createdAt);
|
||||
const reviewHref = `/inspections/new?colonyId=${encodeURIComponent(item.colonyId)}&voice=${encodeURIComponent(item.id)}`;
|
||||
return (
|
||||
<CardShell title={item.colonyName} time={time} status={state.status} detail={state.detail}>
|
||||
{state.action !== "none" ? (
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
nativeButton={false}
|
||||
render={<Link href={reviewHref} />}
|
||||
>
|
||||
{state.action === "review" ? "Prüfen" : "Text ansehen"}
|
||||
</Button>
|
||||
) : null}
|
||||
{state.canRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="min-h-14"
|
||||
disabled={busy}
|
||||
onClick={onRetry}
|
||||
>
|
||||
Nochmal versuchen
|
||||
</Button>
|
||||
) : null}
|
||||
<DiscardMenu subject={`${item.colonyName}, ${time}`} onDiscard={onDiscard} disabled={busy} />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user