Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)
This commit is contained in:
11
templates/components/banner.html
Normal file
11
templates/components/banner.html
Normal 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>
|
||||
57
templates/components/detail-view.html
Normal file
57
templates/components/detail-view.html
Normal 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">
|
||||
• Session only • 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' %}
|
||||
236
templates/components/detail/audio-player.html
Normal file
236
templates/components/detail/audio-player.html
Normal 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>
|
||||
115
templates/components/detail/desktop-chat-section.html
Normal file
115
templates/components/detail/desktop-chat-section.html
Normal 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>
|
||||
65
templates/components/detail/desktop-events-tab.html
Normal file
65
templates/components/detail/desktop-events-tab.html
Normal 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>
|
||||
220
templates/components/detail/desktop-header.html
Normal file
220
templates/components/detail/desktop-header.html
Normal 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>
|
||||
57
templates/components/detail/desktop-notes-tab.html
Normal file
57
templates/components/detail/desktop-notes-tab.html
Normal 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>
|
||||
272
templates/components/detail/desktop-right-panel.html
Normal file
272
templates/components/detail/desktop-right-panel.html
Normal 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>
|
||||
71
templates/components/detail/desktop-summary-tab.html
Normal file
71
templates/components/detail/desktop-summary-tab.html
Normal 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>
|
||||
263
templates/components/detail/desktop-transcription-panel.html
Normal file
263
templates/components/detail/desktop-transcription-panel.html
Normal 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>
|
||||
12
templates/components/detail/empty-state.html
Normal file
12
templates/components/detail/empty-state.html
Normal 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>
|
||||
59
templates/components/detail/mobile-chat-panel.html
Normal file
59
templates/components/detail/mobile-chat-panel.html
Normal 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>
|
||||
65
templates/components/detail/mobile-events-panel.html
Normal file
65
templates/components/detail/mobile-events-panel.html
Normal 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>
|
||||
153
templates/components/detail/mobile-header.html
Normal file
153
templates/components/detail/mobile-header.html
Normal 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>
|
||||
26
templates/components/detail/mobile-notes-panel.html
Normal file
26
templates/components/detail/mobile-notes-panel.html
Normal 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>
|
||||
45
templates/components/detail/mobile-summary-panel.html
Normal file
45
templates/components/detail/mobile-summary-panel.html
Normal 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>
|
||||
130
templates/components/detail/mobile-transcript-panel.html
Normal file
130
templates/components/detail/mobile-transcript-panel.html
Normal 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>
|
||||
20
templates/components/detail/tab-navigation.html
Normal file
20
templates/components/detail/tab-navigation.html
Normal 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>
|
||||
91
templates/components/dictia/help-tab.html
Normal file
91
templates/components/dictia/help-tab.html
Normal 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égrée de DictIA. Sé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é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>
|
||||
99
templates/components/header.html
Normal file
99
templates/components/header.html
Normal 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>
|
||||
189
templates/components/progress-popup.html
Normal file
189
templates/components/progress-popup.html
Normal 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>
|
||||
403
templates/components/recording-view.html
Normal file
403
templates/components/recording-view.html
Normal 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 • 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 • 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>
|
||||
604
templates/components/sidebar.html
Normal file
604
templates/components/sidebar.html
Normal 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>
|
||||
17
templates/components/token_budget_indicator.html
Normal file
17
templates/components/token_budget_indicator.html
Normal 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>
|
||||
404
templates/components/upload-view.html
Normal file
404
templates/components/upload-view.html
Normal 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') } • ${ 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') } • ${ 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>
|
||||
Reference in New Issue
Block a user