Compare commits
No commits in common. "463cb99888ab48ee192f0470eab9ee1c2cb1510d" and "e450381e2f1a2387694439a0c17a4859671ec593" have entirely different histories.
463cb99888
...
e450381e2f
|
|
@ -11,7 +11,6 @@ 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,7 +79,6 @@ 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)
|
||||||
|
|
@ -101,7 +100,6 @@ enum NoteType {
|
||||||
CODE
|
CODE
|
||||||
IMAGE
|
IMAGE
|
||||||
FILE
|
FILE
|
||||||
AUDIO
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tags ───────────────────────────────────────────────────────────
|
// ─── Tags ───────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.7 KiB |
|
|
@ -1,39 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
43
public/sw.js
43
public/sw.js
|
|
@ -1,43 +0,0 @@
|
||||||
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, duration } = body;
|
const { title, content, type, url, language, tags, fileUrl, mimeType, fileSize } = 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,7 +73,6 @@ 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, duration } = body;
|
const { title, content, type, notebookId, url, language, tags, fileUrl, mimeType, fileSize } = 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,7 +79,6 @@ 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,9 +19,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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,8 +1,7 @@
|
||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata } 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'],
|
||||||
|
|
@ -12,12 +11,6 @@ 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.',
|
||||||
|
|
@ -26,10 +19,6 @@ export const metadata: Metadata = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
|
||||||
themeColor: '#0a0a0a',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|
@ -37,13 +26,9 @@ 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,7 +15,6 @@ 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 {
|
||||||
|
|
@ -29,7 +28,6 @@ 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;
|
||||||
|
|
@ -250,16 +248,6 @@ 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,7 +5,6 @@ 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';
|
||||||
|
|
||||||
|
|
@ -16,7 +15,6 @@ 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 {
|
||||||
|
|
@ -53,7 +51,6 @@ 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);
|
||||||
|
|
@ -83,7 +80,6 @@ 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`
|
||||||
|
|
@ -109,7 +105,6 @@ 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]">
|
||||||
|
|
@ -239,43 +234,16 @@ 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 */}
|
||||||
{!showRecorder && (
|
<div>
|
||||||
<div>
|
<label className="block text-sm font-medium text-slate-300 mb-2">Content</label>
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">Content</label>
|
<NoteEditor
|
||||||
<NoteEditor
|
value={content}
|
||||||
value={content}
|
onChange={setContent}
|
||||||
onChange={setContent}
|
type={type}
|
||||||
type={type}
|
placeholder={type === 'CODE' ? 'Paste your code here...' : 'Write in Markdown...'}
|
||||||
placeholder={type === 'CODE' ? 'Paste your code here...' : 'Write in Markdown...'}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Notebook */}
|
{/* Notebook */}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
'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