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,11 @@
<!-- Custom Banner -->
<div v-if="customBannerHtml && showBanner"
class="flex-shrink-0 bg-[var(--bg-accent)] bg-opacity-10 border-b border-[var(--border-accent)]
px-4 py-2 flex items-center gap-3 text-sm text-[var(--text-primary)]">
<i class="fas fa-bullhorn text-[var(--text-accent)] flex-shrink-0"></i>
<div class="flex-1 ai-message banner-content" v-html="customBannerHtml"></div>
<button @click="showBanner = false"
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] flex-shrink-0">
<i class="fas fa-times"></i>
</button>
</div>

View File

@@ -0,0 +1,57 @@
<!-- Detail View Container -->
<div v-if="currentView === 'detail' && selectedRecording" class="flex-1 flex flex-col overflow-hidden">
<!-- Incognito Mode Indicator Bar -->
<div v-if="selectedRecording.incognito" class="bg-gradient-to-r from-violet-500/10 via-purple-500/10 to-violet-500/10 border-b border-violet-300/30 dark:border-violet-500/30 px-4 py-2 flex-shrink-0">
<div class="flex items-center justify-between gap-4 max-w-4xl mx-auto">
<div class="flex items-center gap-3">
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center">
<i class="fas fa-user-secret text-white text-xs"></i>
</div>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-violet-700 dark:text-violet-300">Incognito</span>
<span class="text-xs text-violet-600/80 dark:text-violet-400/80">
&bull; Session only &bull; Not saved to account
</span>
</div>
</div>
<button @click="clearIncognitoRecordingWithConfirm"
class="flex-shrink-0 px-2.5 py-1 text-xs font-medium text-violet-600 dark:text-violet-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title="Discard this recording">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- Mobile View -->
<div v-if="isMobile" class="flex flex-col h-full overflow-hidden">
{% include 'components/detail/mobile-header.html' %}
{% include 'components/detail/audio-player.html' %}
{% include 'components/detail/tab-navigation.html' %}
<!-- Mobile Tab Content -->
<div class="flex-1 overflow-y-auto p-4">
{% include 'components/detail/mobile-transcript-panel.html' %}
{% include 'components/detail/mobile-summary-panel.html' %}
{% include 'components/detail/mobile-notes-panel.html' %}
{% include 'components/detail/mobile-chat-panel.html' %}
{% include 'components/detail/mobile-events-panel.html' %}
</div>
</div>
<!-- Desktop View -->
<div v-else class="flex-1 flex flex-col overflow-hidden">
{% include 'components/detail/desktop-header.html' %}
<!-- Main Content Split View -->
<div id="mainContentColumns" class="flex-1 flex overflow-hidden">
{% include 'components/detail/desktop-transcription-panel.html' %}
<!-- Resizable Divider -->
<div id="mainColumnResizer" @mousedown="startColumnResize"></div>
{% include 'components/detail/desktop-right-panel.html' %}
</div>
</div>
</div>
{% include 'components/detail/empty-state.html' %}

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>

View File

@@ -0,0 +1,91 @@
<div id="content-help" class="hidden tab-content pt-0" style="height: calc(100vh - 280px); margin: 0 -1rem -1rem -1rem;">
<div class="flex h-full">
<!-- Sidebar Navigation -->
<div id="help-sidebar" class="w-72 border-r border-[var(--border-primary)] bg-[var(--bg-secondary)] flex flex-col flex-shrink-0 h-full overflow-hidden">
<!-- Search -->
<div class="p-3 border-b border-[var(--border-primary)]">
<div class="relative">
<input type="text" id="help-search" placeholder="Rechercher dans la doc..."
class="w-full pl-9 pr-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--border-accent)] focus:border-transparent">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)] text-xs"></i>
</div>
<!-- Search results dropdown -->
<div id="help-search-results" class="hidden mt-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg shadow-lg max-h-64 overflow-y-auto"></div>
</div>
<!-- Navigation tree -->
<nav id="help-nav" class="flex-1 overflow-y-auto p-3 space-y-1">
<!-- Populated by JS -->
</nav>
<!-- Home link -->
<div class="p-3 border-t border-[var(--border-primary)]">
<button onclick="loadDocPage('', 'index')" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<i class="fas fa-home text-xs"></i>
<span>Accueil documentation</span>
</button>
</div>
</div>
<!-- Mobile sidebar toggle -->
<button id="help-sidebar-toggle" class="hidden fixed bottom-4 left-4 z-50 bg-[var(--bg-accent)] text-white p-3 rounded-full shadow-lg sm:hidden" onclick="toggleHelpSidebar()">
<i class="fas fa-bars"></i>
</button>
<!-- Main Content Area -->
<div id="help-content-area" class="flex-1 overflow-y-auto">
<!-- Loading state -->
<div id="help-loading" class="hidden flex items-center justify-center h-full">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-2xl text-[var(--text-accent)] mb-3"></i>
<p class="text-sm text-[var(--text-muted)]">Chargement...</p>
</div>
</div>
<!-- Welcome state (shown initially) -->
<div id="help-welcome" class="flex items-center justify-center h-full p-8">
<div class="text-center max-w-lg">
<i class="fas fa-book-open text-5xl text-[var(--text-accent)] mb-6"></i>
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-3">Documentation DictIA</h2>
<p class="text-[var(--text-secondary)] mb-6">Bienvenue dans la documentation int&eacute;gr&eacute;e de DictIA. S&eacute;lectionnez une page dans le menu de gauche pour commencer.</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button onclick="loadDocPage('guide-utilisateur', 'index')" class="flex flex-col items-center p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:border-[var(--border-accent)] hover:shadow-md transition-all">
<i class="fas fa-book-open text-xl text-[var(--text-accent)] mb-2"></i>
<span class="text-sm font-medium text-[var(--text-primary)]">Guide Utilisateur</span>
</button>
<button onclick="loadDocPage('depannage', 'index')" class="flex flex-col items-center p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:border-[var(--border-accent)] hover:shadow-md transition-all">
<i class="fas fa-life-ring text-xl text-[var(--text-accent)] mb-2"></i>
<span class="text-sm font-medium text-[var(--text-primary)]">D&eacute;pannage</span>
</button>
<button id="help-admin-card" onclick="loadDocPage('guide-admin', 'index')" class="hidden flex-col items-center p-4 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border-primary)] hover:border-[var(--border-accent)] hover:shadow-md transition-all">
<i class="fas fa-shield-alt text-xl text-[var(--text-accent)] mb-2"></i>
<span class="text-sm font-medium text-[var(--text-primary)]">Guide Admin</span>
</button>
</div>
</div>
</div>
<!-- Document content -->
<div id="help-doc-content" class="hidden">
<!-- Breadcrumb -->
<div id="help-breadcrumb" class="sticky top-0 z-10 bg-[var(--bg-primary)] border-b border-[var(--border-primary)] px-8 py-3">
<div class="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<button onclick="showHelpWelcome()" class="hover:text-[var(--text-accent)]"><i class="fas fa-home"></i></button>
<i class="fas fa-chevron-right text-xs"></i>
<span id="help-breadcrumb-section"></span>
<i class="fas fa-chevron-right text-xs"></i>
<span id="help-breadcrumb-page" class="text-[var(--text-primary)]"></span>
</div>
</div>
<!-- Rendered content -->
<div id="help-rendered-content" class="prose prose-sm max-w-none px-8 py-6">
<!-- Rendered markdown HTML goes here -->
</div>
<!-- Navigation footer -->
<div id="help-nav-footer" class="border-t border-[var(--border-primary)] px-8 py-4 flex justify-between">
<button id="help-prev-page" class="hidden flex items-center gap-2 text-sm text-[var(--text-accent)] hover:underline" onclick="navigateDocPrev()">
<i class="fas fa-arrow-left"></i> <span id="help-prev-title"></span>
</button>
<div></div>
<button id="help-next-page" class="hidden flex items-center gap-2 text-sm text-[var(--text-accent)] hover:underline" onclick="navigateDocNext()">
<span id="help-next-title"></span> <i class="fas fa-arrow-right"></i>
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,99 @@
<!-- Header Component -->
<header class="bg-[var(--bg-secondary)] border-b border-[var(--border-primary)] px-3 sm:px-4 py-0 flex items-center justify-between flex-shrink-0 z-50">
<!-- Left side: Menu toggle and logo -->
<div class="flex items-center gap-2 sm:gap-4 min-w-0 flex-shrink">
<!-- Menu Toggle Button -->
<button @click="toggleSidebar"
class="p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors duration-200 flex-shrink-0 flex items-center justify-center">
<i class="fas fa-bars text-lg"></i>
</button>
<!-- Logo and Title -->
<div class="flex items-center gap-2 sm:gap-3 min-w-0">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="w-14 h-14 sm:w-16 sm:h-16 flex-shrink-0">
<h1 class="text-lg sm:text-xl font-bold text-[var(--text-primary)] truncate">DictIA</h1>
</div>
</div>
<!-- Right side: User menu and controls -->
<div class="flex items-center gap-2 sm:gap-3">
{% include 'components/token_budget_indicator.html' %}
<!-- Inquire Mode Button -->
{% if inquire_mode_enabled %}
<a href="/inquire"
class="px-2 py-1.5 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-lg hover:opacity-80 transition-opacity text-sm sm:px-4 sm:py-2 sm:min-w-[120px] sm:flex sm:items-center sm:gap-2 sm:justify-center">
<i class="fas fa-search"></i>
<span class="hidden sm:inline" v-text="t('inquire.title')"></span>
</a>
{% endif %}
<!-- Install PWA Button -->
<button v-if="showInstallButton && !isPWAInstalled"
@click="promptInstall"
class="px-2 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm sm:px-4 sm:py-2 sm:flex sm:items-center sm:gap-2"
:title="t('pwa.installApp')">
<i class="fas fa-download"></i>
<span class="hidden sm:inline" v-text="t('pwa.installApp')"></span>
</button>
<!-- New Recording Button -->
<button @click="switchToUploadView"
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 sm:px-4 sm:py-2 sm:min-w-[120px] sm:flex sm:items-center sm:gap-2 sm:justify-center">
<i class="fas fa-plus mr-1"></i><span v-text="t('nav.newRecording')"></span>
</button>
<!-- User menu -->
{% if current_user.is_authenticated %}
<div class="relative">
<button @click="isUserMenuOpen = !isUserMenuOpen"
data-user-menu-toggle
class="flex items-center gap-1 sm:gap-2 p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors duration-200"
:title="t('admin.userMenu')">
<i class="fas fa-user-circle text-lg"></i>
<span class="hidden lg:inline text-sm">{{ (current_user.name or current_user.username) if current_user.is_authenticated else 'User' }}</span>
<i class="fas fa-chevron-down text-xs hidden sm:inline"></i>
</button>
<!-- User dropdown -->
<div v-if="isUserMenuOpen"
data-user-menu-dropdown
class="absolute right-0 mt-2 w-56 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg shadow-lg z-50">
<a href="/account" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
<i class="fas fa-cog mr-2 w-4 text-center"></i><span v-text="t('nav.settings')"></span>
</a>
<a href="/account#tags" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
<i class="fas fa-tags mr-2 w-4 text-center"></i><span v-text="t('help.tagManagement')"></span>
</a>
<button @click="openSharesList" class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
<i class="fas fa-share-alt mr-2 w-4 text-center"></i><span v-text="t('modal.sharedTranscripts')"></span>
</button>
{% if current_user.is_admin %}
<a href="/admin" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
<i class="fas fa-user-shield mr-2 w-4 text-center"></i>
<span v-text="t('admin.title')"></span>
</a>
{% elif is_group_admin %}
<a href="/group-management" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
<i class="fas fa-users-cog mr-2 w-4 text-center"></i>
<span v-text="t('nav.groupManagement')"></span>
</a>
{% endif %}
<div class="border-t border-[var(--border-primary)] my-1"></div>
<button @click="toggleDarkMode" class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
<i :class="isDarkMode ? 'fas fa-sun' : 'fas fa-moon'" class="mr-2 w-4 text-center"></i>
<span v-text="isDarkMode ? t('nav.lightMode') : t('nav.darkMode')"></span>
</button>
<button @click="openColorSchemeModal" class="w-full text-left px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors flex items-center">
<i class="fas fa-palette mr-2 w-4 text-center"></i>
<span v-text="t('modal.colorScheme')"></span>
</button>
<div class="border-t border-[var(--border-primary)] my-1"></div>
<a href="/logout" class="block px-4 py-2 hover:bg-[var(--bg-tertiary)] transition-colors">
<i class="fas fa-sign-out-alt mr-2 w-4 text-center"></i><span v-text="t('nav.signOut')"></span>
</a>
</div>
</div>
{% endif %}
</div>
</header>

View File

@@ -0,0 +1,189 @@
<!-- Processing Queue Popup - Compact Unified Progress Tracking -->
<div v-if="showProcessingPopup && !progressPopupClosed"
:class="[
'fixed bottom-4 left-4 z-[100] w-80 transition-all duration-300',
progressPopupMinimized ? 'minimized' : ''
]">
<div class="bg-[var(--bg-secondary)] border border-[var(--bg-accent)] rounded-lg shadow-xl overflow-hidden backdrop-blur-sm bg-opacity-95">
<!-- Compact Header -->
<div class="bg-gradient-to-r from-[var(--bg-accent)] to-[var(--bg-accent-hover)] px-3 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0">
<i class="fas fa-tasks text-sm text-gray-800 dark:text-white"></i>
<span class="font-medium text-sm text-gray-800 dark:text-white">Processing Queue</span>
<span class="text-xs bg-black dark:bg-white bg-opacity-20 dark:bg-opacity-20 px-1.5 py-0.5 rounded-full text-gray-800 dark:text-white font-medium">
${totalProcessingCount}
</span>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<button v-if="allCompletedCount > 0" @click="clearAllCompleted()"
class="px-1.5 py-0.5 text-xs bg-black dark:bg-white bg-opacity-10 dark:bg-opacity-20 hover:bg-opacity-20 dark:hover:bg-opacity-30 rounded transition-all text-gray-800 dark:text-white">
Clear
</button>
<button @click="progressPopupMinimized = !progressPopupMinimized"
class="p-1 rounded hover:bg-black dark:hover:bg-white hover:bg-opacity-10 dark:hover:bg-opacity-20 transition-colors text-gray-800 dark:text-white">
<i :class="progressPopupMinimized ? 'fas fa-chevron-up text-xs' : 'fas fa-chevron-down text-xs'"></i>
</button>
<button @click="progressPopupClosed = true"
class="p-1 rounded hover:bg-red-500 hover:bg-opacity-30 text-gray-800 dark:text-white hover:text-red-600 dark:hover:text-red-400 transition-colors">
<i class="fas fa-times text-xs"></i>
</button>
</div>
</div>
<!-- Compact Summary Line -->
<div v-if="activeProgressItems.length > 0" class="text-xs text-gray-800 dark:text-white opacity-70 mt-1">
<span v-if="activeProgressItems.filter(i => i.status === 'uploading').length > 0">
${activeProgressItems.filter(i => i.status === 'uploading').length} uploading<span v-if="activeProgressItems.filter(i => i.status !== 'uploading').length > 0">, </span>
</span>
<span v-if="activeProgressItems.filter(i => i.status === 'transcribing').length > 0">
${activeProgressItems.filter(i => i.status === 'transcribing').length} transcribing<span v-if="activeProgressItems.filter(i => ['summarizing', 'queued'].includes(i.status)).length > 0">, </span>
</span>
<span v-if="activeProgressItems.filter(i => i.status === 'summarizing').length > 0">
${activeProgressItems.filter(i => i.status === 'summarizing').length} summarizing<span v-if="activeProgressItems.filter(i => i.status === 'queued').length > 0">, </span>
</span>
<span v-if="activeProgressItems.filter(i => i.status === 'queued').length > 0">
${activeProgressItems.filter(i => i.status === 'queued').length} queued
</span>
</div>
</div>
<!-- Compact Content -->
<div v-if="!progressPopupMinimized" class="p-2 max-h-64 overflow-y-auto space-y-1.5">
<!-- Active Items -->
<template v-for="item in activeProgressItems" :key="item.id">
<div :class="[
'px-2 py-1.5 rounded-md transition-all',
item.status === 'uploading' ? 'bg-blue-500/10 border-l-2 border-blue-500' :
item.status === 'transcribing' ? 'bg-purple-500/10 border-l-2 border-purple-500' :
item.status === 'summarizing' ? 'bg-green-500/10 border-l-2 border-green-500' :
'bg-[var(--bg-tertiary)]/50 border-l-2 border-yellow-500'
]">
<!-- Title row with status icon -->
<div class="flex items-center gap-2">
<i :class="[
'fas text-xs',
getStatusDisplay(item.status).icon,
item.status === 'uploading' ? 'text-blue-500' :
item.status === 'transcribing' ? 'text-purple-500' :
item.status === 'summarizing' ? 'text-green-500' :
'text-yellow-500',
getStatusDisplay(item.status).animate ? 'animate-pulse' : ''
]"></i>
<span class="text-xs font-medium truncate flex-1 text-[var(--text-primary)]">${item.title}</span>
<span v-if="item.status === 'uploading' && item.progress !== null"
class="text-xs font-bold text-blue-500">${item.progress}%</span>
<span v-else :class="[
'text-xs font-medium px-1.5 py-0.5 rounded',
item.status === 'uploading' ? 'bg-blue-500/20 text-blue-500' :
item.status === 'transcribing' ? 'bg-purple-500/20 text-purple-500' :
item.status === 'summarizing' ? 'bg-green-500/20 text-green-500' :
'bg-yellow-500/20 text-yellow-500'
]">${getStatusDisplay(item.status).label}</span>
</div>
<!-- Progress bar for uploads -->
<div v-if="item.status === 'uploading' && item.progress !== null"
class="mt-1 h-1 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-blue-500 rounded-full transition-all duration-300"
:style="{width: item.progress + '%'}"></div>
</div>
<!-- Compact message -->
<div class="flex items-center justify-between mt-0.5">
<span class="text-[10px] text-[var(--text-muted)]">${item.progressMessage}</span>
<button v-if="item.status === 'ready' && item.clientId"
@click="removeProgressItem(item)"
class="text-[10px] text-red-500 hover:text-red-600">
<i class="fas fa-times"></i> Cancel
</button>
</div>
</div>
</template>
<!-- Failed Items -->
<div v-if="failedProgressItems.length > 0" class="mt-2">
<div class="text-[10px] font-semibold text-red-500 uppercase tracking-wider mb-1 flex items-center gap-1">
<i class="fas fa-exclamation-triangle"></i>
Failed (${failedProgressItems.length})
</div>
<div v-for="item in failedProgressItems"
:key="item.id"
:class="[
'px-2 py-1.5 rounded-md mb-1 border-l-2',
item.friendlyError?.type === 'size_limit' ? 'bg-amber-500/10 border-amber-500' :
item.friendlyError?.type === 'timeout' ? 'bg-orange-500/10 border-orange-500' :
item.friendlyError?.type === 'auth' ? 'bg-red-500/10 border-red-500' :
item.friendlyError?.type === 'rate_limit' ? 'bg-yellow-500/10 border-yellow-500' :
item.friendlyError?.type === 'connection' ? 'bg-blue-500/10 border-blue-500' :
item.friendlyError?.type === 'service_error' ? 'bg-purple-500/10 border-purple-500' :
'bg-red-500/10 border-red-500'
]">
<div class="flex items-center gap-2">
<i :class="[
'fas text-xs',
item.friendlyError?.icon || 'fa-exclamation-circle',
item.friendlyError?.type === 'size_limit' ? 'text-amber-500' :
item.friendlyError?.type === 'timeout' ? 'text-orange-500' :
item.friendlyError?.type === 'auth' ? 'text-red-500' :
item.friendlyError?.type === 'rate_limit' ? 'text-yellow-500' :
item.friendlyError?.type === 'connection' ? 'text-blue-500' :
item.friendlyError?.type === 'service_error' ? 'text-purple-500' :
'text-red-500'
]"></i>
<span class="text-xs truncate flex-1 text-[var(--text-primary)]">${item.title}</span>
<div class="flex items-center gap-1">
<button v-if="item.jobId" @click.stop="retryProgressItem(item)"
class="p-1 rounded bg-blue-500/20 hover:bg-blue-500/40 transition-colors"
title="Retry">
<i class="fas fa-redo text-blue-500 text-[10px]"></i>
</button>
<button @click.stop="removeProgressItem(item)"
class="p-1 rounded bg-red-500/20 hover:bg-red-500/40 transition-colors"
title="Delete">
<i class="fas fa-trash text-red-500 text-[10px]"></i>
</button>
</div>
</div>
<!-- Friendly error title and message -->
<div v-if="item.friendlyError" class="mt-0.5">
<span :class="[
'text-[10px] font-medium',
item.friendlyError.type === 'size_limit' ? 'text-amber-500' :
item.friendlyError.type === 'timeout' ? 'text-orange-500' :
item.friendlyError.type === 'auth' ? 'text-red-500' :
item.friendlyError.type === 'rate_limit' ? 'text-yellow-500' :
item.friendlyError.type === 'connection' ? 'text-blue-500' :
item.friendlyError.type === 'service_error' ? 'text-purple-500' :
'text-red-400'
]">${item.friendlyError.title}</span>
<span v-if="item.friendlyError.guidance" class="text-[10px] text-[var(--text-muted)] block truncate">${item.friendlyError.guidance}</span>
</div>
<!-- Fallback to raw error message if no friendly error -->
<span v-else-if="item.errorMessage" class="text-[10px] text-red-400 block truncate mt-0.5">${item.errorMessage}</span>
</div>
</div>
<!-- Completed Items -->
<div v-if="completedProgressItems.length > 0" class="mt-2">
<div class="text-[10px] font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-1 flex items-center gap-1">
<i class="fas fa-check-double"></i>
Completed (${completedProgressItems.length})
</div>
<div v-for="item in completedProgressItems"
:key="item.id"
@click="item.recordingId && selectRecording({id: item.recordingId})"
class="px-2 py-1 bg-[var(--bg-tertiary)]/30 rounded-md flex items-center gap-2 hover:bg-[var(--bg-tertiary)]/50 cursor-pointer mb-1">
<i class="fas fa-check-circle text-xs text-green-500"></i>
<span class="text-xs truncate flex-1 text-[var(--text-primary)]">${item.title}</span>
<i v-if="item.duplicateWarning" class="fas fa-copy text-xs text-amber-500" title="Duplicate file detected"></i>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-green-500/20 text-green-500 font-medium">Done</span>
</div>
</div>
<!-- Empty State -->
<div v-if="unifiedProgressItems.length === 0"
class="text-center py-3 text-[var(--text-muted)] text-xs">
<i class="fas fa-inbox text-lg mb-1 opacity-50"></i>
<p>No items in queue</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,403 @@
<!-- Recording View -->
<div v-else-if="currentView === 'recording'" class="flex-1 flex flex-col p-2 md:p-8 bg-[var(--bg-primary)] min-h-0">
<div class="flex-1 flex flex-col max-w-4xl w-full mx-auto bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-primary)] min-h-0 overflow-hidden">
<!-- Top: Visualizer and Status (Fixed) -->
<div class="flex-shrink-0 p-3 md:p-6 text-center bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)]">
<div v-if="isRecording" class="w-full mx-auto mb-3 md:mb-4">
<!-- Dual visualizer for 'both' mode -->
<div v-if="recordingMode === 'both'" class="max-w-4xl mx-auto flex gap-2 md:gap-4">
<div class="w-1/2 min-h-[8rem] md:min-h-[12rem] h-32 md:h-48 flex flex-col bg-[var(--bg-primary)] rounded-lg p-1.5 md:p-2">
<div class="flex-1 w-full overflow-hidden rounded-md" style="min-height: 6rem;">
<canvas ref="micVisualizer" class="w-full h-full"></canvas>
</div>
<p class="text-xs text-[var(--text-muted)] pt-0.5 md:pt-1" v-text="t('recording.microphone')"></p>
</div>
<div class="w-1/2 min-h-[8rem] md:min-h-[12rem] h-32 md:h-48 flex flex-col bg-[var(--bg-primary)] rounded-lg p-1.5 md:p-2">
<div class="flex-1 w-full overflow-hidden rounded-md" style="min-height: 6rem;">
<canvas ref="systemVisualizer" class="w-full h-full"></canvas>
</div>
<p class="text-xs text-[var(--text-muted)] pt-0.5 md:pt-1" v-text="t('recording.systemAudio')"></p>
</div>
</div>
<!-- Single visualizer for other modes -->
<div v-else class="min-h-[8rem] md:min-h-[12rem] h-32 md:h-48 max-w-2xl mx-auto bg-[var(--bg-primary)] rounded-lg p-1.5 md:p-2 flex flex-col">
<div class="flex-1 w-full overflow-hidden rounded-md" style="min-height: 6rem;">
<canvas ref="visualizer" class="w-full h-full"></canvas>
</div>
<p class="text-xs text-[var(--text-muted)] pt-0.5 md:pt-1 capitalize">${recordingMode}</p>
</div>
</div>
<div v-if="!isRecording && audioBlobURL" class="w-full mb-3 md:mb-4">
<div class="audio-player-container">
<audio :src="audioBlobURL" controls class="w-full">
Your browser does not support the audio element.
</audio>
</div>
</div>
<div>
<div class="text-xl md:text-2xl font-mono text-[var(--text-accent)]">${formatTime(recordingTime)}</div>
<p class="text-xs md:text-sm text-[var(--text-muted)]">${ isRecording ? 'Recording in progress...' : 'Recording finished' }</p>
<!-- Real-time file size display -->
<div v-if="isRecording && estimatedFileSize > 0" class="mt-1.5">
<p class="text-xs text-[var(--text-muted)]">
Estimated size: <span class="font-mono">${formatFileSize(estimatedFileSize)}</span>
<span v-if="actualBitrate > 0" class="ml-2">
(${Math.round(actualBitrate / 1000)}kbps)
</span>
</p>
<!-- Size warning indicator -->
<div v-if="estimatedFileSize > (maxRecordingMB * 1024 * 1024 * 0.8)" class="mt-0.5">
<div class="flex items-center text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
<span v-text="t('help.approachingLimit', { limit: maxRecordingMB })"></span>
</div>
</div>
</div>
<!-- Mobile Background Recording Warning - Below timer -->
<div v-if="isRecording && isMobileDevice"
class="mt-2 p-2 bg-amber-50 dark:bg-amber-900 dark:bg-opacity-20 border border-amber-300 dark:border-amber-700 rounded">
<div class="flex items-start gap-1.5 text-left">
<i class="fas fa-exclamation-triangle text-amber-600 dark:text-amber-400 flex-shrink-0 text-xs" style="margin-top: 2px;"></i>
<div class="flex-1 min-w-0">
<div class="text-xs text-amber-800 dark:text-amber-200 leading-snug">
<strong class="font-semibold">Keep this app visible!</strong>
<span class="block text-[10px] mt-0.5">Recording pauses if minimized or screen locked.</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- MIDDLE: Content Area -->
<div class="flex-1 min-h-0 flex flex-col">
<!-- STATE 1: Recording in progress — notes fill space, no accordion -->
<template v-if="isRecording">
<div class="flex-1 min-h-0 flex flex-col p-3 md:p-6">
<label for="recordingNotes" class="block text-sm font-medium text-[var(--text-secondary)] mb-1.5 flex-shrink-0">
<i class="fas fa-pencil-alt mr-1"></i>
Recording Notes (Markdown)
</label>
<div class="recording-notes-editor recording-active" style="min-height: 150px;">
<textarea ref="recordingNotesEditor" v-model="recordingNotes"
class="w-full h-full bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg p-2 md:p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] resize-none"
:placeholder="t('form.notesPlaceholder')"></textarea>
</div>
</div>
</template>
<!-- STATE 2: Recording finished — accordion -->
<template v-else-if="audioBlobURL">
<!-- Notes Section -->
<div :class="['flex flex-col border-b border-[var(--border-primary)]',
expandedSection === 'notes' ? 'flex-1 min-h-0' : 'flex-shrink-0']">
<!-- Header bar (always visible) -->
<button @click="expandedSection = expandedSection === 'notes' ? 'settings' : 'notes'"
class="flex-shrink-0 flex items-center justify-between w-full px-3 md:px-6 py-2.5
bg-[var(--bg-tertiary)] hover:bg-[var(--bg-accent)] transition-colors">
<span class="flex items-center gap-2 text-sm font-medium text-[var(--text-secondary)]">
<i class="fas fa-pencil-alt text-[var(--text-muted)]"></i>
Recording Notes
<span v-if="recordingNotes" class="text-[10px] text-[var(--text-muted)]">(has content)</span>
</span>
<i :class="['fas text-[var(--text-muted)] transition-transform duration-200',
expandedSection === 'notes' ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
</button>
<!-- Content (when expanded) -->
<div v-show="expandedSection === 'notes'" class="flex-1 min-h-0 flex flex-col p-3 md:p-6">
<div class="recording-notes-editor accordion-expanded">
<textarea ref="recordingNotesEditor" v-model="recordingNotes"
class="w-full h-full bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-lg p-2 md:p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] resize-none"
:placeholder="t('form.notesPlaceholder')"></textarea>
</div>
</div>
</div>
<!-- Settings Section -->
<div :class="['flex flex-col',
expandedSection === 'settings' ? 'flex-1 min-h-0' : 'flex-shrink-0']">
<!-- Header bar -->
<button @click="expandedSection = expandedSection === 'settings' ? 'notes' : 'settings'"
class="flex-shrink-0 flex items-center justify-between w-full px-3 md:px-6 py-2.5
bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)]
hover:bg-[var(--bg-accent)] transition-colors">
<span class="flex items-center gap-2 text-sm font-medium text-[var(--text-secondary)]">
<i class="fas fa-sliders-h text-[var(--text-muted)]"></i>
Upload Settings
</span>
<i :class="['fas text-[var(--text-muted)] transition-transform duration-200',
expandedSection === 'settings' ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
</button>
<!-- Content (when expanded) -->
<div v-show="expandedSection === 'settings'" class="flex-1 min-h-0 overflow-y-auto p-3 md:p-6">
<div class="flex flex-col gap-3 md:gap-4">
<!-- Folder Selection (only shown if folders are enabled) -->
<div v-if="foldersEnabled">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1.5 md:mb-2">
<i class="fas fa-folder mr-1"
:style="{ color: selectedFolderId ? getFolderColor(selectedFolderId) : 'var(--text-muted)' }"></i>
Folder (optional)
</label>
<div class="relative">
<select v-model="selectedFolderId"
class="w-full pl-8 pr-8 py-2 text-sm rounded-lg cursor-pointer focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] appearance-none border border-[var(--border-secondary)] bg-[var(--bg-secondary)] text-[var(--text-primary)] hover:bg-[var(--bg-input)] transition-colors">
<option :value="null">No Folder</option>
<option v-for="folder in availableFolders" :key="folder.id" :value="folder.id">
${ folder.name }
</option>
</select>
<i class="fas fa-folder absolute left-2.5 top-1/2 transform -translate-y-1/2 pointer-events-none"
:style="{ color: selectedFolderId ? getFolderColor(selectedFolderId) : 'var(--text-muted)' }"></i>
<i class="fas fa-chevron-down absolute right-2.5 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] pointer-events-none" style="font-size: 10px;"></i>
</div>
</div>
<!-- Tag Selection -->
<div>
<label for="recordingTagSelect" class="block text-sm font-medium text-[var(--text-secondary)] mb-1.5 md:mb-2">
<i class="fas fa-tags mr-1"></i>
Select Tags (optional, in priority order)
</label>
<!-- Selected Tags Display - Compact with Drag Reorder -->
<div v-if="selectedTags.length > 0" class="mb-2">
<div class="p-1.5 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)] max-h-16 overflow-y-auto"
@touchmove="handleTagTouchMove">
<div class="flex flex-wrap gap-1">
<span v-for="(tag, index) in selectedTags" :key="tag.id"
:data-tag-index="index"
draggable="true"
@dragstart="handleTagDragStart(index, $event)"
@dragover="handleTagDragOver(index, $event)"
@drop="handleTagDrop(index, $event)"
@dragend="handleTagDragEnd"
@touchstart="handleTagTouchStart(index, $event)"
@touchend="handleTagTouchEnd"
:class="[
'inline-flex items-center px-1.5 py-0.5 rounded-full text-[11px] font-medium transition-all duration-150',
draggedTagIndex === index ? 'opacity-50 cursor-grabbing' : 'cursor-grab',
dragOverTagIndex === index && draggedTagIndex !== index ? 'ring-2 ring-[var(--ring-focus)] ring-offset-1' : ''
]"
:style="{ backgroundColor: tag.color, color: getContrastTextColor(tag.color) }"
:title="(tag.group_id ? ('Group: ' + tag.group_name) : tag.name) + ' (drag to reorder)'">
<span class="opacity-75 mr-0.5 text-[9px]">${index + 1}.</span>
<i v-if="tag.group_id" class="fas fa-users mr-1 text-[9px]"></i>
<span v-if="tag.group_id" class="opacity-75">${tag.group_name}: </span>
<span>${tag.name}</span>
<button @click.stop="removeTagFromSelection(tag.id)"
class="ml-1 hover:opacity-100 opacity-70">
<i class="fas fa-times" style="font-size: 9px;"></i>
</button>
</span>
</div>
</div>
<p class="text-[10px] text-[var(--text-muted)] mt-0.5">
<i class="fas fa-grip-vertical mr-0.5" style="font-size: 9px;"></i>
Drag to reorder &bull; First tag's defaults applied
</p>
</div>
<!-- Tag Selection Container - Compact -->
<div v-if="availableTags.filter(tag => !selectedTagIds.includes(tag.id)).length > 0"
class="border border-[var(--border-secondary)] rounded-md bg-[var(--bg-tertiary)] p-2">
<!-- Search Filter -->
<div class="mb-1.5">
<div class="relative">
<input v-model="uploadTagSearchFilter"
type="text"
:placeholder="t('tagsModal.searchTags')"
class="w-full px-2 py-1 pl-6 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
<i class="fas fa-search absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]" style="font-size: 9px;"></i>
<button v-if="uploadTagSearchFilter"
@click="uploadTagSearchFilter = ''"
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-primary)]">
<i class="fas fa-times" style="font-size: 9px;"></i>
</button>
</div>
</div>
<!-- Available Tags Grid -->
<div v-if="filteredAvailableTagsForUpload.length > 0"
class="overflow-y-auto"
style="max-height: 100px;">
<div class="grid grid-cols-2 gap-1">
<button v-for="tag in filteredAvailableTagsForUpload"
:key="tag.id"
@click="addTagToSelection(tag.id)"
class="group flex items-center justify-between px-1.5 py-1 rounded border border-[var(--border-secondary)] hover:border-[var(--border-focus)] bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)] transition-all">
<div class="flex items-center gap-1 min-w-0">
<i v-if="tag.group_id" class="fas fa-users flex-shrink-0 text-[9px]"
:style="{ color: tag.color || '#6B7280' }"></i>
<span v-else class="w-2 h-2 rounded-full flex-shrink-0"
:style="{ backgroundColor: tag.color || '#6B7280' }"></span>
<span class="text-[11px] text-[var(--text-primary)] truncate">
<span v-if="tag.group_id" class="opacity-75">${tag.group_name}: </span>${tag.name}
</span>
</div>
<i class="fas fa-plus text-[var(--text-muted)] group-hover:text-[var(--text-accent)] transition-colors" style="font-size: 9px;"></i>
</button>
</div>
</div>
<div v-else class="py-2 text-center">
<p class="text-[11px] text-[var(--text-muted)]">
<i class="fas fa-search mr-1" style="font-size: 9px;"></i>
No matching tags
</p>
</div>
</div>
<!-- Empty States -->
<div v-else-if="availableTags.length === 0"
class="p-2 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)]">
<p class="text-xs text-[var(--text-muted)] text-center">
<i class="fas fa-info-circle mr-1" style="font-size: 10px;"></i>
<span v-text="t('help.noTagsCreated')"></span> <a href="/account#tags" class="text-[var(--text-accent)] hover:underline" v-text="t('help.createTags')"></a>
</p>
</div>
<div v-else class="p-2 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)]">
<p class="text-xs text-[var(--text-muted)] text-center">
<i class="fas fa-check-circle mr-1" style="font-size: 10px;"></i>
All tags selected
</p>
</div>
<p v-if="selectedTags.some(tag => tag.custom_prompt)" class="text-xs text-[var(--text-muted)] mt-1">
<i class="fas fa-info-circle mr-1 text-[var(--text-accent)]"></i>
Selected tags include custom summary prompts
</p>
<p v-if="selectedTags.length > 0 && connectorSupportsDiarization && selectedTags.some(tag => tag.default_language || tag.default_min_speakers || tag.default_max_speakers)"
class="text-xs text-[var(--text-muted)] mt-1">
<i class="fas fa-cog mr-1 text-[var(--text-accent)]"></i>
First tag's ASR settings will be applied: ${selectedTags[0].name}
</p>
</div>
<!-- Advanced Options for diarization-enabled connectors (collapsible) -->
<div v-if="connectorSupportsDiarization">
<button @click="showAdvancedOptions = !showAdvancedOptions"
class="w-full flex items-center justify-between px-3 py-2 bg-[var(--bg-tertiary)] rounded-md hover:bg-[var(--bg-accent)] transition-colors text-xs font-medium">
<span class="flex items-center gap-2 text-[var(--text-secondary)]">
<i class="fas fa-cog text-[var(--text-muted)]"></i>
<span v-text="t('help.advancedAsrOptions')"></span>
</span>
<i :class="['fas text-[var(--text-muted)]', showAdvancedOptions ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
</button>
<div v-if="showAdvancedOptions" class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)] space-y-3">
<div>
<label for="recordingAsrLanguage" class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Language
</label>
<select id="recordingAsrLanguage" v-model="asrLanguage"
class="w-full px-2 py-1 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)] text-xs">
<option v-for="lang in languageOptions" :key="lang.value" :value="lang.value" v-text="lang.label"></option>
</select>
</div>
<!-- Speaker Settings - only show for connectors that support min/max speakers -->
<div v-if="connectorSupportsSpeakerCount" class="grid grid-cols-2 gap-3">
<div>
<label for="recordingAsrMinSpeakers" class="block text-xs font-medium text-[var(--text-secondary)] mb-1" v-text="t('form.minSpeakers')">
</label>
<input type="number" id="recordingAsrMinSpeakers" v-model="asrMinSpeakers"
min="1" max="20" :placeholder="t('form.auto')"
class="w-full px-2 py-1 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)] text-xs">
</div>
<div>
<label for="recordingAsrMaxSpeakers" class="block text-xs font-medium text-[var(--text-secondary)] mb-1" v-text="t('form.maxSpeakers')">
</label>
<input type="number" id="recordingAsrMaxSpeakers" v-model="asrMaxSpeakers"
min="1" max="20" :placeholder="t('form.auto')"
class="w-full px-2 py-1 bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)] text-xs">
</div>
</div>
</div>
</div>
<!-- Incognito Mode Toggle (only shown if feature is enabled) -->
<div v-if="enableIncognitoMode">
<button @click="incognitoMode = !incognitoMode"
:class="[
'w-full flex items-center justify-between px-3 py-2.5 rounded-lg border transition-all duration-200',
incognitoMode
? 'bg-gradient-to-r from-violet-500/10 to-purple-500/10 border-violet-400/50 dark:border-violet-500/50'
: 'bg-[var(--bg-tertiary)] border-[var(--border-secondary)] hover:border-[var(--border-focus)]'
]">
<div class="flex items-center gap-3">
<div :class="[
'w-8 h-8 rounded-full flex items-center justify-center transition-all',
incognitoMode
? 'bg-gradient-to-br from-violet-500 to-purple-600 text-white'
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)]'
]">
<i class="fas fa-user-secret text-sm"></i>
</div>
<div class="text-left">
<span :class="['text-sm font-medium', incognitoMode ? 'text-violet-700 dark:text-violet-300' : 'text-[var(--text-secondary)]']">
Incognito Mode
</span>
<p v-if="!incognitoMode" class="text-[10px] text-[var(--text-muted)]">
Process without saving
</p>
<p v-else class="text-[10px] text-violet-600 dark:text-violet-400">
Session only &bull; Not saved to account
</p>
</div>
</div>
<div :class="[
'w-10 h-5 rounded-full relative transition-all duration-200',
incognitoMode ? 'bg-gradient-to-r from-violet-500 to-purple-500' : 'bg-[var(--bg-secondary)]'
]">
<div :class="[
'absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all duration-200',
incognitoMode ? 'left-5' : 'left-0.5'
]"></div>
</div>
</button>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- BOTTOM: Action Buttons (always pinned) -->
<div class="flex-shrink-0 p-3 md:p-6 bg-[var(--bg-tertiary)] border-t border-[var(--border-primary)]">
<div v-if="isRecording" class="text-center">
<button @click="stopRecording"
class="px-6 md:px-8 py-2.5 md:py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors animate-pulse text-sm md:text-base">
<i class="fas fa-stop mr-2"></i>Stop Recording
</button>
</div>
<div v-if="!isRecording && audioBlobURL" class="flex flex-col sm:flex-row gap-3 justify-center">
<!-- Normal Upload Button -->
<button v-if="!incognitoMode || !enableIncognitoMode"
@click="uploadRecordedAudio"
class="flex-1 px-6 py-3 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors flex items-center justify-center gap-2">
<i class="fas fa-upload"></i>
<span v-text="t('help.uploadRecordingNotes')"></span>
</button>
<!-- Incognito Upload Button -->
<button v-else
@click="uploadRecordedAudioIncognito"
:disabled="incognitoProcessing"
class="flex-1 px-6 py-3 bg-gradient-to-r from-violet-500 to-purple-600 text-white rounded-lg hover:from-violet-600 hover:to-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2 shadow-sm">
<i :class="incognitoProcessing ? 'fas fa-spinner fa-spin' : 'fas fa-user-secret'"></i>
<span v-if="incognitoProcessing">Processing...</span>
<span v-else>Process in Incognito</span>
</button>
<button @click="discardRecording"
class="px-6 py-3 bg-[var(--bg-danger)] text-white rounded-lg hover:bg-[var(--bg-danger-hover)] transition-colors flex items-center justify-center gap-2">
<i class="fas fa-trash"></i>
<span v-text="t('help.discard')"></span>
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,604 @@
<!-- Mobile Sidebar Backdrop -->
<div v-if="!isSidebarCollapsed && isMobileScreen"
@click="toggleSidebar"
class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden">
</div>
<!-- Sidebar -->
<aside :class="['sidebar', isSidebarCollapsed ? 'collapsed' : '']">
<div class="sidebar-content-wrapper">
<!-- Sidebar Header -->
<div class="p-4 border-b border-[var(--border-primary)] flex-shrink-0">
<div class="flex items-center gap-2 mb-3">
<!-- Folder Selector (replaces static "Recording" title) -->
<div v-if="foldersEnabled && availableFolders.length > 0" class="relative flex-1 min-w-0">
<select v-model="filterFolder"
class="w-full h-9 pl-8 pr-7 text-base font-semibold rounded-md cursor-pointer appearance-none border-0 bg-transparent text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
style="outline: none !important; box-shadow: none !important; background-image: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 fill=%27%236B7280%27 viewBox=%270 0 16 16%27%3E%3Cpath d=%27M8 10.5l-4-4h8l-4 4z%27/%3E%3C/svg%3E'); background-repeat: no-repeat; background-position: right 8px center;">
<option value="">All Recordings</option>
<option value="none">Unfiled</option>
<option v-for="folder in availableFolders" :key="folder.id" :value="folder.id">
${ folder.name }
</option>
</select>
<i class="fas fa-folder absolute left-2.5 top-1/2 transform -translate-y-1/2 pointer-events-none text-sm"
:style="{ color: filterFolder && filterFolder !== 'none' ? getFolderColor(filterFolder) : 'var(--text-muted)' }"></i>
</div>
<h2 v-else class="text-lg font-semibold flex-1" v-text="t('nav.recording')"></h2>
<div class="flex gap-2 flex-shrink-0">
<!-- Selection Mode Toggle -->
<button v-if="!selectionMode && recordings.length > 0"
@click="enterSelectionMode"
class="w-9 h-9 flex items-center justify-center bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-md hover:opacity-80 transition-opacity"
title="Select multiple">
<i class="fas fa-list-check"></i>
</button>
<button v-if="selectionMode"
@click="exitSelectionMode"
class="h-9 px-3 flex items-center bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:opacity-80 transition-opacity text-sm"
title="Exit selection mode">
<i class="fas fa-times mr-1"></i>Done
</button>
<!-- New button - compact when folders enabled -->
<button v-if="!selectionMode && foldersEnabled && availableFolders.length > 0"
@click="switchToUploadView"
class="w-9 h-9 flex items-center justify-center bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] transition-colors"
title="New Recording">
<i class="fas fa-plus"></i>
</button>
<button v-else-if="!selectionMode"
@click="switchToUploadView"
class="h-9 px-3 flex items-center bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] transition-colors text-sm">
<i class="fas fa-plus mr-1"></i><span v-text="t('common.new')"></span>
</button>
</div>
</div>
<!-- Search and Sort Controls -->
<div class="space-y-3">
<!-- Filter Toggle Button -->
<div class="flex items-center gap-1.5">
<button @click="showAdvancedFilters = !showAdvancedFilters"
class="flex-1 h-7 flex items-center justify-between pl-2 pr-2 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-xs hover:bg-[var(--bg-tertiary)] transition-colors">
<span class="flex items-center">
<i class="fas fa-filter mr-1.5 text-[var(--text-muted)] text-[10px]"></i>
<span v-if="!searchQuery && filterTags.length === 0 && filterSpeakers.length === 0 && !filterDatePreset && !filterDateRange.start && !filterDateRange.end && !filterTextQuery && !filterStarred && !filterInbox"
v-text="t('sidebar.searchRecordings')">
</span>
<span v-else class="text-[var(--text-accent)]">
Active filters (${ (filterTags.length > 0 ? 1 : 0) + (filterSpeakers.length > 0 ? 1 : 0) + (filterDatePreset || filterDateRange.start || filterDateRange.end ? 1 : 0) + (filterTextQuery ? 1 : 0) + (filterStarred ? 1 : 0) + (filterInbox ? 1 : 0) })
</span>
</span>
<i :class="['fas fa-chevron-down transition-transform text-[var(--text-muted)] text-[10px]', showAdvancedFilters ? 'rotate-180' : '']"></i>
</button>
<button v-if="searchQuery || filterTags.length > 0 || filterSpeakers.length > 0 || filterDatePreset || filterDateRange.start || filterDateRange.end || filterTextQuery || filterStarred || filterInbox"
@click="clearAllFilters"
class="w-7 h-7 flex items-center justify-center bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-xs hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)] transition-colors"
:title="t('buttons.clearAllFilters')">
<i class="fas fa-times text-[var(--text-muted)] text-[10px]"></i>
</button>
</div>
<!-- Advanced Filters Panel -->
<div v-show="showAdvancedFilters" class="p-3 bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-lg space-y-3">
<!-- Text Search -->
<div>
<label class="block text-xs font-medium text-[var(--text-muted)] mb-1" v-text="t('sidebar.filters')"></label>
<div class="relative">
<input v-model="filterTextQuery"
type="text"
:placeholder="t('sidebar.searchRecordings')"
class="w-full px-3 py-1.5 pl-8 pr-8 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
<i class="fas fa-search absolute left-2.5 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] text-xs"></i>
<button v-if="filterTextQuery"
@click="filterTextQuery = ''"
class="absolute right-2.5 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-primary)] text-xs"
:title="t('buttons.clearSearchText')">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- Quick Filters (Starred/Inbox) -->
<div class="flex gap-2">
<button @click="filterStarred = !filterStarred"
:class="[
'flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all border',
filterStarred
? 'bg-yellow-500/20 border-yellow-500/50 text-yellow-400'
: 'bg-[var(--bg-input)] border-[var(--border-secondary)] text-[var(--text-muted)] hover:border-yellow-500/30 hover:text-yellow-400'
]">
<i class="fas fa-star" style="font-size: 10px;"></i>
<span v-text="t('sidebar.starred')"></span>
</button>
<button @click="filterInbox = !filterInbox"
:class="[
'flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all border',
filterInbox
? 'bg-blue-500/20 border-blue-500/50 text-blue-400'
: 'bg-[var(--bg-input)] border-[var(--border-secondary)] text-[var(--text-muted)] hover:border-blue-500/30 hover:text-blue-400'
]">
<i class="fas fa-inbox" style="font-size: 10px;"></i>
<span v-text="t('sidebar.inbox')"></span>
</button>
</div>
<!-- Tag Filter -->
<div class="bg-[var(--bg-secondary)] rounded-lg p-2.5 border border-[var(--border-primary)]">
<div class="flex items-center justify-between gap-2 mb-2">
<label class="text-xs font-medium text-[var(--text-muted)] whitespace-nowrap" v-text="t('tags.filterByTag')"></label>
<div class="relative flex-1 max-w-[140px]" v-if="availableTags.length > 5">
<input v-model="filterTagSearch"
type="text"
:placeholder="t('tags.searchTags')"
class="w-full px-2 py-1 pl-7 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-xs focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
<i class="fas fa-search absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]" style="font-size: 10px;"></i>
</div>
</div>
<div class="max-h-24 overflow-y-auto scrollbar-thin pr-1">
<div class="flex flex-wrap gap-1.5">
<button v-for="tag in filteredTagsForFilter"
:key="tag.id"
@click="filterTags.includes(tag.id) ? filterTags = filterTags.filter(id => id !== tag.id) : filterTags.push(tag.id)"
:class="[
'px-2 py-1 rounded-full text-xs font-medium transition-all',
filterTags.includes(tag.id)
? 'bg-[var(--bg-accent)] text-[var(--text-accent)] ring-1 ring-[var(--border-accent)]'
: 'bg-[var(--bg-input)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'
]">
<span class="inline-block w-2 h-2 rounded-full mr-1" :style="{ backgroundColor: tag.color || '#6B7280' }"></span>
${ tag.name }
</button>
</div>
</div>
<p v-if="availableTags.length === 0" class="text-xs text-[var(--text-muted)] italic" v-text="t('tags.noTags')"></p>
<p v-else-if="filteredTagsForFilter.length === 0" class="text-xs text-[var(--text-muted)] italic py-1" v-text="t('tags.noMatchingTags')"></p>
</div>
<!-- Speaker Filter -->
<div v-if="availableSpeakers.length > 0" class="bg-[var(--bg-secondary)] rounded-lg p-2.5 border border-[var(--border-primary)]">
<div class="flex items-center justify-between gap-2 mb-2">
<label class="text-xs font-medium text-[var(--text-muted)] whitespace-nowrap" v-text="t('speakers.filterBySpeaker')"></label>
<div class="relative flex-1 max-w-[140px]" v-if="availableSpeakers.length > 5">
<input v-model="filterSpeakerSearch"
type="text"
:placeholder="t('speakers.searchSpeakers')"
class="w-full px-2 py-1 pl-7 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded text-xs focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
<i class="fas fa-search absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]" style="font-size: 10px;"></i>
</div>
</div>
<div class="max-h-24 overflow-y-auto scrollbar-thin pr-1">
<div class="flex flex-wrap gap-1.5">
<button v-for="speaker in filteredSpeakersForFilter"
:key="speaker.id"
@click="filterSpeakers.includes(speaker.name) ? filterSpeakers = filterSpeakers.filter(n => n !== speaker.name) : filterSpeakers.push(speaker.name)"
:class="[
'px-2 py-1 rounded-full text-xs font-medium transition-all',
filterSpeakers.includes(speaker.name)
? 'bg-[var(--bg-accent)] text-[var(--text-accent)] ring-1 ring-[var(--border-accent)]'
: 'bg-[var(--bg-input)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'
]">
<i class="fas fa-user mr-1" style="font-size: 9px;"></i>
${ speaker.name }
</button>
</div>
</div>
<p v-if="filteredSpeakersForFilter.length === 0 && filterSpeakerSearch" class="text-xs text-[var(--text-muted)] italic py-1" v-text="t('speakers.noMatchingSpeakers')"></p>
</div>
<!-- Date Filter -->
<div>
<label class="block text-xs font-medium text-[var(--text-muted)] mb-1" v-text="t('sidebar.dateRange')"></label>
<div class="grid grid-cols-3 gap-1.5 mb-2">
<button v-for="preset in datePresetOptions"
:key="preset.value"
@click="filterDatePreset = filterDatePreset === preset.value ? '' : preset.value; filterDateRange = { start: '', end: '' }"
:class="[
'px-2 py-1 rounded-md text-xs transition-all',
filterDatePreset === preset.value
? 'bg-[var(--bg-accent)] text-[var(--text-accent)]'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-input)]'
]"
:title="preset.label">
${ preset.label }
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<input v-model="filterDateRange.start"
@change="filterDatePreset = ''"
type="date"
class="w-full px-2 py-1 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-md text-xs focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]"
:placeholder="t('form.dateFrom')">
</div>
<div>
<input v-model="filterDateRange.end"
@change="filterDatePreset = ''"
type="date"
class="w-full px-2 py-1 bg-[var(--bg-input)] border border-[var(--border-secondary)] rounded-md text-xs focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]"
:placeholder="t('form.dateTo')">
</div>
</div>
</div>
</div>
<!-- Compact Controls Row -->
<div class="flex items-center gap-1.5">
<!-- Sort Toggle -->
<button @click="sortBy = sortBy === 'created_at' ? 'meeting_date' : 'created_at'"
:class="[
'flex-1 flex items-center justify-center gap-1 px-2 py-1.5 border rounded-md text-xs transition-colors',
'bg-[var(--bg-input)] border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)]'
]"
:title="sortBy === 'meeting_date' ? t('sidebar.sortByMeetingDate') : t('sidebar.sortByDate')">
<i :class="['fas', sortBy === 'meeting_date' ? 'fa-calendar' : 'fa-upload']"></i>
<span class="hidden sm:inline">Sort</span>
<i class="fas fa-exchange-alt text-[var(--text-muted)] text-[10px]"></i>
</button>
<!-- Archived Toggle (only show when audio-only deletion mode is active) -->
<button v-if="enableArchiveToggle" @click="showArchivedRecordings = !showArchivedRecordings"
:class="[
'flex-1 flex items-center justify-center gap-1 px-2 py-1.5 border rounded-md text-xs transition-colors',
showArchivedRecordings
? 'bg-[var(--bg-accent)] border-[var(--border-accent)] text-[var(--text-accent)]'
: 'bg-[var(--bg-input)] border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)]'
]"
:title="t('sidebar.archivedRecordings')">
<i class="fas fa-archive"></i>
<span class="hidden sm:inline">Archived</span>
<i :class="['fas text-[10px]', showArchivedRecordings ? 'fa-toggle-on' : 'fa-toggle-off']"></i>
</button>
<!-- Shared Toggle -->
<button v-if="enableInternalSharing" @click="showSharedWithMe = !showSharedWithMe"
:class="[
'flex-1 flex items-center justify-center gap-1 px-2 py-1.5 border rounded-md text-xs transition-colors',
showSharedWithMe
? 'bg-[var(--bg-accent)] border-[var(--border-accent)] text-[var(--text-accent)]'
: 'bg-[var(--bg-input)] border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)]'
]"
:title="t('sidebar.sharedWithMe')">
<i class="fas fa-users"></i>
<span class="hidden sm:inline">Shared</span>
<i :class="['fas text-[10px]', showSharedWithMe ? 'fa-toggle-on' : 'fa-toggle-off']"></i>
</button>
</div>
</div>
</div>
<!-- Recordings List -->
<div class="flex-1 overflow-y-auto p-4"
@scroll="(e) => {
const element = e.target;
const threshold = 100; // Load more when 100px from bottom
if (element.scrollHeight - element.scrollTop - element.clientHeight < threshold) {
loadMoreRecordings();
}
}">
<!-- Incognito Recording (styled like a regular recording item with a subtle indicator) -->
<div v-if="enableIncognitoMode && incognitoRecording"
@click="selectIncognitoRecording"
:class="[
'group mb-3 p-3 rounded-lg cursor-pointer transition-all duration-200',
selectedRecording?.id === 'incognito'
? 'bg-[var(--bg-accent)] border-l-4 border-[var(--border-accent)]'
: 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border-l-4 border-transparent hover:border-[var(--border-secondary)]'
]">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm text-[var(--text-primary)] truncate">
${ incognitoRecording.title || 'Incognito Recording' }
</span>
</div>
<div class="flex items-center gap-2 flex-wrap">
<!-- Incognito pill badge (matches tag style with contrasting border) -->
<span class="inline-flex items-center px-1.5 py-0.5 rounded-md text-xs font-medium bg-violet-500/15 dark:bg-violet-500/25 text-violet-700 dark:text-violet-300 ring-1 ring-violet-400/50 dark:ring-violet-500/50">
<i class="fas fa-user-secret mr-1" style="font-size: 9px;"></i>
Incognito
</span>
<!-- Duration pill -->
<span v-if="incognitoRecording.audio_duration_seconds" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)]">
<i class="fas fa-clock mr-1" style="font-size: 8px;"></i>
${ Math.floor(incognitoRecording.audio_duration_seconds / 60) }:${ String(incognitoRecording.audio_duration_seconds % 60).padStart(2, '0') }
</span>
</div>
<p class="text-[10px] text-[var(--text-muted)] mt-1.5 opacity-70">
<i class="fas fa-eye-slash mr-1"></i>
Session only
</p>
</div>
<button @click.stop="clearIncognitoRecordingWithConfirm"
class="opacity-0 group-hover:opacity-100 p-1.5 text-[var(--text-muted)] hover:text-red-500 hover:bg-red-500/10 rounded transition-all"
title="Discard">
<i class="fas fa-times text-xs"></i>
</button>
</div>
</div>
<div v-if="isLoadingRecordings && recordings.length === 0" 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.loadingRecordings')"></p>
</div>
<div v-else-if="recordings.length === 0 && !isLoadingRecordings" class="text-center py-8">
<i class="fas fa-microphone-slash text-3xl text-[var(--text-muted)] mb-3"></i>
<p class="text-[var(--text-muted)]" v-text="t('sidebar.noRecordings')"></p>
<p v-if="searchQuery.trim()" class="text-sm text-[var(--text-muted)] mt-1">
<span v-text="t('help.tryAdjustingSearch')"></span> <button @click="clearAllFilters()" class="text-[var(--text-accent)] hover:underline" v-text="t('help.clearFilters')"></button>
</p>
</div>
<div v-if="recordings.length > 0" class="space-y-2">
<!-- Selection Mode Controls -->
<div v-if="selectionMode" class="flex items-center justify-between px-1 py-2 mb-2 border-b border-[var(--border-primary)]">
<div class="flex items-center gap-3">
<button @click="allVisibleSelected ? clearSelection() : selectAll()"
class="text-xs text-[var(--text-accent)] hover:underline">
${ allVisibleSelected ? 'Clear all' : 'Select all' }
</button>
<span class="text-xs text-[var(--text-muted)]">
${ selectedCount } selected
</span>
</div>
</div>
<div v-for="group in groupedRecordings" :key="group.title" class="mb-6">
<h3 class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide mb-2">
${group.title}
</h3>
<div class="space-y-1">
<div v-for="recording in group.items"
:key="recording.id"
@click="selectionMode ? toggleSelection(recording.id) : selectRecording(recording)"
:class="[
'p-3 rounded-lg cursor-pointer transition-all duration-200 border-2',
selectionMode && isSelected(recording.id)
? 'bg-[var(--bg-accent)] border-[var(--border-accent)]'
: selectedRecording?.id === recording.id && !selectionMode
? 'bg-[var(--bg-accent)] border-[var(--border-accent)]'
: recording.is_shared
? 'bg-[var(--bg-secondary)] border-[var(--bg-tertiary)] hover:bg-[var(--bg-accent-hover)]'
: 'bg-[var(--bg-tertiary)] border-transparent hover:bg-[var(--bg-accent-hover)]'
]">
<!-- Title and Status Row -->
<div class="flex items-center justify-between mb-1">
<!-- Checkbox for selection mode -->
<div v-if="selectionMode" @click.stop="toggleSelection(recording.id)" class="flex-shrink-0 mr-2">
<input type="checkbox"
:checked="isSelected(recording.id)"
class="selection-checkbox"
@click.stop="toggleSelection(recording.id)">
</div>
<h4 class="font-medium text-sm truncate flex-1 mr-2" :class="selectedRecording?.id === recording.id && !selectionMode ? 'text-[var(--text-accent)]' : 'text-[var(--text-primary)]'">
${ recording.title || t('common.untitled') }
</h4>
<div class="flex items-center gap-0.5 flex-shrink-0">
<!-- Combined sharing status badge -->
<span v-if="recording.is_shared || recording.has_group_tags || recording.shared_with_count > 0 || recording.public_share_count > 0"
class="inline-flex items-center justify-center gap-1 h-5 px-2 rounded-full text-[9px] leading-none bg-violet-100 dark:bg-violet-900/50 border border-violet-300 dark:border-violet-600"
:title="[
recording.is_shared ? t('sharing.sharedBy') + ' ' + (recording.owner_username || t('sharing.unknown')) : '',
recording.has_group_tags ? t('sharing.teamRecording') : '',
!recording.is_shared && recording.shared_with_count > 0 ? t('sharing.sharedWith') + ' ' + recording.shared_with_count + ' ' + t('sharing.users') : '',
!recording.is_shared && recording.public_share_count > 0 ? recording.public_share_count + ' ' + t('sharing.publicLinksGenerated') : ''
].filter(s => s).join(' • ')">
<i v-if="recording.is_shared" class="fas fa-arrow-down text-purple-600 dark:text-purple-400"></i>
<i v-if="recording.has_group_tags" class="fas fa-users text-blue-600 dark:text-blue-400"></i>
<i v-if="!recording.is_shared && recording.shared_with_count > 0" class="fas fa-arrow-up text-indigo-600 dark:text-indigo-400"></i>
<i v-if="!recording.is_shared && recording.public_share_count > 0" class="fas fa-globe text-teal-600 dark:text-teal-400"></i>
</span>
<!-- Show Audio Deleted badge -->
<span v-if="recording.audio_deleted_at"
class="inline-flex items-center justify-center h-5 px-1.5 rounded-full text-[9px] leading-none bg-gray-200 dark:bg-gray-700 border border-gray-300 dark:border-gray-600"
:title="t('sidebar.archived')">
<i class="fas fa-file-audio text-gray-600 dark:text-gray-400"></i>
</span>
<!-- Show Failed status for failed recordings -->
<span v-if="recording.status === 'FAILED'"
class="inline-flex items-center justify-center h-5 px-1.5 rounded-full text-[9px] leading-none bg-red-100 dark:bg-red-900/50 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400">
<i class="fas fa-exclamation-circle"></i>
</span>
<!-- Show Processing status for non-completed recordings -->
<span v-else-if="recording.status !== 'COMPLETED'"
:class="getStatusClass(recording.status)"
class="status-badge">
${formatStatus(recording.status)}
</span>
<!-- For completed recordings, show highlight and inbox badges -->
<template v-else>
<span v-if="recording.is_highlighted"
@click.stop="toggleHighlight(recording)"
class="inline-flex items-center justify-center h-5 px-1.5 rounded-full text-[9px] leading-none bg-amber-100 dark:bg-amber-900/50 border border-amber-400 dark:border-amber-600 cursor-pointer hover:bg-amber-200 dark:hover:bg-amber-800/50"
:title="t('sidebar.removeFromHighlighted')">
<i class="fas fa-star text-amber-600 dark:text-amber-400"></i>
</span>
<span v-if="recording.is_inbox"
@click.stop="toggleInbox(recording)"
class="inline-flex items-center justify-center h-5 px-1.5 rounded-full text-[9px] leading-none bg-blue-100 dark:bg-blue-900/50 border border-blue-400 dark:border-blue-600 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-800/50"
:title="t('sidebar.markAsRead')">
<i class="fas fa-inbox text-blue-600 dark:text-blue-400"></i>
</span>
</template>
</div>
</div>
<!-- Date and Participants -->
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-[var(--text-muted)]">
<!-- Date -->
<div class="flex items-center flex-shrink-0">
<i class="fas fa-calendar-alt mr-1 text-[var(--text-muted)]" style="font-size: 10px;"></i>
${formatShortDate(sortBy === 'meeting_date' ? recording.meeting_date : recording.created_at)}
</div>
<!-- Participants -->
<div v-if="recording.participants" class="flex items-center min-w-0 flex-1">
<i class="fas fa-users mr-1 text-[var(--text-muted)] flex-shrink-0" style="font-size: 10px;"></i>
<span class="truncate">
${recording.participants}
</span>
</div>
</div>
<!-- Tags -->
<div v-if="getRecordingTags(recording).length > 0 || recording.duplicate_info" class="flex flex-wrap items-center gap-1 mt-1">
<button v-for="tag in getRecordingTags(recording).slice(0, 4)" :key="tag.id"
@click.stop="filterByTag(tag)"
class="inline-flex items-center px-1.5 py-0.5 rounded-md 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) : ('Filter by ' + tag.name)">
<i v-if="tag.group_id" class="fas fa-users mr-1" style="font-size: 9px; vertical-align: middle; line-height: 0;"></i>
<span v-text="tag.name"></span>
</button>
<span v-if="getRecordingTags(recording).length > 4"
class="inline-flex items-center px-1.5 py-0.5 rounded-md text-xs text-[var(--text-muted)]"
:title="'More tags...'">
+${getRecordingTags(recording).length - 4}
</span>
<button v-if="recording.duplicate_info"
@click.stop="openDuplicatesModal(recording.duplicate_info)"
class="text-amber-500 hover:text-amber-400 transition-colors"
:title="recording.duplicate_info.total_copies + ' ' + (t('upload.copies') || 'copies')">
<i class="fas fa-copy text-xs"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Load More Indicator -->
<div v-if="isLoadingMore" class="text-center py-4">
<i class="fas fa-spinner fa-spin text-lg text-[var(--text-muted)]"></i>
<p class="text-sm text-[var(--text-muted)] mt-1" v-text="t('help.loadingMore')"></p>
</div>
<!-- End of Results Indicator -->
<div v-else-if="!hasNextPage && totalRecordings > 0" class="text-center py-4 text-xs text-[var(--text-muted)]">
<i class="fas fa-check-circle mr-1"></i>
<span v-text="t('help.allRecordingsLoaded')"></span>
</div>
</div>
</div>
</div>
</aside>
<!-- Vertical Floating Bulk Action Bar - To the right of sidebar -->
<transition name="slide-right">
<div v-if="selectionMode && selectedCount > 0 && !isSidebarCollapsed"
class="fixed top-1/2 -translate-y-1/2 left-80 z-50 flex flex-col gap-1 p-2 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-r-xl shadow-lg">
<!-- Selection count badge -->
<div class="text-center text-xs font-medium text-[var(--text-accent)] py-1 border-b border-[var(--border-primary)] mb-1">
${ selectedCount }
</div>
<button @click="openBulkTagModal('add')"
class="w-10 h-10 flex items-center justify-center bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-lg transition-colors border border-[var(--border-secondary)]"
title="Add or remove tags">
<i class="fas fa-tags text-[var(--text-muted)]"></i>
</button>
<button v-if="foldersEnabled"
@click="showBulkFolderModal = true"
class="w-10 h-10 flex items-center justify-center bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-lg transition-colors border border-[var(--border-secondary)]"
title="Move to folder">
<i class="fas fa-folder text-emerald-500"></i>
</button>
<button @click="bulkToggleInbox()"
class="w-10 h-10 flex items-center justify-center bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-lg transition-colors border border-[var(--border-secondary)]"
title="Toggle inbox status">
<i class="fas fa-inbox text-blue-500"></i>
</button>
<button @click="bulkToggleHighlight()"
class="w-10 h-10 flex items-center justify-center bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-lg transition-colors border border-[var(--border-secondary)]"
title="Toggle highlight">
<i class="fas fa-star text-amber-500"></i>
</button>
<button @click="openBulkReprocessModal"
class="w-10 h-10 flex items-center justify-center bg-[var(--bg-tertiary)] hover:bg-[var(--bg-input)] rounded-lg transition-colors border border-[var(--border-secondary)]"
title="Reprocess">
<i class="fas fa-redo text-[var(--text-muted)]"></i>
</button>
<div class="border-t border-[var(--border-primary)] my-1"></div>
<button @click="openBulkDeleteModal"
class="w-10 h-10 flex items-center justify-center bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
title="Delete selected">
<i class="fas fa-trash"></i>
</button>
<button @click="exitSelectionMode"
class="w-10 h-10 flex items-center justify-center text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
title="Exit selection mode">
<i class="fas fa-times"></i>
</button>
</div>
</transition>
<!-- Bulk Folder Assignment Modal -->
<div v-if="showBulkFolderModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-[60]" @click.self="showBulkFolderModal = false">
<div class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl max-w-sm w-full mx-4 overflow-hidden">
<div class="p-4 border-b border-[var(--border-primary)]">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-[var(--text-primary)]">
<i class="fas fa-folder mr-2 text-emerald-500"></i>
Move to Folder
</h3>
<button @click="showBulkFolderModal = false" class="text-[var(--text-muted)] hover:text-[var(--text-primary)]">
<i class="fas fa-times"></i>
</button>
</div>
<p class="text-sm text-[var(--text-muted)] mt-1">
${ selectedCount } recording(s) selected
</p>
</div>
<div class="p-4 max-h-64 overflow-y-auto">
<!-- No Folder Option -->
<button @click="bulkAssignFolder(null)"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left hover:bg-[var(--bg-tertiary)] transition-colors mb-2 border border-[var(--border-secondary)]">
<i class="fas fa-folder-minus text-[var(--text-muted)]"></i>
<span class="text-sm text-[var(--text-secondary)]">Remove from folder</span>
</button>
<!-- Divider -->
<div v-if="availableFolders.length > 0" class="border-t border-[var(--border-primary)] my-2"></div>
<!-- Folder Options -->
<button v-for="folder in availableFolders"
:key="folder.id"
@click="bulkAssignFolder(folder.id)"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left hover:bg-[var(--bg-tertiary)] transition-colors mb-1"
:style="{ borderLeft: '3px solid ' + (folder.color || '#10B981') }">
<i class="fas fa-folder" :style="{ color: folder.color || '#10B981' }"></i>
<div class="flex-1 min-w-0">
<span class="text-sm text-[var(--text-primary)] block truncate">${ folder.name }</span>
<span v-if="folder.group_name" class="text-xs text-[var(--text-muted)]">
<i class="fas fa-users mr-1" style="font-size: 9px;"></i>${ folder.group_name }
</span>
</div>
<span class="text-xs text-[var(--text-muted)]">${ folder.recording_count || 0 }</span>
</button>
<!-- Empty State -->
<p v-if="availableFolders.length === 0" class="text-sm text-[var(--text-muted)] text-center py-4">
No folders created. Create folders in your account settings.
</p>
</div>
<div class="p-4 border-t border-[var(--border-primary)] bg-[var(--bg-tertiary)]">
<button @click="showBulkFolderModal = false"
class="w-full px-4 py-2 text-sm text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
Cancel
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,17 @@
<!-- Token Budget Indicator Component -->
<!-- Only shows if user has a budget limit set -->
<div v-if="tokenBudget && tokenBudget.has_budget"
class="hidden sm:flex items-center gap-1.5 px-2 py-1 rounded text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors cursor-default"
:title="t('adminDashboard.tokenUsage') + ': ' + (tokenBudget.usage || 0).toLocaleString() + ' / ' + (tokenBudget.budget || 0).toLocaleString()">
<i class="fas fa-coins text-[10px]"
:style="tokenBudget.percentage >= 100 ? {color: '#ef4444'} : tokenBudget.percentage >= 80 ? {color: '#f59e0b'} : {}"></i>
<span :style="tokenBudget.percentage >= 100 ? {color: '#ef4444'} : tokenBudget.percentage >= 80 ? {color: '#f59e0b'} : {}">${ tokenBudget.percentage }%</span>
<div class="w-12 h-1 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-300 bg-[var(--text-accent)]"
:style="{
width: Math.min(tokenBudget.percentage, 100) + '%',
backgroundColor: tokenBudget.percentage >= 100 ? '#ef4444' : tokenBudget.percentage >= 80 ? '#f59e0b' : null
}">
</div>
</div>
</div>

View File

@@ -0,0 +1,404 @@
<!-- Upload View -->
<div v-if="currentView === 'upload'" class="flex-1 flex items-center justify-center p-4 md:p-8 overflow-y-auto">
<div class="max-w-lg w-full my-auto">
<!-- Compact Header -->
<div class="text-center mb-3">
<h2 class="text-lg font-semibold flex items-center justify-center gap-2">
<i class="fas fa-microphone text-[var(--text-accent)]"></i>
<span v-text="t('upload.title')"></span>
</h2>
</div>
<div class="bg-[var(--bg-secondary)] p-4 md:p-6 rounded-xl border border-[var(--border-primary)]">
<!-- File Upload Area -->
<div @drop="handleDrop" @dragover="handleDragOver" @dragleave="handleDragLeave" @click="$refs.fileInput.click()"
:class="['border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-all duration-300', dragover ? 'border-[var(--border-accent)] bg-[var(--bg-accent)]' : 'border-[var(--border-secondary)] hover:border-[var(--border-accent)]']">
<i class="fas fa-cloud-upload-alt text-2xl text-[var(--text-muted)] mb-2"></i>
<p class="text-sm text-[var(--text-secondary)] mb-1" v-text="t('upload.dropzone')"></p>
<p class="text-xs text-[var(--text-muted)]" v-text="t('upload.supportedFormats')"></p>
</div>
<input ref="fileInput" type="file" @change="handleFileSelect" accept="audio/*,video/*,.mp3,.m4a,.wav,.aac,.ogg,.flac,.wma,.aiff,.opus,.caf,.3gp,.3gpp,.amr,.mp4,.mov,.webm,.mkv,.avi,.m4v,.ts,.mts,.wmv,.flv,.mpeg,.mpg,.ogv,.vob,.asf" multiple class="hidden">
<!-- Queued Files Display -->
<div v-if="pendingQueueFiles.length > 0" class="mt-3">
<div class="flex items-center justify-between mb-1.5">
<h4 class="text-xs font-medium text-[var(--text-secondary)]">
<i class="fas fa-list mr-1"></i>
${ t('upload.filesToUpload') } (${pendingQueueFiles.length})
</h4>
</div>
<div class="space-y-1 max-h-32 overflow-y-auto">
<div v-for="item in pendingQueueFiles" :key="item.clientId"
class="flex items-center justify-between px-2 py-1.5 bg-[var(--bg-tertiary)] rounded border border-[var(--border-secondary)]">
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<i class="fas fa-file-audio text-[var(--text-muted)] text-xs"></i>
<span class="text-xs text-[var(--text-primary)] truncate">${item.file.name}</span>
<span class="text-[10px] text-[var(--text-muted)] flex-shrink-0">(${formatFileSize(item.file.size)})</span>
</div>
<button @click="removeFromQueue(item.clientId)"
class="ml-1.5 p-0.5 text-[var(--text-muted)] hover:text-red-500 transition-colors flex-shrink-0"
title="Remove from queue">
<i class="fas fa-times text-xs"></i>
</button>
</div>
</div>
<!-- Incognito Mode Toggle (only shown if feature is enabled via ENABLE_INCOGNITO_MODE env var) -->
<div v-if="enableIncognitoMode" class="mt-3">
<button @click="incognitoMode = !incognitoMode"
:class="[
'w-full flex items-center justify-between px-3 py-2.5 rounded-lg border transition-all duration-200',
incognitoMode
? 'bg-gradient-to-r from-violet-500/10 to-purple-500/10 border-violet-400/50 dark:border-violet-500/50'
: 'bg-[var(--bg-tertiary)] border-[var(--border-secondary)] hover:border-[var(--border-focus)]'
]">
<div class="flex items-center gap-3">
<div :class="[
'w-8 h-8 rounded-full flex items-center justify-center transition-all',
incognitoMode
? 'bg-gradient-to-br from-violet-500 to-purple-600 text-white'
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)]'
]">
<i class="fas fa-user-secret text-sm"></i>
</div>
<div class="text-left">
<span :class="['text-sm font-medium', incognitoMode ? 'text-violet-700 dark:text-violet-300' : 'text-[var(--text-secondary)]']">
${ t('incognito.mode') }
</span>
<p v-if="!incognitoMode" class="text-[10px] text-[var(--text-muted)]">
${ t('incognito.processWithoutSaving') }
</p>
<p v-else class="text-[10px] text-violet-600 dark:text-violet-400">
${ t('incognito.sessionOnly') } &bull; ${ t('incognito.notSavedToAccount') }
</p>
</div>
</div>
<div :class="[
'w-10 h-5 rounded-full relative transition-all duration-200',
incognitoMode ? 'bg-gradient-to-r from-violet-500 to-purple-500' : 'bg-[var(--bg-secondary)]'
]">
<div :class="[
'absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all duration-200',
incognitoMode ? 'left-5' : 'left-0.5'
]"></div>
</div>
</button>
</div>
<!-- Upload Buttons -->
<div class="mt-2 space-y-2">
<!-- Normal Upload Button (when not in incognito mode or feature disabled) -->
<button v-if="!incognitoMode || !enableIncognitoMode"
@click="startUpload"
class="w-full px-4 py-2.5 bg-[var(--bg-button)] text-[var(--text-button)] rounded-lg hover:bg-[var(--bg-button-hover)] transition-colors flex items-center justify-center gap-2 font-medium text-sm">
<i class="fas fa-cloud-upload-alt"></i>
<span v-text="t('upload.uploadNFiles', { count: pendingQueueFiles.length })"></span>
</button>
<!-- Incognito Upload Button (only when feature enabled and mode selected) -->
<button v-else-if="enableIncognitoMode && incognitoMode"
@click="startIncognitoUpload"
:disabled="incognitoProcessing || pendingQueueFiles.length !== 1"
class="w-full px-4 py-2.5 bg-gradient-to-r from-violet-500 to-purple-600 text-white rounded-lg hover:from-violet-600 hover:to-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2 font-medium text-sm shadow-sm">
<i :class="incognitoProcessing ? 'fas fa-spinner fa-spin' : 'fas fa-user-secret'"></i>
<span v-if="incognitoProcessing">${ t('incognito.processing') }</span>
<span v-else-if="pendingQueueFiles.length !== 1">${ t('incognito.selectExactlyOneFile') }</span>
<span v-else>${ t('incognito.processInIncognito') }</span>
</button>
<p v-if="enableIncognitoMode && incognitoMode && pendingQueueFiles.length > 1" class="text-xs text-violet-600 dark:text-violet-400 text-center">
<i class="fas fa-info-circle mr-1"></i>
${ t('incognito.oneFileAtATime') }
</p>
</div>
</div>
<!-- Divider (hidden when files are selected) -->
<div v-if="pendingQueueFiles.length === 0" class="my-4 flex items-center">
<div class="flex-grow border-t border-[var(--border-secondary)]"></div>
<span class="flex-shrink mx-3 text-[10px] text-[var(--text-muted)] uppercase" v-text="t('common.or')"></span>
<div class="flex-grow border-t border-[var(--border-secondary)]"></div>
</div>
<!-- Audio Recording Options (hidden when files are selected) -->
<div v-if="pendingQueueFiles.length === 0" class="space-y-2">
<h3 class="text-xs font-medium text-[var(--text-secondary)] text-center" v-text="t('recording.title')"></h3>
<!-- Microphone Recording -->
<button @click="startRecording('microphone')"
class="w-full px-3 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center justify-center gap-2 text-sm">
<i class="fas fa-microphone"></i>
<span v-text="t('recording.microphone')"></span>
</button>
<!-- System Audio and Both - Side by Side -->
<div v-if="canRecordSystemAudio" class="grid grid-cols-2 gap-2">
<!-- System Audio Recording -->
<button @click="startRecording('system')"
class="px-3 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-1.5 text-sm">
<i class="fas fa-desktop"></i>
<span v-text="t('recording.systemAudio')"></span>
</button>
<!-- Both Audio Sources -->
<button @click="startRecording('both')"
class="px-3 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center gap-1 text-sm">
<i class="fas fa-microphone"></i>
<i class="fas fa-desktop"></i>
<span class="hidden sm:inline" v-text="t('recording.microphoneAndSystem')"></span>
<span class="sm:hidden">${ t('recording.micPlusSys') }</span>
</button>
</div>
<!-- Help Text - Compact -->
<div class="text-[10px] text-[var(--text-muted)] space-y-0.5 mt-2">
<div class="flex items-center justify-between">
<span><strong v-text="t('recording.microphone') + ':'"></strong> <span v-text="t('help.microphoneDesc')"></span></span>
<i class="fas fa-microphone text-red-500 flex-shrink-0 ml-1"></i>
</div>
<div v-if="canRecordSystemAudio" class="flex items-center justify-between">
<span><strong v-text="t('recording.systemAudio') + ':'"></strong> <span v-text="t('help.systemAudioDesc')"></span></span>
<i class="fas fa-desktop text-blue-500 flex-shrink-0 ml-1"></i>
</div>
<div v-if="canRecordSystemAudio" class="flex items-center justify-between">
<span><strong v-text="t('recording.microphoneAndSystem') + ':'"></strong> <span v-text="t('help.bothAudioDesc')"></span></span>
<div class="flex items-center gap-0.5 flex-shrink-0 ml-1">
<i class="fas fa-microphone text-purple-500"></i>
<i class="fas fa-desktop text-purple-500"></i>
</div>
</div>
<div v-if="!canRecordSystemAudio" class="text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
${ t('recording.systemAudioNotSupported') }
<button @click="showSystemAudioHelp = true" class="ml-1 text-blue-500 hover:text-blue-600 underline">
<span v-text="t('buttons.help')"></span>
</button>
</div>
</div>
</div>
<!-- Folder Selection (only shown if folders are enabled) -->
<div v-if="foldersEnabled" class="mt-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-2">
<i class="fas fa-folder mr-1"
:style="{ color: selectedFolderId ? getFolderColor(selectedFolderId) : 'var(--text-muted)' }"></i>
${ t('form.folder') }
</label>
<div class="relative">
<select v-model="selectedFolderId"
class="w-full pl-8 pr-8 py-2 text-sm rounded-lg cursor-pointer focus:outline-none focus:ring-2 focus:ring-[var(--border-focus)] appearance-none border border-[var(--border-secondary)] bg-[var(--bg-tertiary)] text-[var(--text-primary)] hover:bg-[var(--bg-input)] transition-colors">
<option :value="null">No Folder</option>
<option v-for="folder in availableFolders" :key="folder.id" :value="folder.id">
${ folder.name }
</option>
</select>
<i class="fas fa-folder absolute left-2.5 top-1/2 transform -translate-y-1/2 pointer-events-none"
:style="{ color: selectedFolderId ? getFolderColor(selectedFolderId) : 'var(--text-muted)' }"></i>
<i class="fas fa-chevron-down absolute right-2.5 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] pointer-events-none" style="font-size: 10px;"></i>
</div>
<p v-if="selectedFolderId && getFolderById(selectedFolderId)?.custom_prompt" class="text-xs text-[var(--text-muted)] mt-1">
<i class="fas fa-info-circle mr-1 text-[var(--text-accent)]"></i>
${ t('help.folderHasCustomPrompt') }
</p>
<p v-if="availableFolders.length === 0" class="text-xs text-[var(--text-muted)] mt-1">
<i class="fas fa-info-circle mr-1"></i>
<a href="/account#folders" class="text-[var(--text-accent)] hover:underline">${ t('help.createFolders') }</a> ${ t('help.toOrganizeRecordings') }
</p>
</div>
<!-- Tag Selection -->
<div class="mt-4">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-2">
<i class="fas fa-tags mr-1 text-[var(--text-muted)]"></i>
${ t('tags.title') }
</label>
<!-- Selected Tags Display - Compact with Drag Reorder -->
<div v-if="selectedTags.length > 0" class="mb-2">
<div class="p-1.5 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)] max-h-16 overflow-y-auto"
@touchmove="handleTagTouchMove">
<div class="flex flex-wrap gap-1">
<span v-for="(tag, index) in selectedTags" :key="tag.id"
:data-tag-index="index"
draggable="true"
@dragstart="handleTagDragStart(index, $event)"
@dragover="handleTagDragOver(index, $event)"
@drop="handleTagDrop(index, $event)"
@dragend="handleTagDragEnd"
@touchstart="handleTagTouchStart(index, $event)"
@touchend="handleTagTouchEnd"
:class="[
'inline-flex items-center px-1.5 py-0.5 rounded-full text-[11px] font-medium transition-all duration-150',
draggedTagIndex === index ? 'opacity-50 cursor-grabbing' : 'cursor-grab',
dragOverTagIndex === index && draggedTagIndex !== index ? 'ring-2 ring-[var(--ring-focus)] ring-offset-1' : ''
]"
:style="{ backgroundColor: tag.color, color: getContrastTextColor(tag.color) }"
:title="(tag.group_id ? ('Group: ' + tag.group_name) : tag.name) + ' (drag to reorder)'">
<span class="opacity-75 mr-0.5 text-[9px]">${index + 1}.</span>
<i v-if="tag.group_id" class="fas fa-users mr-1 text-[9px]"></i>
<span v-if="tag.group_id" class="opacity-75">${tag.group_name}: </span>
<span>${tag.name}</span>
<button @click.stop="removeTagFromSelection(tag.id)"
class="ml-1 hover:opacity-100 opacity-70">
<i class="fas fa-times" style="font-size: 9px;"></i>
</button>
</span>
</div>
</div>
<p class="text-[10px] text-[var(--text-muted)] mt-0.5">
<i class="fas fa-grip-vertical mr-0.5" style="font-size: 9px;"></i>
${ t('help.dragToReorder') } &bull; ${ t('help.firstTagDefaultsApplied') }
</p>
</div>
<!-- Tag Selection Container - Compact -->
<div v-if="availableTags.filter(tag => !selectedTagIds.includes(tag.id)).length > 0"
class="border border-[var(--border-secondary)] rounded-md bg-[var(--bg-tertiary)] p-2">
<!-- Search Filter (always visible if tags available) -->
<div class="mb-1.5">
<div class="relative">
<input v-model="uploadTagSearchFilter"
type="text"
:placeholder="t('tagsModal.searchTags')"
class="w-full px-2 py-1 pl-6 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
<i class="fas fa-search absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]" style="font-size: 9px;"></i>
<button v-if="uploadTagSearchFilter"
@click="uploadTagSearchFilter = ''"
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text-primary)]">
<i class="fas fa-times" style="font-size: 9px;"></i>
</button>
</div>
</div>
<!-- Available Tags Grid - Fixed Height -->
<div v-if="filteredAvailableTagsForUpload.length > 0"
class="overflow-y-auto"
style="max-height: 100px;">
<div class="grid grid-cols-2 gap-1">
<button v-for="tag in filteredAvailableTagsForUpload"
:key="tag.id"
@click="addTagToSelection(tag.id)"
class="group flex items-center justify-between px-1.5 py-1 rounded border border-[var(--border-secondary)] hover:border-[var(--border-focus)] bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)] transition-all">
<div class="flex items-center gap-1 min-w-0">
<i v-if="tag.group_id" class="fas fa-users flex-shrink-0" style="font-size: 9px;" :style="{ color: tag.color || '#6B7280' }"></i>
<span v-else class="w-2 h-2 rounded-full flex-shrink-0"
:style="{ backgroundColor: tag.color || '#6B7280' }"></span>
<span class="text-[11px] text-[var(--text-primary)] truncate">
<span v-if="tag.group_id" class="opacity-75">${tag.group_name}: </span>${tag.name}
</span>
</div>
<i class="fas fa-plus text-[var(--text-muted)] group-hover:text-[var(--text-accent)] transition-colors" style="font-size: 9px;"></i>
</button>
</div>
</div>
<div v-else class="py-2 text-center">
<p class="text-[11px] text-[var(--text-muted)]">
<i class="fas fa-search mr-1" style="font-size: 9px;"></i>
${ t('help.noMatchingTags') }
</p>
</div>
</div>
<!-- Empty States -->
<div v-else-if="availableTags.length === 0"
class="p-2 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)]">
<p class="text-xs text-[var(--text-muted)] text-center">
<i class="fas fa-info-circle mr-1" style="font-size: 10px;"></i>
<span v-text="t('help.noTagsCreated')"></span> <a href="/account#tags" class="text-[var(--text-accent)] hover:underline" v-text="t('help.createTags')"></a>
</p>
</div>
<div v-else class="p-2 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-secondary)]">
<p class="text-xs text-[var(--text-muted)] text-center">
<i class="fas fa-check-circle mr-1" style="font-size: 10px;"></i>
${ t('help.allTagsSelected') }
</p>
</div>
<p v-if="selectedTags.some(tag => tag.custom_prompt)" class="text-xs text-[var(--text-muted)] mt-1">
<i class="fas fa-info-circle mr-1 text-[var(--text-accent)]"></i>
${ t('help.selectedTagsCustomPrompts') }
</p>
<p v-if="selectedTags.length > 0 && connectorSupportsDiarization && selectedTags.some(tag => tag.default_language || tag.default_min_speakers || tag.default_max_speakers || tag.default_hotwords || tag.default_initial_prompt)"
class="text-xs text-[var(--text-muted)] mt-1">
<i class="fas fa-cog mr-1 text-[var(--text-accent)]"></i>
${ t('help.firstTagAsrSettings') } ${selectedTags[0].name}
</p>
</div>
<!-- Advanced Options for diarization-enabled connectors (collapsible) -->
<div v-if="connectorSupportsDiarization" class="mt-4">
<button @click="showAdvancedOptions = !showAdvancedOptions"
class="w-full flex items-center justify-between px-3 py-2 bg-[var(--bg-tertiary)] rounded-md hover:bg-[var(--bg-accent)] transition-colors text-xs font-medium">
<span class="flex items-center gap-2 text-[var(--text-secondary)]">
<i class="fas fa-cog text-[var(--text-muted)]"></i>
<span v-text="t('help.advancedAsrOptions')"></span>
</span>
<i :class="['fas text-[var(--text-muted)]', showAdvancedOptions ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
</button>
<div v-show="showAdvancedOptions" class="mt-2 p-3 bg-[var(--bg-tertiary)] rounded-md space-y-3 border border-[var(--border-secondary)]">
<!-- Language Selection -->
<div>
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
${ t('form.transcriptionLanguage') }
</label>
<select v-model="uploadLanguage"
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
<option v-for="lang in languageOptions" :key="lang.value" :value="lang.value" v-text="lang.label"></option>
</select>
</div>
<!-- Speaker Settings - only show for connectors that support min/max speakers -->
<div v-if="connectorSupportsSpeakerCount" class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1" v-text="t('form.minSpeakers')">
</label>
<input v-model.number="uploadMinSpeakers"
type="number"
min="1"
max="20"
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
</div>
<div>
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1" v-text="t('form.maxSpeakers')">
</label>
<input v-model.number="uploadMaxSpeakers"
type="number"
min="1"
max="20"
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
</div>
</div>
<!-- Hotwords -->
<div>
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
${ t('form.hotwords') }
</label>
<input v-model="uploadHotwords"
type="text"
:placeholder="t('form.hotwordsPlaceholder')"
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
<p class="text-[10px] text-[var(--text-muted)] mt-0.5">${ t('form.hotwordsHelp') }</p>
</div>
<!-- Initial Prompt -->
<div>
<label class="block text-xs font-medium text-[var(--text-secondary)] mb-1">
${ t('form.initialPrompt') }
</label>
<textarea v-model="uploadInitialPrompt"
rows="2"
:placeholder="t('form.initialPromptPlaceholder')"
class="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)] resize-y"></textarea>
<p class="text-[10px] text-[var(--text-muted)] mt-0.5">${ t('form.initialPromptHelp') }</p>
</div>
<p class="text-xs text-[var(--text-muted)] pt-1">
<i class="fas fa-info-circle mr-1 text-[var(--text-accent)]"></i>
${ t('upload.settingsApplyToAll') }
</p>
</div>
</div>
</div>
</div>
</div>