Files
Lumotia/src/lib/pages/FilesPage.svelte

264 lines
8.7 KiB
Svelte

<script lang="ts">
// @ts-nocheck
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { settings, addToHistory } from "$lib/stores/page.svelte.js";
import { profilesStore } from "$lib/stores/profiles.svelte.ts";
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,
engine: settings.engine,
language: settings.language,
initialPrompt: "",
profileId: profilesStore.activeProfileId,
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];
addToHistory({
id: crypto.randomUUID(),
date: new Date().toLocaleString("en-GB"),
source: fileName,
profileId: profilesStore.activeProfileId,
preview: text.slice(0, 120),
text,
segments: result.segments,
duration: result.duration,
language: result.language,
engine: result.engine ?? settings.engine,
modelId: result.modelId ?? null,
});
} 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) {
navigator.clipboard.writeText(fileTranscript).catch(() => {
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 = `magnotia-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. The dashed border IS the affordance, so we drop the
outer Card (which doubled the surface treatment per brand spec)
and let the dashed div be the single surface. The region landmark
carries an explicit accessible name per WCAG ARIA13. -->
<div class="px-7 pb-3">
<div
class="flex flex-col items-center justify-center py-10 px-6 rounded-xl border-2 border-dashed
{isDragOver
? 'border-accent bg-accent-subtle scale-[1.01]'
: 'border-border hover:border-text-tertiary'}"
style="transition-duration: var(--duration-ui)"
role="region"
aria-label="Audio file drop zone"
>
{#if !fileTranscript && !transcribing}
<EmptyState
icon={Upload}
message="Drop an audio file or click to browse"
/>
{:else}
<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>
{/if}
<p class="text-[12px] text-text-secondary text-center mt-1.5">
MP3, WAV, M4A, MP4, FLAC, OGG
</p>
</div>
</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(214,132,80,0.2)]"
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(214,132,80,0.4)]"
style="width: {progress}%; transition-duration: var(--duration-ui)"
></div>
</div>
{#if fileName}
<p class="text-[12px] text-text-secondary 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 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>