Handy/src/components/settings/history/HistorySettings.tsx
CJ Pais 9521ab9332
WIP: Add tauri-specta for generating TS bindings (#322)
* initial specta commands

* refactor to use the bindings in the ui

* merge fixes

* remove unused commands

* add clamshell to lib

* Remove Invoke from everything.

* fix settings not being loaded properly

* fix settings being overwritten due to deserialization failure + remove
provider kind

* fix model loading text bug
2025-11-27 17:07:22 +07:00

278 lines
8.2 KiB
TypeScript

import React, { useState, useEffect, useCallback } from "react";
import { AudioPlayer } from "../../ui/AudioPlayer";
import { Button } from "../../ui/Button";
import { Copy, Star, Check, Trash2, FolderOpen } from "lucide-react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { commands, type HistoryEntry } from "@/bindings";
interface OpenRecordingsButtonProps {
onClick: () => void;
}
const OpenRecordingsButton: React.FC<OpenRecordingsButtonProps> = ({
onClick,
}) => (
<Button
onClick={onClick}
variant="secondary"
size="sm"
className="flex items-center gap-2"
title="Open recordings folder"
>
<FolderOpen className="w-4 h-4" />
<span>Open Recordings Folder</span>
</Button>
);
export const HistorySettings: React.FC = () => {
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const loadHistoryEntries = useCallback(async () => {
try {
const result = await commands.getHistoryEntries();
if (result.status === "ok") {
setHistoryEntries(result.data);
}
} catch (error) {
console.error("Failed to load history entries:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadHistoryEntries();
// Listen for history update events
const setupListener = async () => {
const unlisten = await listen("history-updated", () => {
console.log("History updated, reloading entries...");
loadHistoryEntries();
});
// Return cleanup function
return unlisten;
};
let unlistenPromise = setupListener();
return () => {
unlistenPromise.then((unlisten) => {
if (unlisten) {
unlisten();
}
});
};
}, [loadHistoryEntries]);
const toggleSaved = async (id: string) => {
try {
await commands.toggleHistoryEntrySaved(id);
// No need to reload here - the event listener will handle it
} catch (error) {
console.error("Failed to toggle saved status:", error);
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
}
};
const getAudioUrl = async (fileName: string) => {
try {
const result = await commands.getAudioFilePath(fileName);
if (result.status === "ok") {
return convertFileSrc(`${result.data}`, "asset");
}
return null;
} catch (error) {
console.error("Failed to get audio file path:", error);
return null;
}
};
const deleteAudioEntry = async (id: string) => {
try {
await commands.deleteHistoryEntry(id);
} catch (error) {
console.error("Failed to delete audio entry:", error);
throw error;
}
};
const openRecordingsFolder = async () => {
try {
await commands.openRecordingsFolder();
} catch (error) {
console.error("Failed to open recordings folder:", error);
}
};
if (loading) {
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<div className="space-y-2">
<div className="px-4 flex items-center justify-between">
<div>
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
History
</h2>
</div>
<OpenRecordingsButton onClick={openRecordingsFolder} />
</div>
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="px-4 py-3 text-center text-text/60">
Loading history...
</div>
</div>
</div>
</div>
);
}
if (historyEntries.length === 0) {
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<div className="space-y-2">
<div className="px-4 flex items-center justify-between">
<div>
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
History
</h2>
</div>
<OpenRecordingsButton onClick={openRecordingsFolder} />
</div>
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="px-4 py-3 text-center text-text/60">
No transcriptions yet. Start recording to build your history!
</div>
</div>
</div>
</div>
);
}
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<div className="space-y-2">
<div className="px-4 flex items-center justify-between">
<div>
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
History
</h2>
</div>
<OpenRecordingsButton onClick={openRecordingsFolder} />
</div>
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="divide-y divide-mid-gray/20">
{historyEntries.map((entry) => (
<HistoryEntryComponent
key={entry.id}
entry={entry}
onToggleSaved={() => toggleSaved(entry.id)}
onCopyText={() => copyToClipboard(entry.transcription_text)}
getAudioUrl={getAudioUrl}
deleteAudio={deleteAudioEntry}
/>
))}
</div>
</div>
</div>
</div>
);
};
interface HistoryEntryProps {
entry: HistoryEntry;
onToggleSaved: () => void;
onCopyText: () => void;
getAudioUrl: (fileName: string) => Promise<string | null>;
deleteAudio: (id: string) => Promise<void>;
}
const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
entry,
onToggleSaved,
onCopyText,
getAudioUrl,
deleteAudio,
}) => {
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [showCopied, setShowCopied] = useState(false);
useEffect(() => {
const loadAudio = async () => {
const url = await getAudioUrl(entry.file_name);
setAudioUrl(url);
};
loadAudio();
}, [entry.file_name, getAudioUrl]);
const handleCopyText = () => {
onCopyText();
setShowCopied(true);
setTimeout(() => setShowCopied(false), 2000);
};
const handleDeleteEntry = async () => {
try {
await deleteAudio(entry.id);
} catch (error) {
console.error("Failed to delete entry:", error);
alert("Failed to delete entry. Please try again.");
}
};
return (
<div className="px-4 py-2 pb-5 flex flex-col gap-3">
<div className="flex justify-between items-center">
<p className="text-sm font-medium">{entry.title}</p>
<div className="flex items-center gap-1">
<button
onClick={handleCopyText}
className="text-text/50 hover:text-logo-primary hover:border-logo-primary transition-colors cursor-pointer"
title="Copy transcription to clipboard"
>
{showCopied ? (
<Check width={16} height={16} />
) : (
<Copy width={16} height={16} />
)}
</button>
<button
onClick={onToggleSaved}
className={`p-2 rounded transition-colors cursor-pointer ${
entry.saved
? "text-logo-primary hover:text-logo-primary/80"
: "text-text/50 hover:text-logo-primary"
}`}
title={entry.saved ? "Remove from saved" : "Save transcription"}
>
<Star
width={16}
height={16}
fill={entry.saved ? "currentColor" : "none"}
/>
</button>
<button
onClick={handleDeleteEntry}
className="text-text/50 hover:text-logo-primary transition-colors cursor-pointer"
title="Delete entry"
>
<Trash2 width={16} height={16} />
</button>
</div>
</div>
<p className="italic text-text/90 text-sm pb-2">
{entry.transcription_text}
</p>
{audioUrl && <AudioPlayer src={audioUrl} className="w-full" />}
</div>
);
};