From 314b89893a077dc4ce8132760b31ce78bcd8d0b3 Mon Sep 17 00:00:00 2001 From: SpirusNox <78000963+SpirusNox@users.noreply.github.com> Date: Fri, 28 Mar 2025 16:14:42 -0500 Subject: [PATCH] fix: UI enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement side-by-side layout for better usability - Improve navigation with descriptive labels - Convert upload panel to floating modal - Optimize for large screens with better width constraints - Fix scrolling issues for long transcriptions - Replace summarization button icon with star for better clarity - Add warning about HF_API_KEY environment variable requirement in setup page 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/routes/+page.svelte | 480 +++++++++---------- src/routes/WhisperSetup.svelte | 6 + src/routes/components/FilePanel.svelte | 507 ++++++++++++++++++++- src/routes/components/UploadPanel.svelte | 218 +++++---- src/routes/components/files-sidebar.svelte | 72 +-- 5 files changed, 887 insertions(+), 396 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 99c9995..f444e61 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,231 +1,251 @@ - - -
- -
- -
-
SCRIBERR
-
- - - - - - -
- - - - - - My Account - - - - Log out - - - -
-
-
- - -
- {#if activePanel === 'files'} - - {:else if activePanel === 'status'} - - {:else if activePanel === 'templates'} - - {/if} -
-
+ + +
+ +
+ +
+
SCRIBERR
+
+ + + + + + + + + + +
+ + + + + + My Account + + + + Log out + + + +
+
+
+ + +
+ {#if activePanel === 'files'} +
+
+ +
+
+ {#if selectedFileId} +
+ +
+ +
+ + {#key selectedFileId} + {#if $audioFiles.find(f => f.id === selectedFileId)} + f.id === selectedFileId)} isOpen={true} /> + {/if} + {/key} +
+ {:else} +
+

Select a recording from the list to view details

+
+ {/if} +
+
+ {:else if activePanel === 'status'} + + {:else if activePanel === 'templates'} + + {/if} +
+
+ + {#if showUpload} + + {/if}
\ No newline at end of file diff --git a/src/routes/WhisperSetup.svelte b/src/routes/WhisperSetup.svelte index 41442fc..8427ed1 100644 --- a/src/routes/WhisperSetup.svelte +++ b/src/routes/WhisperSetup.svelte @@ -248,6 +248,12 @@ Required for diarization model download. Get your free API key at huggingface.co/settings/tokens

+
+

+ Warning: This input field is not fully implemented yet. Please add your HuggingFace token to the HF_API_KEY + environment variable in your .env file instead. +

+
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as ContextMenu from '$lib/components/ui/context-menu/index.js'; import { apiFetch } from '$lib/api'; import * as Dialog from '$lib/components/ui/dialog'; import { get } from 'svelte/store'; import { tick } from 'svelte'; import * as Tabs from '$lib/components/ui/tabs/index.js'; import { templates } from '$lib/stores/templateStore'; import * as Command from '$lib/components/ui/command/index.js'; import * as Popover from '$lib/components/ui/popover/index.js'; import { Input } from '$lib/components/ui/input'; import { Button } from '$lib/components/ui/button'; import { onMount, onDestroy } from 'svelte'; import { ScrollArea } from '$lib/components/ui/scroll-area'; import { ChevronsUpDown, TextQuote, Check, Mic2, Settings, BrainCircuit } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import { audioFiles } from '$lib/stores/audioFiles'; import { speakerLabels } from '$lib/stores/speakerLabels'; import { getSpeakerColor } from '$lib/speakerColors'; import { processThinkingSections, formatTime } from '$lib/utils'; import AudioPlayer from './AudioPlayer.svelte'; import SpeakerLabels from './SpeakerLabels.svelte'; import ThinkingDisplay from './ThinkingDisplay.svelte'; import { serverUrl } from '$lib/stores/config'; import type { TranscriptSegment } from '$lib/types'; interface FileProps { id: number; fileName: string; title?: string; uploadedAt: string; peaks: number[]; transcript?: TranscriptSegment[]; transcriptionStatus: string; diarization?: boolean; summary?: string; originalFileName?: string; } let { file, isOpen = $bindable() } = $props(); let audioUrl = ''; let summary = ''; let isSummarizing = $state(false); let isGeneratingTitle = $state(false); let selectedTemplateId = $state(null); let selectedTemplate = $state('Select a template...'); let isDialogOpen = $state(false); let titleDialogOpen = $state(false); let newTitle = ''; let error = null; let templateOpen = $state(false); let triggerRef = null; let showThinkingSections = $state(true); let summaryHasThinking = $derived(Boolean(summary && typeof summary === 'string' && summary.includes(''))); let fileSummaryHasThinking = $derived(Boolean(file?.summary && typeof file.summary === 'string' && file.summary.includes(''))); let hasThinkingSections = $derived(summaryHasThinking || fileSummaryHasThinking); function logError(error: any, context: string) { console.error(`${context}:`, error); return error.message || 'An unexpected error occurred'; } function selectTemplate(templateId: string, templateTitle: string) { console.log("Template selected:", templateTitle, templateId); selectedTemplateId = templateId; selectedTemplate = templateTitle; templateOpen = false; } let refreshInterval: ReturnType; onMount(async () => { if (window.Capacitor?.isNative) { audioUrl = get(serverUrl); } if (file?.id) { try { await speakerLabels.loadLabels(file.id); } catch (err) { console.error("Failed to load speaker labels:", err); } if (file.title) { newTitle = file.title; } refreshInterval = setInterval(async () => { if (file?.id && isOpen) { if (file.transcriptionStatus !== 'completed' && file.transcriptionStatus !== 'failed') { console.log(`Auto-refreshing file ${file.id} details while viewing`); await audioFiles.refresh(); } else { if (Math.random() < 0.2) { await audioFiles.refresh(); } } } }, 2000); } }); onDestroy(() => { if (refreshInterval) { clearInterval(refreshInterval); console.log('Cleaned up file refresh interval'); } }); const currentLabels = $derived(get(speakerLabels)[file?.id] || {}); $effect(() => { if (file) { summary = ''; if (file.title) { newTitle = file.title; } } }); function openTitleDialog() { titleDialogOpen = true; newTitle = file.title || ''; } async function handleTitleUpdate() { if (!newTitle.trim()) { error = 'Title cannot be empty'; return; } try { error = null; const response = await apiFetch(`/api/audio/${file.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newTitle }) }); if (!response.ok) { throw new Error('Failed to update title'); } titleDialogOpen = false; audioFiles.refresh(); toast.success('Title updated successfully'); } catch (error) { const errorMessage = logError(error, 'Title update failed'); error = errorMessage; toast.error('Failed to rename file. Please try again.'); } } async function deleteFile(fileId) { let temp = file.title; isOpen = false; await audioFiles.deleteFile(fileId); await audioFiles.refresh(); toast.success(`${temp} deleted`); } function handleSpeakerLabelsClose() { isDialogOpen = false; error = null; } async function generateTitle() { if (!file?.transcript || file.transcript.length === 0) { toast.error('Cannot generate title: Transcript is not available'); return; } try { isGeneratingTitle = true; const transcriptText = file.transcript.map(segment => segment.text).join(' '); const system_prompt = "You are a concise title generator. Always provide titles that are 3-8 words long. Never explain your reasoning. Never use quotes. Never provide multiple options."; const titlePrompt = "Create a short, descriptive title that captures the main subject or theme of this transcript."; const response = await apiFetch('/api/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileId: file.id, prompt: titlePrompt, system_prompt: system_prompt, transcript: transcriptText, processThinking: true, maxLength: 50 }) }); if (!response.ok) { throw new Error('Failed to generate title'); } const data = await response.json(); let generatedTitle = data.summary.trim(); // Remove quotes if present if (generatedTitle.startsWith('"') && generatedTitle.endsWith('"')) { generatedTitle = generatedTitle.substring(1, generatedTitle.length - 1); } await apiFetch(`/api/audio/${file.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: generatedTitle }) }); await audioFiles.refresh(); toast.success('Generated new title'); newTitle = generatedTitle; } catch (error) { const errorMessage = logError(error, 'Title generation failed'); toast.error(errorMessage); } finally { isGeneratingTitle = false; } } function doSummary() { console.log("doSummary called"); if (!file?.transcript || !selectedTemplateId) { toast.error('Please select a template and ensure transcript is available'); return; } isSummarizing = true; console.log("Setting isSummarizing to true"); try { const template = $templates.find((t) => t.id === selectedTemplateId); if (!template) { throw new Error('Template not found'); } const transcriptText = file.transcript.map((segment) => segment.text).join(' '); apiFetch('/api/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileId: file.id, prompt: template.prompt, transcript: transcriptText, processThinking: false }) }) .then(response => { console.log("API response received", response.status); if (!response.ok) { throw new Error('Failed to generate summary'); } return response.json(); }) .then(data => { console.log("Data parsed successfully"); summary = data.summary; return audioFiles.refresh(); }) .then(() => { console.log("Summary generated and files refreshed"); toast.success('Summary generated successfully'); }) .catch(error => { console.error("Promise chain error:", error); const errorMessage = logError(error, 'Summary generation failed'); toast.error(errorMessage); }) .finally(() => { console.log("Setting isSummarizing to false"); isSummarizing = false; }); } catch (error) { console.error("Initial setup error:", error); const errorMessage = logError(error, 'Summary generation failed'); toast.error(errorMessage); isSummarizing = false; } } {#if file} {#if file.transcriptionStatus === 'completed' && file.transcript}
Transcript Summary
Rename {isGeneratingTitle ? 'Generating Title...' : 'Auto-Generate Title'} { deleteFile(file.id); }}>Delete {#if file.diarization} (isDialogOpen = true)} > Label Speakers {/if}
{#each file.transcript as segment}
{#if file.diarization && segment.speaker && segment.speaker.trim() !== ""}
{#if currentLabels[segment.speaker]} {currentLabels[segment.speaker].charAt(0).toUpperCase() + currentLabels[segment.speaker].slice(1)} {:else if segment.speaker.startsWith("SPEAKER_")} Speaker {segment.speaker.split("_")[1]} {:else} {segment.speaker.charAt(0).toUpperCase() + segment.speaker.slice(1)} {/if}
{/if}
{formatTime(segment.start)}
{segment.text}
{/each}
{#snippet child({ props })} {/snippet} No templates found. {#each $templates as template}
selectTemplate(template.id, template.title)} > {template.title}
{/each}
{#if summaryHasThinking || fileSummaryHasThinking} {/if}
{#if file.summary}
{:else if isSummarizing}
Generating summary...
{:else if summary}
{:else}
Select a template and click summarize to generate a summary
{/if}
{/if} Rename File Enter a new title for this file
{#if error}

{error}

{/if}
{#if file.diarization} Label Speakers Assign custom names to speakers in the transcript {/if} {/if} \ No newline at end of file + + +{#if file} + + {#if file.transcriptionStatus === 'completed' && file.transcript} +
+ +
+ + Transcript + Summary + +
+ + + + + + Rename + + {isGeneratingTitle ? 'Generating Title...' : 'Auto-Generate Title'} + + { + deleteFile(file.id); + }}>Delete + {#if file.diarization} + (isDialogOpen = true)} + > + Label Speakers + + {/if} + + +
+
+ + +
+ {#each file.transcript as segment} +
+
+ {#if file.diarization && segment.speaker && segment.speaker.trim() !== ""} +
+ + {#if currentLabels[segment.speaker]} + {currentLabels[segment.speaker].charAt(0).toUpperCase() + + currentLabels[segment.speaker].slice(1)} + {:else if segment.speaker.startsWith("SPEAKER_")} + Speaker {segment.speaker.split("_")[1]} + {:else} + {segment.speaker.charAt(0).toUpperCase() + segment.speaker.slice(1)} + {/if} +
+ {/if} +
{formatTime(segment.start)}
+
+
+ {segment.text} +
+
+ {/each} +
+
+
+ +
+
+ + + {#snippet child({ props })} + + {/snippet} + + + + + + No templates found. + {#each $templates as template} +
selectTemplate(template.id, template.title)} + > + + {template.title} +
+ {/each} +
+
+
+
+
+
+ + {#if summaryHasThinking || fileSummaryHasThinking} + + {/if} +
+
+ +
+ {#if file.summary} +
+ +
+ {:else if isSummarizing} +
+
Generating summary...
+
+ {:else if summary} +
+ +
+ {:else} +
+ Select a template and click summarize to generate a summary +
+ {/if} +
+
+
+
+
+ {/if} + + + + Rename File + Enter a new title for this file + +
+ + {#if error} +

{error}

+ {/if} +
+ +
+ + +
+
+
+
+ {#if file.diarization} + + + + Label Speakers + + Assign custom names to speakers in the transcript + + + + + + {/if} +{/if} \ No newline at end of file diff --git a/src/routes/components/UploadPanel.svelte b/src/routes/components/UploadPanel.svelte index 842850e..44c6a92 100644 --- a/src/routes/components/UploadPanel.svelte +++ b/src/routes/components/UploadPanel.svelte @@ -5,6 +5,7 @@ import { Alert, AlertDescription } from '$lib/components/ui/alert'; import { get } from 'svelte/store'; import { onMount } from 'svelte'; + import { fade } from 'svelte/transition'; import { Loader2, Check, AlertCircle, CircleX, Upload, CircleCheck } from 'lucide-svelte'; import { serverUrl, authToken } from '$lib/stores/config'; import { ScrollArea } from '$lib/components/ui/scroll-area'; @@ -33,7 +34,8 @@ let fileStatus = $state>({}); let showSettings = $state(false); - let url; + let url = $state(''); + let fadeIn = $state(true); let transcriptionOptions = $state({ modelSize: 'base', @@ -62,15 +64,15 @@ } async function uploadFile(file: File) { - console.log("Setting up upload with options:", transcriptionOptions); - console.log("Diarization enabled:", transcriptionOptions.diarization); + console.log("Setting up upload with options:", transcriptionOptions); + console.log("Diarization enabled:", transcriptionOptions.diarization); const formData = new FormData(); formData.append('file', file); formData.append('options', JSON.stringify(transcriptionOptions)); - - // For debugging - const optionsJson = JSON.stringify(transcriptionOptions); - console.log("Options being sent as JSON:", optionsJson); + + // For debugging + const optionsJson = JSON.stringify(transcriptionOptions); + console.log("Options being sent as JSON:", optionsJson); try { fileStatus[file.name] = { @@ -158,104 +160,120 @@ url = base ? `${base}/api/upload` : '/api/upload'; console.log('URL -->', url); }); + + function handleBackdropClick(e) { + // Only close if clicking directly on the backdrop, not on the card + if (e.target === e.currentTarget) { + showUpload = false; + } + } - - -
-

Upload Audio

- -
+{#if showUpload} + +
+ +
+ + +
+

Upload Audio

+ +
- {#if Object.keys(fileStatus).length > 0} -
- -
- {#each Object.entries(fileStatus) as [fileName, status]} - - -
-
- -
-

{fileName}

-

- {status.uploadStatus === 'uploading' - ? 'Uploading...' - : status.uploadStatus === 'success' - ? 'Upload complete' - : 'Upload failed'} -

+ {#if Object.keys(fileStatus).length > 0} +
+ +
+ {#each Object.entries(fileStatus) as [fileName, status]} + + +
+
+
+ + + +
+
+

{fileName}

+

+ {status.uploadStatus === 'uploading' + ? 'Uploading...' + : status.uploadStatus === 'success' + ? 'Upload complete' + : 'Upload failed'} +

+
+
-
-
- {#if status.uploadStatus === 'uploading'} -
- -

- {status.uploadProgress}% -

-
- {:else if status.uploadStatus === 'success'}{/if} + {#if status.uploadStatus === 'uploading'} +
+ +

+ {status.uploadProgress}% +

+
+ {:else if status.uploadStatus === 'success'}{/if} - {#if status.error} - - {status.error} - - {/if} - - - {/each} -
- -
- {:else} - -
- -
-

Drop audio files here

-

or click to select files

+ {#if status.error} + + {status.error} + + {/if} + + + {/each} +
+
-
- - - {/if} + {:else} + +
+ +
+

Drop audio files here

+

or click to select files

+
+
+
+ + {/if} - {#if files.rejected.length > 0} -
- {#each files.rejected as rejection} - - - {rejection.file.name} - {rejection.errors[0].message} - - - {/each} -
- {/if} -
-
+ {#if files.rejected.length > 0} +
+ {#each files.rejected as rejection} + + + {rejection.file.name} - {rejection.errors[0].message} + + + {/each} +
+ {/if} + + +
+{/if} \ No newline at end of file diff --git a/src/routes/components/files-sidebar.svelte b/src/routes/components/files-sidebar.svelte index 0422c20..1fc1631 100644 --- a/src/routes/components/files-sidebar.svelte +++ b/src/routes/components/files-sidebar.svelte @@ -15,8 +15,7 @@ import { setContext } from 'svelte'; import UploadPanel from './UploadPanel.svelte'; - let { showAudioRec = $bindable() } = $props(); - let showUpload = $state(false); + let { showAudioRec = $bindable(), selectedFileId = $bindable(), showUpload = $bindable() } = $props(); let showSettings = $state(false); interface AudioFile { @@ -34,7 +33,7 @@ lastError?: string; } - let selectedFileId = $state(null); + // selectedFileId is now a prop let isLoading = $state(true); let isFileOpen = $state(false); let refreshInterval: ReturnType; @@ -137,12 +136,7 @@ }); - +
{#if isLoading} @@ -171,9 +165,7 @@
@@ -189,7 +181,7 @@
-
- -{#if showUpload} - -{/if} - -{#if selectedFileId} -
-
-
-

- {selectedFile.title || selectedFile.fileName} -

- -
-
-
- - - Uploaded: {new Date(selectedFile.uploadedAt).toLocaleString()} - -
- - {#if selectedFile.transcriptionStatus} -
- - - Status: {selectedFile.transcriptionStatus} - {#if selectedFile.transcriptionStatus === 'failed' && selectedFile.lastError} - - {selectedFile.lastError} - {/if} - -
- {/if} -
- -
-
-{/if} \ No newline at end of file + \ No newline at end of file