feat: release 1.0 - rename to Hearbit AI, fix timestamps, update UI
This commit is contained in:
214
src/App.tsx
214
src/App.tsx
@@ -1,23 +1,145 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Settings as SettingsIcon } from "lucide-react";
|
||||
import Settings from "./components/Settings";
|
||||
import Recorder from "./components/Recorder";
|
||||
import LogViewer, { LogEntry } from "./components/LogViewer";
|
||||
import TranscriptionView from "./components/TranscriptionView";
|
||||
import Tabs from "./components/Tabs";
|
||||
|
||||
interface PromptTemplate {
|
||||
export interface PromptTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [view, setView] = useState<'recorder' | 'settings'>('recorder');
|
||||
const [view, setView] = useState<'recorder' | 'logs' | 'settings' | 'transcription'>('recorder');
|
||||
// Keep track of the *previous* tab to return to from settings
|
||||
const [lastTab, setLastTab] = useState<'recorder' | 'logs' | 'transcription'>('recorder');
|
||||
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('infomaniak_api_key') || '');
|
||||
const [productId, setProductId] = useState(localStorage.getItem('infomaniak_product_id') || '');
|
||||
const [savePath, setSavePath] = useState(localStorage.getItem('infomaniak_save_path') || '');
|
||||
|
||||
// Default prompts if none exist
|
||||
/* eslint-disable no-useless-escape */ // Escape quotes in prompts
|
||||
const defaultPrompts: PromptTemplate[] = [
|
||||
{ id: '1', name: 'General Summary', content: 'Summarize the following text into clear bullet points.' },
|
||||
{ id: '2', name: 'Action Items', content: 'Extract all action items and tasks from this text.' },
|
||||
{ id: '3', name: 'Email Draft', content: 'Draft a follow-up email based on this conversation.' }
|
||||
{
|
||||
id: '1',
|
||||
name: 'Meeting Protokoll (General)',
|
||||
content: `Rolle: Du bist ein hochprofessioneller, effizienter Protokollführer und persönlicher Assistent. Deine Aufgabe ist es, aus dem untenstehenden Roh-Transkript (oder den Notizen) ein strukturiertes, leicht lesbares und handlungsorientiertes Ergebnisprotokoll zu erstellen.
|
||||
|
||||
Anweisungen:
|
||||
- Filterung: Ignoriere Smalltalk, Füllwörter, Begrüßungen und irrelevante Abschweifungen. Konzentriere dich auf Fakten, Entscheidungen und Aufgaben.
|
||||
- Tonalität: Schreibe sachlich, objektiv und präzise (Business German).
|
||||
- Klarheit: Formuliere vage Aussagen in klare Sätze um, ohne den Sinn zu verändern.
|
||||
- Zuordnung: Ordne Aufgaben immer einer Person zu (wenn im Text genannt).
|
||||
|
||||
Gewünschte Struktur des Outputs:
|
||||
|
||||
# Meeting Protokoll: [Thema des Meetings]
|
||||
|
||||
Datum: [Datum einfügen, falls bekannt, sonst "N/A"]
|
||||
Anwesende: [Liste der Namen]
|
||||
|
||||
## 1. Management Summary
|
||||
Eine kurze Zusammenfassung der wichtigsten Punkte in 3-5 Sätzen. Worum ging es im Kern?
|
||||
|
||||
## 2. Wichtige Entscheidungen
|
||||
[Entscheidung 1]
|
||||
[Entscheidung 2]
|
||||
(Liste hier nur Dinge auf, die explizit beschlossen wurden)
|
||||
|
||||
## 3. Offene Fragen / Diskussionspunkte
|
||||
Kurze Stichpunkte zu Themen, die besprochen, aber noch nicht final geklärt wurden.
|
||||
|
||||
## 4. Action Items (To-Dos)
|
||||
| Was ist zu tun? | Wer ist verantwortlich? | Bis wann? (falls genannt) |
|
||||
| :--- | :--- | :--- |
|
||||
| [Aufgabe 1] | [Name] | [Datum] |
|
||||
| [Aufgabe 2] | [Name] | [Datum] |
|
||||
|
||||
## 5. Nächste Schritte / Nächstes Meeting
|
||||
Kurze Info zum weiteren Vorgehen.`
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '1:1 Gespräch / Jour Fixe',
|
||||
content: `Rolle: Du bist ein diskreter und empathischer Executive Assistant. Deine Aufgabe ist es, ein 1:1 Gespräch zwischen einem Mitarbeiter und seinem Vorgesetzten zusammenzufassen. Das Gespräch beinhaltet sowohl geschäftliche als auch private/persönliche Themen.
|
||||
|
||||
Wichtige Anweisungen:
|
||||
- Kategorisierung: Trenne strikt zwischen "Persönlichem/Atmosphäre" und "Operativem Business".
|
||||
- Tonalität bei Privatem: Fasse private Themen (Wochenende, Hobbys, Familie, Wohlbefinden) als fließenden Text zusammen. Sei hier warmherzig, aber diskret. Vermeide Stichpunkte, das wirkt bei Privatem zu mechanisch.
|
||||
- Tonalität bei Business: Sei hier gewohnt präzise, faktenbasiert und nutze Bulletpoints.
|
||||
- Sensibilität: Wenn über Feedback, Karriereentwicklung oder Kritik gesprochen wurde, fasse dies in einem separaten Abschnitt neutral und konstruktiv zusammen.
|
||||
|
||||
Gewünschte Struktur des Outputs:
|
||||
|
||||
# 1:1 Gesprächszusammenfassung
|
||||
|
||||
Datum: [Datum]
|
||||
Teilnehmer: [Namen]
|
||||
|
||||
## 1. Persönlicher Check-in & Atmosphäre
|
||||
[Hier bitte einen kurzen Fließtext schreiben: Wie geht es den Teilnehmern? Was wurde an privaten Updates geteilt (z.B. Urlaub, Familie, Hobbys)? Wie war die Grundstimmung des Gesprächs?]
|
||||
|
||||
## 2. Operative Themen (Business Updates)
|
||||
Thema A: [Kurze Zusammenfassung]
|
||||
Thema B: [Kurze Zusammenfassung]
|
||||
(Führe hier die konkreten Arbeitsthemen auf)
|
||||
|
||||
## 3. Feedback & Entwicklung
|
||||
[Hier notieren, falls über Karriereziele, Gehalt, Weiterbildung oder gegenseitiges Feedback gesprochen wurde. Falls nicht besprochen: "Keine Themen in diesem Meeting".]
|
||||
|
||||
## 4. Vereinbarungen & Action Items
|
||||
| Wer? | Was ist zu tun / zu beachten? | Bis wann? |
|
||||
| :--- | :--- | :--- |
|
||||
| [Name] | [Aufgabe] | [Datum] |
|
||||
| [Name] | [Aufgabe] | [Datum] |`
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Kundenmeeting (Official)',
|
||||
content: `Rolle: Du bist ein professioneller Account Manager und Business Analyst. Erstelle ein Protokoll für ein Kundenmeeting. Deine Tonalität ist höflich, verbindlich und extrem präzise. Das Ergebnis soll so formuliert sein, dass es theoretisch direkt an den Kunden gesendet werden kann.
|
||||
|
||||
Wichtige Anweisungen:
|
||||
- Fokus auf Vereinbarungen: Was wurde genau beschlossen? Wenn der Kunde eine Anforderung geändert hat, notiere das explizit.
|
||||
- Verantwortlichkeiten trennen: Trenne bei den To-Dos strikt zwischen "Aufgaben für uns (Auftragnehmer)" und "Aufgaben für den Kunden" (z.B. Material liefern, Freigaben).
|
||||
- Klarheit vor Länge: Vermeide interne Fachsprache, wenn möglich. Schreibe so, dass der Kunde es versteht.
|
||||
- Diplomatie: Falls es Konflikte gab, formuliere diese lösungsorientiert und neutral (z.B. statt "Kunde hat sich beschwert" schreibe "Es wurde Feedback zu X besprochen, Lösungsweg ist Y").
|
||||
|
||||
Gewünschte Struktur des Outputs:
|
||||
|
||||
# Ergebnisprotokoll: [Projektname / Thema]
|
||||
|
||||
Datum: [Datum]
|
||||
Teilnehmer: [Namen Kunden] & [Namen Intern]
|
||||
|
||||
## 1. Zusammenfassung (Executive Summary)
|
||||
2-3 Sätze zum Ziel des Termins und dem aktuellen Status (z.B. "Wir haben den aktuellen Design-Sprint besprochen und die Anforderungen für Phase 2 finalisiert.")
|
||||
|
||||
## 2. Besprochene Punkte & Projektstatus
|
||||
[Thema 1]: Kurze Zusammenfassung der Diskussion.
|
||||
[Thema 2]: Kurze Zusammenfassung der Diskussion.
|
||||
|
||||
## 3. Wichtige Entscheidungen & Genehmigungen
|
||||
(Dies ist der wichtigste Teil für die Absicherung!)
|
||||
✅ Beschluss: [Was wurde final entschieden/freigegeben?]
|
||||
🔄 Änderung: [Wurde der Projektumfang geändert? Neue Anforderungen?]
|
||||
|
||||
## 4. Action Items (Wer macht was?)
|
||||
|
||||
👉 Aufgaben für uns (Intern):
|
||||
[ ] [Aufgabe] (bis [Datum])
|
||||
[ ] [Aufgabe] (bis [Datum])
|
||||
|
||||
👉 Aufgaben für den Kunden (To-Dos / Lieferungen):
|
||||
[ ] [Aufgabe, z.B. Zugangdaten senden, Design freigeben] (bis [Datum])
|
||||
|
||||
## 5. Nächster Termin / Timeline
|
||||
Wann findet das nächste Meeting statt oder was ist der nächste Meilenstein?`
|
||||
}
|
||||
];
|
||||
|
||||
const [prompts, setPrompts] = useState<PromptTemplate[]>(() => {
|
||||
@@ -25,14 +147,16 @@ function App() {
|
||||
return saved ? JSON.parse(saved) : defaultPrompts;
|
||||
});
|
||||
|
||||
const handleSaveSettings = (newApiKey: string, newProductId: string, newPrompts: PromptTemplate[]) => {
|
||||
const handleSaveSettings = (newApiKey: string, newProductId: string, newPrompts: PromptTemplate[], newSavePath: string) => {
|
||||
setApiKey(newApiKey);
|
||||
setProductId(newProductId);
|
||||
setPrompts(newPrompts);
|
||||
setSavePath(newSavePath);
|
||||
localStorage.setItem('infomaniak_api_key', newApiKey);
|
||||
localStorage.setItem('infomaniak_product_id', newProductId);
|
||||
localStorage.setItem('infomaniak_prompts', JSON.stringify(newPrompts));
|
||||
setView('recorder');
|
||||
localStorage.setItem('infomaniak_save_path', newSavePath);
|
||||
setView(lastTab);
|
||||
};
|
||||
|
||||
// State for Recorder (lifted to persist across view changes)
|
||||
@@ -76,19 +200,56 @@ function App() {
|
||||
const handleLoadHistory = (item: HistoryItem) => {
|
||||
setTranscription(item.transcription);
|
||||
setSummary(item.summary);
|
||||
setView('recorder'); // Ensure we go back to recorder to see it
|
||||
};
|
||||
|
||||
// Logs State
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen<LogEntry>('log-event', (event) => {
|
||||
setLogs((prevLogs) => [...prevLogs, event.payload]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then(f => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col select-none">
|
||||
<div className="flex-1 flex flex-col justify-center h-full">
|
||||
{view === 'recorder' ? (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col select-none overflow-hidden">
|
||||
{/* Top Navigation Bar */}
|
||||
{view !== 'settings' && (
|
||||
<div className="w-full flex justify-center items-center pt-4 pb-2 z-20 relative bg-background/95 backdrop-blur">
|
||||
<div className="absolute right-4 top-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setLastTab(view === 'logs' ? 'logs' : 'recorder');
|
||||
setView('settings');
|
||||
}}
|
||||
className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-full transition-colors"
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
currentTab={view as 'recorder' | 'logs' | 'transcription'}
|
||||
onTabChange={(t) => setView(t)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
|
||||
{view === 'recorder' && (
|
||||
<Recorder
|
||||
apiKey={apiKey}
|
||||
productId={productId}
|
||||
prompts={prompts}
|
||||
onOpenSettings={() => setView('settings')}
|
||||
onOpenSettings={() => {
|
||||
setLastTab('recorder');
|
||||
setView('settings');
|
||||
}}
|
||||
transcription={transcription}
|
||||
setTranscription={setTranscription}
|
||||
summary={summary}
|
||||
@@ -97,23 +258,30 @@ function App() {
|
||||
onSaveToHistory={handleSaveToHistory}
|
||||
onDeleteHistory={handleDeleteHistory}
|
||||
onLoadHistory={handleLoadHistory}
|
||||
savePath={savePath}
|
||||
onRecordingComplete={() => setView('transcription')}
|
||||
/>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{view === 'transcription' && (
|
||||
<TranscriptionView transcription={transcription} summary={summary} />
|
||||
)}
|
||||
|
||||
{view === 'logs' && (
|
||||
<LogViewer logs={logs} />
|
||||
)}
|
||||
|
||||
{view === 'settings' && (
|
||||
<Settings
|
||||
onSave={handleSaveSettings}
|
||||
onBack={() => setView('recorder')}
|
||||
initialApiKey={apiKey}
|
||||
initialProductId={productId}
|
||||
initialPrompts={prompts}
|
||||
onClose={() => setView(lastTab)}
|
||||
apiKey={apiKey}
|
||||
productId={productId}
|
||||
prompts={prompts}
|
||||
savePath={savePath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === 'settings' && (
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
{/* Close button handled inside Settings typically */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
91
src/components/LogViewer.tsx
Normal file
91
src/components/LogViewer.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Terminal, Clock, AlertCircle, Info, CheckCircle, Activity } from 'lucide-react';
|
||||
|
||||
export interface LogEntry {
|
||||
level: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface LogViewerProps {
|
||||
logs: LogEntry[];
|
||||
}
|
||||
|
||||
const LogViewer: React.FC<LogViewerProps> = ({ logs }) => {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
const getIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'ERROR': return <AlertCircle size={14} className="text-destructive mb-0.5" />;
|
||||
case 'WARN': return <AlertCircle size={14} className="text-yellow-500 mb-0.5" />;
|
||||
case 'SUCCESS': return <CheckCircle size={14} className="text-green-500 mb-0.5" />;
|
||||
case 'DEBUG': return <Activity size={14} className="text-blue-400 mb-0.5" />;
|
||||
default: return <Info size={14} className="text-primary mb-0.5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'ERROR': return 'text-destructive';
|
||||
case 'WARN': return 'text-yellow-500';
|
||||
case 'SUCCESS': return 'text-green-500';
|
||||
case 'DEBUG': return 'text-muted-foreground';
|
||||
default: return 'text-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportLogs = () => {
|
||||
const text = logs.map(l => `[${l.timestamp}] [${l.level}] ${l.message}`).join('\n');
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `hearbit_logs_${new Date().toISOString().slice(0, 10)}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background font-mono text-sm">
|
||||
<div className="p-3 border-b border-border/40 bg-secondary/20 flex justify-between items-center">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">System Logs</span>
|
||||
<button
|
||||
onClick={handleExportLogs}
|
||||
className="text-xs bg-secondary hover:bg-secondary/80 text-foreground px-2 py-1 rounded border border-border transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Terminal size={12} className="inline mr-1" /> Export .txt
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-1">
|
||||
{logs.length === 0 && (
|
||||
<div className="text-muted-foreground opacity-50 text-center mt-10 italic">
|
||||
No logs yet...
|
||||
</div>
|
||||
)}
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="flex gap-3 py-1.5 px-3 hover:bg-secondary/30 rounded-md transition-colors items-start">
|
||||
<span className="text-muted-foreground/60 text-xs mt-0.5 shrink-0 select-none flex items-center gap-1.5 min-w-[70px]">
|
||||
<Clock size={10} />
|
||||
{log.timestamp}
|
||||
</span>
|
||||
<div className={`mt-0.5 shrink-0 ${getColor(log.level)}`}>
|
||||
{getIcon(log.level)}
|
||||
</div>
|
||||
<span className={`break-all ${getColor(log.level)}`}>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogViewer;
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Mic, Square, Settings as SettingsIcon, FileText, Sparkles, Copy, Check } from 'lucide-react';
|
||||
import { Mic, Square } from 'lucide-react';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import logo from '../assets/logo.png'; // Import logo
|
||||
import ReactMarkdown from 'react-markdown'; // Import Markdown renderer
|
||||
|
||||
interface PromptTemplate {
|
||||
id: string;
|
||||
@@ -22,7 +21,7 @@ interface RecorderProps {
|
||||
productId: string;
|
||||
prompts: PromptTemplate[];
|
||||
onOpenSettings: () => void;
|
||||
// Lifted State Props
|
||||
// Lifted State Props (still passed for state management, though unused in view)
|
||||
transcription: string;
|
||||
setTranscription: (val: string) => void;
|
||||
summary: string;
|
||||
@@ -32,6 +31,8 @@ interface RecorderProps {
|
||||
onSaveToHistory: (t?: string, s?: string) => void;
|
||||
onDeleteHistory: (id: string) => void;
|
||||
onLoadHistory: (item: HistoryItem) => void;
|
||||
savePath: string | null;
|
||||
onRecordingComplete: () => void;
|
||||
}
|
||||
|
||||
interface AudioDevice {
|
||||
@@ -39,30 +40,19 @@ interface AudioDevice {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const LLM_MODELS = [
|
||||
{ id: 'mixtral', name: 'Mixtral 8x7B (Best for Logic)' },
|
||||
{ id: 'llama3', name: 'Llama 3 (Balanced)' },
|
||||
{ id: 'llama3-70b', name: 'Llama 3 70B (High Quality)' },
|
||||
{ id: 'granite-code', name: 'Granite Code (Coding)' }
|
||||
];
|
||||
|
||||
const Recorder: React.FC<RecorderProps> = ({
|
||||
apiKey, productId, prompts, onOpenSettings,
|
||||
transcription, setTranscription, summary, setSummary,
|
||||
history, onSaveToHistory, onDeleteHistory, onLoadHistory
|
||||
apiKey, productId, prompts,
|
||||
setTranscription, setSummary,
|
||||
onSaveToHistory, savePath, onRecordingComplete
|
||||
}) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [status, setStatus] = useState<string>('Ready to record');
|
||||
const [selectedDevice, setSelectedDevice] = useState<string>('');
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
|
||||
const [selectedModel, setSelectedModel] = useState<string>('mixtral');
|
||||
const [devices, setDevices] = useState<AudioDevice[]>([]);
|
||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>(LLM_MODELS);
|
||||
const [showHistory, setShowHistory] = useState(false); // Toggle history view
|
||||
|
||||
// Local state for copy feedback only
|
||||
const [copiedTrans, setCopiedTrans] = useState(false);
|
||||
const [copiedSum, setCopiedSum] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices();
|
||||
@@ -75,7 +65,6 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
try {
|
||||
const models = await invoke<Array<{ id: string, name: string }>>('get_available_models', { apiKey, productId });
|
||||
if (models && models.length > 0) {
|
||||
// Sort models alphabetically
|
||||
models.sort((a, b) => a.name.localeCompare(b.name));
|
||||
setAvailableModels(models);
|
||||
}
|
||||
@@ -84,13 +73,11 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Set default prompt selection
|
||||
useEffect(() => {
|
||||
if (prompts.length > 0 && !selectedPromptId) {
|
||||
setSelectedPromptId(prompts[0].id);
|
||||
} else if (prompts.length > 0 && selectedPromptId) {
|
||||
// Check if selected still exists
|
||||
if (!prompts.find(p => p.id === selectedPromptId)) {
|
||||
setSelectedPromptId(prompts[0].id);
|
||||
}
|
||||
@@ -100,20 +87,42 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
const devList = await invoke<AudioDevice[]>('get_input_devices');
|
||||
setDevices(devList);
|
||||
if (devList.length > 0 && !selectedDevice) {
|
||||
setSelectedDevice(devList[0].id);
|
||||
// Alias BlackHole
|
||||
const aliasedDevs = devList.map(d => ({
|
||||
...d,
|
||||
name: d.name.includes('BlackHole') ? 'Hearbit Virtual Mic (BlackHole)' : d.name
|
||||
}));
|
||||
setDevices(aliasedDevs);
|
||||
|
||||
// Select Hearbit mic by default if available and no selection made
|
||||
if (!selectedDevice) {
|
||||
const vb = aliasedDevs.find(d => d.name.includes('Hearbit Virtual Mic'));
|
||||
if (vb) {
|
||||
setSelectedDevice(vb.id);
|
||||
} else if (aliasedDevs.length > 0) {
|
||||
setSelectedDevice(aliasedDevs[0].id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load devices', e);
|
||||
}
|
||||
};
|
||||
|
||||
const openAudioSetup = async () => {
|
||||
try {
|
||||
await invoke('open_audio_midi_setup');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setStatus('Failed to open Audio Setup');
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
setStatus('Starting...');
|
||||
await invoke('start_recording', { deviceId: selectedDevice });
|
||||
await invoke('start_recording', { deviceId: selectedDevice, savePath: savePath || null });
|
||||
setIsRecording(true);
|
||||
setIsPaused(false);
|
||||
setTranscription('');
|
||||
setSummary('');
|
||||
setStatus('Recording...');
|
||||
@@ -124,9 +133,26 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const togglePause = async () => {
|
||||
try {
|
||||
if (isPaused) {
|
||||
await invoke('resume_recording');
|
||||
setIsPaused(false);
|
||||
setStatus('Recording...');
|
||||
} else {
|
||||
await invoke('pause_recording');
|
||||
setIsPaused(true);
|
||||
setStatus('Paused');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Pause/Resume error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = async () => {
|
||||
try {
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
setStatus('Processing...');
|
||||
const filePath = await invoke<string>('stop_recording');
|
||||
|
||||
@@ -138,12 +164,9 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
});
|
||||
setTranscription(transText);
|
||||
|
||||
setTranscription(transText);
|
||||
|
||||
// Check if transcription is empty or just whitespace
|
||||
if (!transText || transText.trim().length === 0) {
|
||||
setStatus('Done (No speech detected)');
|
||||
// If empty, set a placeholder so the UI shows something, but DON'T summarize
|
||||
setTranscription('(No speech detected. Check your microphone settings.)');
|
||||
setTimeout(() => setStatus('Ready to record'), 3000);
|
||||
return;
|
||||
@@ -167,6 +190,7 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
onSaveToHistory(transText, sumText);
|
||||
|
||||
setStatus('Done!');
|
||||
onRecordingComplete(); // Auto-switch tab
|
||||
setTimeout(() => setStatus('Ready to record'), 3000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -174,251 +198,127 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const installDriver = async () => {
|
||||
try {
|
||||
setStatus('Installing driver...');
|
||||
const res = await invoke('install_driver');
|
||||
console.log(res);
|
||||
setStatus('Driver installed. Please restart app.');
|
||||
loadDevices();
|
||||
} catch (e) {
|
||||
setStatus(`Install failed: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, isSummary: boolean) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (isSummary) {
|
||||
setCopiedSum(true);
|
||||
setTimeout(() => setCopiedSum(false), 2000);
|
||||
} else {
|
||||
setCopiedTrans(true);
|
||||
setTimeout(() => setCopiedTrans(false), 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy!', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full bg-background relative">
|
||||
{/* Fixed Header */}
|
||||
<div className="w-full flex justify-between items-center p-6 border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-10 sticky top-0 shrink-0">
|
||||
<div className="w-full flex justify-center items-center p-6 shrink-0">
|
||||
<img src={logo} alt="Logo" className="h-12 object-contain" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all ${showHistory
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
<FileText size={16} />
|
||||
{showHistory ? 'Recorder' : 'Records'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-md transition-colors"
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col items-center pb-20">
|
||||
|
||||
{showHistory ? (
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<h2 className="text-xl font-bold mb-4">Saved Recordings</h2>
|
||||
{history.length === 0 && <p className="text-muted-foreground">No saved history.</p>}
|
||||
{history.map(item => (
|
||||
<div key={item.id} className="p-4 rounded-lg bg-card border border-border shadow-sm">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="text-xs text-muted-foreground">{item.date}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { onLoadHistory(item); setShowHistory(false); }}
|
||||
className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteHistory(item.id)}
|
||||
className="text-xs bg-destructive text-destructive-foreground px-2 py-1 rounded"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm truncate font-medium">{item.summary ? item.summary.slice(0, 50) + "..." : "No Summary"}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{item.transcription.slice(0, 50)}...</p>
|
||||
<div className="mb-6 relative shrink-0">
|
||||
<div className={`w-32 h-32 rounded-full flex items-center justify-center transition-all duration-300 ${isRecording ? (isPaused ? 'bg-yellow-500/10' : 'bg-red-500/10 animate-pulse') : 'bg-secondary'}`}>
|
||||
{isRecording ? (
|
||||
<div className={`w-24 h-24 rounded-full flex items-center justify-center shadow-[0_0_20px_rgba(239,68,68,0.5)] ${isPaused ? 'bg-yellow-500' : 'bg-red-500'}`}>
|
||||
<Mic size={40} className="text-white animate-bounce" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6 relative shrink-0">
|
||||
<div className={`w-32 h-32 rounded-full flex items-center justify-center transition-all duration-300 ${isRecording ? 'bg-red-500/10 animate-pulse' : 'bg-secondary'}`}>
|
||||
{isRecording ? (
|
||||
<div className="w-24 h-24 rounded-full bg-red-500 flex items-center justify-center shadow-[0_0_20px_rgba(239,68,68,0.5)]">
|
||||
<Mic size={40} className="text-white animate-bounce" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-primary flex items-center justify-center">
|
||||
<Mic size={40} className="text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-2 text-foreground">
|
||||
{isRecording ? 'Listening...' : 'Ready to Record'}
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-6 text-center text-sm h-6">
|
||||
{status}
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-sm space-y-4 mb-6 shrink-0">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
disabled={!apiKey || !productId}
|
||||
className="w-full py-4 text-lg font-semibold bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
{!apiKey ? 'Configure API Key First' : 'Start Recording'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="w-full py-4 text-lg font-semibold bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-all shadow-md hover:shadow-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
<Square size={20} fill="currentColor" />
|
||||
Stop Recording
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
Input Device
|
||||
</label>
|
||||
<select
|
||||
value={selectedDevice}
|
||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording}
|
||||
>
|
||||
{devices.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
{devices.length === 0 && <option value="">Loading devices...</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
LLM Model
|
||||
</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording}
|
||||
>
|
||||
{availableModels.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
AI Template
|
||||
</label>
|
||||
<select
|
||||
value={selectedPromptId}
|
||||
onChange={(e) => setSelectedPromptId(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording || prompts.length === 0}
|
||||
>
|
||||
{prompts.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
{prompts.length === 0 && <option value="">No templates</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={installDriver}
|
||||
className="text-xs text-primary underline w-full text-center hover:text-primary/80 mt-1 block"
|
||||
>
|
||||
Install BlackHole Driver (for System Audio)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(transcription || summary) && (
|
||||
<div className="w-full space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-10">
|
||||
{/* Transcription Block (Source) */}
|
||||
{transcription && (
|
||||
<div className="p-5 bg-card rounded-xl border border-border shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-blue-500 font-semibold">
|
||||
<FileText size={18} />
|
||||
<h3>Original Transcription</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToClipboard(transcription, false)}
|
||||
className="p-1.5 hover:bg-secondary rounded-md transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Copy Transcription"
|
||||
>
|
||||
{copiedTrans ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/80 whitespace-pre-wrap max-h-[200px] overflow-y-auto pr-2 bg-secondary/30 p-3 rounded-lg border border-border/50 font-medium">
|
||||
{transcription}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Block (Result) */}
|
||||
{summary && (
|
||||
<div className="p-5 bg-card rounded-xl border border-border shadow-sm ring-1 ring-purple-500/20">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-purple-500 font-semibold">
|
||||
<Sparkles size={18} />
|
||||
<h3>AI Summary</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onSaveToHistory()}
|
||||
className="text-xs bg-green-600 text-white px-3 py-1.5 rounded-md hover:bg-green-700 font-medium transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyToClipboard(summary, true)}
|
||||
className="p-1.5 hover:bg-secondary rounded-md transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Copy Summary"
|
||||
>
|
||||
{copiedSum ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-foreground/90 leading-relaxed max-h-[400px] overflow-y-auto pr-2 custom-scrollbar prose prose-sm dark:prose-invert max-w-none prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-li:my-0.5">
|
||||
<ReactMarkdown>
|
||||
{summary}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-primary flex items-center justify-center">
|
||||
<Mic size={40} className="text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-2 text-foreground">
|
||||
{isRecording ? (isPaused ? 'Paused' : 'Listening...') : 'Ready to Record'}
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-6 text-center text-sm h-6">
|
||||
{status}
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-sm space-y-4 mb-6 shrink-0">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
disabled={!apiKey || !productId}
|
||||
className="w-full py-4 text-lg font-semibold bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
{!apiKey ? 'Configure API Key First' : 'Start Recording'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2 w-full">
|
||||
<button
|
||||
onClick={togglePause}
|
||||
className={`flex-1 py-4 text-lg font-semibold rounded-lg transition-all shadow-md hover:shadow-lg flex items-center justify-center gap-2 ${isPaused
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-yellow-500 text-white hover:bg-yellow-600'
|
||||
}`}
|
||||
>
|
||||
{isPaused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="flex-1 py-4 text-lg font-semibold bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-all shadow-md hover:shadow-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
<Square size={20} fill="currentColor" />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
Input Device
|
||||
</label>
|
||||
<select
|
||||
value={selectedDevice}
|
||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording}
|
||||
>
|
||||
{devices.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
{devices.length === 0 && <option value="">Loading devices...</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
LLM Model
|
||||
</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording}
|
||||
>
|
||||
{availableModels.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
AI Template
|
||||
</label>
|
||||
<select
|
||||
value={selectedPromptId}
|
||||
onChange={(e) => setSelectedPromptId(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording || prompts.length === 0}
|
||||
>
|
||||
{prompts.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
{prompts.length === 0 && <option value="">No templates</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-2 w-full">
|
||||
<button
|
||||
onClick={openAudioSetup}
|
||||
className="text-xs text-muted-foreground hover:text-foreground w-full text-center border border-dashed border-border/50 rounded p-1"
|
||||
>
|
||||
Open Audio MIDI Setup (Configure Multi-Output)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,157 +1,319 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
interface PromptTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
import React, { useState } from 'react';
|
||||
import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { encryptData, decryptData } from '../utils/backup';
|
||||
import { PromptTemplate } from '../App';
|
||||
|
||||
interface SettingsProps {
|
||||
onSave: (apiKey: string, productId: string, prompts: PromptTemplate[]) => void;
|
||||
onBack: () => void; // New onBack prop
|
||||
initialApiKey: string;
|
||||
initialProductId: string;
|
||||
initialPrompts: PromptTemplate[];
|
||||
apiKey: string;
|
||||
productId: string;
|
||||
savePath: string;
|
||||
prompts: PromptTemplate[];
|
||||
onSave: (apiKey: string, productId: string, prompts: PromptTemplate[], savePath: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const Settings: React.FC<SettingsProps> = ({ onSave, onBack, initialApiKey, initialProductId, initialPrompts }) => {
|
||||
const [apiKey, setApiKey] = useState(initialApiKey);
|
||||
const [productId, setProductId] = useState(initialProductId);
|
||||
const [prompts, setPrompts] = useState<PromptTemplate[]>(initialPrompts);
|
||||
const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePath, onSave, onClose }) => {
|
||||
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||
const [localProductId, setLocalProductId] = useState(productId);
|
||||
const [localSavePath, setLocalSavePath] = useState(savePath);
|
||||
const [localPrompts, setLocalPrompts] = useState<PromptTemplate[]>(prompts);
|
||||
const [statusIdx, setStatusIdx] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setApiKey(initialApiKey);
|
||||
setProductId(initialProductId);
|
||||
// Only reset prompts if passed different ones (mounting), usually state is preserved in App
|
||||
}, [initialApiKey, initialProductId]);
|
||||
// Backup & Restore State
|
||||
const [backupPassword, setBackupPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [importFileContent, setImportFileContent] = useState<string | null>(null);
|
||||
|
||||
const handleSave = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(apiKey, productId, prompts);
|
||||
const handlePromptChange = (id: string, field: 'name' | 'content', value: string) => {
|
||||
setLocalPrompts(localPrompts.map(p => p.id === id ? { ...p, [field]: value } : p));
|
||||
};
|
||||
|
||||
const addPrompt = () => {
|
||||
setPrompts([...prompts, { id: Date.now().toString(), name: 'New Prompt', content: '' }]);
|
||||
};
|
||||
|
||||
const updatePrompt = (id: string, field: 'name' | 'content', value: string) => {
|
||||
setPrompts(prompts.map(p => p.id === id ? { ...p, [field]: value } : p));
|
||||
setLocalPrompts([...localPrompts, { id: Date.now().toString(), name: 'New Prompt', content: '' }]);
|
||||
};
|
||||
|
||||
const removePrompt = (id: string) => {
|
||||
setPrompts(prompts.filter(p => p.id !== id));
|
||||
setLocalPrompts(localPrompts.filter(p => p.id !== id));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(localApiKey, localProductId, localPrompts, localSavePath);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSelectFolder = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: localSavePath || undefined,
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
setLocalSavePath(selected);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to open directory picker", e);
|
||||
setStatusIdx('Error: Failed to open directory picker.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!backupPassword) {
|
||||
setStatusIdx('Error: Password required to encrypt backup.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = {
|
||||
apiKey: localApiKey,
|
||||
productId: localProductId,
|
||||
prompts: localPrompts,
|
||||
savePath: localSavePath
|
||||
};
|
||||
const encrypted = await encryptData(data, backupPassword);
|
||||
const blob = new Blob([encrypted], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `hearbit_backup_${new Date().toISOString().slice(0, 10)}.conf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
setStatusIdx('Configuration exported successfully!');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setStatusIdx('Export failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const triggerImport = () => {
|
||||
document.getElementById('import-file-input')?.click();
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (event.target?.result) {
|
||||
setImportFileContent(event.target.result as string);
|
||||
setIsImportModalOpen(true);
|
||||
setBackupPassword('');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const confirmImport = async () => {
|
||||
if (!backupPassword) {
|
||||
setStatusIdx('Error: Password required to decrypt.');
|
||||
return;
|
||||
}
|
||||
if (!importFileContent) return;
|
||||
|
||||
try {
|
||||
const data = await decryptData(importFileContent, backupPassword);
|
||||
if (data.apiKey) setLocalApiKey(data.apiKey);
|
||||
if (data.productId) setLocalProductId(data.productId);
|
||||
if (data.prompts) setLocalPrompts(data.prompts);
|
||||
if (data.savePath) setLocalSavePath(data.savePath);
|
||||
|
||||
setStatusIdx('Configuration imported! Click Save to apply.');
|
||||
setIsImportModalOpen(false);
|
||||
setImportFileContent(null);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setStatusIdx('Import failed: Wrong password or corrupted file.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 w-full max-w-2xl mx-auto mt-6 animate-in fade-in duration-500">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-foreground">Infomaniak Settings</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-sm text-muted-foreground hover:text-foreground hover:bg-secondary px-3 py-1.5 rounded-md transition-colors"
|
||||
>
|
||||
Back to Recorder
|
||||
<div className="flex flex-col h-full bg-background font-mono text-sm relative">
|
||||
{/* Import Password Modal */}
|
||||
{isImportModalOpen && (
|
||||
<div className="absolute inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||
<div className="bg-background border border-border rounded-lg shadow-xl p-6 w-full max-w-sm space-y-4">
|
||||
<div className="flex items-center gap-2 text-foreground font-semibold">
|
||||
<Lock size={16} /> Import Configuration
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Enter the password used to encrypt this backup file.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={backupPassword}
|
||||
onChange={(e) => setBackupPassword(e.target.value)}
|
||||
placeholder="Backup Password"
|
||||
className="w-full p-2 pr-8 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setIsImportModalOpen(false)}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded border border-border hover:bg-secondary text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmImport}
|
||||
disabled={!backupPassword}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Decrypt & Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 border-b border-border/40 bg-secondary/20 flex justify-between items-center">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Settings</span>
|
||||
<button onClick={handleSave} className="flex items-center gap-1 text-primary hover:text-primary/80 transition-colors font-semibold">
|
||||
<Save size={16} /> Save
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSave} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<div className="space-y-4 border rounded p-4 border-border/50">
|
||||
<h3 className="text-foreground font-semibold flex items-center gap-2">General</h3>
|
||||
<div>
|
||||
<label htmlFor="apiKey" className="block text-sm font-medium mb-1 text-foreground">
|
||||
Infomaniak API Key
|
||||
</label>
|
||||
<label htmlFor="apiKey" className="block text-sm font-medium mb-1 text-foreground">API Key</label>
|
||||
<input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="ik_..."
|
||||
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none"
|
||||
value={localApiKey}
|
||||
onChange={(e) => setLocalApiKey(e.target.value)}
|
||||
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="productId" className="block text-sm font-medium mb-1 text-foreground">
|
||||
Product ID
|
||||
</label>
|
||||
<label htmlFor="productId" className="block text-sm font-medium mb-1 text-foreground">Product ID</label>
|
||||
<input
|
||||
id="productId"
|
||||
type="text"
|
||||
value={productId}
|
||||
onChange={(e) => setProductId(e.target.value)}
|
||||
placeholder="Number (e.g., 12345)"
|
||||
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none"
|
||||
value={localProductId}
|
||||
onChange={(e) => setLocalProductId(e.target.value)}
|
||||
className="w-full p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="savePath" className="block text-sm font-medium mb-1 text-foreground">Custom Recordings Folder</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="savePath"
|
||||
type="text"
|
||||
value={localSavePath}
|
||||
onChange={(e) => setLocalSavePath(e.target.value)}
|
||||
placeholder="/Users/username/Desktop/Recordings"
|
||||
className="flex-1 p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSelectFolder}
|
||||
className="p-2 aspect-square flex items-center justify-center bg-secondary hover:bg-secondary/80 border border-border rounded text-foreground transition-colors"
|
||||
title="Pick Folder"
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border rounded p-4 border-border/50">
|
||||
<h3 className="text-foreground font-semibold flex items-center gap-2">
|
||||
<Lock size={16} /> Backup & Restore
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Export your settings (keys, prompts, path) to an encrypted file.
|
||||
</p>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={backupPassword}
|
||||
onChange={(e) => setBackupPassword(e.target.value)}
|
||||
placeholder="Encryption Password"
|
||||
className="w-full p-2 pr-8 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded bg-secondary hover:bg-secondary/80 border border-border text-foreground transition-all text-xs font-semibold"
|
||||
>
|
||||
<Download size={14} /> Export Config
|
||||
</button>
|
||||
<button
|
||||
onClick={triggerImport}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2 px-4 rounded bg-secondary hover:bg-secondary/80 border border-border text-foreground transition-all text-xs font-semibold"
|
||||
>
|
||||
<Upload size={14} /> Import Config
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
id="import-file-input"
|
||||
accept=".conf"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Summarization Templates
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPrompt}
|
||||
className="text-xs flex items-center gap-1 bg-secondary hover:bg-secondary/80 text-foreground px-3 py-1 rounded transition-colors"
|
||||
>
|
||||
<Plus size={14} /> Add Template
|
||||
<div className="space-y-4 border rounded p-4 border-border/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-foreground font-semibold">Prompts</h3>
|
||||
<button onClick={addPrompt} className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded hover:bg-primary/90">
|
||||
+ Add Prompt
|
||||
</button>
|
||||
</div>
|
||||
{localPrompts.map((prompt) => (
|
||||
<div key={prompt.id} className="space-y-2 p-3 bg-secondary/30 rounded border border-border/50 relative group">
|
||||
<button
|
||||
onClick={() => removePrompt(prompt.id)}
|
||||
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity text-xs"
|
||||
>
|
||||
<EyeOff size={12} className="inline mr-1" /> Remove
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={prompt.name}
|
||||
onChange={(e) => handlePromptChange(prompt.id, 'name', e.target.value)}
|
||||
className="w-full p-1 bg-transparent border-b border-border/50 focus:border-primary outline-none font-semibold text-sm"
|
||||
placeholder="Prompt Name"
|
||||
/>
|
||||
<textarea
|
||||
value={prompt.content}
|
||||
onChange={(e) => handlePromptChange(prompt.id, 'content', e.target.value)}
|
||||
className="w-full p-2 bg-secondary/50 rounded border border-border/30 focus:border-primary outline-none text-xs resize-y min-h-[60px]"
|
||||
placeholder="Prompt Content"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-h-[400px] overflow-y-auto pr-2">
|
||||
{prompts.map((prompt) => (
|
||||
<div key={prompt.id} className="p-4 rounded-lg border border-border bg-card shadow-sm space-y-3">
|
||||
<div className="flex justify-between gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={prompt.name}
|
||||
onChange={(e) => updatePrompt(prompt.id, 'name', e.target.value)}
|
||||
placeholder="Template Name (e.g., Email Summary)"
|
||||
className="flex-1 font-semibold bg-transparent border-none p-0 focus:ring-0 text-foreground placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePrompt(prompt.id)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove Template"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={prompt.content}
|
||||
onChange={(e) => updatePrompt(prompt.id, 'content', e.target.value)}
|
||||
placeholder="Enter instructions for the AI..."
|
||||
rows={3}
|
||||
className="w-full p-2 text-sm rounded border border-border bg-secondary/50 text-foreground focus:ring-1 focus:ring-primary outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{prompts.length === 0 && (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
No templates defined. Click "Add Template" to create one.
|
||||
</p>
|
||||
)}
|
||||
{statusIdx && (
|
||||
<div className={`p-2 text-xs rounded border ${statusIdx.includes('Error') || statusIdx.includes('failed') ? 'bg-destructive/10 border-destructive text-destructive' : 'bg-green-500/10 border-green-500 text-green-500'}`}>
|
||||
{statusIdx}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="w-1/3 bg-secondary text-secondary-foreground py-3 px-4 rounded-lg hover:bg-secondary/80 transition-all font-semibold"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-2/3 bg-primary text-primary-foreground py-3 px-4 rounded-lg hover:bg-primary/90 transition-all font-semibold shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
|
||||
>
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
54
src/components/Tabs.tsx
Normal file
54
src/components/Tabs.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Mic, Terminal, FileText } from 'lucide-react';
|
||||
interface TabsProps {
|
||||
currentTab: 'recorder' | 'logs' | 'transcription' | 'settings';
|
||||
onTabChange: (tab: 'recorder' | 'logs' | 'transcription' | 'settings') => void;
|
||||
}
|
||||
|
||||
const Tabs: React.FC<TabsProps> = ({ currentTab, onTabChange }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1 bg-secondary/50 p-1 rounded-full border border-border/40 self-center">
|
||||
<button
|
||||
onClick={() => onTabChange('recorder')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${currentTab === 'recorder' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
|
||||
>
|
||||
<Mic size={16} />
|
||||
Recording
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onTabChange('transcription')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${currentTab === 'transcription' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
|
||||
>
|
||||
<FileText size={16} />
|
||||
Transcription
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onTabChange('logs')}
|
||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-full text-sm font-medium transition-all duration-200 ${currentTab === 'logs'
|
||||
? 'bg-background shadow-sm text-foreground ring-1 ring-border/50'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
}`}
|
||||
>
|
||||
<Terminal size={14} />
|
||||
Logs
|
||||
</button>
|
||||
{/* Settings could be a tab, but often better as an icon elsewhere, however sticking to the 'tab' request */}
|
||||
{/* The user didn't explicitly ask for settings tab, but we need a way to get there. Let's keep it here for now or maybe just an icon?
|
||||
The prompt showed "Recording | Summary | Meetings". We are doing "Recording | Logs".
|
||||
Let's keep settings as a tab to simplify navigation for now. */}
|
||||
{/* <button
|
||||
onClick={() => onTabChange('settings')}
|
||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-full text-sm font-medium transition-all duration-200 ${
|
||||
currentTab === 'settings'
|
||||
? 'bg-background shadow-sm text-foreground ring-1 ring-border/50'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
}`}
|
||||
>
|
||||
<Settings size={14} />
|
||||
Settings
|
||||
</button> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tabs;
|
||||
81
src/components/TranscriptionView.tsx
Normal file
81
src/components/TranscriptionView.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
interface TranscriptionViewProps {
|
||||
transcription: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
const TranscriptionView: React.FC<TranscriptionViewProps> = ({ transcription, summary }) => {
|
||||
const [copiedTrans, setCopiedTrans] = useState(false);
|
||||
const [copiedSum, setCopiedSum] = useState(false);
|
||||
|
||||
const handleCopy = async (text: string, isSummary: boolean) => {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (isSummary) {
|
||||
setCopiedSum(true);
|
||||
setTimeout(() => setCopiedSum(false), 2000);
|
||||
} else {
|
||||
setCopiedTrans(true);
|
||||
setTimeout(() => setCopiedTrans(false), 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy!', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background font-mono text-sm overflow-hidden">
|
||||
<div className="flex-1 flex flex-col md:flex-row divide-y md:divide-y-0 md:divide-x divide-border/40 overflow-hidden">
|
||||
{/* Original Transcription */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="p-3 border-b border-border/40 bg-secondary/20 flex justify-between items-center shrink-0">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">Original Transcription</span>
|
||||
<button
|
||||
onClick={() => handleCopy(transcription, false)}
|
||||
className="text-xs flex items-center gap-1 hover:text-primary transition-colors disabled:opacity-50"
|
||||
disabled={!transcription}
|
||||
>
|
||||
{copiedTrans ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
{copiedTrans ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-background">
|
||||
{transcription ? (
|
||||
<p className="whitespace-pre-wrap text-foreground leading-relaxed">{transcription}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic text-xs">No transcription available yet...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Summary */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="p-3 border-b border-border/40 bg-secondary/20 flex justify-between items-center shrink-0">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">AI Summary</span>
|
||||
<button
|
||||
onClick={() => handleCopy(summary, true)}
|
||||
className="text-xs flex items-center gap-1 hover:text-primary transition-colors disabled:opacity-50"
|
||||
disabled={!summary}
|
||||
>
|
||||
{copiedSum ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
{copiedSum ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-secondary/10 prose prose-sm max-w-none prose-p:text-foreground/90 prose-headings:text-foreground prose-strong:text-foreground prose-ul:text-foreground/90">
|
||||
{summary ? (
|
||||
<ReactMarkdown>{summary}</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic text-xs">No summary available yet...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TranscriptionView;
|
||||
92
src/utils/backup.ts
Normal file
92
src/utils/backup.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
// Generate a key from a password
|
||||
async function getKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
const keyMaterial = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(password),
|
||||
{ name: "PBKDF2" },
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: salt as any,
|
||||
iterations: 100000,
|
||||
hash: "SHA-256"
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptData(data: object, password: string): Promise<string> {
|
||||
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await getKey(password, salt);
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const encodedData = enc.encode(JSON.stringify(data));
|
||||
|
||||
const encryptedContent = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: iv
|
||||
},
|
||||
key,
|
||||
encodedData
|
||||
);
|
||||
|
||||
const buffer = new Uint8Array(salt.byteLength + iv.byteLength + encryptedContent.byteLength);
|
||||
buffer.set(salt, 0);
|
||||
buffer.set(iv, salt.byteLength);
|
||||
buffer.set(new Uint8Array(encryptedContent), salt.byteLength + iv.byteLength);
|
||||
|
||||
// Safer binary to string conversion to avoid stack overflow with spread operator
|
||||
let binary = '';
|
||||
const len = buffer.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(buffer[i]);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export async function decryptData(base64Data: string, password: string): Promise<any> {
|
||||
try {
|
||||
const binaryString = atob(base64Data.trim());
|
||||
const len = binaryString.length;
|
||||
const buffer = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
buffer[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const salt = buffer.slice(0, 16);
|
||||
const iv = buffer.slice(16, 28);
|
||||
const ciphertext = buffer.slice(28);
|
||||
|
||||
const key = await getKey(password, salt);
|
||||
|
||||
const decryptedContent = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: iv
|
||||
},
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
const dec = new TextDecoder();
|
||||
return JSON.parse(dec.decode(decryptedContent));
|
||||
} catch (e: any) {
|
||||
console.error("Decryption internal error:", e);
|
||||
// Distinguish between password error (OperationError) and others if possible
|
||||
if (e.name === 'OperationError') {
|
||||
throw new Error("Incorrect password.");
|
||||
}
|
||||
throw new Error(`Import failed: ${e.message || 'Corrupted file'}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user