Compare commits
2 Commits
e450381e2f
...
463cb99888
| Author | SHA1 | Date |
|---|---|---|
|
|
463cb99888 | |
|
|
d7a2372a56 |
|
|
@ -11,6 +11,7 @@ services:
|
|||
- RSPACE_INTERNAL_URL=${RSPACE_INTERNAL_URL:-http://rspace-online:3000}
|
||||
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=${NEXT_PUBLIC_ENCRYPTID_SERVER_URL:-https://encryptid.jeffemmett.com}
|
||||
- RSPACE_INTERNAL_KEY=${RSPACE_INTERNAL_KEY}
|
||||
- VOICE_API_URL=${VOICE_API_URL:-http://voice-command-api:8000}
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
labels:
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ model Note {
|
|||
mimeType String?
|
||||
fileUrl String?
|
||||
fileSize Int?
|
||||
duration Int?
|
||||
isPinned Boolean @default(false)
|
||||
canvasShapeId String?
|
||||
sortOrder Int @default(0)
|
||||
|
|
@ -100,6 +101,7 @@ enum NoteType {
|
|||
CODE
|
||||
IMAGE
|
||||
FILE
|
||||
AUDIO
|
||||
}
|
||||
|
||||
// ─── Tags ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "rNotes - Universal Knowledge Capture",
|
||||
"short_name": "rNotes",
|
||||
"description": "Capture notes, clips, bookmarks, code, audio, and files. Organize in notebooks, tag freely.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"id": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a",
|
||||
"orientation": "portrait-primary",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
const CACHE_NAME = 'rnotes-v1';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((names) =>
|
||||
Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Always go to network for API calls
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// Network-first for pages, cache-first for static assets
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
);
|
||||
});
|
||||
|
|
@ -37,7 +37,7 @@ export async function POST(
|
|||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, content, type, url, language, tags, fileUrl, mimeType, fileSize } = body;
|
||||
const { title, content, type, url, language, tags, fileUrl, mimeType, fileSize, duration } = body;
|
||||
|
||||
if (!title?.trim()) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
|
|
@ -73,6 +73,7 @@ export async function POST(
|
|||
fileUrl: fileUrl || null,
|
||||
mimeType: mimeType || null,
|
||||
fileSize: fileSize || null,
|
||||
duration: duration || null,
|
||||
tags: {
|
||||
create: tagRecords.map((tag) => ({
|
||||
tagId: tag.id,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export async function POST(request: NextRequest) {
|
|||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
const body = await request.json();
|
||||
const { title, content, type, notebookId, url, language, tags, fileUrl, mimeType, fileSize } = body;
|
||||
const { title, content, type, notebookId, url, language, tags, fileUrl, mimeType, fileSize, duration } = body;
|
||||
|
||||
if (!title?.trim()) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
|
|
@ -79,6 +79,7 @@ export async function POST(request: NextRequest) {
|
|||
fileUrl: fileUrl || null,
|
||||
mimeType: mimeType || null,
|
||||
fileSize: fileSize || null,
|
||||
duration: duration || null,
|
||||
tags: {
|
||||
create: tagRecords.map((tag) => ({
|
||||
tagId: tag.id,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ const ALLOWED_MIME_TYPES = new Set([
|
|||
// Code
|
||||
'text/javascript', 'text/typescript', 'text/html', 'text/css',
|
||||
'application/x-python-code', 'text/x-python',
|
||||
// Audio
|
||||
'audio/webm', 'audio/mpeg', 'audio/wav', 'audio/ogg',
|
||||
'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/flac',
|
||||
]);
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
|
||||
const VOICE_API_URL = process.env.VOICE_API_URL || 'http://voice-command-api:8000';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
|
||||
const formData = await request.formData();
|
||||
const audio = formData.get('audio') as File | null;
|
||||
|
||||
if (!audio) {
|
||||
return NextResponse.json({ error: 'No audio file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Forward to voice-command API
|
||||
const proxyForm = new FormData();
|
||||
proxyForm.append('audio', audio, audio.name || 'recording.webm');
|
||||
|
||||
const res = await fetch(`${VOICE_API_URL}/api/voice/transcribe`, {
|
||||
method: 'POST',
|
||||
body: proxyForm,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
console.error('Voice API error:', res.status, err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Transcription failed' },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('Transcribe proxy error:', error);
|
||||
return NextResponse.json({ error: 'Transcription failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import type { Metadata } from 'next'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { AuthProvider } from '@/components/AuthProvider'
|
||||
import { PWAInstall } from '@/components/PWAInstall'
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
|
|
@ -11,6 +12,12 @@ const inter = Inter({
|
|||
export const metadata: Metadata = {
|
||||
title: 'rNotes - Universal Knowledge Capture',
|
||||
description: 'Capture notes, clips, bookmarks, code, and files. Organize in notebooks, tag freely, and collaborate on a visual canvas shared across r*Spaces.',
|
||||
manifest: '/manifest.json',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'rNotes',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'rNotes - Universal Knowledge Capture',
|
||||
description: 'Capture notes, clips, bookmarks, code, and files with a collaborative canvas.',
|
||||
|
|
@ -19,6 +26,10 @@ export const metadata: Metadata = {
|
|||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#0a0a0a',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
|
|
@ -26,9 +37,13 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<PWAInstall />
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const TYPE_COLORS: Record<string, string> = {
|
|||
CODE: 'bg-green-500/20 text-green-400',
|
||||
IMAGE: 'bg-pink-500/20 text-pink-400',
|
||||
FILE: 'bg-slate-500/20 text-slate-400',
|
||||
AUDIO: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
interface NoteData {
|
||||
|
|
@ -28,6 +29,7 @@ interface NoteData {
|
|||
fileUrl: string | null;
|
||||
mimeType: string | null;
|
||||
fileSize: number | null;
|
||||
duration: number | null;
|
||||
isPinned: boolean;
|
||||
canvasShapeId: string | null;
|
||||
createdAt: string;
|
||||
|
|
@ -248,6 +250,16 @@ export default function NoteDetailPage() {
|
|||
</a>
|
||||
</div>
|
||||
)}
|
||||
{note.fileUrl && note.type === 'AUDIO' && (
|
||||
<div className="mb-6 p-4 bg-slate-800/50 border border-slate-700 rounded-lg space-y-3">
|
||||
<audio controls src={note.fileUrl} className="w-full" />
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||||
{note.duration != null && <span>{Math.floor(note.duration / 60)}:{(note.duration % 60).toString().padStart(2, '0')}</span>}
|
||||
{note.mimeType && <span>{note.mimeType}</span>}
|
||||
{note.fileSize && <span>{(note.fileSize / 1024).toFixed(1)} KB</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{editing ? (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||
import Link from 'next/link';
|
||||
import { NoteEditor } from '@/components/NoteEditor';
|
||||
import { FileUpload } from '@/components/FileUpload';
|
||||
import { VoiceRecorder } from '@/components/VoiceRecorder';
|
||||
import { UserMenu } from '@/components/UserMenu';
|
||||
import { authFetch } from '@/lib/authFetch';
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ const NOTE_TYPES = [
|
|||
{ value: 'CODE', label: 'Code', desc: 'Code snippet' },
|
||||
{ value: 'IMAGE', label: 'Image', desc: 'Upload image' },
|
||||
{ value: 'FILE', label: 'File', desc: 'Upload file' },
|
||||
{ value: 'AUDIO', label: 'Audio', desc: 'Voice recording' },
|
||||
];
|
||||
|
||||
interface NotebookOption {
|
||||
|
|
@ -51,6 +53,7 @@ function NewNoteForm() {
|
|||
const [fileUrl, setFileUrl] = useState('');
|
||||
const [mimeType, setMimeType] = useState('');
|
||||
const [fileSize, setFileSize] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [notebookId, setNotebookId] = useState(preselectedNotebook || '');
|
||||
const [notebooks, setNotebooks] = useState<NotebookOption[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
@ -80,6 +83,7 @@ function NewNoteForm() {
|
|||
if (fileUrl) body.fileUrl = fileUrl;
|
||||
if (mimeType) body.mimeType = mimeType;
|
||||
if (fileSize) body.fileSize = fileSize;
|
||||
if (duration) body.duration = duration;
|
||||
|
||||
const endpoint = notebookId
|
||||
? `/api/notebooks/${notebookId}/notes`
|
||||
|
|
@ -105,6 +109,7 @@ function NewNoteForm() {
|
|||
const showUrl = ['CLIP', 'BOOKMARK'].includes(type);
|
||||
const showUpload = ['IMAGE', 'FILE'].includes(type);
|
||||
const showLanguage = type === 'CODE';
|
||||
const showRecorder = type === 'AUDIO';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a]">
|
||||
|
|
@ -234,16 +239,43 @@ function NewNoteForm() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Voice recorder */}
|
||||
{showRecorder && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Recording</label>
|
||||
<VoiceRecorder
|
||||
onResult={(result) => {
|
||||
setFileUrl(result.fileUrl);
|
||||
setMimeType(result.mimeType);
|
||||
setFileSize(result.fileSize);
|
||||
setDuration(result.duration);
|
||||
setContent(result.transcript);
|
||||
if (!title) setTitle(`Voice note ${new Date().toLocaleDateString()}`);
|
||||
}}
|
||||
/>
|
||||
{content && (
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Transcript</label>
|
||||
<div className="p-4 bg-slate-800/50 border border-slate-700 rounded-lg text-slate-300 text-sm leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Content</label>
|
||||
<NoteEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
type={type}
|
||||
placeholder={type === 'CODE' ? 'Paste your code here...' : 'Write in Markdown...'}
|
||||
/>
|
||||
</div>
|
||||
{!showRecorder && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Content</label>
|
||||
<NoteEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
type={type}
|
||||
placeholder={type === 'CODE' ? 'Paste your code here...' : 'Write in Markdown...'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notebook */}
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const TYPE_COLORS: Record<string, string> = {
|
|||
CODE: 'bg-green-500/20 text-green-400',
|
||||
IMAGE: 'bg-pink-500/20 text-pink-400',
|
||||
FILE: 'bg-slate-500/20 text-slate-400',
|
||||
AUDIO: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
interface NoteCardProps {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
export function PWAInstall() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Register service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(console.error);
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
const isStandalone =
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(navigator as unknown as { standalone?: boolean }).standalone === true;
|
||||
|
||||
if (isStandalone) return;
|
||||
|
||||
// Detect iOS
|
||||
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
setIsIOS(ios);
|
||||
|
||||
// Check dismiss cooldown (24h)
|
||||
const dismissedAt = localStorage.getItem('pwa_dismissed');
|
||||
if (dismissedAt && Date.now() - parseInt(dismissedAt) < 86400000) return;
|
||||
|
||||
setShowBanner(true);
|
||||
|
||||
// Capture the install prompt
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handler);
|
||||
window.addEventListener('appinstalled', () => {
|
||||
setShowBanner(false);
|
||||
setDeferredPrompt(null);
|
||||
});
|
||||
|
||||
return () => window.removeEventListener('beforeinstallprompt', handler);
|
||||
}, []);
|
||||
|
||||
const handleInstall = useCallback(async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
if (outcome === 'accepted') {
|
||||
setShowBanner(false);
|
||||
}
|
||||
setDeferredPrompt(null);
|
||||
} else {
|
||||
// No native prompt available — show manual instructions
|
||||
setShowInstructions(true);
|
||||
}
|
||||
}, [deferredPrompt]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setShowBanner(false);
|
||||
localStorage.setItem('pwa_dismissed', Date.now().toString());
|
||||
}, []);
|
||||
|
||||
if (!showBanner) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 z-50 max-w-lg mx-auto">
|
||||
<div className="bg-slate-800 border border-slate-700 rounded-xl p-4 shadow-2xl flex items-start gap-3">
|
||||
<span className="text-2xl flex-shrink-0">📲</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{showInstructions ? (
|
||||
isIOS ? (
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white mb-1">Add to Home Screen</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Tap{' '}
|
||||
<span className="bg-slate-700 px-1.5 py-0.5 rounded text-slate-300 font-mono">
|
||||
⎋ Share
|
||||
</span>{' '}
|
||||
then{' '}
|
||||
<span className="bg-slate-700 px-1.5 py-0.5 rounded text-slate-300 font-mono">
|
||||
Add to Home Screen
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white mb-1">Install rNotes</p>
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
1. Tap{' '}
|
||||
<span className="bg-slate-700 px-1.5 py-0.5 rounded text-slate-300 font-mono">
|
||||
⋮
|
||||
</span>{' '}
|
||||
(three dots) at top-right
|
||||
<br />
|
||||
2. Tap{' '}
|
||||
<span className="bg-slate-700 px-1.5 py-0.5 rounded text-slate-300 font-mono">
|
||||
Add to Home screen
|
||||
</span>
|
||||
<br />
|
||||
3. Tap{' '}
|
||||
<span className="bg-slate-700 px-1.5 py-0.5 rounded text-slate-300 font-mono">
|
||||
Install
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Install rNotes</p>
|
||||
<p className="text-xs text-slate-400">Add to your home screen for quick access</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!showInstructions && (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="px-4 py-1.5 rounded-full bg-amber-500 hover:bg-amber-400 text-black text-sm font-semibold transition-colors"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-slate-500 hover:text-slate-300 text-lg leading-none transition-colors"
|
||||
title="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { authFetch } from '@/lib/authFetch';
|
||||
|
||||
interface VoiceRecorderResult {
|
||||
fileUrl: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
duration: number;
|
||||
transcript: string;
|
||||
}
|
||||
|
||||
interface VoiceRecorderProps {
|
||||
onResult: (result: VoiceRecorderResult) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VoiceRecorder({ onResult, className }: VoiceRecorderProps) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [processingStep, setProcessingStep] = useState('');
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (audioUrl) URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
}, [audioUrl]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
const s = (seconds % 60).toString().padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
};
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm',
|
||||
});
|
||||
|
||||
chunksRef.current = [];
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start(1000);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
startTimeRef.current = Date.now();
|
||||
setRecording(true);
|
||||
setElapsed(0);
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Microphone access denied');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
const mediaRecorder = mediaRecorderRef.current;
|
||||
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
|
||||
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
const duration = Math.floor((Date.now() - startTimeRef.current) / 1000);
|
||||
setRecording(false);
|
||||
setProcessing(true);
|
||||
|
||||
// Wait for final data
|
||||
const blob = await new Promise<Blob>((resolve) => {
|
||||
mediaRecorder.onstop = () => {
|
||||
mediaRecorder.stream.getTracks().forEach((t) => t.stop());
|
||||
resolve(new Blob(chunksRef.current, { type: mediaRecorder.mimeType }));
|
||||
};
|
||||
mediaRecorder.stop();
|
||||
});
|
||||
|
||||
// Preview URL
|
||||
const previewUrl = URL.createObjectURL(blob);
|
||||
setAudioUrl(previewUrl);
|
||||
|
||||
try {
|
||||
// Upload audio file
|
||||
setProcessingStep('Uploading audio...');
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append('file', blob, 'recording.webm');
|
||||
|
||||
const uploadRes = await authFetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
body: uploadForm,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
const data = await uploadRes.json();
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const uploadResult = await uploadRes.json();
|
||||
|
||||
// Transcribe
|
||||
setProcessingStep('Transcribing...');
|
||||
const transcribeForm = new FormData();
|
||||
transcribeForm.append('audio', blob, 'recording.webm');
|
||||
|
||||
const transcribeRes = await authFetch('/api/voice/transcribe', {
|
||||
method: 'POST',
|
||||
body: transcribeForm,
|
||||
});
|
||||
|
||||
let transcript = '';
|
||||
if (transcribeRes.ok) {
|
||||
const transcribeResult = await transcribeRes.json();
|
||||
transcript = transcribeResult.text || '';
|
||||
} else {
|
||||
console.warn('Transcription failed, saving audio without transcript');
|
||||
}
|
||||
|
||||
onResult({
|
||||
fileUrl: uploadResult.url,
|
||||
mimeType: uploadResult.mimeType,
|
||||
fileSize: uploadResult.size,
|
||||
duration,
|
||||
transcript,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Processing failed');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
setProcessingStep('');
|
||||
}
|
||||
}, [onResult]);
|
||||
|
||||
const discard = useCallback(() => {
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
setAudioUrl(null);
|
||||
}
|
||||
setElapsed(0);
|
||||
setError(null);
|
||||
}, [audioUrl]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="border border-slate-700 rounded-lg p-6 bg-slate-800/30">
|
||||
{/* Recording controls */}
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{!recording && !processing && !audioUrl && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startRecording}
|
||||
className="w-20 h-20 rounded-full bg-red-500 hover:bg-red-400 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<p className="text-sm text-slate-400">Tap to start recording</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recording && (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-3 h-3 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-2xl font-mono text-white">{formatTime(elapsed)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopRecording}
|
||||
className="w-20 h-20 rounded-full bg-slate-700 hover:bg-slate-600 transition-colors flex items-center justify-center border-2 border-red-500"
|
||||
>
|
||||
<div className="w-7 h-7 rounded bg-red-500" />
|
||||
</button>
|
||||
<p className="text-sm text-slate-400">Tap to stop</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{processing && (
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<p className="text-sm text-slate-400">{processingStep}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audioUrl && !processing && (
|
||||
<div className="w-full space-y-3">
|
||||
<audio controls src={audioUrl} className="w-full" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-400">{formatTime(elapsed)} recorded</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={discard}
|
||||
className="text-sm text-slate-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Discard & re-record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm mt-4 text-center">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue