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,73 @@
|
||||
import Link from "next/link";
|
||||
import { MicOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const STEPS: readonly { browser: string; text: string }[] = [
|
||||
{
|
||||
browser: "Chrome und Edge",
|
||||
text: "Tippe in der Adresszeile auf das Symbol links neben der Adresse, dann auf „Berechtigungen“ und erlaube das Mikrofon.",
|
||||
},
|
||||
{
|
||||
browser: "Safari am iPhone",
|
||||
text: "Öffne Einstellungen, dann Safari, dann Mikrofon, und wähle „Fragen“ oder „Erlauben“.",
|
||||
},
|
||||
{
|
||||
browser: "Firefox",
|
||||
text: "Tippe in der Adresszeile auf das Symbol links neben der Adresse und hebe die Sperre für das Mikrofon auf.",
|
||||
},
|
||||
];
|
||||
|
||||
interface MicrophoneHelpProps {
|
||||
reason: "denied" | "no-microphone" | "unsupported";
|
||||
typeInsteadHref: string;
|
||||
onRetry?(): void;
|
||||
}
|
||||
|
||||
const TITLES: Record<MicrophoneHelpProps["reason"], string> = {
|
||||
denied: "Das Mikrofon ist nicht erlaubt.",
|
||||
"no-microphone": "Es wurde kein Mikrofon gefunden.",
|
||||
unsupported: "Dieses Gerät kann hier keine Sprachnotiz aufnehmen.",
|
||||
};
|
||||
|
||||
/** Mikrofon verweigert: what to do per browser, and a way to type instead. */
|
||||
export function MicrophoneHelp({ reason, typeInsteadHref, onRetry }: MicrophoneHelpProps) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="voice-mic-help"
|
||||
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4"
|
||||
>
|
||||
<h2 id="voice-mic-help" className="flex items-center gap-2 font-heading text-lg font-semibold">
|
||||
<MicOff className="size-5" aria-hidden />
|
||||
{TITLES[reason]}
|
||||
</h2>
|
||||
{reason === "denied" ? (
|
||||
<>
|
||||
<p className="text-sm">So erlaubst du es wieder:</p>
|
||||
<dl className="flex flex-col gap-2 text-sm">
|
||||
{STEPS.map((step) => (
|
||||
<div key={step.browser}>
|
||||
<dt className="font-medium">{step.browser}</dt>
|
||||
<dd className="text-muted-foreground">{step.text}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
nativeButton={false}
|
||||
render={<Link href={typeInsteadHref} />}
|
||||
>
|
||||
Stattdessen tippen
|
||||
</Button>
|
||||
{onRetry && reason !== "unsupported" ? (
|
||||
<Button type="button" size="touch" variant="outline" className="min-h-14" onClick={onRetry}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RECORDING_NOTICE } from "@/app/(app)/settings/voice/voice-privacy-text";
|
||||
|
||||
interface RecordingNoticeDialogProps {
|
||||
open: boolean;
|
||||
onConfirm(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
/** Shown once before the first recording (spec 9.5); it can be read again in the settings. */
|
||||
export function RecordingNoticeDialog({ open, onConfirm, onCancel }: RecordingNoticeDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
|
||||
<DialogContent role="alertdialog" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bevor du aufnimmst</DialogTitle>
|
||||
<DialogDescription>{RECORDING_NOTICE}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" size="touch" className="min-h-14" onClick={onCancel}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="button" size="touch" className="min-h-14" onClick={onConfirm}>
|
||||
Verstanden, Aufnahme starten
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
pickMimeType,
|
||||
startRecording,
|
||||
type Recording,
|
||||
type RecordingHandle,
|
||||
} from "@/lib/voice/recorder";
|
||||
|
||||
export const MAX_RECORDING_SEC = 180;
|
||||
export const WARN_AT_SEC = 150;
|
||||
/** Announce the running time to screen readers this often. */
|
||||
export const ANNOUNCE_EVERY_SEC = 30;
|
||||
/** Cancelling a recording longer than this asks first. */
|
||||
export const CONFIRM_CANCEL_AFTER_SEC = 5;
|
||||
|
||||
export type RecorderPhase =
|
||||
| "idle"
|
||||
| "starting"
|
||||
| "recording"
|
||||
| "recorded"
|
||||
| "denied"
|
||||
| "no-microphone"
|
||||
| "unsupported"
|
||||
| "failed";
|
||||
|
||||
export interface FinishedRecording extends Recording {
|
||||
/** Object URL for the native player; revoked on reset and unmount. */
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface UseVoiceRecorder {
|
||||
phase: RecorderPhase;
|
||||
sec: number;
|
||||
/** Text for the polite live region: changes every 30 s and at start, warning and stop. */
|
||||
announcement: string;
|
||||
warning: boolean;
|
||||
recording: FinishedRecording | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
cancel(): void;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function formatClock(totalSec: number): string {
|
||||
const minutes = Math.floor(totalSec / 60);
|
||||
const seconds = String(totalSec % 60).padStart(2, "0");
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/** Spoken form of the time, for the live region. */
|
||||
export function spokenDuration(totalSec: number): string {
|
||||
const minutes = Math.floor(totalSec / 60);
|
||||
const seconds = totalSec % 60;
|
||||
const parts: string[] = [];
|
||||
if (minutes > 0) {
|
||||
parts.push(minutes === 1 ? "1 Minute" : `${minutes} Minuten`);
|
||||
}
|
||||
if (seconds > 0 || minutes === 0) {
|
||||
parts.push(seconds === 1 ? "1 Sekunde" : `${seconds} Sekunden`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
const DENIED_ERRORS = new Set(["NotAllowedError", "SecurityError", "PermissionDeniedError"]);
|
||||
const MISSING_ERRORS = new Set(["NotFoundError", "OverconstrainedError", "NotReadableError"]);
|
||||
|
||||
function phaseForStartError(error: unknown): RecorderPhase {
|
||||
// Duck-typed: a DOMException is not an Error in every realm.
|
||||
const { name = "", message = "" } = (error ?? {}) as { name?: string; message?: string };
|
||||
if (DENIED_ERRORS.has(name)) {
|
||||
return "denied";
|
||||
}
|
||||
if (MISSING_ERRORS.has(name)) {
|
||||
return "no-microphone";
|
||||
}
|
||||
if (message === "Recording is not supported on this device") {
|
||||
return "unsupported";
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function makeUrl(blob: Blob): string | null {
|
||||
return typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
|
||||
? URL.createObjectURL(blob)
|
||||
: null;
|
||||
}
|
||||
|
||||
function revokeUrl(url: string | null): void {
|
||||
if (url && typeof URL.revokeObjectURL === "function") {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
const NEVER_CHANGES = () => () => undefined;
|
||||
|
||||
/** True when this browser cannot record voice at all (checked on the client only). */
|
||||
export function useRecordingUnsupported(): boolean {
|
||||
return useSyncExternalStore(
|
||||
NEVER_CHANGES,
|
||||
() => pickMimeType() === null,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The recording flow as state. The microphone is asked for only in start();
|
||||
* the recorder module releases it on stop, cancel, auto-stop and error.
|
||||
*/
|
||||
export interface UseVoiceRecorderOptions {
|
||||
/** The limit (or a hidden page) ended the recording; it is finished and playable. */
|
||||
onAutoStop?(recording: Recording): void;
|
||||
}
|
||||
|
||||
export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoiceRecorder {
|
||||
const autoStopHandler = useRef(options.onAutoStop);
|
||||
useEffect(() => {
|
||||
autoStopHandler.current = options.onAutoStop;
|
||||
});
|
||||
const unsupported = useRecordingUnsupported();
|
||||
const [phase, setPhase] = useState<RecorderPhase>("idle");
|
||||
const [sec, setSec] = useState(0);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [warning, setWarning] = useState(false);
|
||||
const [recording, setRecording] = useState<FinishedRecording | null>(null);
|
||||
const handle = useRef<RecordingHandle | null>(null);
|
||||
const urlRef = useRef<string | null>(null);
|
||||
// Bumped by cancel/reset/unmount so a late start or stop answer is ignored.
|
||||
const generation = useRef(0);
|
||||
|
||||
const finish = useCallback((result: Recording, note: string) => {
|
||||
revokeUrl(urlRef.current);
|
||||
urlRef.current = makeUrl(result.blob);
|
||||
handle.current = null;
|
||||
setRecording({ ...result, url: urlRef.current });
|
||||
setPhase("recorded");
|
||||
setAnnouncement(`${note} Dauer ${spokenDuration(result.durationSec)}.`);
|
||||
}, []);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (handle.current) {
|
||||
return;
|
||||
}
|
||||
const mine = ++generation.current;
|
||||
setPhase("starting");
|
||||
setSec(0);
|
||||
setWarning(false);
|
||||
setAnnouncement("");
|
||||
startRecording({
|
||||
maxSec: MAX_RECORDING_SEC,
|
||||
warnAtSec: WARN_AT_SEC,
|
||||
onTick(tick) {
|
||||
if (generation.current !== mine) return;
|
||||
setSec(tick);
|
||||
if (tick % ANNOUNCE_EVERY_SEC === 0) {
|
||||
setAnnouncement(`Aufnahme läuft, ${spokenDuration(tick)}.`);
|
||||
}
|
||||
},
|
||||
onWarn() {
|
||||
if (generation.current !== mine) return;
|
||||
setWarning(true);
|
||||
setAnnouncement("Noch 30 Sekunden.");
|
||||
},
|
||||
onAutoStop(result) {
|
||||
if (generation.current !== mine) return;
|
||||
finish(result, "Aufnahme beendet.");
|
||||
autoStopHandler.current?.(result);
|
||||
},
|
||||
}).then(
|
||||
(started) => {
|
||||
if (generation.current !== mine) {
|
||||
started.cancel();
|
||||
return;
|
||||
}
|
||||
handle.current = started;
|
||||
setPhase("recording");
|
||||
setAnnouncement("Aufnahme läuft.");
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (generation.current === mine) {
|
||||
setPhase(phaseForStartError(error));
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [finish]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const active = handle.current;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const mine = generation.current;
|
||||
handle.current = null;
|
||||
active.stop().then(
|
||||
(result) => {
|
||||
if (generation.current === mine) finish(result, "Aufnahme beendet.");
|
||||
},
|
||||
() => {
|
||||
if (generation.current === mine) setPhase("failed");
|
||||
},
|
||||
);
|
||||
}, [finish]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
generation.current += 1;
|
||||
handle.current?.cancel();
|
||||
handle.current = null;
|
||||
revokeUrl(urlRef.current);
|
||||
urlRef.current = null;
|
||||
setRecording(null);
|
||||
setSec(0);
|
||||
setWarning(false);
|
||||
setAnnouncement("");
|
||||
setPhase("idle");
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
generation.current += 1;
|
||||
handle.current?.cancel();
|
||||
revokeUrl(urlRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
phase: unsupported && phase === "idle" ? "unsupported" : phase,
|
||||
sec,
|
||||
announcement,
|
||||
warning,
|
||||
recording,
|
||||
start,
|
||||
stop,
|
||||
cancel: reset,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface VoiceConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Leaves the note as it is. */
|
||||
cancelLabel: string;
|
||||
confirmLabel: string;
|
||||
onCancel(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A question before a recording or note is thrown away. Both buttons are 56 px
|
||||
* high for gloved hands; the safe answer comes first.
|
||||
*/
|
||||
export function VoiceConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: VoiceConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
|
||||
<DialogContent role="alertdialog" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" size="touch" className="min-h-14" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchVoiceConfig } from "@/lib/voice/api";
|
||||
import { VoiceEntryLink } from "./voice-entry-link";
|
||||
|
||||
let permissions = new Set<string>();
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ hasPermission: (key: string) => permissions.has(key) }),
|
||||
}));
|
||||
vi.mock("@/lib/voice/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/voice/api")>()),
|
||||
fetchVoiceConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const CONFIG = {
|
||||
recordingEnabled: true,
|
||||
extractionEnabled: true,
|
||||
model: "claude-sonnet-5-5",
|
||||
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
|
||||
};
|
||||
|
||||
function renderLink(round?: string) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<VoiceEntryLink colonyId="c-1" roundApiaryId={round} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("VoiceEntryLink", () => {
|
||||
beforeEach(() => {
|
||||
permissions = new Set(["voice-captures:use", "inspections:create"]);
|
||||
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG);
|
||||
});
|
||||
|
||||
it("links to the recording page for the colony", async () => {
|
||||
renderLink();
|
||||
|
||||
const link = await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" });
|
||||
expect(link).toHaveAttribute("href", "/voice/new?colonyId=c-1");
|
||||
});
|
||||
|
||||
it("has a 56 px target", async () => {
|
||||
renderLink();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" })).toHaveClass(
|
||||
"min-h-14",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the stand round", async () => {
|
||||
renderLink("a-1");
|
||||
|
||||
const link = await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" });
|
||||
expect(link).toHaveAttribute("href", "/voice/new?colonyId=c-1&round=a-1");
|
||||
});
|
||||
|
||||
it("is not there without both permissions", async () => {
|
||||
permissions = new Set(["voice-captures:use"]);
|
||||
renderLink();
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Sprachnotiz aufnehmen" })).not.toBeInTheDocument();
|
||||
expect(fetchVoiceConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is not there while recording is switched off", async () => {
|
||||
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, recordingEnabled: false });
|
||||
renderLink();
|
||||
|
||||
await waitFor(() => expect(fetchVoiceConfig).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Sprachnotiz aufnehmen" })).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays when the config cannot be read", async () => {
|
||||
vi.mocked(fetchVoiceConfig).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
renderLink();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Mic } from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { offersRecording, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface VoiceEntryLinkProps {
|
||||
colonyId: string;
|
||||
/** The stand round the note belongs to; keeps the round going afterwards. */
|
||||
roundApiaryId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Sprachnotiz aufnehmen“ next to „Durchsicht erfassen“. Not there without the
|
||||
* rights or while the operator has recording switched off - no dead button.
|
||||
*/
|
||||
export function VoiceEntryLink({ colonyId, roundApiaryId }: VoiceEntryLinkProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const allowed = hasPermission("voice-captures:use") && hasPermission("inspections:create");
|
||||
const recordingSwitch = useVoiceRecordingSwitch(allowed);
|
||||
if (!allowed || !offersRecording(recordingSwitch)) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams({ colonyId });
|
||||
if (roundApiaryId) {
|
||||
params.set("round", roundApiaryId);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="min-h-14"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/voice/new?${params.toString()}`} />}
|
||||
>
|
||||
<Mic aria-hidden />
|
||||
Sprachnotiz aufnehmen
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import axe from "axe-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Recording, StartRecordingOptions } from "@/lib/voice/recorder";
|
||||
import { RECORDING_NOTICE } from "@/app/(app)/settings/voice/voice-privacy-text";
|
||||
import { VoiceRecorder } from "./voice-recorder";
|
||||
|
||||
let connectivity: "online" | "offline" = "online";
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => connectivity }));
|
||||
|
||||
const handle = { stop: vi.fn(), cancel: vi.fn() };
|
||||
let options: StartRecordingOptions;
|
||||
const startRecording = vi.fn();
|
||||
vi.mock("@/lib/voice/recorder", () => ({
|
||||
pickMimeType: () => "audio/webm;codecs=opus",
|
||||
startRecording: (opts: StartRecordingOptions) => startRecording(opts),
|
||||
}));
|
||||
const syncPendingEntries = vi.fn();
|
||||
vi.mock("@/lib/offline/sync", () => ({
|
||||
syncPendingEntries: (...args: unknown[]) => syncPendingEntries(...args),
|
||||
}));
|
||||
|
||||
const RECORDING: Recording = {
|
||||
blob: new Blob(["audio"], { type: "audio/webm;codecs=opus" }),
|
||||
mime: "audio/webm;codecs=opus",
|
||||
durationSec: 42,
|
||||
};
|
||||
|
||||
function renderRecorder(props: Partial<React.ComponentProps<typeof VoiceRecorder>> = {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<VoiceRecorder
|
||||
colonyId="colony-1"
|
||||
inspectionDate="2026-10-16"
|
||||
typeInsteadHref="/inspections/new?colonyId=colony-1"
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function startAndRecord(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
await screen.findByRole("button", { name: "Aufnahme beenden" });
|
||||
}
|
||||
|
||||
function tick(sec: number) {
|
||||
act(() => {
|
||||
for (let second = 1; second <= sec; second += 1) options.onTick(second);
|
||||
});
|
||||
}
|
||||
|
||||
function liveText(): string {
|
||||
return document.querySelector('[aria-live="polite"]')?.textContent ?? "";
|
||||
}
|
||||
|
||||
describe("VoiceRecorder", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
// The module keeps its first connection: empty the store instead of the factory.
|
||||
const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
await discardAllPendingVoice("user-1");
|
||||
window.localStorage.clear();
|
||||
window.localStorage.setItem("apicore.voice.notice-seen", "1");
|
||||
connectivity = "online";
|
||||
handle.stop.mockReset().mockResolvedValue(RECORDING);
|
||||
handle.cancel.mockReset();
|
||||
startRecording.mockReset().mockImplementation(async (opts: StartRecordingOptions) => {
|
||||
options = opts;
|
||||
return handle;
|
||||
});
|
||||
syncPendingEntries.mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
});
|
||||
|
||||
it("shows words and not just color while recording", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await startAndRecord(user);
|
||||
tick(75);
|
||||
|
||||
expect(screen.getByText("Aufnahme läuft", { selector: "p" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/1:15/)).toBeInTheDocument();
|
||||
// The same place: the start button became the stop button.
|
||||
expect(screen.queryByRole("button", { name: "Aufnahme starten" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not pulse for people who asked for less motion", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await startAndRecord(user);
|
||||
|
||||
const dot = screen.getByText("Aufnahme läuft", { selector: "p" }).querySelector("span");
|
||||
expect(dot?.className).toContain("motion-safe:animate-pulse");
|
||||
expect(dot?.className.split(" ")).not.toContain("animate-pulse");
|
||||
});
|
||||
|
||||
it("announces the time only every 30 seconds", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
const heard = new Set<string>();
|
||||
for (let second = 1; second <= 61; second += 1) {
|
||||
act(() => options.onTick(second));
|
||||
heard.add(liveText());
|
||||
}
|
||||
|
||||
expect([...heard]).toEqual([
|
||||
"Aufnahme läuft.",
|
||||
"Aufnahme läuft, 30 Sekunden.",
|
||||
"Aufnahme läuft, 1 Minute.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns 30 seconds before the end and announces it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
tick(150);
|
||||
act(() => options.onWarn());
|
||||
|
||||
expect(screen.getByText("Noch 30 Sekunden")).toBeInTheDocument();
|
||||
expect(liveText()).toBe("Noch 30 Sekunden.");
|
||||
});
|
||||
|
||||
it("keeps what was recorded when the limit stops the recording", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: /Aufnahme fertig · 3:00/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Senden" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the denied-state with a way to type instead", async () => {
|
||||
startRecording.mockRejectedValue(new DOMException("denied", "NotAllowedError"));
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
|
||||
expect(await screen.findByText("Das Mikrofon ist nicht erlaubt.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Safari am iPhone")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Stattdessen tippen" })).toHaveAttribute(
|
||||
"href",
|
||||
"/inspections/new?colonyId=colony-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the notice once before the first recording", async () => {
|
||||
window.localStorage.clear();
|
||||
const user = userEvent.setup();
|
||||
const first = renderRecorder();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(within(dialog).getByText(RECORDING_NOTICE)).toBeInTheDocument();
|
||||
expect(startRecording).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: /Verstanden/ }));
|
||||
await screen.findByRole("button", { name: "Aufnahme beenden" });
|
||||
expect(startRecording).toHaveBeenCalledTimes(1);
|
||||
|
||||
first.unmount();
|
||||
renderRecorder();
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
|
||||
expect(startRecording).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not record when the notice is declined", async () => {
|
||||
window.localStorage.clear();
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
expect(startRecording).not.toHaveBeenCalled();
|
||||
expect(window.localStorage.getItem("apicore.voice.notice-seen")).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels at once when only a moment was recorded", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
tick(2);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
expect(handle.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("button", { name: "Aufnahme starten" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks before cancelling a longer recording", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
tick(12);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(handle.cancel).not.toHaveBeenCalled();
|
||||
await user.click(within(dialog).getByRole("button", { name: "Aufnahme löschen" }));
|
||||
|
||||
expect(handle.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByRole("button", { name: "Aufnahme starten" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offline the main action reads Speichern and the note lands in the queue", async () => {
|
||||
connectivity = "offline";
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Gespeichert. Wird gesendet, sobald Netz da ist."),
|
||||
).toBeInTheDocument();
|
||||
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
expect(await listPendingVoice("user-1")).toHaveLength(1);
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("online it sends right away and says so", async () => {
|
||||
const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
syncPendingEntries.mockImplementation(async () => {
|
||||
await discardAllPendingVoice("user-1");
|
||||
return { synced: 1, failed: 0, skippedForeign: 0 };
|
||||
});
|
||||
const onSaved = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderRecorder({ onSaved });
|
||||
await startAndRecord(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Senden" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Gesendet. Du bekommst eine Meldung, wenn es fertig ist."),
|
||||
).toBeInTheDocument();
|
||||
expect(onSaved).toHaveBeenCalledWith({ kind: "sent" });
|
||||
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true });
|
||||
});
|
||||
|
||||
it("says why the server refused the recording and offers a new one", async () => {
|
||||
const { listPendingVoice, markVoiceFailed } = await import("@/lib/voice/pending-voice");
|
||||
syncPendingEntries.mockImplementation(async () => {
|
||||
const [note] = await listPendingVoice("user-1");
|
||||
await markVoiceFailed(note.id, { status: 413, message: "zu groß", details: { code: "TOO_LARGE" } } as never);
|
||||
return { synced: 0, failed: 1, skippedForeign: 0 };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Senden" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Die Sprachnotiz ist zu groß");
|
||||
await user.click(screen.getByRole("button", { name: "Neu aufnehmen" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Aufnahme starten" })).toBeInTheDocument();
|
||||
await waitFor(async () => expect(await listPendingVoice("user-1")).toHaveLength(0));
|
||||
});
|
||||
|
||||
|
||||
describe("focus", () => {
|
||||
async function recordAndFinish(user: ReturnType<typeof userEvent.setup>) {
|
||||
await startAndRecord(user);
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
return screen.findByRole("button", { name: /Senden|Speichern/ });
|
||||
}
|
||||
const resultHeading = () => screen.getByRole("heading", { name: "Ergebnis" });
|
||||
|
||||
it("moves to the result heading after a sent note", async () => {
|
||||
const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
syncPendingEntries.mockImplementation(async () => {
|
||||
await discardAllPendingVoice("user-1");
|
||||
return { synced: 1, failed: 0, skippedForeign: 0 };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(await recordAndFinish(user));
|
||||
|
||||
await screen.findByText(/^Gesendet/);
|
||||
await waitFor(() => expect(document.activeElement).toBe(resultHeading()));
|
||||
});
|
||||
|
||||
it("moves to the result heading after a queued note", async () => {
|
||||
connectivity = "offline";
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(await recordAndFinish(user));
|
||||
|
||||
await screen.findByText(/^Gespeichert/);
|
||||
await waitFor(() => expect(document.activeElement).toBe(resultHeading()));
|
||||
});
|
||||
|
||||
it("moves to the result heading after a refused note", async () => {
|
||||
const { listPendingVoice, markVoiceFailed } = await import("@/lib/voice/pending-voice");
|
||||
syncPendingEntries.mockImplementation(async () => {
|
||||
const [note] = await listPendingVoice("user-1");
|
||||
await markVoiceFailed(note.id, {
|
||||
status: 413,
|
||||
message: "x",
|
||||
details: { code: "TOO_LARGE" },
|
||||
} as never);
|
||||
return { synced: 0, failed: 1, skippedForeign: 0 };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(await recordAndFinish(user));
|
||||
|
||||
await screen.findByRole("alert");
|
||||
await waitFor(() => expect(document.activeElement).toBe(resultHeading()));
|
||||
});
|
||||
|
||||
it("keeps the main button focusable (aria-disabled) while the microphone starts", async () => {
|
||||
startRecording.mockReturnValue(new Promise(() => undefined));
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
|
||||
const button = screen.getByRole("button", { name: "Aufnahme starten" });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(document.activeElement).toBe(button);
|
||||
await user.click(button);
|
||||
expect(startRecording).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns to the main button after cancelling", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
const button = await screen.findByRole("button", { name: "Aufnahme starten" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(button));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a recording the limit stopped", () => {
|
||||
it("is on the device at once and shows the saved state", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Gespeichert. Wird gesendet, sobald Netz da ist."),
|
||||
).toBeInTheDocument();
|
||||
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
expect(await listPendingVoice("user-1")).toHaveLength(1);
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is only synced, not queued twice, when Senden is pressed", async () => {
|
||||
const { listPendingVoice, discardAllPendingVoice } = await import(
|
||||
"@/lib/voice/pending-voice"
|
||||
);
|
||||
syncPendingEntries.mockImplementation(async () => {
|
||||
await discardAllPendingVoice("user-1");
|
||||
return { synced: 1, failed: 0, skippedForeign: 0 };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||
await screen.findByText(/^Gespeichert/);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Senden" }));
|
||||
|
||||
expect(await screen.findByText(/^Gesendet/)).toBeInTheDocument();
|
||||
expect(syncPendingEntries).toHaveBeenCalledTimes(1);
|
||||
expect(await listPendingVoice("user-1")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is removed from the device when the user records anew", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||
await screen.findByText(/^Gespeichert/);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Neu aufnehmen" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Neu aufnehmen" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Aufnahme starten" });
|
||||
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
await waitFor(async () => expect(await listPendingVoice("user-1")).toHaveLength(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaving", () => {
|
||||
function leave(): boolean {
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
|
||||
it("is guarded while recording and while the result is unsaved, and free otherwise", async () => {
|
||||
connectivity = "offline";
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
expect(leave()).toBe(false);
|
||||
|
||||
await startAndRecord(user);
|
||||
expect(leave()).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
await screen.findByRole("button", { name: "Speichern" });
|
||||
expect(leave()).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await screen.findByText(/^Gespeichert/);
|
||||
expect(leave()).toBe(false);
|
||||
});
|
||||
|
||||
it("is free again after cancelling", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
expect(leave()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("asks before throwing away a finished recording", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Neu aufnehmen" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(screen.queryByRole("button", { name: "Aufnahme starten" })).not.toBeInTheDocument();
|
||||
await user.click(within(dialog).getByRole("button", { name: "Neu aufnehmen" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Aufnahme starten" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives every dialog button a 56 px target", async () => {
|
||||
window.localStorage.clear();
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
const notice = await screen.findByRole("alertdialog");
|
||||
for (const button of within(notice).getAllByRole("button")) {
|
||||
expect(button).toHaveClass("min-h-14");
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks recording with a reason until a colony is chosen", () => {
|
||||
renderRecorder({ blockedReason: "Bitte zuerst ein Volk wählen." });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Aufnahme starten" })).toBeDisabled();
|
||||
expect(screen.getByText("Bitte zuerst ein Volk wählen.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("axe finds nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderRecorder();
|
||||
const run = () => axe.run(container, { rules: { "color-contrast": { enabled: false } } });
|
||||
|
||||
expect((await run()).violations).toEqual([]);
|
||||
await startAndRecord(user);
|
||||
tick(5);
|
||||
expect((await run()).violations).toEqual([]);
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
await screen.findByRole("button", { name: "Senden" });
|
||||
expect((await run()).violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Mic, Square } from "lucide-react";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
||||
import { voiceKeys } from "@/lib/voice/api";
|
||||
import { hasSeenRecordingNotice, markRecordingNoticeSeen } from "@/lib/voice/consent";
|
||||
import { discardPendingVoice, VoiceStorageFullError } from "@/lib/voice/pending-voice";
|
||||
import type { Recording } from "@/lib/voice/recorder";
|
||||
import { saveVoiceNote, type SaveVoiceOutcome } from "@/lib/voice/save-voice-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MicrophoneHelp } from "./microphone-help";
|
||||
import { RecordingNoticeDialog } from "./recording-notice-dialog";
|
||||
import { VoiceConfirmDialog } from "./voice-confirm-dialog";
|
||||
import {
|
||||
CONFIRM_CANCEL_AFTER_SEC,
|
||||
MAX_RECORDING_SEC,
|
||||
formatClock,
|
||||
useVoiceRecorder,
|
||||
} from "./use-voice-recorder";
|
||||
|
||||
const SENT_TEXT = "Gesendet. Du bekommst eine Meldung, wenn es fertig ist.";
|
||||
const QUEUED_TEXT = "Gespeichert. Wird gesendet, sobald Netz da ist.";
|
||||
const SAVE_FAILED_TEXT =
|
||||
"Die Sprachnotiz konnte nicht gespeichert werden. Bitte noch einmal versuchen.";
|
||||
const FAILED_TEXT = "Die Aufnahme hat nicht geklappt. Bitte noch einmal versuchen.";
|
||||
|
||||
type Confirming = "cancel" | "restart" | "discard" | null;
|
||||
|
||||
const NEVER_CHANGES = () => () => undefined;
|
||||
|
||||
function useWakeLockMissing(): boolean {
|
||||
return useSyncExternalStore(
|
||||
NEVER_CHANGES,
|
||||
() => !("wakeLock" in navigator),
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export interface VoiceRecorderProps {
|
||||
colonyId: string;
|
||||
/** Calendar day "YYYY-MM-DD". */
|
||||
inspectionDate: string;
|
||||
/** Where "Stattdessen tippen" leads: the inspection form for the same colony. */
|
||||
typeInsteadHref: string;
|
||||
/** Recording is off for this reason (e.g. no colony chosen yet). */
|
||||
blockedReason?: string | null;
|
||||
/** True while audio exists that is not on the device yet: the page locks colony and date. */
|
||||
onBusyChange?(busy: boolean): void;
|
||||
onSaved?(outcome: SaveVoiceOutcome): void;
|
||||
/** Rendered after saving, e.g. "Weiter mit B4". */
|
||||
savedActions?: ReactNode;
|
||||
}
|
||||
|
||||
function outcomeText(outcome: SaveVoiceOutcome): string {
|
||||
if (outcome.kind === "sent") return SENT_TEXT;
|
||||
if (outcome.kind === "queued") {
|
||||
return outcome.hint ? `${QUEUED_TEXT} ${outcome.hint}` : QUEUED_TEXT;
|
||||
}
|
||||
return outcome.text;
|
||||
}
|
||||
|
||||
export function VoiceRecorder({
|
||||
colonyId,
|
||||
inspectionDate,
|
||||
typeInsteadHref,
|
||||
blockedReason = null,
|
||||
onBusyChange,
|
||||
onSaved,
|
||||
savedActions,
|
||||
}: VoiceRecorderProps) {
|
||||
const userId = useCurrentUserId();
|
||||
const online = useConnectivity() === "online";
|
||||
const queryClient = useQueryClient();
|
||||
const wakeLockMissing = useWakeLockMissing();
|
||||
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||
const [confirming, setConfirming] = useState<Confirming>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [outcome, setOutcome] = useState<SaveVoiceOutcome | null>(null);
|
||||
// The recording's note on the device, once it has one: a limit stop queues at once.
|
||||
const [queuedNoteId, setQueuedNoteId] = useState<string | null>(null);
|
||||
const resultHeading = useRef<HTMLHeadingElement>(null);
|
||||
const mainButton = useRef<HTMLButtonElement>(null);
|
||||
const focusMainWhenIdle = useRef(false);
|
||||
|
||||
function afterQueueChange() {
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
}
|
||||
|
||||
/** A recording the limit ended must not depend on the user pressing "Speichern". */
|
||||
function queueAtOnce(recording: Recording) {
|
||||
if (!userId) return;
|
||||
saveVoiceNote({
|
||||
userId,
|
||||
recording,
|
||||
colonyId,
|
||||
inspectionDate,
|
||||
online: false,
|
||||
onQueued: setQueuedNoteId,
|
||||
}).then(afterQueueChange, (error: unknown) =>
|
||||
setSaveError(error instanceof VoiceStorageFullError ? error.message : SAVE_FAILED_TEXT),
|
||||
);
|
||||
}
|
||||
|
||||
const recorder = useVoiceRecorder({ onAutoStop: queueAtOnce });
|
||||
const { phase, sec, recording } = recorder;
|
||||
|
||||
const unsaved =
|
||||
phase === "starting" ||
|
||||
phase === "recording" ||
|
||||
(phase === "recorded" && !outcome && !queuedNoteId);
|
||||
useEffect(() => {
|
||||
onBusyChange?.(unsaved);
|
||||
}, [unsaved, onBusyChange]);
|
||||
|
||||
// Leaving the page would lose the recording (or the finished result).
|
||||
useEffect(() => {
|
||||
if (!unsaved) return;
|
||||
const guard = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", guard);
|
||||
return () => window.removeEventListener("beforeunload", guard);
|
||||
}, [unsaved]);
|
||||
|
||||
// The big button disappears after the stop and the result changes again after
|
||||
// saving: keyboard focus follows to the result.
|
||||
const showsResult = phase === "recorded" || outcome !== null;
|
||||
useEffect(() => {
|
||||
if (showsResult) resultHeading.current?.focus();
|
||||
}, [phase, outcome, showsResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === "idle" && focusMainWhenIdle.current) {
|
||||
focusMainWhenIdle.current = false;
|
||||
mainButton.current?.focus();
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
function pressMainButton() {
|
||||
if (phase === "starting") return;
|
||||
if (phase === "recording") {
|
||||
recorder.stop();
|
||||
return;
|
||||
}
|
||||
if (hasSeenRecordingNotice()) {
|
||||
recorder.start();
|
||||
} else {
|
||||
setNoticeOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmNotice() {
|
||||
markRecordingNoticeSeen();
|
||||
setNoticeOpen(false);
|
||||
recorder.start();
|
||||
}
|
||||
|
||||
function cancelRecording() {
|
||||
if (sec > CONFIRM_CANCEL_AFTER_SEC) {
|
||||
setConfirming("cancel");
|
||||
} else {
|
||||
focusMainWhenIdle.current = true;
|
||||
recorder.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the screen for the next recording; the saved note stays on the device. */
|
||||
function startOver() {
|
||||
setOutcome(null);
|
||||
setSaveError(null);
|
||||
setQueuedNoteId(null);
|
||||
recorder.reset();
|
||||
}
|
||||
|
||||
/** The user gives this recording up: a note it already has on the device goes too. */
|
||||
async function throwAway() {
|
||||
setConfirming(null);
|
||||
focusMainWhenIdle.current = true;
|
||||
if (queuedNoteId && userId) {
|
||||
await discardPendingVoice(queuedNoteId, userId).catch(() => undefined);
|
||||
afterQueueChange();
|
||||
}
|
||||
startOver();
|
||||
}
|
||||
|
||||
async function startOverAfterRejection(noteId: string) {
|
||||
if (userId) {
|
||||
await discardPendingVoice(noteId, userId).catch(() => undefined);
|
||||
afterQueueChange();
|
||||
}
|
||||
startOver();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!recording || !userId || saving) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const result = await saveVoiceNote({
|
||||
userId,
|
||||
recording,
|
||||
colonyId,
|
||||
inspectionDate,
|
||||
online,
|
||||
queuedNoteId,
|
||||
onQueued: setQueuedNoteId,
|
||||
});
|
||||
setOutcome(result);
|
||||
afterQueueChange();
|
||||
void queryClient.invalidateQueries({ queryKey: voiceKeys.all });
|
||||
onSaved?.(result);
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof VoiceStorageFullError ? error.message : SAVE_FAILED_TEXT);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const liveRegion = (
|
||||
<p role="status" aria-live="polite" className="sr-only">
|
||||
{recorder.announcement}
|
||||
</p>
|
||||
);
|
||||
|
||||
if (phase === "denied" || phase === "no-microphone" || phase === "unsupported") {
|
||||
return (
|
||||
<>
|
||||
{liveRegion}
|
||||
<MicrophoneHelp
|
||||
reason={phase}
|
||||
typeInsteadHref={typeInsteadHref}
|
||||
onRetry={phase === "unsupported" ? undefined : recorder.reset}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (outcome) {
|
||||
const rejected = outcome.kind === "rejected";
|
||||
return (
|
||||
<section aria-labelledby="voice-result" className="flex flex-col gap-4">
|
||||
{liveRegion}
|
||||
<h2 id="voice-result" ref={resultHeading} tabIndex={-1} className="sr-only">
|
||||
Ergebnis
|
||||
</h2>
|
||||
<p
|
||||
role={rejected ? "alert" : "status"}
|
||||
className="flex items-start gap-2 text-lg font-medium"
|
||||
>
|
||||
{rejected ? null : (
|
||||
<CheckCircle2
|
||||
className="mt-0.5 size-5 shrink-0 text-condition-good-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{outcomeText(outcome)}
|
||||
</p>
|
||||
{outcome.kind === "rejected" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
onClick={() => void startOverAfterRejection(outcome.noteId)}
|
||||
>
|
||||
Neu aufnehmen
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{savedActions}
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="min-h-14"
|
||||
onClick={startOver}
|
||||
>
|
||||
Noch eine Aufnahme
|
||||
</Button>
|
||||
<Button
|
||||
size="touch"
|
||||
variant="ghost"
|
||||
className="min-h-14"
|
||||
nativeButton={false}
|
||||
render={<Link href="/voice" />}
|
||||
>
|
||||
Zu den Sprachnotizen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "recorded" && recording) {
|
||||
return (
|
||||
<section aria-labelledby="voice-result" className="flex flex-col gap-4">
|
||||
{liveRegion}
|
||||
<h2
|
||||
id="voice-result"
|
||||
ref={resultHeading}
|
||||
tabIndex={-1}
|
||||
className="font-heading text-lg font-semibold"
|
||||
>
|
||||
Aufnahme fertig · {formatClock(recording.durationSec)}
|
||||
</h2>
|
||||
{queuedNoteId ? (
|
||||
<p role="status" className="flex items-start gap-2 text-base font-medium">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 size-5 shrink-0 text-condition-good-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
{QUEUED_TEXT}
|
||||
</p>
|
||||
) : null}
|
||||
{recording.url ? (
|
||||
<audio controls src={recording.url} className="w-full" aria-label="Aufnahme anhören" />
|
||||
) : null}
|
||||
{saveError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{saveError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
className="min-h-14 text-base"
|
||||
disabled={saving || !userId}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? "Wird gespeichert…" : online ? "Senden" : "Speichern"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="min-h-14"
|
||||
disabled={saving}
|
||||
onClick={() => setConfirming("restart")}
|
||||
>
|
||||
Neu aufnehmen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="ghost"
|
||||
className="min-h-14"
|
||||
disabled={saving}
|
||||
onClick={() => setConfirming("discard")}
|
||||
>
|
||||
Verwerfen
|
||||
</Button>
|
||||
<VoiceConfirmDialog
|
||||
open={confirming === "restart" || confirming === "discard"}
|
||||
title={confirming === "restart" ? "Neu aufnehmen?" : "Aufnahme verwerfen?"}
|
||||
description="Die Aufnahme wird gelöscht und nicht gesendet."
|
||||
cancelLabel="Behalten"
|
||||
confirmLabel={confirming === "restart" ? "Neu aufnehmen" : "Verwerfen"}
|
||||
onCancel={() => setConfirming(null)}
|
||||
onConfirm={() => void throwAway()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const recordingNow = phase === "recording";
|
||||
const starting = phase === "starting";
|
||||
const blocked = Boolean(blockedReason);
|
||||
return (
|
||||
<section aria-label="Aufnahme" className="flex flex-col items-center gap-4">
|
||||
{liveRegion}
|
||||
{recordingNow ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="flex items-center gap-2 text-lg font-semibold text-destructive">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-3 rounded-full bg-destructive motion-safe:animate-pulse"
|
||||
/>
|
||||
Aufnahme läuft
|
||||
</p>
|
||||
<p className="font-heading text-4xl font-semibold tabular-nums">
|
||||
{formatClock(sec)}
|
||||
<span className="text-base font-normal text-muted-foreground">
|
||||
{" "}
|
||||
von {formatClock(MAX_RECORDING_SEC)}
|
||||
</span>
|
||||
</p>
|
||||
{recorder.warning ? <p className="text-base font-medium">Noch 30 Sekunden</p> : null}
|
||||
{wakeLockMissing ? (
|
||||
<p className="text-sm text-muted-foreground">Display anlassen</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
ref={mainButton}
|
||||
type="button"
|
||||
size="touch"
|
||||
variant={recordingNow ? "destructive" : "default"}
|
||||
className="h-18 w-full max-w-sm text-lg"
|
||||
// aria-disabled keeps the keyboard focus on the button while the microphone starts.
|
||||
disabled={blocked}
|
||||
aria-disabled={starting || undefined}
|
||||
aria-describedby={blockedReason ? "voice-blocked" : undefined}
|
||||
onClick={pressMainButton}
|
||||
>
|
||||
{recordingNow ? <Square aria-hidden /> : <Mic aria-hidden />}
|
||||
{recordingNow ? "Aufnahme beenden" : "Aufnahme starten"}
|
||||
</Button>
|
||||
{blockedReason ? (
|
||||
<p id="voice-blocked" className="text-sm text-muted-foreground">
|
||||
{blockedReason}
|
||||
</p>
|
||||
) : null}
|
||||
{phase === "failed" ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{FAILED_TEXT}
|
||||
</p>
|
||||
) : null}
|
||||
{recordingNow ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="min-h-14"
|
||||
onClick={cancelRecording}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
) : null}
|
||||
<RecordingNoticeDialog
|
||||
open={noticeOpen}
|
||||
onConfirm={confirmNotice}
|
||||
onCancel={() => setNoticeOpen(false)}
|
||||
/>
|
||||
<VoiceConfirmDialog
|
||||
open={confirming === "cancel"}
|
||||
title="Aufnahme abbrechen?"
|
||||
description="Die Aufnahme wird gelöscht. Das lässt sich nicht rückgängig machen."
|
||||
cancelLabel="Weiter aufnehmen"
|
||||
confirmLabel="Aufnahme löschen"
|
||||
onCancel={() => setConfirming(null)}
|
||||
onConfirm={() => {
|
||||
setConfirming(null);
|
||||
focusMainWhenIdle.current = true;
|
||||
recorder.cancel();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user