Files
Lumotia/src/lib/pages/FilesPage.svelte
Jake bc2db91520
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
agent: lumotia-rebrand — fix QC blockers for phase 0
Phase 0 QC found two real blockers in the baseline commits:

1. crates/llm/src/lib.rs:215 — early-break guard was inverted. The
   guard fired when grammar.is_some() (where grammar already enforces
   shape, making the check redundant) and was disabled for
   grammar.is_none() (the content-tags path that actually needs it).
   Flipped to grammar.is_none() so the JSON-envelope short-circuit
   fires for free-form JSON generation as the commit message
   implied.

2. src/lib/pages/FilesPage.svelte — handleExport() success path had
   no toast, asymmetric with DictationPage which does. Added the
   toasts import and a toasts.success() call after the native save
   so both export surfaces give consistent feedback.

cargo test -p magnotia-llm --lib: 21 pass. npm run check: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:42:22 +01:00

287 lines
9.5 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 { hasTauriRuntime } from "$lib/utils/runtime";
import { toasts } from "$lib/stores/toasts.svelte.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(() => {});
});
}
}
async function handleExport(format) {
if (!fileTranscript) return;
showExportMenu = false;
const content = exportTranscript(fileTranscript, segments, format);
const extMap = { txt: "txt", vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" };
const ext = extMap[format] || "txt";
const defaultPath = `magnotia-file-${new Date().toISOString().slice(0, 10)}.${ext}`;
if (hasTauriRuntime()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
title: `Export ${format.toUpperCase()} transcript`,
defaultPath,
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
});
if (!path) return;
await invoke("write_text_file_cmd", { path, contents: content });
toasts.success(`Exported ${format.toUpperCase()} transcript`);
return;
} catch (err) {
console.error("file transcript export failed", err);
error = `Export failed: ${typeof err === "string" ? err : err.message || err}`;
return;
}
}
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = defaultPath;
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-[var(--shadow-accent-raised)]"
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-[var(--shadow-accent-glow)]"
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>