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

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

View File

@@ -0,0 +1,353 @@
<!-- Speaker Identification Modal -->
<div v-if="showSpeakerModal" @click.self="closeSpeakerModal" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-2 sm:p-4 backdrop-blur-sm">
<div @click="closeSpeakerSuggestionsOnClick" class="bg-[var(--bg-secondary)] rounded-xl shadow-2xl w-full max-w-4xl flex flex-col h-[95vh] sm:h-[85vh]">
<!-- Header -->
<div class="p-3 sm:p-5 border-b border-[var(--border-primary)] flex-shrink-0">
<div class="flex items-center justify-between">
<h3 class="text-base sm:text-xl font-bold text-[var(--text-primary)]" v-text="t('modal.identifySpeakers')"></h3>
<button @click="closeSpeakerModal" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- Warning for too many speakers -->
<div v-if="modalSpeakers.length > 16" class="mt-3 p-3 bg-[var(--bg-warn-light)] border border-amber-300 dark:border-amber-800 rounded-lg">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle text-[var(--text-warn-strong)] mt-0.5 mr-2"></i>
<div class="text-sm">
<p class="font-medium text-[var(--text-warn-strong)]" v-text="t('help.moreSpeakersThanColors')"></p>
<p class="text-[var(--text-warn-strong)] mt-1" v-text="t('help.youHaveXSpeakers', { count: modalSpeakers.length })">
</p>
</div>
</div>
</div>
</div>
<!-- Mobile Tab Switcher (hidden on lg screens) -->
<div class="lg:hidden flex border-b border-[var(--border-primary)] flex-shrink-0">
<button
@click="speakerModalTab = 'speakers'"
class="flex-1 py-2 text-xs font-medium transition-colors relative"
:class="speakerModalTab === 'speakers' ? 'text-[var(--text-accent)]' : 'text-[var(--text-muted)]'">
<i class="fas fa-users mr-1"></i>Speakers
<span v-if="speakerModalTab === 'speakers'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--bg-accent)]"></span>
</button>
<button
@click="speakerModalTab = 'transcript'"
class="flex-1 py-2 text-xs font-medium transition-colors relative"
:class="speakerModalTab === 'transcript' ? 'text-[var(--text-accent)]' : 'text-[var(--text-muted)]'">
<i class="fas fa-file-lines mr-1"></i>Transcript
<span v-if="speakerModalTab === 'transcript'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--bg-accent)]"></span>
</button>
</div>
<!-- Main Content - Tab-based on mobile, side-by-side on desktop -->
<div class="flex-grow flex flex-col lg:flex-row overflow-hidden modal-content">
<!-- Speaker List Panel - shown on mobile when speakers tab active, always on desktop -->
<div class="lg:w-1/3 p-3 sm:p-6 space-y-2 sm:space-y-4 overflow-y-auto custom-scrollbar lg:border-r border-[var(--border-primary)]"
:class="{ 'hidden lg:block': speakerModalTab !== 'speakers', 'flex-1': speakerModalTab === 'speakers' }">
<div v-for="(speaker, index) in modalSpeakers" :key="`${selectedRecording.id}-speaker-${index}-${speaker}`" class="space-y-1.5 sm:space-y-3">
<!-- Speaker label with color indicator and "This is Me" checkbox -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center space-x-1.5 min-w-0">
<div :class="speakerMap[speaker].color" class="w-3 h-3 sm:w-4 sm:h-4 rounded-full border border-white/30 shadow-sm flex-shrink-0"></div>
<span class="font-mono text-xs sm:text-sm text-[var(--text-muted)] truncate">${ speakerDisplayMap[speaker] || speaker }</span>
<span v-if="index >= 16" class="text-[10px] text-[var(--text-warn-strong)] bg-[var(--bg-warn-light)] px-1.5 py-0.5 rounded-full flex-shrink-0" :title="`Color repeats from speaker ${((index % 16) + 1)}`">
Repeat
</span>
</div>
<label class="flex items-center text-xs sm:text-sm text-[var(--text-muted)] cursor-pointer hover:text-[var(--text-primary)] transition-colors flex-shrink-0">
<input type="checkbox"
v-model="speakerMap[speaker].isMe"
@change="handleIsMeChange(speaker)"
@focus="highlightSpeakerInTranscript(speaker)"
@blur="clearSpeakerHighlight"
class="speaker-checkbox w-4 h-4 sm:w-4 sm:h-4">
<span class="ml-1.5 select-none" v-text="t('help.me')"></span>
</label>
</div>
<!-- Autocomplete input field -->
<div class="relative">
<input
type="text"
v-model="speakerMap[speaker].name"
@input="searchSpeakers($event.target.value, speaker)"
@focus="focusSpeaker(speaker)"
@blur="blurSpeaker()"
:disabled="speakerMap[speaker].isMe"
class="w-full px-2 py-2 sm:py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[var(--ring-focus)] focus:border-[var(--border-focus)] text-sm bg-[var(--bg-input)] text-[var(--text-primary)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed"
:class="{ 'pr-28': shouldShowVoiceSuggestionPill(speaker) }"
:placeholder="speakerMap[speaker].isMe ? currentUserName : t('help.enterNameFor') + ' ' + (speakerDisplayMap[speaker] || speaker)"
autocomplete="off">
<!-- Voice match suggestion pill -->
<button v-if="shouldShowVoiceSuggestionPill(speaker)"
@click.stop="applyVoiceSuggestion(speaker, voiceSuggestions[speaker][0])"
class="absolute right-1.5 top-1/2 transform -translate-y-1/2 px-1.5 py-0.5 bg-gradient-to-r from-blue-500 to-purple-500 text-white text-[10px] rounded-full flex items-center gap-0.5 hover:from-blue-600 hover:to-purple-600 transition-all shadow-sm hover:shadow group active:scale-95">
<i class="fas fa-waveform-lines text-[9px]"></i>
<span class="font-medium truncate max-w-[50px] sm:max-w-[80px]">${ voiceSuggestions[speaker][0].name }</span>
<span class="text-[9px] opacity-90">${ voiceSuggestions[speaker][0].similarity }%</span>
</button>
<!-- Loading indicator -->
<div v-if="loadingSuggestions[speaker] && !speakerMap[speaker].isMe" class="absolute right-2 top-1/2 transform -translate-y-1/2">
<i class="fas fa-spinner fa-spin text-[var(--text-muted)] text-xs"></i>
</div>
<!-- Suggestions dropdown -->
<div v-if="activeSpeakerInput === speaker && speakerSuggestions[speaker] && speakerSuggestions[speaker].length > 0 && !speakerMap[speaker].isMe"
@click.stop
class="absolute z-10 w-full mt-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-md shadow-lg max-h-40 overflow-y-auto">
<div class="py-0.5">
<div v-for="suggestion in speakerSuggestions[speaker]"
:key="suggestion.id"
@click="selectSpeakerSuggestion(speaker, suggestion)"
class="px-2 py-1.5 cursor-pointer hover:bg-[var(--bg-tertiary)] active:bg-[var(--bg-accent)] flex items-center justify-between">
<div class="flex-grow min-w-0">
<div class="text-xs font-medium text-[var(--text-primary)] truncate">${ suggestion.name }</div>
<div class="text-[10px] text-[var(--text-muted)]">
Used ${ suggestion.use_count }x
</div>
</div>
<i class="fas fa-user text-[var(--text-muted)] ml-1.5 flex-shrink-0 text-[10px]"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Add Speaker Button -->
<div class="pt-2 sm:pt-4 border-t border-[var(--border-primary)]">
<button @click="openAddSpeakerModal" class="w-full px-3 py-2 sm:py-2 bg-[var(--bg-accent)] text-[var(--text-accent)] rounded-md hover:bg-[var(--bg-accent-hover)] active:scale-[0.98] transition-all flex items-center justify-center text-xs sm:text-sm font-medium">
<i class="fas fa-plus mr-1.5"></i>
<span v-text="t('buttons.addSpeaker')"></span>
</button>
</div>
</div>
<!-- Audio Player & Transcript Panel - shown on mobile when transcript tab active, always on desktop -->
<div class="lg:w-2/3 p-3 sm:p-6 flex flex-col overflow-hidden"
:class="{ 'hidden lg:flex': speakerModalTab !== 'transcript', 'flex-1': speakerModalTab === 'transcript' }">
<!-- Audio Player Section -->
<div class="mb-2 sm:mb-4 flex-shrink-0">
<!-- Show message if audio has been deleted -->
<div v-if="selectedRecording.audio_deleted_at"
class="text-[var(--text-muted)] text-xs 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 -->
<div v-else-if="selectedRecording.incognito"
class="text-[var(--text-muted)] text-xs flex items-center gap-2">
<i class="fas fa-user-secret"></i>
<span>Audio not stored in incognito mode</span>
</div>
<!-- Custom Audio/Video Player (Independent from main player) -->
<div v-else>
<component :is="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') ? 'video' : 'audio'"
ref="speakerModalAudioRef"
:class="selectedRecording.mime_type && selectedRecording.mime_type.startsWith('video/') ? 'w-full rounded-lg mb-2' : 'hidden'"
:key="'speaker-modal-' + selectedRecording.id"
:src="'/audio/' + selectedRecording.id"
:volume="playerVolume"
@play="handleModalAudioPlayPause"
@pause="handleModalAudioPlayPause"
@timeupdate="handleModalAudioTimeUpdate"
@loadedmetadata="handleModalAudioLoadedMetadata"
@ended="modalAudioIsPlaying = false">
</component>
<div class="flex items-center gap-2">
<!-- Play/Pause -->
<button @click="$refs.speakerModalAudioRef?.paused ? $refs.speakerModalAudioRef.play() : $refs.speakerModalAudioRef.pause()"
class="w-8 h-8 flex items-center justify-center rounded-full bg-[var(--bg-accent)] hover:bg-[var(--bg-accent-hover)] text-white transition-all flex-shrink-0 shadow-sm"
:title="modalAudioIsPlaying ? 'Pause' : 'Play'">
<i :class="modalAudioIsPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-xs" :style="!modalAudioIsPlaying ? 'margin-left: 1px' : ''"></i>
</button>
<!-- Time -->
<div class="flex flex-col items-end flex-shrink-0 leading-none">
<span class="text-xs text-[var(--text-primary)] font-mono">${ formatAudioTime(modalAudioCurrentTime) }</span>
<span class="text-[10px] text-[var(--text-muted)] font-mono">${ formatAudioTime(modalAudioDuration) }</span>
</div>
<!-- Progress Bar -->
<div class="flex-1 h-1.5 bg-[var(--bg-tertiary)] rounded-full cursor-pointer group relative"
@click="(e) => { const rect = e.currentTarget.getBoundingClientRect(); const pct = (e.clientX - rect.left) / rect.width; if ($refs.speakerModalAudioRef) $refs.speakerModalAudioRef.currentTime = pct * modalAudioDuration; }">
<div class="h-full bg-[var(--bg-accent)] rounded-full transition-all duration-100"
:style="{ width: modalAudioProgressPercent + '%' }">
</div>
</div>
<!-- Volume Control -->
<div class="relative flex items-center flex-shrink-0"
@mouseenter="showModalVolumeSlider = true"
@mouseleave="showModalVolumeSlider = false">
<button @click="if ($refs.speakerModalAudioRef) { $refs.speakerModalAudioRef.muted = !$refs.speakerModalAudioRef.muted; audioIsMuted = $refs.speakerModalAudioRef.muted; }"
class="w-7 h-7 flex items-center justify-center rounded hover:bg-[var(--bg-tertiary)] transition-all flex-shrink-0"
:class="audioIsMuted || playerVolume === 0 ? 'text-[var(--text-muted)]' : 'text-[var(--text-primary)]'">
<i :class="audioIsMuted || playerVolume === 0 ? 'fas fa-volume-mute' : playerVolume < 0.5 ? 'fas fa-volume-down' : 'fas fa-volume-up'" class="text-xs"></i>
</button>
<!-- Volume Slider Popup (outer div is invisible hover bridge) -->
<div class="absolute top-full left-1/2 -translate-x-1/2 pt-2 z-[9999] transition-all duration-200"
:class="showModalVolumeSlider ? 'opacity-100 pointer-events-auto scale-100' : 'opacity-0 pointer-events-none scale-95'"
@mouseenter="showModalVolumeSlider = true"
@mouseleave="showModalVolumeSlider = false">
<div class="px-2.5 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-[9px] 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: 70px;">
</div>
</div>
</div>
<!-- Speed Control (compact - tap to cycle) -->
<button @click="cycleModalPlaybackRate(); if ($refs.speakerModalAudioRef) $refs.speakerModalAudioRef.playbackRate = modalPlaybackRate"
class="w-7 h-7 flex items-center justify-center rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all flex-shrink-0"
title="Playback speed (tap to change)">
<span class="text-[10px] font-semibold font-mono">${ formatPlaybackRate(modalPlaybackRate) }</span>
</button>
</div>
</div>
</div>
<!-- Transcript Section (Virtual Scrolling for performance) -->
<div ref="speakerModalTranscriptRef"
class="flex-grow overflow-y-auto custom-scrollbar speaker-modal-transcript min-h-0"
@click="(e) => { const t = e.target.closest('[data-start-time]')?.dataset.startTime; if (t && $refs.speakerModalAudioRef) { $refs.speakerModalAudioRef.currentTime = parseFloat(t); $refs.speakerModalAudioRef.play(); } }"
@scroll="onSpeakerModalScroll">
<div v-if="processedTranscription.isJson">
<!-- Virtual scroll spacer (top) -->
<div :style="{ height: speakerModalSpacerBefore + 'px' }"></div>
<!-- Only render visible segments -->
<div v-for="segment in speakerModalVisibleSegments"
:key="getVirtualItemKey(segment, 'sm')"
class="speaker-segment mb-1.5 sm:mb-2 group relative"
:data-start-time="segment.startTime"
:data-segment-index="segment._originalIndex">
<div class="flex items-start gap-1">
<!-- Speaker tag on its own line on mobile -->
<span :class="[segment.color, 'speaker-tag', { 'speaker-highlight': highlightedSpeaker === segment.speakerId }]" :data-speaker-id="segment.speakerId" class="inline-block flex-shrink-0 truncate text-xs sm:text-sm font-medium w-16 sm:w-24" :title="segment.speaker">${ segment.speaker }</span>
<!-- Text content -->
<span class="word cursor-pointer hover:bg-[var(--bg-accent)] hover:text-[var(--text-accent)] rounded px-0.5 flex-1 text-xs sm:text-sm leading-relaxed">${ segment.sentence }</span>
<!-- Edit buttons - inline on mobile -->
<span class="flex items-center gap-0.5 flex-shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
<button @click.stop="openSpeakerChangeDropdown(segment._originalIndex)" class="p-1 rounded bg-[var(--bg-tertiary)] hover:bg-[var(--bg-accent)] text-[var(--text-muted)] hover:text-[var(--text-accent)] transition-colors active:scale-95" title="Change speaker">
<i class="fas fa-user-edit text-[10px] sm:text-xs"></i>
</button>
<button @click.stop="openEditTextModal(segment._originalIndex)" class="p-1 rounded bg-[var(--bg-tertiary)] hover:bg-[var(--bg-accent)] text-[var(--text-muted)] hover:text-[var(--text-accent)] transition-colors active:scale-95" title="Edit text">
<i class="fas fa-edit text-[10px] sm:text-xs"></i>
</button>
</span>
</div>
<!-- Speaker Change Dropdown - compact on mobile -->
<div v-if="editingSpeakerIndex === segment._originalIndex" @click.stop class="mt-1 p-1.5 bg-[var(--bg-tertiary)] rounded-md border border-[var(--border-primary)] shadow-lg">
<p class="text-[10px] text-[var(--text-muted)] mb-1">Select speaker:</p>
<div class="flex flex-wrap gap-1">
<button v-for="speaker in modalSpeakers" :key="speaker" @click="changeSpeaker(segment._originalIndex, speaker)" class="px-2 py-1 rounded-md hover:bg-[var(--bg-accent)] hover:text-[var(--text-accent)] active:scale-[0.98] transition-all flex items-center gap-1 text-xs bg-[var(--bg-secondary)]">
<div :class="speakerMap[speaker].color" class="w-2.5 h-2.5 rounded-full flex-shrink-0"></div>
<span class="truncate max-w-[60px]">${ speakerMap[speaker].name || speaker }</span>
</button>
</div>
</div>
</div>
<!-- Virtual scroll spacer (bottom) -->
<div :style="{ height: speakerModalSpacerAfter + 'px' }"></div>
</div>
<div v-else class="whitespace-pre-wrap text-sm text-[var(--text-primary)]">${ processedTranscription.plainText }</div>
</div>
<!-- Speaker Navigation Controls - more compact -->
<div class="mt-1.5 sm:mt-3 p-1.5 sm:p-3 bg-[var(--bg-tertiary)] rounded-md flex flex-col gap-1.5 flex-shrink-0">
<!-- Speaker Selector Row -->
<div class="flex items-center gap-1.5">
<select
:value="highlightedSpeaker"
@change="selectSpeakerForNavigation($event.target.value)"
class="flex-1 px-2 py-1 text-xs bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--ring-focus)]">
<option value="">Navigate to speaker...</option>
<option v-for="speaker in modalSpeakers" :key="speaker" :value="speaker">
${ speakerMap[speaker]?.name || speakerDisplayMap[speaker] || speaker }
</option>
</select>
<span v-if="highlightedSpeaker && speakerGroups.length > 0" class="text-[10px] text-[var(--text-muted)] flex-shrink-0 tabular-nums">
${ currentSpeakerGroupIndex + 1 }/${ speakerGroups.length }
</span>
</div>
<!-- Navigation Buttons Row -->
<div class="flex items-center gap-1.5" :class="{'opacity-50': !highlightedSpeaker || speakerGroups.length <= 1}">
<button @mousedown.prevent @click="navigateToPrevSpeakerGroup"
class="flex-1 px-2 py-1.5 bg-[var(--bg-secondary)] hover:bg-[var(--bg-primary)] text-[var(--text-secondary)] rounded border border-[var(--border-secondary)] transition-all active:scale-95 flex items-center justify-center text-xs disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!highlightedSpeaker || speakerGroups.length <= 1">
<i class="fas fa-chevron-up mr-1"></i>Prev
</button>
<button @mousedown.prevent @click="navigateToNextSpeakerGroup"
class="flex-1 px-2 py-1.5 bg-[var(--bg-secondary)] hover:bg-[var(--bg-primary)] text-[var(--text-secondary)] rounded border border-[var(--border-secondary)] transition-all active:scale-95 flex items-center justify-center text-xs disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!highlightedSpeaker || speakerGroups.length <= 1">
Next<i class="fas fa-chevron-down ml-1"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Regenerate Summary Checkbox -->
<div class="p-2 sm:p-4 bg-[var(--bg-tertiary)] border-t border-[var(--border-primary)] flex-shrink-0">
<label class="flex items-center text-xs sm:text-sm text-[var(--text-muted)] cursor-pointer hover:text-[var(--text-primary)] transition-colors">
<input type="checkbox"
v-model="regenerateSummaryAfterSpeakerUpdate"
class="speaker-checkbox w-4 h-4">
<span class="ml-1.5 select-none" v-text="t('help.regenerateSummaryAfterNames')"></span>
</label>
</div>
<!-- Footer Buttons -->
<div class="bg-[var(--bg-tertiary)] px-3 sm:px-6 py-2 sm:py-4 flex flex-row justify-between items-center gap-2 border-t border-[var(--border-primary)] flex-shrink-0 rounded-b-xl">
<!-- Left: Auto Identify Split Button + Apply Suggested -->
<div class="flex items-center gap-2">
<!-- Split Button: Auto Identify -->
<div class="relative inline-flex" ref="autoIdSplitBtn">
<!-- Main button: identify missing only -->
<button @click="autoIdentifySpeakers(false)" class="px-2 sm:px-3 py-1.5 sm:py-2 bg-purple-600 text-white rounded-l-md hover:bg-purple-700 active:scale-[0.98] transition-all flex items-center justify-center text-xs sm:text-sm font-medium" :disabled="isAutoIdentifying">
<i class="fas fa-magic mr-1 sm:mr-1.5"></i>
<span v-if="!isAutoIdentifying" class="hidden sm:inline" v-text="t('help.autoIdentify')"></span>
<span v-if="!isAutoIdentifying" class="sm:hidden" v-text="t('help.autoIdentifyMobile')"></span>
<span v-else>
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
<!-- Dropdown arrow -->
<button @click="showAutoIdDropdown = !showAutoIdDropdown" class="px-1.5 py-1.5 sm:py-2 bg-purple-600 text-white rounded-r-md border-l border-purple-500 hover:bg-purple-700 active:scale-[0.98] transition-all text-xs sm:text-sm" :disabled="isAutoIdentifying">
<i class="fas fa-caret-down"></i>
</button>
<!-- Dropdown menu -->
<div v-if="showAutoIdDropdown" class="absolute bottom-full left-0 mb-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-md shadow-lg z-50 min-w-[180px]">
<button @click="autoIdentifySpeakers(true)" class="w-full px-3 py-2 text-left text-xs sm:text-sm text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors rounded-md">
<i class="fas fa-users mr-2"></i><span v-text="t('help.identifyAllSpeakers')"></span>
</button>
</div>
</div>
<!-- Apply Suggested Button -->
<button v-if="hasAnySuggestions" @click="applySuggestedNames" class="px-2 sm:px-3 py-1.5 sm:py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-md hover:from-blue-700 hover:to-purple-700 active:scale-[0.98] transition-all flex items-center text-xs sm:text-sm font-medium">
<i class="fas fa-lightbulb mr-1 sm:mr-1.5"></i>
<span class="hidden sm:inline" v-text="t('help.applySuggested')"></span>
<span class="sm:hidden" v-text="t('help.applySuggestedMobile')"></span>
</button>
</div>
<!-- Right: Cancel & Save Buttons -->
<div class="flex gap-2">
<button @click="closeSpeakerModal" class="px-3 sm:px-4 py-1.5 sm:py-2 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-md border border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)] active:scale-[0.98] transition-all text-xs sm:text-sm" v-text="t('common.cancel')"></button>
<button
@click="saveSpeakerNames"
:disabled="!hasSpeakerNames"
class="px-3 sm:px-4 py-1.5 sm:py-2 rounded-md transition-all text-xs sm:text-sm font-medium active:scale-[0.98]"
:class="hasSpeakerNames
? 'bg-[var(--bg-accent)] text-[var(--text-accent)] hover:bg-[var(--bg-accent-hover)] cursor-pointer'
: 'bg-gray-400 text-gray-200 cursor-not-allowed'"
v-text="t('buttons.saveNames')"
></button>
</div>
</div>
</div>
</div>