Files
Lumotia/src/lib/pages/FilesPage.svelte
jake f1ef171acb feat(history): migrate history to SQLite with FTS5 search
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:34:43 +00:00

258 lines
8.6 KiB
Svelte

<script>
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { settings } from "$lib/stores/page.svelte.js";
import { loadHistory } from "$lib/stores/history.svelte.js";
import Card from "$lib/components/Card.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import { exportTranscript } from "$lib/utils/export.js";
import { Upload } from 'lucide-svelte';
let fileTranscript = $state("");
let segments = $state([]);
let isDragOver = $state(false);
let progress = $state(0);
let progressText = $state("");
let fileName = $state("");
let error = $state("");
let transcribing = $state(false);
let showExportMenu = $state(false);
let unlistenDrop = null;
let unlistenEnter = null;
let unlistenLeave = null;
onMount(async () => {
unlistenDrop = await listen("tauri://drag-drop", async (event) => {
isDragOver = false;
if (event.payload?.paths?.length > 0) {
await transcribeFiles(event.payload.paths);
}
});
unlistenEnter = await listen("tauri://drag-enter", () => { isDragOver = true; });
unlistenLeave = await listen("tauri://drag-leave", () => { isDragOver = false; });
});
onDestroy(() => {
if (unlistenDrop) unlistenDrop();
if (unlistenEnter) unlistenEnter();
if (unlistenLeave) unlistenLeave();
});
async function handleBrowse() {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const paths = await open({
multiple: true,
filters: [{
name: "Audio/Video",
extensions: ["mp3", "wav", "m4a", "mp4", "flac", "ogg", "webm", "mov", "mkv"],
}],
});
if (paths) {
const pathList = Array.isArray(paths) ? paths : [paths];
await transcribeFiles(pathList);
}
} catch (err) {
error = typeof err === "string" ? err : err.message || "Failed to open file dialog";
}
}
async function transcribeFiles(paths) {
transcribing = true;
error = "";
fileTranscript = "";
segments = [];
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
fileName = path.split(/[\\/]/).pop() || path;
progress = (i / paths.length) * 100;
progressText = `Transcribing ${i + 1}/${paths.length}...`;
try {
const result = await invoke("transcribe_file", {
path,
language: settings.language,
initialPrompt: "",
removeFillers: settings.removeFillers,
britishEnglish: settings.britishEnglish,
antiHallucination: settings.antiHallucination,
formatMode: settings.formatMode,
});
const text = result.segments.map((s) => s.text).join(" ").trim();
if (paths.length > 1) {
fileTranscript += `--- ${fileName} ---\n${text}\n\n`;
} else {
fileTranscript = text;
}
segments = [...segments, ...result.segments];
// transcribe_file auto-persists to SQLite (Task 2).
// Refresh local history state to pick up the new entry.
loadHistory();
} catch (err) {
error = `Failed: ${fileName}${typeof err === "string" ? err : err.message}`;
}
}
progress = 100;
progressText = "Done";
transcribing = false;
setTimeout(() => {
progress = 0;
progressText = "";
fileName = "";
}, 3000);
}
function copyAll() {
if (fileTranscript) invoke("copy_to_clipboard", { text: fileTranscript }).catch(() => {});
}
function handleExport(format) {
if (!fileTranscript) return;
showExportMenu = false;
const content = exportTranscript(fileTranscript, segments, format);
const extMap = { vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" };
const ext = extMap[format] || "txt";
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `kon-file-${new Date().toISOString().slice(0, 10)}.${ext}`;
a.click();
URL.revokeObjectURL(url);
}
</script>
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
<!-- Title -->
<h2 class="font-display text-[26px] italic text-text px-7 pt-6 pb-4">File Transcription</h2>
<!-- Drop zone -->
<div class="px-7 pb-3">
<Card>
{#if !fileTranscript && !transcribing}
<div
class="flex flex-col items-center justify-center py-10 px-6 rounded-xl border-2 border-dashed m-3
{isDragOver
? 'border-accent bg-accent-subtle scale-[1.01]'
: 'border-border hover:border-text-tertiary'}"
style="transition-duration: var(--duration-ui)"
role="region"
>
<EmptyState
icon={Upload}
message="Drop an audio file or click to browse"
/>
<p class="text-[11px] text-text-tertiary text-center mt-1.5">
MP3, WAV, M4A, MP4, FLAC, OGG
</p>
</div>
{:else}
<div
class="flex flex-col items-center justify-center py-10 px-6 rounded-xl border-2 border-dashed m-3
{isDragOver
? 'border-accent bg-accent-subtle scale-[1.01]'
: 'border-border hover:border-text-tertiary'}"
style="transition-duration: var(--duration-ui)"
role="region"
>
<div class="w-10 h-10 rounded-full bg-bg-elevated flex items-center justify-center mb-3">
<Upload size={20} class="text-text-tertiary" aria-hidden="true" />
</div>
<p class="text-[13px] text-text-secondary text-center">
Drop audio or video files here
</p>
<p class="text-[11px] text-text-tertiary text-center mt-1.5">
MP3, WAV, M4A, MP4, FLAC, OGG
</p>
</div>
{/if}
</Card>
</div>
<!-- Browse buttons + progress -->
<div class="flex items-center gap-2 px-7 pb-3">
<button
class="btn-lg rounded-lg font-medium text-white bg-accent hover:bg-accent-hover
shadow-[0_2px_8px_rgba(232,168,124,0.2)] active:scale-[0.97]"
style="transition-duration: var(--duration-ui)"
onclick={handleBrowse}
disabled={transcribing}
>
Browse Files
</button>
<div class="flex-1"></div>
{#if progressText}
<span class="text-[12px] text-text-secondary animate-fade-in">{progressText}</span>
{/if}
</div>
<!-- Error -->
{#if error}
<div class="px-7 pb-2 animate-fade-in">
<div class="px-4 py-2 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger">
{error}
</div>
</div>
{/if}
<!-- Progress bar -->
{#if progress > 0}
<div class="px-7 pb-3 animate-fade-in">
<div class="h-[3px] bg-bg-elevated rounded-full overflow-hidden">
<div
class="h-full bg-accent rounded-full shadow-[0_0_8px_rgba(232,168,124,0.4)]"
style="width: {progress}%; transition-duration: var(--duration-ui)"
></div>
</div>
{#if fileName}
<p class="text-[11px] text-text-tertiary mt-1.5">{fileName}</p>
{/if}
</div>
{/if}
<!-- File transcript -->
<div class="flex-1 px-7 pb-3 min-h-0">
<Card classes="h-full flex flex-col">
<textarea
class="font-transcript flex-1 w-full bg-transparent text-text p-6
resize-none focus:outline-none placeholder:text-text-tertiary"
placeholder="Transcribed text will appear here..."
bind:value={fileTranscript}
data-no-transition
aria-label="File transcript"
aria-live="polite"
></textarea>
</Card>
</div>
<!-- Bottom actions -->
<div class="flex gap-0.5 px-7 pb-4">
<button class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text" onclick={copyAll}>Copy</button>
<div class="relative">
<button
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text"
onclick={() => showExportMenu = !showExportMenu}
>Export</button>
{#if showExportMenu}
<div class="absolute left-0 bottom-full mb-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[120px]">
{#each [["txt", "Plain Text"], ["md", "Markdown"], ["csv", "CSV"], ["html", "HTML"], ["srt", "SRT Subtitles"], ["vtt", "WebVTT"]] as [fmt, label]}
<button
class="w-full text-left btn-md text-text-secondary hover:bg-hover hover:text-text"
onclick={() => handleExport(fmt)}
>{label}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>