refactor: migrate BriefingView chat to use ChatPanel component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||||
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
|
|
||||||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
|
||||||
import { useListenMode } from '@/composables/useListenMode'
|
|
||||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import ChatPanel from '@/components/ChatPanel.vue'
|
||||||
import ChatMessage from '@/components/ChatMessage.vue'
|
|
||||||
import WeatherCard from '@/components/WeatherCard.vue'
|
import WeatherCard from '@/components/WeatherCard.vue'
|
||||||
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
||||||
import {
|
import {
|
||||||
@@ -15,16 +10,12 @@ import {
|
|||||||
getBriefingConfig,
|
getBriefingConfig,
|
||||||
getBriefingConversations,
|
getBriefingConversations,
|
||||||
getBriefingToday,
|
getBriefingToday,
|
||||||
getBriefingConvMessages,
|
|
||||||
triggerBriefingSlot,
|
triggerBriefingSlot,
|
||||||
postRssReaction,
|
postRssReaction,
|
||||||
deleteRssReaction,
|
deleteRssReaction,
|
||||||
getNewsItems,
|
getNewsItems,
|
||||||
transcribeAudio,
|
|
||||||
type BriefingConversation,
|
type BriefingConversation,
|
||||||
type BriefingMessage,
|
|
||||||
} from '@/api/client'
|
} from '@/api/client'
|
||||||
import type { Message } from '@/types/chat'
|
|
||||||
import type { NewsItem } from '@/types/news'
|
import type { NewsItem } from '@/types/news'
|
||||||
|
|
||||||
interface WeatherData {
|
interface WeatherData {
|
||||||
@@ -41,7 +32,6 @@ interface WeatherData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const settingsStore = useSettingsStore()
|
|
||||||
|
|
||||||
// Setup wizard
|
// Setup wizard
|
||||||
const showWizard = ref(false)
|
const showWizard = ref(false)
|
||||||
@@ -65,10 +55,6 @@ const todayConvId = ref<number | null>(null)
|
|||||||
|
|
||||||
const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
||||||
|
|
||||||
// Messages for selected conversation
|
|
||||||
const messages = ref<BriefingMessage[]>([])
|
|
||||||
const loadingMessages = ref(false)
|
|
||||||
|
|
||||||
// Weather panel (left column)
|
// Weather panel (left column)
|
||||||
const weatherData = ref<WeatherData[]>([])
|
const weatherData = ref<WeatherData[]>([])
|
||||||
const tempUnit = ref<string>('C')
|
const tempUnit = ref<string>('C')
|
||||||
@@ -97,17 +83,6 @@ async function loadNews() {
|
|||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to bottom of messages
|
|
||||||
const messagesEl = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
function scrollToBottom() {
|
|
||||||
nextTick(() => {
|
|
||||||
if (messagesEl.value) {
|
|
||||||
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
const [convList, today] = await Promise.all([
|
const [convList, today] = await Promise.all([
|
||||||
getBriefingConversations(),
|
getBriefingConversations(),
|
||||||
@@ -125,90 +100,41 @@ async function loadAll() {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
selectedConvId.value = today.id
|
selectedConvId.value = today.id
|
||||||
messages.value = today.messages
|
|
||||||
await chatStore.fetchConversation(today.id)
|
await chatStore.fetchConversation(today.id)
|
||||||
}
|
}
|
||||||
scrollToBottom()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(selectedConvId, async (id) => {
|
watch(selectedConvId, async (id) => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
if (id === todayConvId.value) {
|
|
||||||
await chatStore.fetchConversation(id)
|
|
||||||
messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
|
|
||||||
scrollToBottom()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loadingMessages.value = true
|
|
||||||
try {
|
try {
|
||||||
messages.value = await getBriefingConvMessages(id)
|
await chatStore.fetchConversation(id)
|
||||||
scrollToBottom()
|
} catch {
|
||||||
} finally {
|
// Historical conversation unavailable — do nothing
|
||||||
loadingMessages.value = false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Refresh messages after streaming ends — read from store directly (already updated before streaming=false)
|
// Keep send() as a helper for discussArticle
|
||||||
watch(() => chatStore.streaming, async (streaming) => {
|
async function send(text: string) {
|
||||||
if (!streaming && _mounted && selectedConvId.value === todayConvId.value) {
|
if (!text || !todayConvId.value || chatStore.streaming) return
|
||||||
const storeMessages = chatStore.currentConversation?.messages
|
|
||||||
if (_mounted && storeMessages?.length) {
|
|
||||||
messages.value = storeMessages as unknown as BriefingMessage[]
|
|
||||||
}
|
|
||||||
await nextTick()
|
|
||||||
if (_mounted) scrollToBottom()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Input
|
|
||||||
const input = ref('')
|
|
||||||
const sending = ref(false)
|
|
||||||
|
|
||||||
async function send(overrideText?: string) {
|
|
||||||
const text = (overrideText ?? input.value).trim()
|
|
||||||
if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
|
|
||||||
if (chatStore.currentConversation?.id !== todayConvId.value) {
|
if (chatStore.currentConversation?.id !== todayConvId.value) {
|
||||||
await chatStore.fetchConversation(todayConvId.value)
|
await chatStore.fetchConversation(todayConvId.value)
|
||||||
}
|
}
|
||||||
input.value = ''
|
await chatStore.sendMessage(text, undefined, undefined, true)
|
||||||
sending.value = true
|
|
||||||
try {
|
|
||||||
await chatStore.sendMessage(text)
|
|
||||||
} finally {
|
|
||||||
sending.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discussArticle(item: NewsItem) {
|
async function discussArticle(item: NewsItem) {
|
||||||
|
if (!todayConvId.value) return
|
||||||
|
if (!isToday.value) selectedConvId.value = todayConvId.value
|
||||||
const body = (item.content || item.snippet || '').trim()
|
const body = (item.content || item.snippet || '').trim()
|
||||||
const bodyBlock = body ? `\n\n---\n${body}\n---` : ''
|
const bodyBlock = body ? `\n\n---\n${body}\n---` : ''
|
||||||
const text = `Please summarize and share your thoughts on this article. Do not use any research or search tools — respond conversationally based only on the content below.\n\n**${item.title}**\nSource: ${item.source}${bodyBlock}`
|
const text = `Please summarize and share your thoughts on this article. Do not use any research or search tools — respond conversationally based only on the content below.\n\n**${item.title}**\nSource: ${item.source}${bodyBlock}`
|
||||||
|
// Scroll to chat column
|
||||||
// Add the user message optimistically so it appears immediately
|
|
||||||
const optimisticMsg: BriefingMessage = {
|
|
||||||
id: Date.now(),
|
|
||||||
role: 'user',
|
|
||||||
content: text,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
messages.value = [...messages.value, optimisticMsg]
|
|
||||||
|
|
||||||
// Scroll the chat panel into view
|
|
||||||
await nextTick(() => {
|
await nextTick(() => {
|
||||||
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
scrollToBottom()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await send(text)
|
await send(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RSS reactions: map of rss_item_id -> 'up' | 'down' | null
|
// RSS reactions: map of rss_item_id -> 'up' | 'down' | null
|
||||||
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
||||||
|
|
||||||
@@ -259,69 +185,13 @@ function convLabel(c: BriefingConversation): string {
|
|||||||
return c.title || 'Briefing'
|
return c.title || 'Briefing'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Voice ────────────────────────────────────────────────────────────────────
|
|
||||||
// Mic PTT needs STT; listen/speak mode also needs TTS
|
|
||||||
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
|
|
||||||
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
|
||||||
const listenMode = useListenMode()
|
|
||||||
const transcribing = ref(false)
|
|
||||||
const tts = useStreamingTts({
|
|
||||||
streamingContent: computed(() => chatStore.streamingContent),
|
|
||||||
streaming: computed(() => chatStore.streaming),
|
|
||||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
|
||||||
})
|
|
||||||
|
|
||||||
const recorder = useVoiceRecorder()
|
|
||||||
const audio = useVoiceAudio()
|
|
||||||
|
|
||||||
// PTT: hold to record, release to transcribe and send
|
|
||||||
async function startPtt() {
|
|
||||||
if (!voiceEnabled.value || recorder.recording.value) return
|
|
||||||
audio.stop()
|
|
||||||
await recorder.startRecording()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopPtt() {
|
|
||||||
if (!recorder.recording.value) return
|
|
||||||
transcribing.value = true
|
|
||||||
try {
|
|
||||||
const blob = await recorder.stopRecording()
|
|
||||||
const { transcript } = await transcribeAudio(blob)
|
|
||||||
if (transcript.trim()) {
|
|
||||||
input.value = transcript.trim()
|
|
||||||
await send()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// transcription failure — leave input empty
|
|
||||||
} finally {
|
|
||||||
transcribing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Convert BriefingMessage to Message for ChatMessage component
|
|
||||||
function toMsg(m: BriefingMessage): Message {
|
|
||||||
return {
|
|
||||||
id: m.id,
|
|
||||||
conversation_id: -1,
|
|
||||||
role: m.role,
|
|
||||||
content: m.content,
|
|
||||||
context_note_id: null,
|
|
||||||
context_note_title: null,
|
|
||||||
created_at: m.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
||||||
async function _backgroundRefreshMessages() {
|
async function _backgroundRefreshMessages() {
|
||||||
try {
|
try {
|
||||||
const today = await getBriefingToday()
|
const today = await getBriefingToday()
|
||||||
if (!today) return
|
if (!today) return
|
||||||
const fresh = today.messages
|
if (_mounted && isToday.value && chatStore.currentConversation?.id === todayConvId.value) {
|
||||||
const last = fresh[fresh.length - 1]
|
await chatStore.fetchConversation(today.id)
|
||||||
const cur = messages.value[messages.value.length - 1]
|
|
||||||
if (fresh.length !== messages.value.length || last?.content !== cur?.content) {
|
|
||||||
messages.value = fresh
|
|
||||||
}
|
}
|
||||||
await loadNews()
|
await loadNews()
|
||||||
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
||||||
@@ -384,103 +254,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- Center column: Chat -->
|
<!-- Center column: Chat -->
|
||||||
<div class="briefing-center">
|
<div class="briefing-center">
|
||||||
<div class="briefing-messages-wrap" ref="messagesEl">
|
<ChatPanel
|
||||||
<div v-if="loadingMessages" class="briefing-loading">Loading…</div>
|
variant="full"
|
||||||
<template v-else>
|
briefingMode
|
||||||
<div v-if="!messages.length" class="briefing-empty">
|
:readOnly="!isToday"
|
||||||
<p>No briefing yet for today.</p>
|
placeholder="Reply to your briefing…"
|
||||||
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
|
class="briefing-chat-panel"
|
||||||
</div>
|
/>
|
||||||
<div v-else class="briefing-messages">
|
|
||||||
<template v-for="msg in messages" :key="msg.id">
|
|
||||||
<ChatMessage
|
|
||||||
:message="toMsg(msg)"
|
|
||||||
:is-streaming="false"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<!-- Live streaming bubble for today -->
|
|
||||||
<ChatMessage
|
|
||||||
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
|
|
||||||
:message="{
|
|
||||||
id: -1,
|
|
||||||
conversation_id: todayConvId ?? -1,
|
|
||||||
role: 'assistant',
|
|
||||||
content: chatStore.streamingContent,
|
|
||||||
context_note_id: null,
|
|
||||||
context_note_title: null,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
}"
|
|
||||||
:is-streaming="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Input bar (today only) -->
|
|
||||||
<div v-if="isToday" class="briefing-input-bar">
|
|
||||||
<!-- Listen toggle -->
|
|
||||||
<button
|
|
||||||
v-if="voiceTtsEnabled"
|
|
||||||
class="btn-icon"
|
|
||||||
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': tts.speaking.value }"
|
|
||||||
@click="listenMode = !listenMode; if (listenMode) tts.speak([...messages].reverse().find(m => m.role === 'assistant')?.content ?? ''); else tts.stop()"
|
|
||||||
:title="listenMode ? 'Stop auto-read' : 'Read replies aloud'"
|
|
||||||
aria-label="Toggle listen mode"
|
|
||||||
>
|
|
||||||
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
|
||||||
</svg>
|
|
||||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- Stop TTS playback -->
|
|
||||||
<button
|
|
||||||
v-if="voiceTtsEnabled && tts.speaking.value"
|
|
||||||
class="btn-icon btn-icon-danger"
|
|
||||||
@click="tts.stop()"
|
|
||||||
title="Stop playback"
|
|
||||||
aria-label="Stop playback"
|
|
||||||
>
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M6 6h12v12H6z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<textarea
|
|
||||||
v-model="input"
|
|
||||||
class="briefing-textarea"
|
|
||||||
:placeholder="transcribing ? 'Transcribing…' : recorder.recording.value ? 'Recording…' : 'Reply to your briefing…'"
|
|
||||||
rows="1"
|
|
||||||
@keydown="onKeydown"
|
|
||||||
:disabled="transcribing || recorder.recording.value"
|
|
||||||
></textarea>
|
|
||||||
<!-- Mic PTT button -->
|
|
||||||
<button
|
|
||||||
v-if="voiceEnabled && recorder.isSupported"
|
|
||||||
class="btn-icon btn-mic-ptt"
|
|
||||||
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribing }"
|
|
||||||
@mousedown.prevent="startPtt"
|
|
||||||
@mouseup.prevent="stopPtt"
|
|
||||||
@touchstart.prevent="startPtt"
|
|
||||||
@touchend.prevent="stopPtt"
|
|
||||||
:disabled="transcribing || chatStore.streaming || sending"
|
|
||||||
:title="recorder.recording.value ? 'Release to send' : 'Hold to speak'"
|
|
||||||
aria-label="Push to talk"
|
|
||||||
>
|
|
||||||
<svg v-if="!transcribing" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
|
||||||
</svg>
|
|
||||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-send"
|
|
||||||
@click="send()"
|
|
||||||
:disabled="!input.trim() || chatStore.streaming || sending"
|
|
||||||
aria-label="Send"
|
|
||||||
>↑</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right column: News -->
|
<!-- Right column: News -->
|
||||||
@@ -644,83 +424,11 @@ onMounted(async () => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.briefing-messages-wrap {
|
.briefing-chat-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
|
||||||
padding: 1rem;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.briefing-loading,
|
|
||||||
.briefing-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 1rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.briefing-empty-hint {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.briefing-messages {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.briefing-input-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
|
||||||
margin: 0 0.75rem 1rem;
|
|
||||||
background: var(--color-input-bar-bg);
|
|
||||||
border-radius: 20px;
|
|
||||||
box-shadow: 0 2px 12px var(--color-shadow);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.briefing-textarea {
|
|
||||||
flex: 1;
|
|
||||||
resize: none;
|
|
||||||
padding: 0.4rem 0.5rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-input-bar-text);
|
|
||||||
outline: none;
|
|
||||||
max-height: 120px;
|
|
||||||
overflow-y: auto;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.briefing-textarea::placeholder { color: var(--color-input-bar-placeholder); }
|
|
||||||
.briefing-textarea:disabled { opacity: 0.5; }
|
|
||||||
|
|
||||||
.btn-send {
|
|
||||||
width: 34px;
|
|
||||||
min-width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: box-shadow 0.15s;
|
|
||||||
}
|
|
||||||
.btn-send:not(:disabled):hover { box-shadow: 0 0 14px rgba(129, 140, 248, 0.5); }
|
|
||||||
.btn-send:disabled { opacity: 0.35; cursor: default; }
|
|
||||||
|
|
||||||
/* ─── Right column (News) ────────────────────────────────────────────────── */
|
/* ─── Right column (News) ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.briefing-right {
|
.briefing-right {
|
||||||
@@ -842,55 +550,6 @@ a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
|||||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Icon buttons (input bar) ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-input-bar-text);
|
|
||||||
opacity: 0.5;
|
|
||||||
padding: 0.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: opacity 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.btn-icon:hover:not(:disabled) { opacity: 1; }
|
|
||||||
.btn-icon:disabled { opacity: 0.25; cursor: default; }
|
|
||||||
.btn-icon-active {
|
|
||||||
color: var(--color-primary) !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
.btn-icon-busy {
|
|
||||||
color: var(--color-primary) !important;
|
|
||||||
opacity: 0.8 !important;
|
|
||||||
animation: icon-pulse 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.btn-icon-danger {
|
|
||||||
color: #ef4444 !important;
|
|
||||||
opacity: 0.8 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mic-ptt {
|
|
||||||
touch-action: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.btn-mic-ptt.mic-recording {
|
|
||||||
color: #ef4444 !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
animation: icon-pulse 0.8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.btn-mic-ptt.mic-transcribing {
|
|
||||||
color: var(--color-primary) !important;
|
|
||||||
opacity: 0.7 !important;
|
|
||||||
}
|
|
||||||
@keyframes icon-pulse {
|
|
||||||
0%, 100% { opacity: 0.6; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Responsive ─────────────────────────────────────────────────────────── */
|
/* ─── Responsive ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user