- Add update_segment Tauri command (calls existing update_segment_text query) - Wire onTextEdit handler from TranscriptEditor to invoke update_segment - Edits are saved to SQLite immediately when user presses Enter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
777 lines
21 KiB
Svelte
777 lines
21 KiB
Svelte
<script lang="ts">
|
|
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
|
|
import { listen } from '@tauri-apps/api/event';
|
|
import { open, save } from '@tauri-apps/plugin-dialog';
|
|
import WaveformPlayer from '$lib/components/WaveformPlayer.svelte';
|
|
import TranscriptEditor from '$lib/components/TranscriptEditor.svelte';
|
|
import SpeakerManager from '$lib/components/SpeakerManager.svelte';
|
|
import AIChatPanel from '$lib/components/AIChatPanel.svelte';
|
|
import ProgressOverlay from '$lib/components/ProgressOverlay.svelte';
|
|
import SettingsModal from '$lib/components/SettingsModal.svelte';
|
|
import { segments, speakers } from '$lib/stores/transcript';
|
|
import { settings, loadSettings } from '$lib/stores/settings';
|
|
import type { Segment, Speaker } from '$lib/types/transcript';
|
|
import { onMount, tick } from 'svelte';
|
|
|
|
let appReady = $state(false);
|
|
let waveformPlayer: WaveformPlayer;
|
|
let audioUrl = $state('');
|
|
let showSettings = $state(false);
|
|
|
|
// Project management state
|
|
let currentProjectId = $state<string | null>(null);
|
|
let currentProjectName = $state('');
|
|
let savedProjects = $state<Array<{id: string, name: string, created_at: string}>>([]);
|
|
let showProjectMenu = $state(false);
|
|
|
|
onMount(() => {
|
|
loadSettings();
|
|
loadProjects();
|
|
|
|
// Global keyboard shortcuts
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
// Don't trigger shortcuts when typing in inputs
|
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
|
|
|
if (e.key === ' ' && !e.ctrlKey && !e.metaKey) {
|
|
e.preventDefault();
|
|
waveformPlayer?.togglePlayPause?.();
|
|
} else if (e.key === 'o' && (e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
handleFileImport();
|
|
} else if (e.key === ',' && (e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
showSettings = true;
|
|
} else if (e.key === 'Escape') {
|
|
showExportMenu = false;
|
|
showProjectMenu = false;
|
|
showSettings = false;
|
|
}
|
|
}
|
|
|
|
// Close export dropdown on outside click
|
|
function handleClickOutside(e: MouseEvent) {
|
|
const target = e.target as HTMLElement;
|
|
if (showExportMenu) {
|
|
if (!target.closest('.export-dropdown')) {
|
|
showExportMenu = false;
|
|
}
|
|
}
|
|
if (showProjectMenu) {
|
|
if (!target.closest('.project-dropdown')) {
|
|
showProjectMenu = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
document.addEventListener('click', handleClickOutside);
|
|
|
|
appReady = true;
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
document.removeEventListener('click', handleClickOutside);
|
|
};
|
|
});
|
|
let isTranscribing = $state(false);
|
|
let transcriptionProgress = $state(0);
|
|
let transcriptionStage = $state('');
|
|
let transcriptionMessage = $state('');
|
|
|
|
// Speaker color palette for auto-assignment
|
|
const speakerColors = ['#e94560', '#4ecdc4', '#ffe66d', '#a8e6cf', '#ff8b94', '#c7ceea', '#ffd93d', '#6bcb77'];
|
|
|
|
async function loadProjects() {
|
|
try {
|
|
const projects = await invoke<Array<{id: string, name: string, created_at: string}>>('list_projects');
|
|
savedProjects = projects;
|
|
} catch (err) {
|
|
console.error('Failed to load projects:', err);
|
|
}
|
|
}
|
|
|
|
async function loadProject(projectId: string) {
|
|
try {
|
|
const result = await invoke<{
|
|
project_id: string;
|
|
name: string;
|
|
file_path: string;
|
|
segments: Array<{
|
|
text: string;
|
|
start_ms: number;
|
|
end_ms: number;
|
|
speaker: string | null;
|
|
words: Array<{
|
|
word: string;
|
|
start_ms: number;
|
|
end_ms: number;
|
|
confidence: number;
|
|
}>;
|
|
}>;
|
|
speakers: string[];
|
|
}>('load_project_transcript', { projectId });
|
|
|
|
// Set project info
|
|
currentProjectId = result.project_id;
|
|
currentProjectName = result.name;
|
|
|
|
// Rebuild speakers
|
|
const newSpeakers: Speaker[] = (result.speakers || []).map((label, idx) => ({
|
|
id: `speaker-${idx}`,
|
|
project_id: result.project_id,
|
|
label,
|
|
display_name: null,
|
|
color: speakerColors[idx % speakerColors.length],
|
|
}));
|
|
speakers.set(newSpeakers);
|
|
|
|
// Build speaker label -> id lookup
|
|
const speakerLookup = new Map(newSpeakers.map(s => [s.label, s.id]));
|
|
|
|
// Rebuild segments
|
|
const newSegments: Segment[] = result.segments.map((seg, idx) => ({
|
|
id: `seg-${idx}`,
|
|
project_id: result.project_id,
|
|
media_file_id: '',
|
|
speaker_id: seg.speaker ? (speakerLookup.get(seg.speaker) ?? null) : null,
|
|
start_ms: seg.start_ms,
|
|
end_ms: seg.end_ms,
|
|
text: seg.text,
|
|
original_text: null,
|
|
confidence: null,
|
|
is_edited: false,
|
|
edited_at: null,
|
|
segment_index: idx,
|
|
words: seg.words.map((w, widx) => ({
|
|
id: `word-${idx}-${widx}`,
|
|
segment_id: `seg-${idx}`,
|
|
word: w.word,
|
|
start_ms: w.start_ms,
|
|
end_ms: w.end_ms,
|
|
confidence: w.confidence,
|
|
word_index: widx,
|
|
})),
|
|
}));
|
|
segments.set(newSegments);
|
|
|
|
// Load audio from saved file path
|
|
if (result.file_path) {
|
|
audioUrl = convertFileSrc(result.file_path);
|
|
waveformPlayer?.loadAudio(audioUrl);
|
|
}
|
|
|
|
showProjectMenu = false;
|
|
} catch (err) {
|
|
console.error('Failed to load project:', err);
|
|
alert(`Failed to load project: ${err}`);
|
|
}
|
|
}
|
|
|
|
async function deleteProject(projectId: string) {
|
|
try {
|
|
await invoke('delete_project', { projectId });
|
|
await loadProjects();
|
|
if (currentProjectId === projectId) {
|
|
currentProjectId = null;
|
|
currentProjectName = '';
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to delete project:', err);
|
|
alert(`Failed to delete project: ${err}`);
|
|
}
|
|
}
|
|
|
|
function handleWordClick(timeMs: number) {
|
|
console.log('[voice-to-notes] Word clicked, seeking to', timeMs, 'ms');
|
|
waveformPlayer?.seekTo(timeMs);
|
|
}
|
|
|
|
async function handleTextEdit(segmentId: string, newText: string) {
|
|
try {
|
|
await invoke('update_segment', { segmentId, newText });
|
|
} catch (err) {
|
|
console.error('Failed to save segment edit:', err);
|
|
}
|
|
}
|
|
|
|
async function handleFileImport() {
|
|
const filePath = await open({
|
|
multiple: false,
|
|
filters: [{
|
|
name: 'Audio/Video',
|
|
extensions: ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'wma',
|
|
'mp4', 'mkv', 'avi', 'mov', 'webm'],
|
|
}],
|
|
});
|
|
if (!filePath) return;
|
|
|
|
// Convert file path to asset URL for wavesurfer
|
|
audioUrl = convertFileSrc(filePath);
|
|
waveformPlayer?.loadAudio(audioUrl);
|
|
|
|
// Clear previous results
|
|
segments.set([]);
|
|
speakers.set([]);
|
|
|
|
// Start pipeline (transcription + diarization)
|
|
isTranscribing = true;
|
|
transcriptionProgress = 0;
|
|
transcriptionStage = 'Starting...';
|
|
transcriptionMessage = 'Initializing pipeline...';
|
|
|
|
// Flush DOM so the progress overlay renders before the blocking invoke
|
|
await tick();
|
|
|
|
// Listen for progress events from the sidecar
|
|
const unlisten = await listen<{
|
|
percent: number;
|
|
stage: string;
|
|
message: string;
|
|
}>('pipeline-progress', (event) => {
|
|
console.log('[voice-to-notes] Progress event:', event.payload);
|
|
const { percent, stage, message } = event.payload;
|
|
if (typeof percent === 'number') transcriptionProgress = percent;
|
|
if (typeof stage === 'string') transcriptionStage = stage;
|
|
if (typeof message === 'string') transcriptionMessage = message;
|
|
});
|
|
|
|
const unlistenSegment = await listen<{
|
|
index: number;
|
|
text: string;
|
|
start_ms: number;
|
|
end_ms: number;
|
|
words: Array<{ word: string; start_ms: number; end_ms: number; confidence: number }>;
|
|
}>('pipeline-segment', (event) => {
|
|
const seg = event.payload;
|
|
const newSeg: Segment = {
|
|
id: `seg-${seg.index}`,
|
|
project_id: '',
|
|
media_file_id: '',
|
|
speaker_id: null,
|
|
start_ms: seg.start_ms,
|
|
end_ms: seg.end_ms,
|
|
text: seg.text,
|
|
original_text: null,
|
|
confidence: null,
|
|
is_edited: false,
|
|
edited_at: null,
|
|
segment_index: seg.index,
|
|
words: seg.words.map((w, widx) => ({
|
|
id: `word-${seg.index}-${widx}`,
|
|
segment_id: `seg-${seg.index}`,
|
|
word: w.word,
|
|
start_ms: w.start_ms,
|
|
end_ms: w.end_ms,
|
|
confidence: w.confidence,
|
|
word_index: widx,
|
|
})),
|
|
};
|
|
segments.update(segs => [...segs, newSeg]);
|
|
});
|
|
|
|
const unlistenSpeaker = await listen<{
|
|
updates: Array<{ index: number; speaker: string }>;
|
|
}>('pipeline-speaker-update', (event) => {
|
|
const { updates } = event.payload;
|
|
// Build speakers from unique labels
|
|
const uniqueLabels = [...new Set(updates.map(u => u.speaker))].sort();
|
|
const newSpeakers: Speaker[] = uniqueLabels.map((label, idx) => ({
|
|
id: `speaker-${idx}`,
|
|
project_id: '',
|
|
label,
|
|
display_name: null,
|
|
color: speakerColors[idx % speakerColors.length],
|
|
}));
|
|
speakers.set(newSpeakers);
|
|
|
|
// Update existing segments with speaker assignments
|
|
const speakerLookup = new Map(newSpeakers.map(s => [s.label, s.id]));
|
|
segments.update(segs =>
|
|
segs.map((seg, i) => {
|
|
const update = updates.find(u => u.index === i);
|
|
if (update) {
|
|
return { ...seg, speaker_id: speakerLookup.get(update.speaker) ?? null };
|
|
}
|
|
return seg;
|
|
})
|
|
);
|
|
});
|
|
|
|
try {
|
|
const result = await invoke<{
|
|
segments: Array<{
|
|
text: string;
|
|
start_ms: number;
|
|
end_ms: number;
|
|
speaker: string | null;
|
|
words: Array<{
|
|
word: string;
|
|
start_ms: number;
|
|
end_ms: number;
|
|
confidence: number;
|
|
}>;
|
|
}>;
|
|
language: string;
|
|
duration_ms: number;
|
|
speakers: string[];
|
|
num_speakers: number;
|
|
}>('run_pipeline', {
|
|
filePath,
|
|
model: $settings.transcription_model || undefined,
|
|
device: $settings.transcription_device || undefined,
|
|
language: $settings.transcription_language || undefined,
|
|
skipDiarization: $settings.skip_diarization || undefined,
|
|
hfToken: $settings.hf_token || undefined,
|
|
numSpeakers: $settings.num_speakers && $settings.num_speakers > 0 ? $settings.num_speakers : undefined,
|
|
});
|
|
|
|
// Create speaker entries from pipeline result
|
|
const newSpeakers: Speaker[] = (result.speakers || []).map((label, idx) => ({
|
|
id: `speaker-${idx}`,
|
|
project_id: '',
|
|
label,
|
|
display_name: null,
|
|
color: speakerColors[idx % speakerColors.length],
|
|
}));
|
|
speakers.set(newSpeakers);
|
|
|
|
// Build speaker label → id lookup
|
|
const speakerLookup = new Map(newSpeakers.map(s => [s.label, s.id]));
|
|
|
|
// Convert result to our store format
|
|
const newSegments: Segment[] = result.segments.map((seg, idx) => ({
|
|
id: `seg-${idx}`,
|
|
project_id: '',
|
|
media_file_id: '',
|
|
speaker_id: seg.speaker ? (speakerLookup.get(seg.speaker) ?? null) : null,
|
|
start_ms: seg.start_ms,
|
|
end_ms: seg.end_ms,
|
|
text: seg.text,
|
|
original_text: null,
|
|
confidence: null,
|
|
is_edited: false,
|
|
edited_at: null,
|
|
segment_index: idx,
|
|
words: seg.words.map((w, widx) => ({
|
|
id: `word-${idx}-${widx}`,
|
|
segment_id: `seg-${idx}`,
|
|
word: w.word,
|
|
start_ms: w.start_ms,
|
|
end_ms: w.end_ms,
|
|
confidence: w.confidence,
|
|
word_index: widx,
|
|
})),
|
|
}));
|
|
|
|
segments.set(newSegments);
|
|
|
|
// Auto-save project
|
|
try {
|
|
const fileName = filePath.split(/[\\/]/).pop() || 'Untitled';
|
|
const projectName = fileName.replace(/\.[^.]+$/, '');
|
|
const projectId = await invoke<string>('create_project', { name: projectName });
|
|
await invoke('save_project_transcript', {
|
|
projectId,
|
|
filePath,
|
|
segments: result.segments,
|
|
speakers: result.speakers.map((label, idx) => ({
|
|
label,
|
|
display_name: null,
|
|
color: speakerColors[idx % speakerColors.length],
|
|
})),
|
|
});
|
|
currentProjectId = projectId;
|
|
currentProjectName = projectName;
|
|
await loadProjects();
|
|
} catch (saveErr) {
|
|
console.error('Auto-save failed:', saveErr);
|
|
}
|
|
} catch (err) {
|
|
console.error('Pipeline failed:', err);
|
|
alert(`Pipeline failed: ${err}`);
|
|
} finally {
|
|
unlisten();
|
|
unlistenSegment();
|
|
unlistenSpeaker();
|
|
isTranscribing = false;
|
|
}
|
|
}
|
|
|
|
const exportFormats = [
|
|
{ name: 'SubRip Subtitle', ext: 'srt', format: 'srt' },
|
|
{ name: 'WebVTT', ext: 'vtt', format: 'vtt' },
|
|
{ name: 'Advanced SubStation Alpha', ext: 'ass', format: 'ass' },
|
|
{ name: 'Plain Text', ext: 'txt', format: 'txt' },
|
|
{ name: 'Markdown', ext: 'md', format: 'md' },
|
|
];
|
|
|
|
let showExportMenu = $state(false);
|
|
|
|
async function handleExport(format: string, ext: string, filterName: string) {
|
|
showExportMenu = false;
|
|
|
|
const outputPath = await save({
|
|
filters: [{ name: filterName, extensions: [ext] }],
|
|
});
|
|
if (!outputPath) return;
|
|
|
|
// Build speaker lookup: speaker_id → display_name
|
|
const speakerMap: Record<string, string> = {};
|
|
for (const s of $speakers) {
|
|
speakerMap[s.label] = s.display_name || s.label;
|
|
}
|
|
|
|
// Build export segments from store
|
|
const exportSegments = $segments.map(seg => {
|
|
const speaker = $speakers.find(s => s.id === seg.speaker_id);
|
|
return {
|
|
text: seg.text,
|
|
start_ms: seg.start_ms,
|
|
end_ms: seg.end_ms,
|
|
speaker: speaker?.label ?? null,
|
|
};
|
|
});
|
|
|
|
try {
|
|
await invoke('export_transcript', {
|
|
segments: exportSegments,
|
|
speakers: speakerMap,
|
|
format,
|
|
outputPath,
|
|
title: 'Voice to Notes Transcript',
|
|
});
|
|
alert(`Exported to ${outputPath}`);
|
|
} catch (err) {
|
|
console.error('Export failed:', err);
|
|
alert(`Export failed: ${err}`);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if !appReady}
|
|
<div class="splash-screen">
|
|
<h1 class="splash-title">Voice to Notes</h1>
|
|
<p class="splash-subtitle">Loading...</p>
|
|
<div class="splash-spinner"></div>
|
|
</div>
|
|
{:else}
|
|
<div class="app-shell">
|
|
<div class="app-header">
|
|
<h1>Voice to Notes</h1>
|
|
<div class="header-actions">
|
|
<div class="project-dropdown">
|
|
<button class="project-btn" onclick={() => showProjectMenu = !showProjectMenu}>
|
|
{currentProjectName ? `Project: ${currentProjectName}` : 'No project'}
|
|
</button>
|
|
{#if showProjectMenu}
|
|
<div class="project-menu">
|
|
{#if savedProjects.length === 0}
|
|
<div class="project-empty">No saved projects</div>
|
|
{:else}
|
|
{#each savedProjects as project}
|
|
<div class="project-item">
|
|
<button class="project-option" onclick={() => loadProject(project.id)}>
|
|
{project.name}
|
|
</button>
|
|
<button
|
|
class="project-delete"
|
|
onclick={(e) => { e.stopPropagation(); deleteProject(project.id); }}
|
|
title="Delete project"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<button class="import-btn" onclick={handleFileImport} disabled={isTranscribing}>
|
|
{#if isTranscribing}
|
|
Processing...
|
|
{:else}
|
|
Import Audio/Video
|
|
{/if}
|
|
</button>
|
|
<button class="settings-btn" onclick={() => showSettings = true} title="Settings">
|
|
Settings
|
|
</button>
|
|
{#if $segments.length > 0}
|
|
<div class="export-dropdown">
|
|
<button class="export-btn" onclick={() => showExportMenu = !showExportMenu}>
|
|
Export
|
|
</button>
|
|
{#if showExportMenu}
|
|
<div class="export-menu">
|
|
{#each exportFormats as fmt}
|
|
<button class="export-option" onclick={() => handleExport(fmt.format, fmt.ext, fmt.name)}>
|
|
{fmt.name} (.{fmt.ext})
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="workspace">
|
|
<div class="main-content">
|
|
<WaveformPlayer bind:this={waveformPlayer} {audioUrl} />
|
|
<TranscriptEditor onWordClick={handleWordClick} onTextEdit={handleTextEdit} />
|
|
</div>
|
|
<div class="sidebar-right">
|
|
<SpeakerManager />
|
|
<AIChatPanel />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ProgressOverlay
|
|
visible={isTranscribing}
|
|
percent={transcriptionProgress}
|
|
stage={transcriptionStage}
|
|
message={transcriptionMessage}
|
|
/>
|
|
|
|
<SettingsModal
|
|
visible={showSettings}
|
|
onClose={() => showSettings = false}
|
|
/>
|
|
{/if}
|
|
|
|
<style>
|
|
.app-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.5rem 1rem;
|
|
background: #0f3460;
|
|
color: #e0e0e0;
|
|
}
|
|
h1 {
|
|
font-size: 1.25rem;
|
|
margin: 0;
|
|
}
|
|
.import-btn {
|
|
background: #e94560;
|
|
border: none;
|
|
color: white;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
}
|
|
.import-btn:hover:not(:disabled) {
|
|
background: #d63851;
|
|
}
|
|
.import-btn:disabled {
|
|
opacity: 0.7;
|
|
cursor: not-allowed;
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 0.7; }
|
|
50% { opacity: 1; }
|
|
}
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
.settings-btn {
|
|
background: none;
|
|
border: 1px solid #4a5568;
|
|
color: #e0e0e0;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
}
|
|
.settings-btn:hover {
|
|
background: rgba(255,255,255,0.05);
|
|
border-color: #e94560;
|
|
}
|
|
.export-dropdown {
|
|
position: relative;
|
|
}
|
|
.export-btn {
|
|
background: #0f3460;
|
|
border: 1px solid #4a5568;
|
|
color: #e0e0e0;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
}
|
|
.export-btn:hover {
|
|
background: #1a4a7a;
|
|
}
|
|
.export-menu {
|
|
position: absolute;
|
|
top: 100%;
|
|
right: 0;
|
|
margin-top: 0.25rem;
|
|
background: #16213e;
|
|
border: 1px solid #4a5568;
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
z-index: 10;
|
|
min-width: 220px;
|
|
}
|
|
.export-option {
|
|
display: block;
|
|
width: 100%;
|
|
background: none;
|
|
border: none;
|
|
color: #e0e0e0;
|
|
padding: 0.5rem 1rem;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
font-size: 0.8rem;
|
|
}
|
|
.export-option:hover {
|
|
background: rgba(233, 69, 96, 0.2);
|
|
}
|
|
.project-dropdown {
|
|
position: relative;
|
|
}
|
|
.project-btn {
|
|
background: #0f3460;
|
|
border: 1px solid #4a5568;
|
|
color: #e0e0e0;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
max-width: 200px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.project-btn:hover {
|
|
background: #1a4a7a;
|
|
}
|
|
.project-menu {
|
|
position: absolute;
|
|
top: 100%;
|
|
right: 0;
|
|
margin-top: 0.25rem;
|
|
background: #16213e;
|
|
border: 1px solid #4a5568;
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
z-index: 10;
|
|
min-width: 220px;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
}
|
|
.project-empty {
|
|
padding: 0.5rem 1rem;
|
|
color: #888;
|
|
font-size: 0.8rem;
|
|
}
|
|
.project-item {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.project-option {
|
|
flex: 1;
|
|
background: none;
|
|
border: none;
|
|
color: #e0e0e0;
|
|
padding: 0.5rem 1rem;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
font-size: 0.8rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.project-option:hover {
|
|
background: rgba(233, 69, 96, 0.2);
|
|
}
|
|
.project-delete {
|
|
background: none;
|
|
border: none;
|
|
color: #888;
|
|
padding: 0.5rem 0.75rem;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
line-height: 1;
|
|
flex-shrink: 0;
|
|
}
|
|
.project-delete:hover {
|
|
color: #e94560;
|
|
}
|
|
.app-shell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
.workspace {
|
|
display: flex;
|
|
gap: 1rem;
|
|
padding: 1rem;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
background: #0a0a23;
|
|
}
|
|
.main-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
}
|
|
.sidebar-right {
|
|
width: 300px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
flex-shrink: 0;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
}
|
|
.splash-screen {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100vh;
|
|
background: #0a0a23;
|
|
color: #e0e0e0;
|
|
gap: 1rem;
|
|
}
|
|
.splash-title {
|
|
font-size: 2rem;
|
|
margin: 0;
|
|
color: #e94560;
|
|
}
|
|
.splash-subtitle {
|
|
font-size: 1rem;
|
|
color: #888;
|
|
margin: 0;
|
|
}
|
|
.splash-spinner {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 3px solid #2a3a5e;
|
|
border-top-color: #e94560;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
</style>
|