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}
|
- RSPACE_INTERNAL_URL=${RSPACE_INTERNAL_URL:-http://rspace-online:3000}
|
||||||
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=${NEXT_PUBLIC_ENCRYPTID_SERVER_URL:-https://encryptid.jeffemmett.com}
|
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=${NEXT_PUBLIC_ENCRYPTID_SERVER_URL:-https://encryptid.jeffemmett.com}
|
||||||
- RSPACE_INTERNAL_KEY=${RSPACE_INTERNAL_KEY}
|
- RSPACE_INTERNAL_KEY=${RSPACE_INTERNAL_KEY}
|
||||||
|
- VOICE_API_URL=${VOICE_API_URL:-http://voice-command-api:8000}
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
labels:
|
labels:
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ model Note {
|
||||||
mimeType String?
|
mimeType String?
|
||||||
fileUrl String?
|
fileUrl String?
|
||||||
fileSize Int?
|
fileSize Int?
|
||||||
|
duration Int?
|
||||||
isPinned Boolean @default(false)
|
isPinned Boolean @default(false)
|
||||||
canvasShapeId String?
|
canvasShapeId String?
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
|
|
@ -100,6 +101,7 @@ enum NoteType {
|
||||||
CODE
|
CODE
|
||||||
IMAGE
|
IMAGE
|
||||||
FILE
|
FILE
|
||||||
|
AUDIO
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tags ───────────────────────────────────────────────────────────
|
// ─── 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 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()) {
|
if (!title?.trim()) {
|
||||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||||
|
|
@ -73,6 +73,7 @@ export async function POST(
|
||||||
fileUrl: fileUrl || null,
|
fileUrl: fileUrl || null,
|
||||||
mimeType: mimeType || null,
|
mimeType: mimeType || null,
|
||||||
fileSize: fileSize || null,
|
fileSize: fileSize || null,
|
||||||
|
duration: duration || null,
|
||||||
tags: {
|
tags: {
|
||||||
create: tagRecords.map((tag) => ({
|
create: tagRecords.map((tag) => ({
|
||||||
tagId: tag.id,
|
tagId: tag.id,
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export async function POST(request: NextRequest) {
|
||||||
if (!isAuthed(auth)) return auth;
|
if (!isAuthed(auth)) return auth;
|
||||||
const { user } = auth;
|
const { user } = auth;
|
||||||
const body = await request.json();
|
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()) {
|
if (!title?.trim()) {
|
||||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||||
|
|
@ -79,6 +79,7 @@ export async function POST(request: NextRequest) {
|
||||||
fileUrl: fileUrl || null,
|
fileUrl: fileUrl || null,
|
||||||
mimeType: mimeType || null,
|
mimeType: mimeType || null,
|
||||||
fileSize: fileSize || null,
|
fileSize: fileSize || null,
|
||||||
|
duration: duration || null,
|
||||||
tags: {
|
tags: {
|
||||||
create: tagRecords.map((tag) => ({
|
create: tagRecords.map((tag) => ({
|
||||||
tagId: tag.id,
|
tagId: tag.id,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||||
// Code
|
// Code
|
||||||
'text/javascript', 'text/typescript', 'text/html', 'text/css',
|
'text/javascript', 'text/typescript', 'text/html', 'text/css',
|
||||||
'application/x-python-code', 'text/x-python',
|
'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 {
|
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 { Inter } from 'next/font/google'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { AuthProvider } from '@/components/AuthProvider'
|
import { AuthProvider } from '@/components/AuthProvider'
|
||||||
|
import { PWAInstall } from '@/components/PWAInstall'
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
|
|
@ -11,6 +12,12 @@ const inter = Inter({
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'rNotes - Universal Knowledge Capture',
|
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.',
|
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: {
|
openGraph: {
|
||||||
title: 'rNotes - Universal Knowledge Capture',
|
title: 'rNotes - Universal Knowledge Capture',
|
||||||
description: 'Capture notes, clips, bookmarks, code, and files with a collaborative canvas.',
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|
@ -26,9 +37,13 @@ export default function RootLayout({
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
</head>
|
||||||
<body className={`${inter.variable} font-sans antialiased`}>
|
<body className={`${inter.variable} font-sans antialiased`}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
{children}
|
{children}
|
||||||
|
<PWAInstall />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const TYPE_COLORS: Record<string, string> = {
|
||||||
CODE: 'bg-green-500/20 text-green-400',
|
CODE: 'bg-green-500/20 text-green-400',
|
||||||
IMAGE: 'bg-pink-500/20 text-pink-400',
|
IMAGE: 'bg-pink-500/20 text-pink-400',
|
||||||
FILE: 'bg-slate-500/20 text-slate-400',
|
FILE: 'bg-slate-500/20 text-slate-400',
|
||||||
|
AUDIO: 'bg-red-500/20 text-red-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface NoteData {
|
interface NoteData {
|
||||||
|
|
@ -28,6 +29,7 @@ interface NoteData {
|
||||||
fileUrl: string | null;
|
fileUrl: string | null;
|
||||||
mimeType: string | null;
|
mimeType: string | null;
|
||||||
fileSize: number | null;
|
fileSize: number | null;
|
||||||
|
duration: number | null;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
canvasShapeId: string | null;
|
canvasShapeId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -248,6 +250,16 @@ export default function NoteDetailPage() {
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 */}
|
{/* Content */}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { NoteEditor } from '@/components/NoteEditor';
|
import { NoteEditor } from '@/components/NoteEditor';
|
||||||
import { FileUpload } from '@/components/FileUpload';
|
import { FileUpload } from '@/components/FileUpload';
|
||||||
|
import { VoiceRecorder } from '@/components/VoiceRecorder';
|
||||||
import { UserMenu } from '@/components/UserMenu';
|
import { UserMenu } from '@/components/UserMenu';
|
||||||
import { authFetch } from '@/lib/authFetch';
|
import { authFetch } from '@/lib/authFetch';
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ const NOTE_TYPES = [
|
||||||
{ value: 'CODE', label: 'Code', desc: 'Code snippet' },
|
{ value: 'CODE', label: 'Code', desc: 'Code snippet' },
|
||||||
{ value: 'IMAGE', label: 'Image', desc: 'Upload image' },
|
{ value: 'IMAGE', label: 'Image', desc: 'Upload image' },
|
||||||
{ value: 'FILE', label: 'File', desc: 'Upload file' },
|
{ value: 'FILE', label: 'File', desc: 'Upload file' },
|
||||||
|
{ value: 'AUDIO', label: 'Audio', desc: 'Voice recording' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface NotebookOption {
|
interface NotebookOption {
|
||||||
|
|
@ -51,6 +53,7 @@ function NewNoteForm() {
|
||||||
const [fileUrl, setFileUrl] = useState('');
|
const [fileUrl, setFileUrl] = useState('');
|
||||||
const [mimeType, setMimeType] = useState('');
|
const [mimeType, setMimeType] = useState('');
|
||||||
const [fileSize, setFileSize] = useState(0);
|
const [fileSize, setFileSize] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
const [notebookId, setNotebookId] = useState(preselectedNotebook || '');
|
const [notebookId, setNotebookId] = useState(preselectedNotebook || '');
|
||||||
const [notebooks, setNotebooks] = useState<NotebookOption[]>([]);
|
const [notebooks, setNotebooks] = useState<NotebookOption[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
@ -80,6 +83,7 @@ function NewNoteForm() {
|
||||||
if (fileUrl) body.fileUrl = fileUrl;
|
if (fileUrl) body.fileUrl = fileUrl;
|
||||||
if (mimeType) body.mimeType = mimeType;
|
if (mimeType) body.mimeType = mimeType;
|
||||||
if (fileSize) body.fileSize = fileSize;
|
if (fileSize) body.fileSize = fileSize;
|
||||||
|
if (duration) body.duration = duration;
|
||||||
|
|
||||||
const endpoint = notebookId
|
const endpoint = notebookId
|
||||||
? `/api/notebooks/${notebookId}/notes`
|
? `/api/notebooks/${notebookId}/notes`
|
||||||
|
|
@ -105,6 +109,7 @@ function NewNoteForm() {
|
||||||
const showUrl = ['CLIP', 'BOOKMARK'].includes(type);
|
const showUrl = ['CLIP', 'BOOKMARK'].includes(type);
|
||||||
const showUpload = ['IMAGE', 'FILE'].includes(type);
|
const showUpload = ['IMAGE', 'FILE'].includes(type);
|
||||||
const showLanguage = type === 'CODE';
|
const showLanguage = type === 'CODE';
|
||||||
|
const showRecorder = type === 'AUDIO';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a]">
|
<div className="min-h-screen bg-[#0a0a0a]">
|
||||||
|
|
@ -234,16 +239,43 @@ function NewNoteForm() {
|
||||||
</div>
|
</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 */}
|
{/* Content */}
|
||||||
<div>
|
{!showRecorder && (
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">Content</label>
|
<div>
|
||||||
<NoteEditor
|
<label className="block text-sm font-medium text-slate-300 mb-2">Content</label>
|
||||||
value={content}
|
<NoteEditor
|
||||||
onChange={setContent}
|
value={content}
|
||||||
type={type}
|
onChange={setContent}
|
||||||
placeholder={type === 'CODE' ? 'Paste your code here...' : 'Write in Markdown...'}
|
type={type}
|
||||||
/>
|
placeholder={type === 'CODE' ? 'Paste your code here...' : 'Write in Markdown...'}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notebook */}
|
{/* Notebook */}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const TYPE_COLORS: Record<string, string> = {
|
||||||
CODE: 'bg-green-500/20 text-green-400',
|
CODE: 'bg-green-500/20 text-green-400',
|
||||||
IMAGE: 'bg-pink-500/20 text-pink-400',
|
IMAGE: 'bg-pink-500/20 text-pink-400',
|
||||||
FILE: 'bg-slate-500/20 text-slate-400',
|
FILE: 'bg-slate-500/20 text-slate-400',
|
||||||
|
AUDIO: 'bg-red-500/20 text-red-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface NoteCardProps {
|
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