@@ -134,7 +137,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
size="touch"
variant="outline"
className="min-h-14"
- disabled={busy}
+ disabled={locked}
aria-expanded={editing}
onClick={() => setEditing((open) => !open)}
>
@@ -144,7 +147,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
{editing ? (
// Colony and date are fixed while audio is being recorded or waits to be saved.
Volk und Datum ändern
@@ -177,7 +180,9 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
typeInsteadHref={inspectionHref(colonyId, roundApiaryId)}
blockedReason={colonyId ? null : NO_COLONY_REASON}
onBusyChange={setBusy}
+ onReset={() => setSaved(false)}
onSaved={(outcome) => {
+ setSaved(true);
if (outcome.kind !== "rejected" && stand.round) {
setSavedRound(stand.markDone(colonyId));
}
diff --git a/ApiCore-Frontend/src/app/(app)/voice/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/voice/page.spec.tsx
index d54ec59..829ffd7 100644
--- a/ApiCore-Frontend/src/app/(app)/voice/page.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/voice/page.spec.tsx
@@ -299,12 +299,37 @@ describe("VoiceListPage", () => {
expect(await screen.findByText(/Du bist offline/)).toBeInTheDocument();
expect(screen.queryByText(/konnten nicht geladen werden/)).not.toBeInTheDocument();
- expect(screen.queryByText("Noch keine Sprachnotizen.")).not.toBeInTheDocument();
+ // Nothing on the device either: the empty state stays, with the offline hint.
+ expect(screen.getByText("Noch keine Sprachnotizen.")).toBeInTheDocument();
} finally {
onlineManager.setOnline(true);
}
});
+ it("moves focus to the list heading after the last device note is discarded", async () => {
+ connectivity = "offline";
+ await queueVoiceNote({
+ ownerUserId: "user-1",
+ blob: new Blob(["a"]),
+ mime: "audio/webm",
+ durationSec: 20,
+ colonyId: "c-2",
+ inspectionDate: "2026-10-12",
+ });
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(await screen.findByRole("button", { name: /Mehr zu B4/ }));
+ await user.click(await screen.findByRole("menuitem", { name: "Verwerfen" }));
+ await user.click(
+ within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
+ );
+
+ const heading = await screen.findByRole("heading", { name: "Liste der Sprachnotizen" });
+ await waitFor(() => expect(document.activeElement).toBe(heading));
+ expect(await screen.findByText("Noch keine Sprachnotizen.")).toBeInTheDocument();
+ });
+
it("reads a transcribed note as Text liegt vor when the evaluation is off", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, extractionEnabled: false });
vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({ status: "TRANSCRIBED" })]);
diff --git a/ApiCore-Frontend/src/app/(app)/voice/page.tsx b/ApiCore-Frontend/src/app/(app)/voice/page.tsx
index 28753d4..a1da353 100644
--- a/ApiCore-Frontend/src/app/(app)/voice/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/voice/page.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useRef } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Mic } from "lucide-react";
@@ -111,6 +112,10 @@ function VoiceNotes() {
});
const coloniesQuery = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
+ const listHeading = useRef(null);
+ // The card with the focus is gone after a discard: the focus goes to the list's heading.
+ const focusList = () => listHeading.current?.focus();
+
const retry = useMutation({
mutationFn: (id: string) => retryVoiceCapture(id),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }),
@@ -118,12 +123,18 @@ function VoiceNotes() {
});
const discard = useMutation({
mutationFn: (id: string) => discardVoiceCapture(id),
- onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }),
+ onSuccess: () => {
+ focusList();
+ void queryClient.invalidateQueries({ queryKey: voiceKeys.all });
+ },
onError: () => toast.error("Die Sprachnotiz konnte nicht verworfen werden."),
});
const discardLocal = useMutation({
mutationFn: (id: string) => discardPendingVoice(id, userId ?? ""),
- onSuccess: () => void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }),
+ onSuccess: () => {
+ focusList();
+ void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
+ },
onError: () => toast.error("Die Sprachnotiz konnte nicht verworfen werden."),
});
@@ -134,66 +145,60 @@ function VoiceNotes() {
isListedVoiceStatus(item.status),
);
- if (serverQuery.isLoading && local.length === 0) {
- return ;
- }
// No answer because there is no network: not an error, the notes appear with the network.
const serverOffline =
serverQuery.data === undefined &&
(serverQuery.fetchStatus === "paused" || isNetworkError(serverQuery.error));
const serverFailed = serverQuery.data === undefined && !serverQuery.isLoading && !serverOffline;
- if (local.length === 0 && listed.length === 0) {
- if (serverOffline) {
- return ;
- }
- return serverFailed ? (
- void serverQuery.refetch()}
- isRetrying={serverQuery.isFetching}
- />
- ) : (
-
- );
- }
+ const isLoading = serverQuery.isLoading && local.length === 0;
+ const isEmpty = local.length === 0 && listed.length === 0;
return (
- {serverOffline ?
: null}
- {serverFailed ? (
+
+ Liste der Sprachnotizen
+
+ {isLoading ?
: null}
+ {!isLoading && serverOffline ?
: null}
+ {!isLoading && serverFailed ? (
void serverQuery.refetch()}
isRetrying={serverQuery.isFetching}
/>
) : null}
-
- {local.map((note) => (
-
- discardLocal.mutate(note.id)}
- />
-
- ))}
- {listed.map((item) => (
-
- retry.mutate(item.id)}
- onDiscard={() => discard.mutate(item.id)}
- />
-
- ))}
-
+ {!isLoading && isEmpty && !serverFailed ? (
+
+ ) : null}
+ {isEmpty ? null : (
+
+ {local.map((note) => (
+
+ discardLocal.mutate(note.id)}
+ />
+
+ ))}
+ {listed.map((item) => (
+
+ retry.mutate(item.id)}
+ onDiscard={() => discard.mutate(item.id)}
+ />
+
+ ))}
+
+ )}
);
}
diff --git a/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx b/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx
index 6fd5e71..f8ac024 100644
--- a/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx
+++ b/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { act, render, screen, waitFor } from "@testing-library/react";
+import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { summarizeQueue } from "@/lib/offline/db";
@@ -66,6 +66,18 @@ describe("LogoutConfirmDialog", () => {
expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible();
});
+ it("has 56 px buttons and a 44 px close target", async () => {
+ vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
+
+ renderDialog();
+
+ const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
+ for (const name of ["Abbrechen", "Erst senden", "Abmelden und Einträge löschen"]) {
+ expect(screen.getByRole("button", { name })).toHaveClass("min-h-14");
+ }
+ expect(within(dialog).getByRole("button", { name: "Close" })).toHaveClass("size-11");
+ });
+
it("asks to discard waiting voice notes and says they cannot be restored", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 3, failed: 0, foreign: 0, voice: 2 });
diff --git a/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx b/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx
index 43eaecb..b3e4847 100644
--- a/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx
+++ b/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx
@@ -152,13 +152,20 @@ export function LogoutConfirmDialog({
type="button"
variant="outline"
size="touch"
+ className="min-h-14"
disabled={isBusy}
onClick={() => onOpenChange(false)}
>
Abbrechen
{hasEntries && isOnline ? (
- void sendFirst()}>
+ void sendFirst()}
+ >
Erst senden
) : null}
@@ -166,6 +173,7 @@ export function LogoutConfirmDialog({
type="button"
variant="destructive"
size="touch"
+ className="min-h-14"
disabled={isBusy}
onClick={() => void signOut(hasEntries)}
>
diff --git a/ApiCore-Frontend/src/components/form/form-back-link.spec.tsx b/ApiCore-Frontend/src/components/form/form-back-link.spec.tsx
index 6890f7d..07c99b2 100644
--- a/ApiCore-Frontend/src/components/form/form-back-link.spec.tsx
+++ b/ApiCore-Frontend/src/components/form/form-back-link.spec.tsx
@@ -20,6 +20,12 @@ describe("FormBackLink", () => {
);
});
+ it("takes extra classes for a bigger target", () => {
+ render( );
+
+ expect(screen.getByRole("link", { name: /Zum Volk/ })).toHaveClass("min-h-14");
+ });
+
it("asks before leaving and flushes the draft", async () => {
const flush = vi.fn();
render(
diff --git a/ApiCore-Frontend/src/components/form/form-back-link.tsx b/ApiCore-Frontend/src/components/form/form-back-link.tsx
index d1c9de4..7d774fa 100644
--- a/ApiCore-Frontend/src/components/form/form-back-link.tsx
+++ b/ApiCore-Frontend/src/components/form/form-back-link.tsx
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
@@ -21,6 +22,8 @@ export interface FormBackLinkProps {
dirty: boolean;
/** The form's draft; flushed so leaving does not race the autosave delay. */
draft?: { flush: () => void };
+ /** Extra classes, e.g. a bigger target on screens used with gloves. */
+ className?: string;
}
const NOT_PLAIN_CLICK = (event: MouseEvent) =>
@@ -31,7 +34,7 @@ const NOT_PLAIN_CLICK = (event: MouseEvent) =>
* untouched; once something was typed it asks first, saying honestly whether
* the input stays as a draft on this device or is lost.
*/
-export function FormBackLink({ href, label, dirty, draft }: FormBackLinkProps) {
+export function FormBackLink({ href, label, dirty, draft, className }: FormBackLinkProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
@@ -53,7 +56,10 @@ export function FormBackLink({ href, label, dirty, draft }: FormBackLinkProps) {
{label}
diff --git a/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx b/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx
index 602c7fd..d877aa6 100644
--- a/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx
+++ b/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx
@@ -143,6 +143,25 @@ describe("OfflineBand", () => {
expect(screen.getByRole("status")).toHaveTextContent("davon 1 Sprachnotiz");
});
+ it("opens the queue from the band when voice notes wait, offline too", async () => {
+ setOnLine(false);
+ vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0, voice: 1, voiceWaiting: 1 });
+
+ renderBand();
+
+ expect(await screen.findByRole("button", { name: "Warteschlange ansehen" })).toBeVisible();
+ });
+
+ it("offers no queue button when nothing voice-related waits", async () => {
+ setOnLine(true);
+ vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
+
+ renderBand();
+
+ await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" });
+ expect(screen.queryByRole("button", { name: "Warteschlange ansehen" })).not.toBeInTheDocument();
+ });
+
it("says „1 Eintrag wartet\" for a single entry", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
diff --git a/ApiCore-Frontend/src/components/offline/offline-band.tsx b/ApiCore-Frontend/src/components/offline/offline-band.tsx
index 234fdce..9dfdeca 100644
--- a/ApiCore-Frontend/src/components/offline/offline-band.tsx
+++ b/ApiCore-Frontend/src/components/offline/offline-band.tsx
@@ -188,6 +188,20 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
{`${entriesRejected(queue.failed)} – ansehen`}
+ ) : queue.voiceWaiting ? (
+ // Waiting voice notes cannot be seen anywhere else but the list: the sheet opens from here too.
+
+ }
+ >
+ Warteschlange ansehen
+
) : null
}
/>
diff --git a/ApiCore-Frontend/src/components/ui/dialog.tsx b/ApiCore-Frontend/src/components/ui/dialog.tsx
index 78ec8c3..7217c3b 100644
--- a/ApiCore-Frontend/src/components/ui/dialog.tsx
+++ b/ApiCore-Frontend/src/components/ui/dialog.tsx
@@ -65,7 +65,7 @@ function DialogContent({
render={
}
diff --git a/ApiCore-Frontend/src/components/voice/microphone-help.tsx b/ApiCore-Frontend/src/components/voice/microphone-help.tsx
index cfa9193..4f2a27c 100644
--- a/ApiCore-Frontend/src/components/voice/microphone-help.tsx
+++ b/ApiCore-Frontend/src/components/voice/microphone-help.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useRef } from "react";
import Link from "next/link";
import { MicOff } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -20,6 +21,8 @@ const STEPS: readonly { browser: string; text: string }[] = [
interface MicrophoneHelpProps {
reason: "denied" | "no-microphone" | "unsupported";
typeInsteadHref: string;
+ /** The user just tried to record: the heading takes the focus. */
+ focusOnShow?: boolean;
onRetry?(): void;
}
@@ -30,13 +33,26 @@ const TITLES: Record = {
};
/** Mikrofon verweigert: what to do per browser, and a way to type instead. */
-export function MicrophoneHelp({ reason, typeInsteadHref, onRetry }: MicrophoneHelpProps) {
+export function MicrophoneHelp({
+ reason,
+ typeInsteadHref,
+ focusOnShow = false,
+ onRetry,
+}: MicrophoneHelpProps) {
+ const heading = useRef(null);
+ useEffect(() => {
+ if (focusOnShow) heading.current?.focus();
+ }, [focusOnShow]);
return (
-
+
{TITLES[reason]}
diff --git a/ApiCore-Frontend/src/components/voice/voice-recorder.spec.tsx b/ApiCore-Frontend/src/components/voice/voice-recorder.spec.tsx
index 3f8be7c..25f835b 100644
--- a/ApiCore-Frontend/src/components/voice/voice-recorder.spec.tsx
+++ b/ApiCore-Frontend/src/components/voice/voice-recorder.spec.tsx
@@ -139,8 +139,7 @@ describe("VoiceRecorder", () => {
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
- expect(await screen.findByRole("heading", { name: /Aufnahme fertig · 3:00/ })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Senden" })).toBeInTheDocument();
+ expect(await screen.findByText(/^Gespeichert/)).toBeInTheDocument();
});
it("shows the denied-state with a way to type instead", async () => {
@@ -360,9 +359,11 @@ describe("VoiceRecorder", () => {
});
describe("a recording the limit stopped", () => {
- it("is on the device at once and shows the saved state", async () => {
+ it("is saved like a press on Senden and then offers no second save", async () => {
+ const { listPendingVoice } = await import("@/lib/voice/pending-voice");
+ const onSaved = vi.fn();
const user = userEvent.setup();
- renderRecorder();
+ renderRecorder({ onSaved });
await startAndRecord(user);
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
@@ -370,15 +371,19 @@ describe("VoiceRecorder", () => {
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();
+ expect(syncPendingEntries).toHaveBeenCalledTimes(1);
+ expect(onSaved).toHaveBeenCalledWith({ kind: "queued" });
+ expect(screen.queryByRole("button", { name: /^(Senden|Speichern)$/ })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Noch eine Aufnahme" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Zu den Sprachnotizen" })).toHaveAttribute(
+ "href",
+ "/voice",
+ );
});
- it("is only synced, not queued twice, when Senden is pressed", async () => {
- const { listPendingVoice, discardAllPendingVoice } = await import(
- "@/lib/voice/pending-voice"
- );
+ it("says Gesendet when the sync took it at once", async () => {
+ const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
syncPendingEntries.mockImplementation(async () => {
await discardAllPendingVoice("user-1");
return { synced: 1, failed: 0, skippedForeign: 0 };
@@ -386,30 +391,61 @@ describe("VoiceRecorder", () => {
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" }));
+ act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
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 () => {
+ it("stays offline on the device only and does not sync", async () => {
+ connectivity = "offline";
+ const user = userEvent.setup();
+ renderRecorder();
+ await startAndRecord(user);
+
+ act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
+
+ await screen.findByText(/^Gespeichert/);
+ expect(syncPendingEntries).not.toHaveBeenCalled();
+ });
+
+ it("keeps the note on the device when the user records anew", async () => {
+ const { listPendingVoice } = await import("@/lib/voice/pending-voice");
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 user.click(screen.getByRole("button", { name: "Noch eine Aufnahme" }));
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));
+ expect(await listPendingVoice("user-1")).toHaveLength(1);
+ });
+ });
+
+ it("shows the recorder's own duration, not the media element's", async () => {
+ const user = userEvent.setup();
+ renderRecorder();
+ await startAndRecord(user);
+ await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
+
+ const heading = await screen.findByRole("heading", { name: /Aufnahme fertig · 0:42/ });
+ expect(heading).toBeInTheDocument();
+ const audio = document.querySelector("audio");
+ expect(audio).toHaveAttribute("aria-label", "Aufnahme anhören, Dauer 0:42");
+ });
+
+ describe("when the microphone cannot be used", () => {
+ it("moves focus to the help heading after a refused start", async () => {
+ startRecording.mockRejectedValue(new DOMException("denied", "NotAllowedError"));
+ const user = userEvent.setup();
+ renderRecorder();
+
+ await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
+
+ const heading = await screen.findByRole("heading", { name: "Das Mikrofon ist nicht erlaubt." });
+ await waitFor(() => expect(document.activeElement).toBe(heading));
});
});
diff --git a/ApiCore-Frontend/src/components/voice/voice-recorder.tsx b/ApiCore-Frontend/src/components/voice/voice-recorder.tsx
index 074530c..25e74aa 100644
--- a/ApiCore-Frontend/src/components/voice/voice-recorder.tsx
+++ b/ApiCore-Frontend/src/components/voice/voice-recorder.tsx
@@ -52,6 +52,8 @@ export interface VoiceRecorderProps {
/** 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;
+ /** The user cleared the saved result to record anew. */
+ onReset?(): void;
/** Rendered after saving, e.g. "Weiter mit B4". */
savedActions?: ReactNode;
}
@@ -71,6 +73,7 @@ export function VoiceRecorder({
blockedReason = null,
onBusyChange,
onSaved,
+ onReset,
savedActions,
}: VoiceRecorderProps) {
const userId = useCurrentUserId();
@@ -87,27 +90,40 @@ export function VoiceRecorder({
const resultHeading = useRef(null);
const mainButton = useRef(null);
const focusMainWhenIdle = useRef(false);
+ // The help for a refused microphone takes the focus only after the user tried.
+ const [tried, setTried] = useState(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) {
+ /** Puts the recording on the device and, online, sends it - for Senden and for a limit stop alike. */
+ async function persist(finished: 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),
- );
+ setSaving(true);
+ setSaveError(null);
+ try {
+ const result = await saveVoiceNote({
+ userId,
+ recording: finished,
+ 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 recorder = useVoiceRecorder({ onAutoStop: queueAtOnce });
+ const recorder = useVoiceRecorder({ onAutoStop: (finished) => void persist(finished) });
const { phase, sec, recording } = recorder;
const unsaved =
@@ -145,6 +161,7 @@ export function VoiceRecorder({
function pressMainButton() {
if (phase === "starting") return;
+ setTried(true);
if (phase === "recording") {
recorder.stop();
return;
@@ -177,6 +194,7 @@ export function VoiceRecorder({
setSaveError(null);
setQueuedNoteId(null);
recorder.reset();
+ onReset?.();
}
/** The user gives this recording up: a note it already has on the device goes too. */
@@ -198,29 +216,8 @@ export function VoiceRecorder({
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);
- }
+ function save() {
+ if (recording && !saving) void persist(recording);
}
const liveRegion = (
@@ -236,6 +233,7 @@ export function VoiceRecorder({
>
@@ -320,7 +318,7 @@ export function VoiceRecorder({
) : null}
{recording.url ? (
-
+
) : null}
{saveError ? (
@@ -332,7 +330,7 @@ export function VoiceRecorder({
size="touch"
className="min-h-14 text-base"
disabled={saving || !userId}
- onClick={() => void save()}
+ onClick={save}
>
{saving ? "Wird gespeichert…" : online ? "Senden" : "Speichern"}
diff --git a/ApiCore-Frontend/src/proxy.spec.ts b/ApiCore-Frontend/src/proxy.spec.ts
new file mode 100644
index 0000000..048f84d
--- /dev/null
+++ b/ApiCore-Frontend/src/proxy.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { proxy } from "./proxy";
+
+function csp(): string {
+ return proxy().headers.get("Content-Security-Policy") ?? "";
+}
+
+describe("proxy security headers", () => {
+ it("lets the page play its own voice recordings (blob:) and nothing broader", () => {
+ const directives = csp().split(";").map((part) => part.trim());
+
+ expect(directives).toContain("media-src 'self' blob:");
+ expect(directives.filter((part) => part.startsWith("media-src"))).toHaveLength(1);
+ });
+
+ it("keeps the default of the same origin only", () => {
+ expect(csp()).toContain("default-src 'self'");
+ });
+});
diff --git a/ApiCore-Frontend/src/proxy.ts b/ApiCore-Frontend/src/proxy.ts
index 82f13f8..1345f92 100644
--- a/ApiCore-Frontend/src/proxy.ts
+++ b/ApiCore-Frontend/src/proxy.ts
@@ -37,6 +37,7 @@ function cspHeader(): string {
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
+ media-src 'self' blob:;
font-src 'self';
connect-src 'self' ${apiOrigin()};
object-src 'none';