Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)

This commit is contained in:
InnovA AI
2026-03-16 21:47:37 +00:00
commit 42772a31ed
365 changed files with 103572 additions and 0 deletions

View File

@@ -0,0 +1,236 @@
<!-- Audio Player Component (Mobile) -->
<div class="bg-[var(--bg-secondary)] px-4 py-3 border-b border-[var(--border-primary)] flex-shrink-0">
<!-- Show message if audio has been deleted -->
<div v-if="selectedRecording.audio_deleted_at"
class="text-[var(--text-muted)] text-sm flex items-center gap-2">
<i class="fas fa-info-circle"></i>
<span v-text="t('help.audioDeletedMessage')"></span>
</div>
<!-- Show message for incognito recordings (no audio stored) -->
<div v-else-if="selectedRecording.incognito"
class="text-[var(--text-muted)] text-sm flex items-center gap-2">
<i class="fas fa-user-secret"></i>
<span v-text="t('incognito.audioNotStored')"></span>
</div>
<!-- Custom Audio Player -->
<div v-else>
<!-- Video/Audio element wrapped in Teleport for fullscreen -->
<Teleport to="body" :disabled="!videoFullscreen">
<div :class="videoFullscreen ? 'video-fullscreen-overlay' : ''"
@mousemove="videoFullscreen && handleFullscreenMouseMove()"
@click.self="videoFullscreen && toggleAudioPlayback()">
<component :is="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') ? 'video' : 'audio'"
ref="audioPlayerElement"
:src="'/audio/' + selectedRecording.id"
:volume="playerVolume"
@play="handleAudioPlayPause"
@pause="handleAudioPlayPause"
@timeupdate="handleCustomAudioTimeUpdate"
@loadedmetadata="handleAudioLoadedMetadata"
@durationchange="handleAudioDurationChange"
@ended="handleAudioEnded"
@waiting="handleAudioWaiting"
@canplay="handleAudioCanPlay"
@click="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') && toggleAudioPlayback()"
@dblclick="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') && !videoFullscreen && enterVideoFullscreen()"
:class="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/')
? (videoFullscreen ? 'video-fullscreen-video' : (videoCollapsed ? 'hidden' : 'w-full rounded-lg mb-2 cursor-pointer'))
: 'hidden'">
</component>
<!-- Fullscreen Subtitle Overlay -->
<div v-if="videoFullscreen && currentSubtitle"
class="video-fullscreen-subtitle"
:class="{ 'subtitle-shifted': fullscreenControlsVisible }">
<span v-if="currentSubtitle.speaker"
class="video-fullscreen-subtitle-speaker"
:style="{ color: 'var(--' + currentSubtitle.color + ')' }">${ currentSubtitle.speaker }:</span>
<span>${ currentSubtitle.text }</span>
</div>
<!-- Fullscreen Control Bar -->
<div v-if="videoFullscreen"
class="video-fullscreen-controls"
:class="{ visible: fullscreenControlsVisible }"
@mousemove.stop="handleFullscreenMouseMove()">
<!-- Progress Bar -->
<div class="px-4 mb-2">
<div class="w-full h-5 rounded-full cursor-pointer relative group flex items-center"
@mousedown="startProgressDrag"
@touchstart.prevent="startProgressDrag">
<div class="progress-track w-full h-1 rounded-full relative bg-white/30">
<div class="h-full bg-white rounded-full pointer-events-none"
:style="{ width: audioProgressPercent + '%' }"></div>
</div>
<div class="absolute top-1/2 w-3 h-3 bg-white rounded-full shadow-md transition-transform group-hover:scale-125 pointer-events-none -translate-y-1/2"
:style="{ left: 'clamp(0px, calc(' + audioProgressPercent + '% - 6px), calc(100% - 12px))' }"></div>
</div>
</div>
<!-- Controls Row -->
<div class="flex items-center gap-3 px-4 pb-4">
<!-- Play/Pause -->
<button @click.stop="toggleAudioPlayback"
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white/20 transition-all">
<i :class="audioIsPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-lg" :style="!audioIsPlaying ? 'margin-left: 2px' : ''"></i>
</button>
<!-- Volume -->
<div class="flex items-center gap-2">
<button @click.stop="toggleAudioMute"
class="w-8 h-8 flex items-center justify-center rounded-full text-white hover:bg-white/20 transition-all">
<i :class="audioIsMuted || playerVolume === 0 ? 'fas fa-volume-mute' : playerVolume < 0.5 ? 'fas fa-volume-down' : 'fas fa-volume-up'" class="text-sm"></i>
</button>
<input type="range" min="0" max="1" step="0.05"
:value="audioIsMuted ? 0 : playerVolume"
@input="(e) => setAudioVolume(parseFloat(e.target.value))"
class="fullscreen-volume-slider w-20">
</div>
<!-- Time -->
<div class="flex items-baseline gap-1 text-white">
<span class="text-sm font-mono">${ formatAudioTime(displayCurrentTime) }</span>
<span class="text-xs opacity-60">/</span>
<span class="text-xs opacity-60 font-mono">${ formatAudioTime(audioDuration) }</span>
</div>
<div class="flex-1"></div>
<!-- Speed -->
<button @click.stop="cyclePlaybackRate"
class="px-2 h-8 flex items-center justify-center rounded-lg text-white hover:bg-white/20 transition-all"
title="Playback speed">
<span class="text-xs font-semibold font-mono">${ formatPlaybackRate(playbackRate) }</span>
</button>
<!-- Exit Fullscreen -->
<button @click.stop="exitVideoFullscreen"
class="w-8 h-8 flex items-center justify-center rounded-full text-white hover:bg-white/20 transition-all"
:title="t('tooltips.exitFullscreen')">
<i class="fas fa-compress text-sm"></i>
</button>
</div>
</div>
</div>
</Teleport>
<!-- Normal Controls (hidden when fullscreen) -->
<div v-show="!videoFullscreen">
<!-- Progress Bar (draggable, touch support) -->
<div class="w-full h-4 rounded-full cursor-pointer relative mb-2 group flex items-center"
@mousedown="startProgressDrag"
@touchstart.prevent="startProgressDrag">
<!-- Track background -->
<div class="progress-track w-full h-2 rounded-full relative bg-[var(--border-accent)] opacity-40">
<!-- Progress fill -->
<div class="h-full bg-[var(--text-accent)] rounded-full pointer-events-none opacity-100"
:style="{ width: audioProgressPercent + '%' }"></div>
</div>
<!-- Progress dot - stays within track bounds -->
<div class="absolute top-1/2 w-4 h-4 bg-[var(--text-accent)] rounded-full shadow-md transition-transform group-hover:scale-110 pointer-events-none -translate-y-1/2"
:style="{ left: 'clamp(0px, calc(' + audioProgressPercent + '% - 8px), calc(100% - 16px))' }"
style="box-shadow: 0 2px 6px rgba(0,0,0,0.3);"></div>
</div>
<!-- Controls Row -->
<div class="flex items-center gap-2">
<!-- Play/Pause Button -->
<button @click="toggleAudioPlayback"
class="w-10 h-10 flex items-center justify-center rounded-full player-play-button transition-all duration-200 flex-shrink-0 shadow-md hover:shadow-lg active:scale-95"
:title="audioIsPlaying ? t('tooltips.pause') : t('tooltips.play')">
<i :class="audioIsPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-base" :style="!audioIsPlaying ? 'margin-left: 2px' : ''"></i>
</button>
<!-- Time Display -->
<div class="flex items-baseline gap-1 flex-shrink-0">
<span class="text-sm font-semibold font-mono" :class="isDraggingProgress ? 'text-[var(--text-accent)]' : 'text-[var(--text-primary)]'">${ formatAudioTime(displayCurrentTime) }</span>
<span class="text-xs text-[var(--text-muted)]">/</span>
<span class="text-xs text-[var(--text-muted)] font-mono">${ formatAudioTime(audioDuration) }</span>
</div>
<!-- Playback Speed Control -->
<div class="relative flex-shrink-0">
<button ref="speedButtonMobile"
@click="showSpeedMenu = !showSpeedMenu; $nextTick(() => updateSpeedMenuPosition($refs.speedButtonMobile))"
data-speed-toggle
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] text-[var(--text-accent)] hover:opacity-80 transition-all"
:title="t('tooltips.playbackSpeed') || 'Playback speed'">
<span class="text-xs font-semibold font-mono">${ formatPlaybackRate(playbackRate) }</span>
</button>
<!-- Dropdown menu (teleported to body, fixed positioning) -->
<Teleport to="body">
<div v-if="showSpeedMenu" @click.stop
data-speed-dropdown
class="fixed bg-[var(--bg-tertiary)] border border-[var(--border-accent)] rounded-md shadow-xl z-[9999] speed-dropdown overflow-y-auto backdrop-blur-sm"
:style="speedMenuPosition">
<div class="py-0.5">
<button v-for="speed in playbackSpeeds" :key="speed"
@mousedown.prevent="setPlaybackRate(speed); showSpeedMenu = false"
class="w-full px-2 py-0.5 text-[11px] font-mono text-left hover:bg-[var(--bg-accent-light)] transition-colors"
:class="speed === playbackRate ? 'text-[var(--text-accent)] font-semibold bg-[var(--bg-accent-light)]' : 'text-[var(--text-primary)]'">
${ speed }x
</button>
</div>
</div>
</Teleport>
</div>
<!-- Spacer -->
<div class="flex-1"></div>
<!-- Volume Control -->
<div class="relative flex items-center flex-shrink-0 group/vol"
@mouseenter="showVolumeSlider = true"
@mouseleave="showVolumeSlider = false">
<button @click="toggleAudioMute"
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] transition-all"
:class="audioIsMuted || playerVolume === 0 ? 'text-[var(--text-muted)]' : 'text-[var(--text-accent)]'"
:title="audioIsMuted ? t('tooltips.unmute') : t('tooltips.mute')">
<i :class="audioIsMuted || playerVolume === 0 ? 'fas fa-volume-mute' : playerVolume < 0.5 ? 'fas fa-volume-down' : 'fas fa-volume-up'" class="text-sm"></i>
</button>
<!-- Volume Slider (expands on hover, outer div is invisible hover bridge) -->
<div class="absolute top-full left-1/2 -translate-x-1/2 pt-2 z-50 transition-all duration-200"
:class="showVolumeSlider ? 'opacity-100 pointer-events-auto scale-100' : 'opacity-0 pointer-events-none scale-95'"
@mouseenter="showVolumeSlider = true"
@mouseleave="showVolumeSlider = false">
<div class="px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border-accent)] rounded-lg shadow-xl flex flex-col items-center gap-1">
<span class="text-[10px] font-mono text-[var(--text-muted)]">${ Math.round(playerVolume * 100) }</span>
<input type="range" min="0" max="1" step="0.05"
:value="audioIsMuted ? 0 : playerVolume"
@input="(e) => setAudioVolume(parseFloat(e.target.value))"
class="volume-slider-vertical"
style="height: 80px;">
</div>
</div>
</div>
<!-- Download Button -->
<a :href="'/audio/' + selectedRecording.id + '?download=true'"
download
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] text-[var(--text-accent)] hover:opacity-80 transition-all flex-shrink-0"
:title="t('buttons.downloadAudio') || 'Download audio'">
<i class="fas fa-download text-sm"></i>
</a>
<!-- Video Fullscreen Button -->
<button v-if="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') && !videoCollapsed"
@click="enterVideoFullscreen"
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] text-[var(--text-accent)] hover:opacity-80 transition-all flex-shrink-0"
:title="t('tooltips.fullscreenVideo')">
<i class="fas fa-expand text-sm"></i>
</button>
<!-- Video Toggle Button -->
<button v-if="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/')"
@click="videoCollapsed = !videoCollapsed"
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] transition-all flex-shrink-0"
:class="videoCollapsed ? 'text-[var(--text-muted)]' : 'text-[var(--text-accent)]'"
:title="videoCollapsed ? t('tooltips.showVideo') : t('tooltips.hideVideo')">
<i :class="videoCollapsed ? 'fas fa-eye-slash' : 'fas fa-eye'" class="text-sm"></i>
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,115 @@
<!-- Desktop Chat Section -->
<div class="border-t border-[var(--border-primary)] flex flex-col" :class="{'flex-1 overflow-hidden': isChatMaximized}">
<!-- Chat Toggle -->
<div class="bg-[var(--bg-tertiary)] px-4 py-3 hover:bg-[var(--bg-accent-hover)] transition-colors flex items-center">
<button @click="() => { showChat = !showChat; if (!showChat) isChatMaximized = false; }"
class="flex-1 text-left flex items-center">
<span class="font-medium flex items-center">
<i class="fas fa-comments mr-2"></i>
${ t('chat.chatWithTranscription') }
</span>
</button>
<div class="flex items-center gap-2">
<button v-if="chatMessages.length > 0"
@click="downloadChat"
class="p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
:title="t('buttons.downloadChat')">
<i class="fas fa-download text-sm"></i>
</button>
<button @click="toggleChatMaximize"
class="p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
:title="isChatMaximized ? t('tooltips.restoreChat') : t('tooltips.maximizeChat')">
<i :class="['fas text-sm', isChatMaximized ? 'fa-compress' : 'fa-expand']"></i>
</button>
<button @click="() => { showChat = !showChat; if (!showChat) isChatMaximized = false; }"
class="p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)]">
<i :class="['fas transition-transform duration-200', showChat ? 'fa-chevron-down' : 'fa-chevron-up']"></i>
</button>
</div>
</div>
<!-- Chat Content -->
<div v-if="showChat" class="flex flex-col relative" :class="isChatMaximized ? 'flex-1 overflow-hidden' : 'max-h-96'">
<!-- Clear button - fixed at top right -->
<button v-if="chatMessages.length > 0"
@click="clearChat"
class="absolute top-2 right-2 p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:text-[var(--text-danger)] rounded transition-all duration-200 z-20"
title="Clear chat">
<i class="fas fa-times text-xs"></i>
</button>
<!-- Chat Messages -->
<div ref="chatMessagesRef"
class="flex-1 overflow-y-auto p-4 space-y-4 bg-[var(--bg-secondary)]">
<div v-if="chatMessages.length === 0" class="text-center py-8">
<i class="fas fa-robot text-3xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)]" v-text="t('help.askAboutTranscription')"></p>
</div>
<div v-for="(message, index) in chatMessages"
:key="index"
:class="[
'message relative group',
message.role === 'user' ? 'user-message ml-auto' : 'ai-message',
message.role === 'assistant' ? 'pr-10' : ''
]">
<!-- Copy button for assistant messages -->
<button v-if="message.role === 'assistant'"
@click="copyMessage(message.content, $event)"
class="absolute top-2 right-2 p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded transition-all duration-200"
:title="t('buttons.copyMessage')">
<i class="fas fa-copy text-sm"></i>
</button>
<!-- Show thinking content if available -->
<div v-if="message.thinking && message.role === 'assistant'" class="mb-2">
<button @click="message.thinkingExpanded = !message.thinkingExpanded"
class="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] flex items-center gap-1">
<i :class="['fas', message.thinkingExpanded ? 'fa-chevron-down' : 'fa-chevron-right']"></i>
<span v-text="t('help.modelReasoning')"></span>
<span class="text-[var(--text-muted)]">(${message.thinking.split('\n').length} lines)</span>
</button>
<div v-if="message.thinkingExpanded"
class="mt-2 p-3 bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-lg text-xs text-[var(--text-muted)] max-h-64 overflow-y-auto">
<pre class="whitespace-pre-wrap font-mono">${message.thinking}</pre>
</div>
</div>
<!-- Main message content -->
<div v-if="message.html" v-html="message.html"></div>
<div v-else class="whitespace-pre-wrap">${message.content}</div>
</div>
<div v-if="isChatLoading" class="ai-message">
<i class="fas fa-spinner fa-spin mr-2"></i>
${ t('chat.thinking') }
</div>
</div>
<!-- Chat Input -->
<div class="border-t border-[var(--border-primary)] p-4 bg-[var(--bg-tertiary)]">
<div class="flex gap-2">
<textarea v-model="chatInput"
ref="chatInputRef"
@keydown="handleChatKeydown"
:disabled="selectedRecording.status !== 'COMPLETED' || processedTranscription.isError"
:placeholder="t('chat.placeholderWithHint')"
class="flex-1 px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] text-sm"
rows="2">
</textarea>
<button @click="sendChatMessage"
:disabled="!chatInput.trim() || isChatLoading || selectedRecording.status !== 'COMPLETED' || processedTranscription.isError"
class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-paper-plane"></i>
</button>
</div>
<p v-if="processedTranscription.isError"
class="text-xs text-amber-500 mt-2">
<i class="fas fa-exclamation-triangle mr-1"></i>
${ t('chat.cannotChatTranscriptionFailed') }
</p>
<p v-else-if="selectedRecording.status !== 'COMPLETED'"
class="text-xs text-[var(--text-muted)] mt-2">
${ t('chat.availableAfterTranscription') }
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,65 @@
<!-- Desktop Events Tab -->
<div v-if="selectedTab === 'events' && selectedRecording.events && selectedRecording.events.length > 0" class="h-full p-4 overflow-y-auto">
<div class="space-y-4">
<div v-for="event in selectedRecording.events" :key="event.id"
class="bg-[var(--bg-tertiary)] rounded-lg p-4 border border-[var(--border-primary)] hover:border-[var(--border-accent)] transition-colors">
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
<i class="fas fa-calendar-check mr-2 text-[var(--text-accent)]"></i>
${ event.title }
</h3>
<p v-if="event.description" class="text-sm text-[var(--text-secondary)] mb-2">
${ event.description }
</p>
</div>
<div class="flex items-center gap-2">
<button @click="downloadEventICS(event)"
class="px-3 py-1.5 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors text-sm flex items-center gap-1.5"
:title="t('events.addToCalendar')">
<i class="fas fa-download"></i>
<span v-text="t('events.addToCalendar')"></span>
</button>
<button @click="deleteEvent(event)"
class="p-1.5 text-[var(--text-muted)] hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
:title="t('events.delete')">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2" v-if="event.start_datetime">
<i class="fas fa-clock text-[var(--text-muted)] w-4"></i>
<span class="text-[var(--text-secondary)]">
<strong v-text="t('events.start') + ':'"></strong> ${ formatEventDateTime(event.start_datetime) }
</span>
</div>
<div class="flex items-center gap-2" v-if="event.end_datetime">
<i class="fas fa-clock text-[var(--text-muted)] w-4"></i>
<span class="text-[var(--text-secondary)]">
<strong v-text="t('events.end') + ':'"></strong> ${ formatEventDateTime(event.end_datetime) }
</span>
</div>
<div class="flex items-center gap-2" v-if="event.location">
<i class="fas fa-map-marker-alt text-[var(--text-muted)] w-4"></i>
<span class="text-[var(--text-secondary)]">
<strong v-text="t('events.location') + ':'"></strong> ${ event.location }
</span>
</div>
<div class="flex items-start gap-2" v-if="event.attendees && event.attendees.length > 0">
<i class="fas fa-users text-[var(--text-muted)] w-4 mt-0.5"></i>
<div class="text-[var(--text-secondary)]">
<strong v-text="t('events.attendees') + ':'"></strong>
<span class="ml-1">${ event.attendees.join(', ') }</span>
</div>
</div>
</div>
</div>
<div v-if="selectedRecording.events.length === 0" class="text-center py-8">
<i class="fas fa-calendar-times text-3xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)]" v-text="t('events.noEvents')"></p>
</div>
</div>
</div>

View File

@@ -0,0 +1,220 @@
<!-- Desktop Recording Header -->
<div class="bg-[var(--bg-secondary)] border-b border-[var(--border-primary)] p-6 flex-shrink-0">
<div class="flex flex-col">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<h1 v-if="!editingTitle"
@dblclick="selectedRecording.can_edit !== false ? toggleEditTitle() : null"
:class="[
'text-2xl font-bold truncate transition-opacity',
selectedRecording.is_shared ? 'text-[var(--text-accent)]' : 'text-[var(--text-primary)]',
selectedRecording.can_edit !== false ? 'cursor-text hover:opacity-80' : ''
]"
:title="selectedRecording.can_edit !== false ? 'Double-click to edit' : selectedRecording.title || 'Untitled Recording'">
${selectedRecording.title || 'Untitled Recording'}
</h1>
<input v-else
v-model="selectedRecording.title"
@blur="saveTitle"
@keyup.enter="saveTitle"
@keyup.esc="cancelEditTitle"
ref="titleInput"
class="text-2xl font-bold bg-transparent border-b-2 border-[var(--border-focus)] focus:outline-none text-[var(--text-primary)] flex-1 px-1"
placeholder="Untitled Recording">
<button v-if="!editingTitle && selectedRecording.can_edit !== false"
@click="toggleEditTitle"
class="p-1 text-[var(--text-muted)] hover:text-[var(--text-accent)] transition-colors opacity-60 hover:opacity-100 flex-shrink-0"
:title="'Edit title'">
<i class="fas fa-pen text-sm"></i>
</button>
<!-- Status Badge (for non-completed recordings) -->
<span v-if="!editingTitle && selectedRecording.status !== 'COMPLETED'"
:class="getStatusClass(selectedRecording.status)"
class="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded-full whitespace-nowrap flex-shrink-0">
${formatStatus(selectedRecording.status)}
</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2 ml-4">
<!-- Incognito recordings have limited actions -->
<template v-if="!selectedRecording.incognito">
<!-- Folder Assignment (icon-only dropdown matching other buttons) -->
<div v-if="foldersEnabled && selectedRecording.can_edit !== false"
class="relative p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
:title="selectedRecording.folder_id ? getFolderName(selectedRecording.folder_id) : 'Assign Folder'">
<select @change="assignFolderToRecording(selectedRecording.id, $event.target.value || null)"
:value="selectedRecording.folder_id || ''"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
<option value="">No Folder</option>
<option v-for="folder in availableFolders" :key="folder.id" :value="folder.id">
${ folder.name }
</option>
</select>
<i class="fas fa-folder"
:style="{ color: selectedRecording.folder_id ? getFolderColor(selectedRecording.folder_id) : '' }"></i>
</div>
<button @click="toggleInbox(selectedRecording)"
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
:class="selectedRecording.is_inbox ? 'text-blue-500' : ''"
:title="selectedRecording.is_inbox ? 'Mark as Read' : 'Move to Inbox'">
<i class="fas fa-inbox"></i>
</button>
<button @click="toggleHighlight(selectedRecording)"
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
:class="selectedRecording.is_highlighted ? 'text-yellow-500' : ''"
:title="selectedRecording.is_highlighted ? 'Remove Highlight' : 'Highlight'">
<i class="fas fa-star"></i>
</button>
<button @click="editRecordingTags(selectedRecording)"
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
:title="t('buttons.editTags')">
<i class="fas fa-tags"></i>
</button>
<button @click="confirmReprocess('transcription', selectedRecording)" v-if="selectedRecording && selectedRecording.can_edit !== false && (selectedRecording.status === 'COMPLETED' || selectedRecording.status === 'FAILED')"
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
:title="useAsrEndpoint ? 'Reprocess with ASR' : 'Reprocess transcription'">
<i class="fas fa-redo-alt"></i>
</button>
<button @click="confirmReprocess('summary', selectedRecording)" v-if="selectedRecording && selectedRecording.can_edit !== false && (selectedRecording.status === 'COMPLETED' || selectedRecording.status === 'FAILED')"
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
:title="t('buttons.reprocessSummary')">
<i class="fas fa-sync-alt"></i>
</button>
<button @click="confirmReset(selectedRecording)" v-if="['PENDING', 'PROCESSING', 'SUMMARIZING', 'FAILED'].includes(selectedRecording.status)"
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors text-orange-500"
:title="t('buttons.resetStuckProcessing')">
<i class="fas fa-undo"></i>
</button>
<button @click="openSpeakerModal" v-if="processedTranscription.hasDialogue"
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
:title="t('buttons.identifySpeakers')">
<i class="fas fa-user-tag"></i>
</button>
<button v-if="!selectedRecording.is_shared || (selectedRecording.share_info && selectedRecording.share_info.can_reshare)"
@click="openUnifiedShareModal(selectedRecording)"
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
:title="t('buttons.shareRecording')">
<i class="fas fa-share-alt"></i>
</button>
<button v-if="canDeleteRecordings && selectedRecording.can_delete !== false" @click="confirmDelete(selectedRecording)"
class="p-2 rounded-lg hover:bg-[var(--bg-danger-light)] text-[var(--text-danger)] transition-colors">
<i class="fas fa-trash"></i>
</button>
</template>
<!-- Incognito mode: only show discard button -->
<template v-else>
<span class="text-xs text-amber-600 dark:text-amber-400 mr-2">
<i class="fas fa-user-secret mr-1"></i>
Incognito
</span>
<button @click="clearIncognitoRecordingWithConfirm"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 transition-colors"
title="Discard incognito recording">
<i class="fas fa-trash"></i>
</button>
</template>
</div>
</div>
<!-- Metadata Row -->
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-[var(--text-muted)] mt-2">
<!-- Folder Pill, Shared Status Badges and Tags -->
<div v-if="(foldersEnabled && selectedRecording.folder_id && !selectedRecording.incognito) || selectedRecording.is_shared || selectedRecording.shared_with_count > 0 || selectedRecording.public_share_count > 0 || selectedRecording.has_group_tags || (selectedRecording.tags && selectedRecording.tags.length > 0)" class="flex items-center gap-1.5 flex-wrap">
<!-- Folder Pill (shown when folder assigned) -->
<span v-if="foldersEnabled && selectedRecording.folder_id && !selectedRecording.incognito"
class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full"
:style="{ backgroundColor: getFolderColor(selectedRecording.folder_id), color: getContrastTextColor(getFolderColor(selectedRecording.folder_id)) }"
:title="'Folder: ' + getFolderName(selectedRecording.folder_id)">
<i class="fas fa-folder mr-1 text-[10px]" style="vertical-align: middle; line-height: 0;"></i>
${ getFolderName(selectedRecording.folder_id) }
</span>
<!-- Shared by someone else (INCOMING) -->
<span v-if="selectedRecording.is_shared"
class="inline-flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full bg-purple-500 text-white"
:title="t('sharing.sharedBy') + ' ' + (selectedRecording.owner_username || t('sharing.unknown'))">
<i class="fas fa-arrow-down" style="vertical-align: middle; line-height: 0;"></i>
</span>
<!-- Group indicator (show for both owned and shared recordings with group tags) -->
<span v-if="selectedRecording.has_group_tags"
class="inline-flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full bg-blue-500 text-white"
:title="t('sharing.teamRecording')">
<i class="fas fa-users" style="vertical-align: middle; line-height: 0;"></i>
</span>
<!-- Shared with others (OUTGOING) -->
<span v-if="!selectedRecording.is_shared && selectedRecording.shared_with_count > 0"
class="inline-flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full bg-indigo-500 text-white"
:title="t('sharing.sharedWith') + ' ' + selectedRecording.shared_with_count + ' ' + t('sharing.users')">
<i class="fas fa-arrow-up" style="vertical-align: middle; line-height: 0;"></i>
</span>
<!-- Public link shares -->
<span v-if="!selectedRecording.is_shared && selectedRecording.public_share_count > 0"
class="inline-flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full bg-teal-500 text-white"
:title="selectedRecording.public_share_count + ' ' + t('sharing.publicLinksGenerated')">
<i class="fas fa-globe" style="vertical-align: middle; line-height: 0;"></i>
</span>
<!-- Tags -->
<span v-for="tag in selectedRecording.tags" :key="tag.id"
class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full"
:style="{ backgroundColor: tag.color || '#6B7280', color: getContrastTextColor(tag.color || '#6B7280') }"
:title="tag.group_id ? ('Group: ' + tag.group_name) : tag.name">
<i v-if="tag.group_id" class="fas fa-users mr-1 text-[10px]" style="vertical-align: middle; line-height: 0;"></i>
${tag.name}
</span>
</div>
<!-- Participants -->
<div class="flex items-center gap-2">
<i class="fas fa-users text-[var(--text-accent)]"></i>
<span @click="openParticipantsModal"
class="cursor-pointer hover:text-[var(--text-accent)] transition-colors max-w-[300px] truncate inline-block"
:title="selectedRecording.participants || t('help.noParticipants')">
${selectedRecording.participants || t('help.noParticipants')}
</span>
</div>
<!-- Owner (for shared recordings) -->
<div v-if="selectedRecording.is_shared" class="flex items-center gap-2">
<i class="fas fa-user text-[var(--text-accent)]"></i>
<span class="max-w-[300px] truncate inline-block"
:title="'Owner: ' + (selectedRecording.owner_username || t('sharing.unknown'))">
${selectedRecording.owner_username || t('sharing.unknown')}
</span>
</div>
<!-- Meeting Date -->
<div class="flex items-center gap-2">
<i class="fas fa-calendar text-[var(--text-accent)]"></i>
<span @click="openMeetingDatePicker"
class="cursor-pointer hover:text-[var(--text-accent)] transition-colors">
${selectedRecording.meeting_date ? formatDisplayDate(selectedRecording.meeting_date) : 'No date set'}
</span>
</div>
<!-- Other Metadata -->
<div v-if="activeRecordingMetadata && activeRecordingMetadata.length > 0" class="flex flex-wrap items-center gap-x-6 gap-y-2">
<span v-for="(item, index) in activeRecordingMetadata" :key="index" class="flex items-center gap-1.5">
<i :class="item.icon"></i>
<span :title="item.fullText || item.text">${item.text}</span>
</span>
</div>
<!-- Duplicate Indicator -->
<button v-if="selectedRecording.duplicate_info"
@click="openDuplicatesModal(selectedRecording.duplicate_info)"
class="flex items-center gap-1.5 text-amber-500 hover:text-amber-400 transition-colors cursor-pointer">
<i class="fas fa-copy"></i>
<span>${ selectedRecording.duplicate_info.total_copies } ${ t('upload.copies') || 'copies' }</span>
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,57 @@
<!-- Desktop Notes Tab -->
<div v-if="selectedTab === 'notes'" class="h-full p-4 overflow-y-auto">
<div class="content-box h-full relative">
<div v-if="!editingNotes" class="absolute top-2 right-4 flex gap-1 z-10">
<button @click="copyNotes"
:title="t('buttons.copyNotes')"
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
<i class="fas fa-copy text-sm"></i>
</button>
<button @click="downloadNotes"
:title="t('buttons.downloadNotes')"
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
<i class="fas fa-download text-sm"></i>
</button>
<button @click="toggleEditNotes"
:title="t('buttons.editNotes')"
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
<i class="fas fa-edit text-sm"></i>
</button>
</div>
<div v-if="!editingNotes"
class="notes-box h-full"
@click="clickToEditNotes">
<div v-if="selectedRecording.notes_html"
v-html="selectedRecording.notes_html">
</div>
<div v-else-if="selectedRecording.notes"
class="whitespace-pre-wrap">
${selectedRecording.notes}
</div>
<div v-else class="text-[var(--text-muted)] italic cursor-pointer hover:text-[var(--text-secondary)]">
${ t('help.clickToAddNotes') }
</div>
</div>
<div v-else class="h-full flex flex-col">
<div class="markdown-editor-container flex-1">
<textarea ref="notesMarkdownEditor"
v-model="selectedRecording.notes"
class="w-full h-full p-4 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]"
:placeholder="t('form.enterNotesMarkdown')">
</textarea>
</div>
<div class="flex justify-end gap-2 mt-2">
<button @click="cancelEditNotes"
class="px-3 py-1 text-sm bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded hover:bg-[var(--bg-tertiary)]">
Cancel
</button>
<button @click="saveEditNotes"
class="px-3 py-1 text-sm bg-[var(--bg-button)] text-[var(--text-button)] rounded hover:bg-[var(--bg-button-hover)]">
Save
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,272 @@
<!-- Desktop Right Panel (Summary/Notes/Chat) -->
<div id="rightMainColumn" class="flex flex-col overflow-hidden" :style="{width: rightColumnWidth + '%'}">
<!-- Custom Audio Player -->
<div class="px-4 py-3 border-b border-[var(--border-primary)] flex-shrink-0">
<!-- Show message if audio has been deleted -->
<div v-if="selectedRecording.audio_deleted_at"
class="text-[var(--text-muted)] text-sm flex items-center gap-2">
<i class="fas fa-info-circle"></i>
<span v-text="t('help.audioDeletedMessage')"></span>
</div>
<!-- Show message for incognito recordings (no audio stored) -->
<div v-else-if="selectedRecording.incognito"
class="text-[var(--text-muted)] text-sm flex items-center gap-2 py-2">
<i class="fas fa-user-secret"></i>
<span v-text="t('incognito.audioNotStored')"></span>
</div>
<!-- Custom Audio Player Card -->
<div v-else class="bg-[var(--bg-tertiary)] rounded-xl p-4 shadow-sm border border-[var(--border-primary)] overflow-hidden">
<!-- Video/Audio element wrapped in Teleport for fullscreen -->
<Teleport to="body" :disabled="!videoFullscreen">
<div :class="videoFullscreen ? 'video-fullscreen-overlay' : ''"
@mousemove="videoFullscreen && handleFullscreenMouseMove()"
@click.self="videoFullscreen && toggleAudioPlayback()">
<component :is="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') ? 'video' : 'audio'"
ref="audioPlayerElement"
:src="'/audio/' + selectedRecording.id"
:volume="playerVolume"
@play="handleAudioPlayPause"
@pause="handleAudioPlayPause"
@timeupdate="handleCustomAudioTimeUpdate"
@loadedmetadata="handleAudioLoadedMetadata"
@durationchange="handleAudioDurationChange"
@ended="handleAudioEnded"
@waiting="handleAudioWaiting"
@canplay="handleAudioCanPlay"
@click="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') && toggleAudioPlayback()"
@dblclick="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') && !videoFullscreen && enterVideoFullscreen()"
:class="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/')
? (videoFullscreen ? 'video-fullscreen-video' : (videoCollapsed ? 'hidden' : 'w-full rounded-lg mb-3 cursor-pointer'))
: 'hidden'">
</component>
<!-- Fullscreen Subtitle Overlay -->
<div v-if="videoFullscreen && currentSubtitle"
class="video-fullscreen-subtitle"
:class="{ 'subtitle-shifted': fullscreenControlsVisible }">
<span v-if="currentSubtitle.speaker"
class="video-fullscreen-subtitle-speaker"
:style="{ color: 'var(--' + currentSubtitle.color + ')' }">${ currentSubtitle.speaker }:</span>
<span>${ currentSubtitle.text }</span>
</div>
<!-- Fullscreen Control Bar -->
<div v-if="videoFullscreen"
class="video-fullscreen-controls"
:class="{ visible: fullscreenControlsVisible }"
@mousemove.stop="handleFullscreenMouseMove()">
<!-- Progress Bar -->
<div class="px-4 mb-2">
<div class="w-full h-5 rounded-full cursor-pointer relative group flex items-center"
@mousedown="startProgressDrag"
@touchstart.prevent="startProgressDrag">
<div class="progress-track w-full h-1 rounded-full relative bg-white/30">
<div class="h-full bg-white rounded-full pointer-events-none"
:style="{ width: audioProgressPercent + '%' }"></div>
</div>
<div class="absolute top-1/2 w-3 h-3 bg-white rounded-full shadow-md transition-transform group-hover:scale-125 pointer-events-none -translate-y-1/2"
:style="{ left: 'clamp(0px, calc(' + audioProgressPercent + '% - 6px), calc(100% - 12px))' }"></div>
</div>
</div>
<!-- Controls Row -->
<div class="flex items-center gap-3 px-4 pb-4">
<!-- Play/Pause -->
<button @click.stop="toggleAudioPlayback"
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white/20 transition-all">
<i :class="audioIsPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-lg" :style="!audioIsPlaying ? 'margin-left: 2px' : ''"></i>
</button>
<!-- Volume -->
<div class="flex items-center gap-2">
<button @click.stop="toggleAudioMute"
class="w-8 h-8 flex items-center justify-center rounded-full text-white hover:bg-white/20 transition-all">
<i :class="audioIsMuted || playerVolume === 0 ? 'fas fa-volume-mute' : playerVolume < 0.5 ? 'fas fa-volume-down' : 'fas fa-volume-up'" class="text-sm"></i>
</button>
<input type="range" min="0" max="1" step="0.05"
:value="audioIsMuted ? 0 : playerVolume"
@input="(e) => setAudioVolume(parseFloat(e.target.value))"
class="fullscreen-volume-slider w-20">
</div>
<!-- Time -->
<div class="flex items-baseline gap-1 text-white">
<span class="text-sm font-mono">${ formatAudioTime(displayCurrentTime) }</span>
<span class="text-xs opacity-60">/</span>
<span class="text-xs opacity-60 font-mono">${ formatAudioTime(audioDuration) }</span>
</div>
<div class="flex-1"></div>
<!-- Speed -->
<button @click.stop="cyclePlaybackRate"
class="px-2 h-8 flex items-center justify-center rounded-lg text-white hover:bg-white/20 transition-all"
title="Playback speed">
<span class="text-xs font-semibold font-mono">${ formatPlaybackRate(playbackRate) }</span>
</button>
<!-- Exit Fullscreen -->
<button @click.stop="exitVideoFullscreen"
class="w-8 h-8 flex items-center justify-center rounded-full text-white hover:bg-white/20 transition-all"
:title="t('tooltips.exitFullscreen')">
<i class="fas fa-compress text-sm"></i>
</button>
</div>
</div>
</div>
</Teleport>
<!-- Normal Controls (hidden when fullscreen) -->
<div v-show="!videoFullscreen">
<!-- Progress Bar (now on top, full width, draggable) -->
<div class="w-full h-4 rounded-full cursor-pointer relative mb-3 group flex items-center"
@mousedown="startProgressDrag">
<!-- Track background -->
<div class="progress-track w-full h-2 rounded-full relative bg-[var(--border-accent)] opacity-40">
<!-- Progress fill -->
<div class="h-full bg-[var(--text-accent)] rounded-full pointer-events-none opacity-100"
:style="{ width: audioProgressPercent + '%' }"></div>
</div>
<!-- Progress dot - stays within track bounds -->
<div class="absolute top-1/2 w-4 h-4 bg-[var(--text-accent)] rounded-full shadow-md transition-transform group-hover:scale-110 pointer-events-none -translate-y-1/2"
:style="{ left: 'clamp(0px, calc(' + audioProgressPercent + '% - 8px), calc(100% - 16px))' }"
style="box-shadow: 0 2px 6px rgba(0,0,0,0.3);"></div>
</div>
<!-- Controls Row -->
<div class="flex items-center gap-2 min-w-0">
<!-- Play/Pause Button -->
<button @click="toggleAudioPlayback"
class="w-10 h-10 flex items-center justify-center rounded-full player-play-button transition-all duration-200 flex-shrink-0 shadow-md hover:shadow-lg hover:scale-105"
:title="audioIsPlaying ? t('tooltips.pause') : t('tooltips.play')">
<i :class="audioIsPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-base" :style="!audioIsPlaying ? 'margin-left: 2px' : ''"></i>
</button>
<!-- Time Display -->
<div class="flex items-baseline gap-1 flex-shrink-0 min-w-0">
<span class="text-sm font-semibold font-mono" :class="isDraggingProgress ? 'text-[var(--text-accent)]' : 'text-[var(--text-primary)]'">${ formatAudioTime(displayCurrentTime) }</span>
<span class="text-xs text-[var(--text-muted)]">/</span>
<span class="text-xs text-[var(--text-muted)] font-mono">${ formatAudioTime(audioDuration) }</span>
</div>
<!-- Playback Speed Control -->
<div class="relative flex-shrink-0">
<button ref="speedButtonDesktop"
@click="showSpeedMenu = !showSpeedMenu; $nextTick(() => updateSpeedMenuPosition($refs.speedButtonDesktop))"
data-speed-toggle
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] text-[var(--text-accent)] hover:opacity-80 transition-all"
:title="t('tooltips.playbackSpeed') || 'Playback speed'">
<span class="text-xs font-semibold font-mono">${ formatPlaybackRate(playbackRate) }</span>
</button>
<!-- Dropdown menu (teleported to body, fixed positioning) -->
<Teleport to="body">
<div v-if="showSpeedMenu" @click.stop
data-speed-dropdown
class="fixed bg-[var(--bg-tertiary)] border border-[var(--border-accent)] rounded-md shadow-xl z-[9999] speed-dropdown overflow-y-auto backdrop-blur-sm"
:style="speedMenuPosition">
<div class="py-0.5">
<button v-for="speed in playbackSpeeds" :key="speed"
@mousedown.prevent="setPlaybackRate(speed); showSpeedMenu = false"
class="w-full px-2 py-0.5 text-[11px] font-mono text-left hover:bg-[var(--bg-accent-light)] transition-colors"
:class="speed === playbackRate ? 'text-[var(--text-accent)] font-semibold bg-[var(--bg-accent-light)]' : 'text-[var(--text-primary)]'">
${ speed }x
</button>
</div>
</div>
</Teleport>
</div>
<!-- Spacer -->
<div class="flex-1 min-w-0"></div>
<!-- Volume Control - hidden on very narrow screens -->
<div class="hidden sm:flex items-center gap-1 flex-shrink-0">
<button @click="toggleAudioMute"
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] hover:opacity-80 transition-all"
:class="audioIsMuted || playerVolume === 0 ? 'text-[var(--text-muted)]' : 'text-[var(--text-accent)]'"
:title="audioIsMuted ? t('tooltips.unmute') : t('tooltips.mute')">
<i :class="audioIsMuted || playerVolume === 0 ? 'fas fa-volume-mute' : playerVolume < 0.5 ? 'fas fa-volume-down' : 'fas fa-volume-up'" class="text-sm"></i>
</button>
<input type="range" min="0" max="1" step="0.05"
:value="audioIsMuted ? 0 : playerVolume"
@input="(e) => setAudioVolume(parseFloat(e.target.value))"
class="volume-slider w-16">
</div>
<!-- Download Button -->
<a :href="'/audio/' + selectedRecording.id + '?download=true'"
download
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] text-[var(--text-accent)] hover:opacity-80 transition-all flex-shrink-0"
:title="t('buttons.downloadAudio') || 'Download audio'">
<i class="fas fa-download text-sm"></i>
</a>
<!-- Video Fullscreen Button -->
<button v-if="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') && !videoCollapsed"
@click="enterVideoFullscreen"
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] text-[var(--text-accent)] hover:opacity-80 transition-all flex-shrink-0"
:title="t('tooltips.fullscreenVideo')">
<i class="fas fa-expand text-sm"></i>
</button>
<!-- Video Toggle Button -->
<button v-if="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/')"
@click="videoCollapsed = !videoCollapsed"
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--bg-primary)] transition-all flex-shrink-0"
:class="videoCollapsed ? 'text-[var(--text-muted)]' : 'text-[var(--text-accent)]'"
:title="videoCollapsed ? t('tooltips.showVideo') : t('tooltips.hideVideo')">
<i :class="videoCollapsed ? 'fas fa-eye-slash' : 'fas fa-eye'" class="text-sm"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Summary/Notes Tabs -->
<div :class="{'flex-1': !isChatMaximized, 'flex-none': isChatMaximized}" class="flex flex-col overflow-hidden">
<!-- Tab Navigation -->
<div class="bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)] flex">
<button @click="selectedTab = 'summary'"
:class="[
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
selectedTab === 'summary'
? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
]">
${ t('summary.title') }
</button>
<button @click="selectedTab = 'notes'"
:class="[
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
selectedTab === 'notes'
? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
]">
${ t('notes.title') }
</button>
<button v-if="selectedRecording.events && selectedRecording.events.length > 0"
@click="selectedTab = 'events'"
:class="[
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
selectedTab === 'events'
? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
]">
<i class="fas fa-calendar-alt mr-1"></i>
${ t('events.title') } (${ selectedRecording.events.length })
</button>
</div>
<!-- Tab Content -->
<div v-if="!isChatMaximized" class="flex-1 overflow-hidden">
{% include 'components/detail/desktop-summary-tab.html' %}
{% include 'components/detail/desktop-notes-tab.html' %}
{% include 'components/detail/desktop-events-tab.html' %}
</div>
</div>
<!-- Chat Section -->
{% include 'components/detail/desktop-chat-section.html' %}
</div>

View File

@@ -0,0 +1,71 @@
<!-- Desktop Summary Tab -->
<div v-if="selectedTab === 'summary'" class="h-full p-4 overflow-y-auto">
<div v-if="selectedRecording.status === 'SUMMARIZING'" class="text-center py-8">
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
<p class="mt-2 text-[var(--text-muted)]" v-text="t('summary.summaryInProgress')"></p>
</div>
<div v-else-if="!selectedRecording.summary" class="text-center py-8">
<i class="fas fa-file-alt text-3xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)] mb-4" v-text="t('summary.noSummary')"></p>
<!-- Show message if transcription is an error -->
<p v-if="processedTranscription.isError" class="text-sm text-amber-500 mb-2">
<i class="fas fa-exclamation-triangle mr-1"></i>
Cannot generate summary: transcription failed
</p>
<button @click="generateSummary"
:disabled="processedTranscription.isError"
:class="[
'px-6 py-3 font-medium rounded-lg shadow-lg transition-all duration-200',
processedTranscription.isError
? 'bg-gray-400 text-gray-200 cursor-not-allowed'
: 'bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:shadow-xl hover:from-blue-700 hover:to-purple-700 transform hover:scale-105'
]">
<i class="fas fa-magic mr-2"></i>${ t('summary.generateSummary') }
</button>
</div>
<div v-else class="content-box relative">
<div v-if="!editingSummary" class="absolute top-2 right-4 flex gap-1 z-10">
<button @click="copySummary"
:title="t('buttons.copySummary')"
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
<i class="fas fa-copy text-sm"></i>
</button>
<button @click="downloadSummary"
:title="t('buttons.downloadSummary')"
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
<i class="fas fa-download text-sm"></i>
</button>
<button @click="toggleEditSummary"
:title="t('buttons.editSummary')"
class="w-8 h-8 flex items-center justify-center bg-transparent hover:bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md border border-transparent hover:border-[var(--border-secondary)] transition-all duration-200 opacity-60 hover:opacity-100">
<i class="fas fa-edit text-sm"></i>
</button>
</div>
<div v-if="!editingSummary"
class="summary-box"
v-html="selectedRecording.summary_html || selectedRecording.summary">
</div>
<div v-else class="h-full flex flex-col">
<div class="markdown-editor-container flex-1">
<textarea ref="summaryMarkdownEditor"
v-model="selectedRecording.summary"
class="w-full h-full p-4 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)]"
:placeholder="t('form.enterSummaryMarkdown')">
</textarea>
</div>
<div class="flex justify-end gap-2 mt-2">
<button @click="cancelEditSummary"
class="px-3 py-1 text-sm bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded hover:bg-[var(--bg-tertiary)]">
Cancel
</button>
<button @click="saveEditSummary"
class="px-3 py-1 text-sm bg-[var(--bg-button)] text-[var(--text-button)] rounded hover:bg-[var(--bg-button-hover)]">
Save
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,263 @@
<!-- Desktop Transcription Panel (Left Column) -->
<div id="leftMainColumn" class="flex flex-col overflow-hidden" :style="{width: leftColumnWidth + '%'}">
<!-- Transcription Header -->
<div class="bg-[var(--bg-tertiary)] px-4 py-3 border-b border-[var(--border-primary)] flex items-center justify-between">
<h3 class="font-semibold flex items-center">
<i class="fas fa-file-text mr-2"></i>
<span v-text="t('transcription.title')"></span>
</h3>
<div class="flex items-center gap-2">
<!-- Follow Player Checkbox -->
<div v-if="processedTranscription.isJson && processedTranscription.hasDialogue"
class="follow-player-control text-[var(--text-muted)] hover:text-[var(--text-primary)]"
@click="toggleFollowPlayerMode"
:title="followPlayerMode ? t('tooltips.followPlayerEnabled') : t('tooltips.followPlayerDisabled')">
<input type="checkbox"
:checked="followPlayerMode"
@click.stop="toggleFollowPlayerMode">
<i class="fas fa-arrows-alt-v follow-icon"></i>
</div>
<!-- View Mode Toggle -->
<div v-if="processedTranscription.hasDialogue" class="view-mode-toggle">
<button @click="toggleTranscriptionViewMode"
:class="['toggle-button', transcriptionViewMode === 'simple' ? 'active' : '']">
<i class="fas fa-list"></i><span v-text="t('transcription.simple')"></span>
</button>
<button @click="toggleTranscriptionViewMode"
:class="['toggle-button', transcriptionViewMode === 'bubble' ? 'active' : '']">
<i class="fas fa-comments"></i><span v-text="t('transcription.bubble')"></span>
</button>
</div>
<!-- Copy Button -->
<button @click="copyTranscription"
class="copy-btn"
:title="t('tooltips.copyTranscript')">
<i class="fas fa-copy"></i>
</button>
<!-- Download Button with Dropdown -->
<div v-if="selectedRecording && selectedRecording.transcription" class="relative">
<button @click="showDownloadMenu = !showDownloadMenu"
data-download-toggle
class="copy-btn flex items-center"
:title="t('tooltips.downloadTranscriptWithTemplate')">
<i class="fas fa-download"></i>
<i class="fas fa-caret-down ml-1 text-[10px] opacity-50"></i>
</button>
<!-- Dropdown Menu -->
<div v-if="showDownloadMenu"
data-download-dropdown
class="absolute right-0 top-full mt-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg z-50 overflow-hidden whitespace-nowrap">
<button @click="downloadWithDefaultTemplate(); showDownloadMenu = false"
class="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--bg-tertiary)] transition-colors flex items-center gap-2">
<i class="fas fa-star text-[var(--text-accent)] text-[10px]"></i>
<span v-text="t('transcriptTemplates.downloadDefault')"></span>
</button>
<button @click="downloadTranscriptWord(); showDownloadMenu = false"
class="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--bg-tertiary)] transition-colors flex items-center gap-2 border-t border-[var(--border-secondary)]">
<i class="fas fa-file-word text-blue-500 text-[10px]"></i>
<span>Télécharger Word</span>
</button>
<button @click="showTemplateSelector(); showDownloadMenu = false"
class="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--bg-tertiary)] transition-colors flex items-center gap-2 border-t border-[var(--border-secondary)]">
<i class="fas fa-list text-[var(--text-muted)] text-[10px]"></i>
<span v-text="t('transcriptTemplates.chooseTemplate')"></span>
</button>
</div>
</div>
<!-- Edit Transcription Button -->
<button @click="openTranscriptionEditor"
v-if="selectedRecording && selectedRecording.transcription"
class="copy-btn"
:title="t('tooltips.editTranscript')">
<i class="fas fa-edit"></i>
</button>
</div>
</div>
<!-- Transcription Content -->
<div class="flex-1 overflow-y-auto p-4 relative">
<!-- Floating Processing Indicator -->
<div v-if="selectedRecording.status === 'PROCESSING'"
:class="['processing-indicator-floating', processingIndicatorMinimized ? 'minimized' : '']">
<template v-if="!processingIndicatorMinimized">
<div class="processing-indicator-content">
<i class="fas fa-spinner fa-spin text-[var(--text-accent)]"></i>
<span class="text-sm text-[var(--text-secondary)]" v-text="t('help.processingTranscription')"></span>
</div>
<button @click="processingIndicatorMinimized = true"
class="processing-indicator-minimize"
:title="t('tooltips.minimize')">
<i class="fas fa-chevron-up"></i>
</button>
</template>
<template v-else>
<button @click="processingIndicatorMinimized = false"
class="processing-indicator-expand"
:title="t('help.processingTranscription')">
<i class="fas fa-spinner fa-spin"></i>
</button>
</template>
</div>
<!-- No transcription state (only show if not processing and no transcription) -->
<div v-if="selectedRecording.status !== 'PROCESSING' && !selectedRecording.transcription" class="text-center py-8">
<i class="fas fa-file-text text-3xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)]" v-text="t('transcription.noTranscription')"></p>
</div>
<!-- Error Display (when transcription is an error message) -->
<div v-if="processedTranscription.isError" class="error-display-container">
<div :class="[
'rounded-lg p-5 border',
processedTranscription.error.type === 'size_limit' ? 'bg-amber-500/10 border-amber-500/30' :
processedTranscription.error.type === 'timeout' ? 'bg-orange-500/10 border-orange-500/30' :
processedTranscription.error.type === 'auth' ? 'bg-red-500/10 border-red-500/30' :
processedTranscription.error.type === 'rate_limit' ? 'bg-yellow-500/10 border-yellow-500/30' :
processedTranscription.error.type === 'connection' ? 'bg-blue-500/10 border-blue-500/30' :
processedTranscription.error.type === 'service_error' ? 'bg-purple-500/10 border-purple-500/30' :
'bg-gray-500/10 border-gray-500/30'
]">
<div class="flex items-start gap-4">
<div :class="[
'flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center',
processedTranscription.error.type === 'size_limit' ? 'bg-amber-500/20' :
processedTranscription.error.type === 'timeout' ? 'bg-orange-500/20' :
processedTranscription.error.type === 'auth' ? 'bg-red-500/20' :
processedTranscription.error.type === 'rate_limit' ? 'bg-yellow-500/20' :
processedTranscription.error.type === 'connection' ? 'bg-blue-500/20' :
processedTranscription.error.type === 'service_error' ? 'bg-purple-500/20' :
'bg-gray-500/20'
]">
<i :class="[
'fas text-xl',
processedTranscription.error.icon,
processedTranscription.error.type === 'size_limit' ? 'text-amber-500' :
processedTranscription.error.type === 'timeout' ? 'text-orange-500' :
processedTranscription.error.type === 'auth' ? 'text-red-500' :
processedTranscription.error.type === 'rate_limit' ? 'text-yellow-500' :
processedTranscription.error.type === 'connection' ? 'text-blue-500' :
processedTranscription.error.type === 'service_error' ? 'text-purple-500' :
'text-gray-500'
]"></i>
</div>
<div class="flex-1 min-w-0">
<h3 :class="[
'text-lg font-semibold mb-2',
processedTranscription.error.type === 'size_limit' ? 'text-amber-600 dark:text-amber-400' :
processedTranscription.error.type === 'timeout' ? 'text-orange-600 dark:text-orange-400' :
processedTranscription.error.type === 'auth' ? 'text-red-600 dark:text-red-400' :
processedTranscription.error.type === 'rate_limit' ? 'text-yellow-600 dark:text-yellow-400' :
processedTranscription.error.type === 'connection' ? 'text-blue-600 dark:text-blue-400' :
processedTranscription.error.type === 'service_error' ? 'text-purple-600 dark:text-purple-400' :
'text-gray-600 dark:text-gray-400'
]">
${processedTranscription.error.title}
</h3>
<p class="text-[var(--text-primary)] mb-3">
${processedTranscription.error.message}
</p>
<div v-if="processedTranscription.error.guidance" class="flex items-start gap-2 text-sm text-[var(--text-secondary)] bg-[var(--bg-tertiary)]/50 rounded-lg p-3">
<i class="fas fa-lightbulb text-yellow-500 mt-0.5 flex-shrink-0"></i>
<span>${processedTranscription.error.guidance}</span>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="mt-4 flex items-center gap-3 pt-4 border-t border-[var(--border-secondary)]">
<button @click="reprocessTranscription(selectedRecording.id)"
v-if="selectedRecording.can_edit !== false"
class="px-4 py-2 bg-[var(--bg-accent)] hover:bg-[var(--bg-accent-hover)] text-white rounded-lg transition-colors flex items-center gap-2">
<i class="fas fa-redo-alt"></i>
<span>Reprocess</span>
</button>
<details class="text-xs w-full">
<summary class="cursor-pointer text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
Technical details
</summary>
<pre v-if="processedTranscription.error.technical" class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-lg text-[var(--text-muted)] text-xs max-h-32 overflow-auto whitespace-pre-wrap break-all w-full">${processedTranscription.error.technical}</pre>
</details>
</div>
</div>
</div>
<!-- Speaker Legend (for dialogue transcriptions in bubble view only) -->
<div v-if="!processedTranscription.isError && processedTranscription.hasDialogue && processedTranscription.speakers.length > 0 && transcriptionViewMode === 'bubble'"
:class="['speaker-legend', legendExpanded ? 'expanded' : '']">
<div class="speaker-legend-header" @click="legendExpanded = !legendExpanded">
<div class="speaker-legend-title">
<i class="fas fa-users"></i>
<span v-text="t('help.speakers')"></span>
<span class="speaker-count-indicator">(${processedTranscription.speakers.length})</span>
</div>
<i class="fas fa-chevron-down speaker-legend-toggle"></i>
</div>
<div class="speaker-legend-content">
<div v-for="(speaker, index) in processedTranscription.speakers"
:key="`${selectedRecording.id}-speaker-legend-${index}`"
:class="['speaker-legend-item', speaker.color]">
<span class="speaker-name">${speaker.name}</span>
</div>
</div>
</div>
<!-- Transcription Display (hide if it's an error) -->
<div v-if="selectedRecording.transcription && !processedTranscription.isError">
<!-- Simple View -->
<div v-if="!processedTranscription.hasDialogue || transcriptionViewMode === 'simple'"
class="transcription-simple-view">
<div v-if="processedTranscription.hasDialogue">
<div v-for="(segment, index) in processedTranscription.simpleSegments"
:key="`seg-${index}-${segment.startTime}`"
:class="['speaker-segment', { 'active-playing-segment': currentPlayingSegmentIndex === index }]"
@click="seekAudioFromEvent"
:data-start-time="segment.startTime"
:data-segment-index="index">
<div v-if="segment.showSpeaker"
:class="['speaker-tablet', segment.color]">
${segment.speaker}
</div>
<div class="speaker-text">
${segment.sentence}
</div>
</div>
</div>
<div v-else>
<div v-if="processedTranscription.simpleSegments && processedTranscription.simpleSegments.length > 0">
<div v-for="(segment, index) in processedTranscription.simpleSegments"
:key="`seg-${index}-${segment.startTime}`"
:class="['transcript-segment cursor-pointer hover:bg-[var(--bg-accent-hover)] p-2 rounded transition-colors', { 'active-playing-segment': currentPlayingSegmentIndex === index }]"
@click="seekAudioFromEvent"
:data-start-time="segment.startTime"
:data-segment-index="index">
${segment.sentence}
</div>
</div>
<div v-else class="whitespace-pre-wrap">
${processedTranscription.content}
</div>
</div>
</div>
<!-- Bubble View -->
<div v-else-if="transcriptionViewMode === 'bubble'"
class="transcription-with-speakers">
<template v-for="(row, rowIndex) in processedTranscription.bubbleRows" :key="`${selectedRecording.id}-bubble-row-${rowIndex}`">
<div :class="['bubble-row', row.isMe ? 'speaker-me' : '']">
<div v-for="(bubble, bubbleIndex) in row.bubbles"
:key="`bubble-${rowIndex}-${bubbleIndex}-${bubble.startTime}`"
:class="['speaker-bubble', bubble.color, row.isMe ? 'speaker-me' : '', { 'active-playing-segment': currentPlayingSegmentIndex === getBubbleGlobalIndex(rowIndex, bubbleIndex) }]"
@click="seekAudioFromEvent"
:data-start-time="bubble.startTime"
:data-segment-index="getBubbleGlobalIndex(rowIndex, bubbleIndex)">
<div class="speaker-bubble-content">
${bubble.sentence}
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,12 @@
<!-- Empty State -->
<div v-if="currentView === 'detail' && !selectedRecording" class="flex-1 flex items-center justify-center">
<div class="text-center">
<i class="fas fa-microphone text-6xl text-[var(--text-muted)] mb-4"></i>
<h2 class="text-2xl font-bold mb-2" v-text="t('colorScheme.selectRecording')"></h2>
<p class="text-[var(--text-muted)] mb-6" v-text="t('colorScheme.chooseRecording')"></p>
<button @click="switchToUploadView"
class="px-6 py-3 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors">
<i class="fas fa-plus mr-2"></i>Upload New Recording
</button>
</div>
</div>

View File

@@ -0,0 +1,59 @@
<!-- Mobile Chat Panel -->
<div v-if="mobileTab === 'chat'" class="h-full flex flex-col rounded-lg border border-[var(--border-primary)] overflow-hidden relative">
<!-- Clear button - fixed at top right -->
<button v-if="chatMessages.length > 0"
@click="clearChat"
class="absolute top-2 right-2 p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:text-[var(--text-danger)] rounded transition-all duration-200 z-20"
title="Clear chat">
<i class="fas fa-times text-xs"></i>
</button>
<div ref="chatMessagesRef" class="flex-1 overflow-y-auto p-4 space-y-4 bg-[var(--bg-secondary)]">
<div v-if="chatMessages.length === 0" class="text-center py-8">
<i class="fas fa-robot text-3xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)]" v-text="t('help.askAboutTranscription')"></p>
</div>
<div v-for="(message, index) in chatMessages" :key="index"
:class="['message relative group', message.role === 'user' ? 'user-message ml-auto' : 'ai-message',
message.role === 'assistant' ? 'pr-10' : '']">
<!-- Copy button for assistant messages -->
<button v-if="message.role === 'assistant'"
@click="copyMessage(message.content, $event)"
class="absolute top-2 right-2 p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded transition-all duration-200"
:title="t('buttons.copyMessage')">
<i class="fas fa-copy text-sm"></i>
</button>
<!-- Show thinking content if available -->
<div v-if="message.thinking && message.role === 'assistant'" class="mb-2">
<button @click="message.thinkingExpanded = !message.thinkingExpanded"
class="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] flex items-center gap-1">
<i :class="['fas', message.thinkingExpanded ? 'fa-chevron-down' : 'fa-chevron-right']"></i>
<span v-text="t('help.modelReasoning')"></span>
<span class="text-[var(--text-muted)]">(${message.thinking.split('\n').length} lines)</span>
</button>
<div v-if="message.thinkingExpanded"
class="mt-2 p-3 bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-lg text-xs text-[var(--text-muted)] max-h-64 overflow-y-auto">
<pre class="whitespace-pre-wrap font-mono">${message.thinking}</pre>
</div>
</div>
<!-- Main message content -->
<div v-if="message.html" v-html="message.html"></div>
<div v-else class="whitespace-pre-wrap">${message.content}</div>
</div>
<div v-if="isChatLoading" class="ai-message">
<i class="fas fa-spinner fa-spin mr-2"></i> ${ t('chat.thinking') }
</div>
</div>
<div class="border-t border-[var(--border-primary)] p-4 bg-[var(--bg-tertiary)]">
<div class="flex gap-2">
<textarea v-model="chatInput" ref="chatInputRef" @keydown="handleChatKeydown" :disabled="selectedRecording.status !== 'COMPLETED' || processedTranscription.isError" :placeholder="t('chat.placeholder')" class="flex-1 px-3 py-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] text-sm" rows="2"></textarea>
<button @click="sendChatMessage" :disabled="!chatInput.trim() || isChatLoading || selectedRecording.status !== 'COMPLETED' || processedTranscription.isError" class="px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-paper-plane"></i>
</button>
</div>
<p v-if="processedTranscription.isError" class="text-xs text-amber-500 mt-2">
<i class="fas fa-exclamation-triangle mr-1"></i>
${ t('chat.cannotChatTranscriptionFailed') }
</p>
</div>
</div>

View File

@@ -0,0 +1,65 @@
<!-- Mobile Events Panel -->
<div v-if="mobileTab === 'events' && selectedRecording.events && selectedRecording.events.length > 0" class="h-full flex flex-col space-y-4">
<div class="flex items-center justify-end gap-2 flex-shrink-0 pr-3">
<button @click="downloadICS" v-if="selectedRecording.events && selectedRecording.events.length > 0"
class="copy-btn"
:title="t('buttons.exportCalendar')">
<i class="fas fa-calendar-plus"></i>
</button>
</div>
<div class="flex-grow overflow-y-auto mobile-content-box">
<div class="space-y-3">
<div v-for="event in selectedRecording.events" :key="event.id"
class="bg-[var(--bg-tertiary)] rounded-lg p-3 border border-[var(--border-primary)]">
<div class="flex justify-between items-start mb-2">
<div class="flex-1 min-w-0 pr-2">
<h3 class="text-base font-semibold text-[var(--text-primary)] mb-1">
<i class="fas fa-calendar-check mr-2 text-[var(--text-accent)] text-sm"></i>
${ event.title }
</h3>
<p v-if="event.description" class="text-xs text-[var(--text-secondary)] mb-2">
${ event.description }
</p>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<button @click="downloadEventICS(event)"
class="px-2 py-1 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors text-xs flex items-center gap-1"
:title="t('events.addToCalendar')">
<i class="fas fa-download text-xs"></i>
<span v-text="t('events.add')"></span>
</button>
<button @click="deleteEvent(event)"
class="p-1.5 text-[var(--text-muted)] hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
:title="t('events.delete')">
<i class="fas fa-trash-alt text-xs"></i>
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-2 text-xs">
<div class="flex items-center text-[var(--text-muted)]">
<i class="fas fa-clock mr-2 text-[var(--text-accent)]"></i>
<span>
${ formatEventDateTime(event.start_datetime) }
<template v-if="event.end_datetime">
- ${ formatEventDateTime(event.end_datetime, true) }
</template>
</span>
</div>
<div v-if="event.location" class="flex items-center text-[var(--text-muted)]">
<i class="fas fa-map-marker-alt mr-2 text-[var(--text-accent)]"></i>
<span>${ event.location }</span>
</div>
<div v-if="event.attendees && event.attendees.length > 0" class="flex items-start text-[var(--text-muted)]">
<i class="fas fa-users mr-2 text-[var(--text-accent)] mt-0.5"></i>
<div class="flex-1">
<span v-for="(attendee, index) in event.attendees" :key="index"
class="inline-block bg-[var(--bg-secondary)] px-2 py-0.5 rounded mr-1 mb-1">
${ attendee }
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,153 @@
<!-- Mobile Header for Detail View -->
<div class="bg-[var(--bg-secondary)] border-b border-[var(--border-primary)] p-4 flex-shrink-0">
<div @click="isMetadataExpanded = !isMetadataExpanded" class="cursor-pointer">
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1">
<h1 v-if="!editingTitle"
@dblclick.stop="selectedRecording.can_edit !== false ? toggleEditTitle() : null"
:class="[
'text-lg font-bold truncate',
selectedRecording.is_shared ? 'text-[var(--text-accent)]' : 'text-[var(--text-primary)]',
selectedRecording.can_edit !== false ? 'cursor-text hover:opacity-80 transition-opacity' : ''
]"
:title="selectedRecording.can_edit !== false ? 'Double-click to edit' : selectedRecording.title || 'Untitled Recording'">
${selectedRecording.title || 'Untitled Recording'}
</h1>
<input v-else
v-model="selectedRecording.title"
@blur="saveTitle"
@keyup.enter="saveTitle"
@keyup.esc="cancelEditTitle"
@click.stop
ref="titleInput"
class="text-lg font-bold bg-transparent border-b-2 border-[var(--border-focus)] focus:outline-none text-[var(--text-primary)] flex-1 px-1"
placeholder="Untitled Recording">
<button v-if="!editingTitle && selectedRecording.can_edit !== false"
@click.stop="toggleEditTitle"
class="p-1.5 text-[var(--text-muted)] hover:text-[var(--text-accent)] transition-colors flex-shrink-0">
<i class="fas fa-pen text-xs"></i>
</button>
<!-- Status Badge (for non-completed recordings) -->
<span v-if="!editingTitle && selectedRecording.status !== 'COMPLETED'"
:class="getStatusClass(selectedRecording.status)"
class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full whitespace-nowrap flex-shrink-0">
${formatStatus(selectedRecording.status)}
</span>
</div>
<p class="text-sm text-[var(--text-muted)] truncate">
${selectedRecording.participants || t('help.noParticipants')}
</p>
<!-- Folder Pill, Tags and Share Status -->
<div v-if="(foldersEnabled && selectedRecording.folder_id) || getRecordingTags(selectedRecording).length > 0 || selectedRecording.is_shared || selectedRecording.shared_with_count > 0 || selectedRecording.public_share_count > 0" class="flex flex-wrap gap-1 mt-2">
<!-- Folder Pill -->
<span v-if="foldersEnabled && selectedRecording.folder_id && !selectedRecording.incognito"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
:style="{ backgroundColor: getFolderColor(selectedRecording.folder_id), color: getContrastTextColor(getFolderColor(selectedRecording.folder_id)) }"
:title="'Folder: ' + getFolderName(selectedRecording.folder_id)">
<i class="fas fa-folder mr-1" style="vertical-align: middle; line-height: 0;"></i>
${ getFolderName(selectedRecording.folder_id) }
</span>
<button v-for="tag in getRecordingTags(selectedRecording)" :key="tag.id"
@click.stop="filterByTag(tag)"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium hover:opacity-80 transition-all cursor-pointer"
:style="{ backgroundColor: tag.color, color: getContrastTextColor(tag.color) }"
:title="tag.group_id ? ('Group: ' + tag.group_name) : tag.name">
<i v-if="tag.group_id" class="fas fa-users mr-1" style="vertical-align: middle; line-height: 0;"></i>
<i v-else class="fas fa-tag mr-1" style="vertical-align: middle; line-height: 0;"></i>
<span v-text="tag.name"></span>
</button>
<span v-if="selectedRecording.is_shared" class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-500 text-white">
<i class="fas fa-arrow-down mr-1"></i>Shared
</span>
<span v-if="!selectedRecording.is_shared && selectedRecording.shared_with_count > 0" class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-indigo-500 text-white">
<i class="fas fa-arrow-up mr-1"></i>${selectedRecording.shared_with_count}
</span>
<span v-if="!selectedRecording.is_shared && selectedRecording.public_share_count > 0" class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-teal-500 text-white">
<i class="fas fa-globe mr-1"></i>${selectedRecording.public_share_count}
</span>
</div>
</div>
<!-- Expand/Collapse Button -->
<button @click.stop="isMetadataExpanded = !isMetadataExpanded"
class="flex items-center justify-center w-8 h-8 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-muted)] hover:bg-[var(--bg-accent-hover)] hover:text-[var(--text-accent)] transition-colors flex-shrink-0">
<i :class="['fas', 'fa-chevron-down', 'text-sm', 'transition-transform', { 'rotate-180': isMetadataExpanded }]"></i>
</button>
</div>
</div>
<!-- Expandable Metadata and Actions -->
<div v-if="isMetadataExpanded" class="mt-4 space-y-4">
<div class="space-y-2 text-sm text-[var(--text-muted)]">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<div class="flex items-center gap-2" @click.stop="openMeetingDatePicker">
<i class="fas fa-calendar text-[var(--text-accent)]"></i>
<span class="cursor-pointer hover:text-[var(--text-accent)] transition-colors">${selectedRecording.meeting_date ? formatDisplayDate(selectedRecording.meeting_date) : 'No date set'}</span>
</div>
<div v-if="selectedRecording.is_shared" class="flex items-center gap-2">
<i class="fas fa-user text-[var(--text-accent)]"></i>
<span :title="'Owner: ' + (selectedRecording.owner_username || t('sharing.unknown'))">
${selectedRecording.owner_username || t('sharing.unknown')}
</span>
</div>
<template v-if="activeRecordingMetadata" v-for="(item, index) in activeRecordingMetadata" :key="index">
<span v-if="!item.isTagItem" class="flex items-center gap-1.5">
<i :class="item.icon"></i>
<span :title="item.fullText || item.text">${item.text}</span>
</span>
</template>
</div>
<!-- Duplicate Indicator -->
<button v-if="selectedRecording.duplicate_info"
@click.stop="openDuplicatesModal(selectedRecording.duplicate_info)"
class="flex items-center gap-1.5 text-sm text-amber-500 hover:text-amber-400 transition-colors cursor-pointer mt-2">
<i class="fas fa-copy"></i>
<span>${ selectedRecording.duplicate_info.total_copies } ${ t('upload.copies') || 'copies' }</span>
</button>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2 overflow-x-auto pb-2">
<!-- Incognito recordings have limited actions -->
<template v-if="!selectedRecording.incognito">
<!-- Folder Assignment (icon-only dropdown matching other buttons) -->
<div v-if="foldersEnabled && selectedRecording.can_edit !== false"
class="relative p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors flex-shrink-0"
:title="selectedRecording.folder_id ? getFolderName(selectedRecording.folder_id) : 'Assign Folder'">
<select @change="assignFolderToRecording(selectedRecording.id, $event.target.value || null)"
:value="selectedRecording.folder_id || ''"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
<option value="">No Folder</option>
<option v-for="folder in availableFolders" :key="folder.id" :value="folder.id">
${ folder.name }
</option>
</select>
<i class="fas fa-folder"
:style="{ color: selectedRecording.folder_id ? getFolderColor(selectedRecording.folder_id) : '' }"></i>
</div>
<button @click="toggleInbox(selectedRecording)" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors" :class="selectedRecording.is_inbox ? 'text-blue-500' : ''"><i class="fas fa-inbox"></i></button>
<button @click="toggleHighlight(selectedRecording)" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors" :class="selectedRecording.is_highlighted ? 'text-yellow-500' : ''"><i class="fas fa-star"></i></button>
<button @click="editRecordingTags(selectedRecording)" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"><i class="fas fa-tags"></i></button>
<button @click="confirmReprocess('transcription', selectedRecording)" v-if="selectedRecording && selectedRecording.can_edit !== false && (selectedRecording.status === 'COMPLETED' || selectedRecording.status === 'FAILED')" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"><i class="fas fa-redo-alt"></i></button>
<button @click="confirmReprocess('summary', selectedRecording)" v-if="selectedRecording && selectedRecording.can_edit !== false && (selectedRecording.status === 'COMPLETED' || selectedRecording.status === 'FAILED')" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"><i class="fas fa-sync-alt"></i></button>
<button @click="confirmReset(selectedRecording)" v-if="['PENDING', 'PROCESSING', 'SUMMARIZING', 'FAILED'].includes(selectedRecording.status)" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors text-orange-500"><i class="fas fa-undo"></i></button>
<button @click="openSpeakerModal" v-if="processedTranscription.hasDialogue" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"><i class="fas fa-user-tag"></i></button>
<button v-if="!selectedRecording.is_shared || (selectedRecording.share_info && selectedRecording.share_info.can_reshare)" @click="openUnifiedShareModal(selectedRecording)" class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"><i class="fas fa-share-alt"></i></button>
<button v-if="canDeleteRecordings && selectedRecording.can_delete !== false" @click="confirmDelete(selectedRecording)" class="p-2 rounded-lg hover:bg-[var(--bg-danger-light)] text-[var(--text-danger)] transition-colors"><i class="fas fa-trash"></i></button>
</template>
<!-- Incognito mode: only show discard button -->
<template v-else>
<span class="text-xs text-amber-600 dark:text-amber-400 px-2">
<i class="fas fa-user-secret mr-1"></i>
Incognito Mode
</span>
<button @click="clearIncognitoRecordingWithConfirm"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 transition-colors"
title="Discard incognito recording">
<i class="fas fa-trash"></i>
</button>
</template>
</div>
</div>
</div>

View File

@@ -0,0 +1,26 @@
<!-- Mobile Notes Panel -->
<div v-if="mobileTab === 'notes'" class="h-full flex flex-col space-y-4">
<div class="flex items-center justify-end gap-2 flex-shrink-0 pr-3">
<button @click="copyNotes" class="copy-btn" :title="t('buttons.copyNotes')"><i class="fas fa-copy"></i></button>
<button @click="downloadNotes" class="copy-btn" :title="t('buttons.downloadAsWord')"><i class="fas fa-download"></i></button>
<button @click="toggleEditNotes" class="copy-btn" :title="t('buttons.editNotes')"><i class="fas fa-edit"></i></button>
</div>
<div class="flex-grow overflow-y-auto mobile-content-box">
<div v-if="editingNotes" class="h-full flex flex-col">
<div class="markdown-editor-container flex-1">
<textarea ref="notesMarkdownEditor" v-model="selectedRecording.notes"></textarea>
</div>
<div class="flex justify-end gap-2 mt-2">
<button @click="cancelEditNotes" class="px-3 py-1 text-sm bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded hover:bg-[var(--bg-tertiary)]" v-text="t('common.cancel')"></button>
<button @click="saveEditNotes" class="px-3 py-1 text-sm bg-[var(--bg-button)] text-[var(--text-button)] border border-transparent rounded hover:bg-[var(--bg-button-hover)]" v-text="t('common.save')"></button>
</div>
</div>
<div v-else @click="clickToEditNotes">
<div v-if="!selectedRecording.notes" class="text-center py-8 cursor-pointer hover:text-[var(--text-secondary)]">
<i class="fas fa-sticky-note text-3xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)]" v-text="t('help.clickToAddNotes')"></p>
</div>
<div v-else class="notes-box h-full" v-html="selectedRecording.notes_html || selectedRecording.notes"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,45 @@
<!-- Mobile Summary Panel -->
<div v-if="mobileTab === 'summary'" class="h-full flex flex-col space-y-4">
<div class="flex items-center justify-end gap-2 flex-shrink-0 pr-3">
<button @click="copySummary" class="copy-btn" :title="t('buttons.copySummary')"><i class="fas fa-copy"></i></button>
<button @click="downloadSummary" class="copy-btn" :title="t('buttons.downloadAsWord')"><i class="fas fa-download"></i></button>
<button @click="toggleEditSummary" class="copy-btn" :title="t('buttons.editSummary')"><i class="fas fa-edit"></i></button>
</div>
<div class="flex-grow overflow-y-auto mobile-content-box">
<div v-if="editingSummary" class="h-full flex flex-col">
<div class="markdown-editor-container flex-1">
<textarea ref="summaryMarkdownEditor" v-model="selectedRecording.summary"></textarea>
</div>
<div class="flex justify-end gap-2 mt-2">
<button @click="cancelEditSummary" class="px-3 py-1 text-sm bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-secondary)] rounded hover:bg-[var(--bg-tertiary)]" v-text="t('common.cancel')"></button>
<button @click="saveEditSummary" class="px-3 py-1 text-sm bg-[var(--bg-button)] text-[var(--text-button)] border border-transparent rounded hover:bg-[var(--bg-button-hover)]" v-text="t('common.save')"></button>
</div>
</div>
<div v-else>
<div v-if="selectedRecording.status === 'SUMMARIZING'" class="text-center py-8">
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-muted)]"></i>
<p class="mt-2 text-[var(--text-muted)]" v-text="t('help.generatingSummary')"></p>
</div>
<div v-else-if="!selectedRecording.summary" class="text-center py-8">
<i class="fas fa-file-alt text-3xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)] mb-4" v-text="t('summary.noSummary')"></p>
<!-- Show message if transcription is an error -->
<p v-if="processedTranscription.isError" class="text-sm text-amber-500 mb-2">
<i class="fas fa-exclamation-triangle mr-1"></i>
Cannot generate summary: transcription failed
</p>
<button @click="generateSummary"
:disabled="processedTranscription.isError"
:class="[
'px-6 py-3 font-medium rounded-lg shadow-lg transition-all duration-200',
processedTranscription.isError
? 'bg-gray-400 text-gray-200 cursor-not-allowed'
: 'bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:shadow-xl hover:from-blue-700 hover:to-purple-700 transform hover:scale-105'
]">
<i class="fas fa-magic mr-2"></i>Generate Summary
</button>
</div>
<div v-else class="summary-box h-full" v-html="selectedRecording.summary_html || selectedRecording.summary"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,130 @@
<!-- Mobile Transcript Panel -->
<div v-if="mobileTab === 'transcript'" class="h-full flex flex-col space-y-4">
<div class="flex items-center justify-between gap-2 flex-shrink-0 px-3">
<!-- Follow Player Checkbox -->
<div v-if="processedTranscription.isJson && processedTranscription.hasDialogue"
class="follow-player-control text-[var(--text-muted)]"
@click="toggleFollowPlayerMode"
:title="followPlayerMode ? t('tooltips.followPlayerEnabled') : t('tooltips.followPlayerDisabled')">
<input type="checkbox"
:checked="followPlayerMode"
@click.stop="toggleFollowPlayerMode">
<i class="fas fa-arrows-alt-v follow-icon"></i>
</div>
<div class="flex items-center gap-2">
<div v-if="processedTranscription.hasDialogue" class="view-mode-toggle">
<button @click="toggleTranscriptionViewMode" :class="['toggle-button', transcriptionViewMode === 'simple' ? 'active' : '']"><i class="fas fa-list"></i></button>
<button @click="toggleTranscriptionViewMode" :class="['toggle-button', transcriptionViewMode === 'bubble' ? 'active' : '']"><i class="fas fa-comments"></i></button>
</div>
<button @click="copyTranscription" class="copy-btn" :title="t('tooltips.copyTranscript')"><i class="fas fa-copy"></i></button>
<button @click="downloadTranscript" v-if="selectedRecording && selectedRecording.transcription" class="copy-btn" :title="t('tooltips.downloadTranscriptWithTemplate')"><i class="fas fa-download"></i></button>
<button @click="openTranscriptionEditor" v-if="selectedRecording && selectedRecording.transcription" class="copy-btn" :title="t('tooltips.editTranscript')"><i class="fas fa-edit"></i></button>
</div>
</div>
<div class="flex-grow overflow-y-auto mobile-content-box relative">
<!-- Floating Processing Indicator (Mobile) -->
<div v-if="selectedRecording.status === 'PROCESSING'"
:class="['processing-indicator-floating', processingIndicatorMinimized ? 'minimized' : '']">
<template v-if="!processingIndicatorMinimized">
<div class="processing-indicator-content">
<i class="fas fa-spinner fa-spin text-[var(--text-accent)]"></i>
<span class="text-sm text-[var(--text-secondary)]" v-text="t('help.processingTranscription')"></span>
</div>
<button @click="processingIndicatorMinimized = true"
class="processing-indicator-minimize"
:title="t('tooltips.minimize')">
<i class="fas fa-chevron-up"></i>
</button>
</template>
<template v-else>
<button @click="processingIndicatorMinimized = false"
class="processing-indicator-expand"
:title="t('help.processingTranscription')">
<i class="fas fa-spinner fa-spin"></i>
</button>
</template>
</div>
<!-- No transcription state (only show if not processing and no transcription) -->
<div v-if="!selectedRecording.transcription && selectedRecording.status !== 'PROCESSING'" class="text-center py-8">
<i class="fas fa-file-text text-3xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)]" v-text="t('transcription.noTranscription')"></p>
</div>
<!-- Error Display (when transcription is an error message) -->
<div v-if="processedTranscription.isError" class="error-display-container p-2">
<div :class="[
'rounded-lg p-4 border',
processedTranscription.error.type === 'size_limit' ? 'bg-amber-500/10 border-amber-500/30' :
processedTranscription.error.type === 'timeout' ? 'bg-orange-500/10 border-orange-500/30' :
processedTranscription.error.type === 'auth' ? 'bg-red-500/10 border-red-500/30' :
'bg-gray-500/10 border-gray-500/30'
]">
<div class="flex items-start gap-3">
<div :class="[
'flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center',
processedTranscription.error.type === 'size_limit' ? 'bg-amber-500/20' :
processedTranscription.error.type === 'timeout' ? 'bg-orange-500/20' :
processedTranscription.error.type === 'auth' ? 'bg-red-500/20' :
'bg-gray-500/20'
]">
<i :class="[
'fas',
processedTranscription.error.icon,
processedTranscription.error.type === 'size_limit' ? 'text-amber-500' :
processedTranscription.error.type === 'timeout' ? 'text-orange-500' :
processedTranscription.error.type === 'auth' ? 'text-red-500' :
'text-gray-500'
]"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-[var(--text-primary)] mb-1">
${processedTranscription.error.title}
</h3>
<p class="text-sm text-[var(--text-secondary)] mb-2">
${processedTranscription.error.message}
</p>
<div v-if="processedTranscription.error.guidance" class="text-xs text-[var(--text-muted)] bg-[var(--bg-tertiary)]/50 rounded p-2">
<i class="fas fa-lightbulb text-yellow-500 mr-1"></i>
${processedTranscription.error.guidance}
</div>
</div>
</div>
<div class="mt-3 pt-3 border-t border-[var(--border-secondary)]">
<button @click="reprocessTranscription(selectedRecording.id)"
v-if="selectedRecording.can_edit !== false"
class="w-full px-3 py-2 bg-[var(--bg-accent)] hover:bg-[var(--bg-accent-hover)] text-white rounded-lg transition-colors text-sm">
<i class="fas fa-redo-alt mr-2"></i>Reprocess
</button>
</div>
</div>
</div>
<!-- Transcription content (show regardless of processing state if transcription exists) -->
<div v-if="selectedRecording.transcription && !processedTranscription.isError">
<div v-if="!processedTranscription.hasDialogue || transcriptionViewMode === 'simple'" class="transcription-simple-view">
<div v-if="processedTranscription.hasDialogue">
<div v-for="(segment, index) in processedTranscription.simpleSegments" :key="`seg-${index}-${segment.startTime}`" :class="['speaker-segment', { 'active-playing-segment': currentPlayingSegmentIndex === index }]" @click="seekAudioFromEvent" :data-start-time="segment.startTime" :data-segment-index="index">
<div v-if="segment.showSpeaker" :class="['speaker-tablet', segment.color]">${segment.speaker}</div>
<div class="speaker-text">${segment.sentence}</div>
</div>
</div>
<div v-else>
<div v-if="processedTranscription.simpleSegments && processedTranscription.simpleSegments.length > 0">
<div v-for="(segment, index) in processedTranscription.simpleSegments" :key="`seg-${index}-${segment.startTime}`" :class="['transcript-segment cursor-pointer hover:bg-[var(--bg-accent-hover)] p-2 rounded transition-colors', { 'active-playing-segment': currentPlayingSegmentIndex === index }]" @click="seekAudioFromEvent" :data-start-time="segment.startTime" :data-segment-index="index">${segment.sentence}</div>
</div>
<div v-else class="whitespace-pre-wrap">${processedTranscription.content}</div>
</div>
</div>
<div v-else-if="transcriptionViewMode === 'bubble'" class="transcription-with-speakers">
<template v-for="(row, rowIndex) in processedTranscription.bubbleRows" :key="`${selectedRecording.id}-bubble-row-${rowIndex}`">
<div :class="['bubble-row', row.isMe ? 'speaker-me' : '']">
<div v-for="(bubble, bubbleIndex) in row.bubbles" :key="`bubble-${rowIndex}-${bubbleIndex}-${bubble.startTime}`" :class="['speaker-bubble', bubble.color, row.isMe ? 'speaker-me' : '', { 'active-playing-segment': currentPlayingSegmentIndex === getBubbleGlobalIndex(rowIndex, bubbleIndex) }]" @click="seekAudioFromEvent" :data-start-time="bubble.startTime" :data-segment-index="getBubbleGlobalIndex(rowIndex, bubbleIndex)">
<div class="speaker-bubble-content">${bubble.sentence}</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,20 @@
<!-- Tab Navigation for Mobile -->
<div class="flex-shrink-0 bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)] flex overflow-x-auto">
<button @click="mobileTab = 'transcript'"
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors', mobileTab === 'transcript' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']"
v-text="t('transcription.title')"></button>
<button @click="mobileTab = 'summary'"
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors', mobileTab === 'summary' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']"
v-text="t('summary.title')"></button>
<button @click="mobileTab = 'notes'"
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors', mobileTab === 'notes' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']"
v-text="t('notes.title')"></button>
<button @click="mobileTab = 'chat'"
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors', mobileTab === 'chat' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']"
v-text="t('chat.title')"></button>
<button v-if="selectedRecording.events && selectedRecording.events.length > 0"
@click="mobileTab = 'events'"
:class="['flex-1 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap', mobileTab === 'events' ? 'bg-[var(--bg-secondary)] text-[var(--text-accent)] border-b-2 border-[var(--border-focus)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]']">
<i class="fas fa-calendar-alt mr-1"></i>${ t('events.title') } (${ selectedRecording.events.length })
</button>
</div>