MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m15s
Code blocks lose border and header bar: language at the top left, a round copy button at the bottom right on hover; converting back to text moves to the bullet's action bubble (also for numbered items). Code, audio and dictation blocks use fixed round corners whatever radius the theme sets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
6.1 KiB
TypeScript
184 lines
6.1 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Loader2, Mic, Square, Upload, X } from 'lucide-react';
|
|
import { UploadedMedia, uploadMedia } from '../../lib/media';
|
|
import { VoiceRecorder } from '../../lib/voice';
|
|
import { VoiceWave } from '../../components/VoiceWave';
|
|
import { triggerHaptic } from '../../lib/api';
|
|
|
|
interface AudioRecorderPickerProps {
|
|
docId: string;
|
|
onDone: (media: UploadedMedia) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
const MAX_RECORDING_MS = 30 * 60 * 1000;
|
|
|
|
function formatTime(ms: number): string {
|
|
const s = Math.floor(ms / 1000);
|
|
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
|
}
|
|
|
|
// Inline bubble an empty bullet turns into after /audio. Green button = start, red button = stop and
|
|
// save; the live wave runs left of the red button. An existing audio file can be uploaded instead.
|
|
export const AudioRecorderPicker: React.FC<AudioRecorderPickerProps> = ({ docId, onDone, onCancel }) => {
|
|
const [state, setState] = useState<'ready' | 'recording' | 'uploading'>('ready');
|
|
const [elapsed, setElapsed] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const recorderRef = useRef<VoiceRecorder | null>(null);
|
|
const startedRef = useRef(0);
|
|
const tickRef = useRef<number | null>(null);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
|
|
const stopTicking = () => {
|
|
if (tickRef.current !== null) window.clearInterval(tickRef.current);
|
|
tickRef.current = null;
|
|
};
|
|
|
|
useEffect(() => {
|
|
boxRef.current?.focus();
|
|
return () => {
|
|
stopTicking();
|
|
recorderRef.current?.cancel();
|
|
};
|
|
}, []);
|
|
|
|
const upload = async (blob: Blob, name: string) => {
|
|
setState('uploading');
|
|
setError(null);
|
|
try {
|
|
onDone(await uploadMedia(docId, blob, name));
|
|
} catch (err: any) {
|
|
setError(err?.message || 'Hochladen fehlgeschlagen');
|
|
setState('ready');
|
|
}
|
|
};
|
|
|
|
const stop = async () => {
|
|
const rec = recorderRef.current;
|
|
if (!rec) return;
|
|
recorderRef.current = null;
|
|
stopTicking();
|
|
triggerHaptic(20);
|
|
const blob = await rec.stop();
|
|
if (!blob) {
|
|
setError('Die Aufnahme ist leer');
|
|
setState('ready');
|
|
return;
|
|
}
|
|
const ext = blob.type.includes('mp4') ? 'm4a' : blob.type.includes('ogg') ? 'ogg' : 'webm';
|
|
await upload(blob, `sprachnotiz.${ext}`);
|
|
};
|
|
|
|
const start = async () => {
|
|
setError(null);
|
|
const rec = new VoiceRecorder();
|
|
try {
|
|
await rec.start();
|
|
} catch {
|
|
setError('Kein Zugriff auf das Mikrofon (Berechtigung, oder die Seite läuft nicht über HTTPS)');
|
|
return;
|
|
}
|
|
recorderRef.current = rec;
|
|
startedRef.current = Date.now();
|
|
setElapsed(0);
|
|
setState('recording');
|
|
triggerHaptic([15, 60, 25]);
|
|
tickRef.current = window.setInterval(() => {
|
|
const ms = Date.now() - startedRef.current;
|
|
setElapsed(ms);
|
|
if (ms >= MAX_RECORDING_MS) void stop();
|
|
}, 250);
|
|
};
|
|
|
|
const cancel = () => {
|
|
stopTicking();
|
|
recorderRef.current?.cancel();
|
|
recorderRef.current = null;
|
|
onCancel();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={boxRef}
|
|
tabIndex={-1}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
cancel();
|
|
}
|
|
}}
|
|
className="my-1 inline-flex max-w-full flex-col gap-2 rounded-3xl border border-border bg-panel-elevated px-4 py-2 focus:outline-none"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{state === 'recording' && (
|
|
<>
|
|
<span className="text-xs font-mono text-main tabular-nums">{formatTime(elapsed)}</span>
|
|
<VoiceWave getLevel={() => recorderRef.current?.level() ?? null} bars={11} className="h-6 text-status-err" />
|
|
</>
|
|
)}
|
|
{state === 'ready' && <span className="text-xs text-muted">Sprachnotiz aufnehmen</span>}
|
|
{state === 'uploading' && (
|
|
<span className="text-xs text-muted flex items-center gap-1.5">
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Wird gespeichert…
|
|
</span>
|
|
)}
|
|
|
|
{state === 'recording' ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => void stop()}
|
|
title="Aufnahme beenden und speichern"
|
|
aria-label="Aufnahme beenden und speichern"
|
|
className="w-14 h-14 sm:w-10 sm:h-10 rounded-full bg-status-err text-on-accent flex items-center justify-center shadow-panel shrink-0"
|
|
>
|
|
<Square className="w-5 h-5 sm:w-4 sm:h-4 fill-current" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => void start()}
|
|
disabled={state === 'uploading'}
|
|
title="Aufnahme starten"
|
|
aria-label="Aufnahme starten"
|
|
className="w-14 h-14 sm:w-10 sm:h-10 rounded-full bg-status-ok text-on-accent flex items-center justify-center shadow-panel shrink-0 disabled:opacity-40"
|
|
>
|
|
<Mic className="w-6 h-6 sm:w-5 sm:h-5" />
|
|
</button>
|
|
)}
|
|
|
|
{state === 'ready' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => fileRef.current?.click()}
|
|
title="Audiodatei hochladen"
|
|
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center gap-1.5 shrink-0"
|
|
>
|
|
<Upload className="w-3.5 h-3.5" /> <span className="hidden sm:inline">Datei</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={cancel}
|
|
disabled={state === 'uploading'}
|
|
title="Abbrechen (Esc)"
|
|
className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-full text-muted hover:text-main hover:bg-panel shrink-0 disabled:opacity-40"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
{error && <p className="text-xs text-status-err">{error}</p>}
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
accept="audio/*"
|
|
hidden
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) void upload(f, f.name || 'audio');
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|