diff --git a/apps/admin/src/components/EditorShell.tsx b/apps/admin/src/components/EditorShell.tsx
index b6706e0..b119594 100644
--- a/apps/admin/src/components/EditorShell.tsx
+++ b/apps/admin/src/components/EditorShell.tsx
@@ -1,5 +1,6 @@
-import { Typography } from '@mui/material';
+import { Box, Typography } from '@mui/material';
import AdminLayout from '../layout/AdminLayout';
+import Recorder from '../features/recorder/Recorder';
export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
return (
@@ -7,9 +8,12 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
Welcome to VoxBlog Editor
-
- Coming next: audio recorder, rich editor, AI tools, and Ghost publish flow.
-
+
+
+
+ Coming next: rich editor, AI tools, and Ghost publish flow.
+
+
);
}
diff --git a/apps/admin/src/features/recorder/Recorder.tsx b/apps/admin/src/features/recorder/Recorder.tsx
new file mode 100644
index 0000000..165a31f
--- /dev/null
+++ b/apps/admin/src/features/recorder/Recorder.tsx
@@ -0,0 +1,76 @@
+import { useEffect, useRef, useState } from 'react';
+import { Box, Button, Stack, Typography } from '@mui/material';
+
+export default function Recorder() {
+ const mediaRecorderRef = useRef(null);
+ const chunksRef = useRef([]);
+ const [recording, setRecording] = useState(false);
+ const [audioUrl, setAudioUrl] = useState(null);
+ const [error, setError] = useState('');
+
+ const requestStream = async (): Promise => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ return stream;
+ } catch (e) {
+ setError('Microphone permission denied');
+ return null;
+ }
+ };
+
+ const startRecording = async () => {
+ setError('');
+ const stream = await requestStream();
+ if (!stream) return;
+
+ const mr = new MediaRecorder(stream);
+ mediaRecorderRef.current = mr;
+ chunksRef.current = [];
+
+ mr.ondataavailable = (e: BlobEvent) => {
+ if (e.data && e.data.size > 0) {
+ chunksRef.current.push(e.data);
+ }
+ };
+ mr.onstop = () => {
+ const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
+ const url = URL.createObjectURL(blob);
+ setAudioUrl((prev) => {
+ if (prev) URL.revokeObjectURL(prev);
+ return url;
+ });
+ // stop all tracks to release mic
+ stream.getTracks().forEach(t => t.stop());
+ };
+
+ mr.start();
+ setRecording(true);
+ };
+
+ const stopRecording = () => {
+ mediaRecorderRef.current?.stop();
+ setRecording(false);
+ };
+
+ useEffect(() => {
+ return () => {
+ if (audioUrl) URL.revokeObjectURL(audioUrl);
+ };
+ }, [audioUrl]);
+
+ return (
+
+ Audio Recorder
+
+
+
+
+ {error && {error}}
+ {audioUrl && (
+
+
+
+ )}
+
+ );
+}