Fetch and display the next 14 days of events in the briefing right column, grouped by day with color dots, time, and location. Fills the empty space below the weather card. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
829 lines
22 KiB
Vue
829 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
|
import { useChatStore } from '@/stores/chat'
|
|
import { useSettingsStore } from '@/stores/settings'
|
|
import ChatPanel from '@/components/ChatPanel.vue'
|
|
import WeatherCard from '@/components/WeatherCard.vue'
|
|
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
|
import {
|
|
apiGet,
|
|
apiPost,
|
|
getBriefingConfig,
|
|
getBriefingConversations,
|
|
getBriefingToday,
|
|
triggerBriefingSlot,
|
|
postRssReaction,
|
|
deleteRssReaction,
|
|
getNewsItems,
|
|
listEvents,
|
|
type BriefingConversation,
|
|
type EventEntry,
|
|
} from '@/api/client'
|
|
import type { NewsItem } from '@/types/news'
|
|
|
|
interface WeatherData {
|
|
location: string
|
|
fetched_at: string
|
|
current_temp: number
|
|
condition: string
|
|
today_high: number | null
|
|
today_low: number | null
|
|
yesterday_high: number | null
|
|
yesterday_low: number | null
|
|
wind_unit?: string
|
|
forecast: { day: string; condition: string; high: number; low: number; precip_probability: number | null; precip_mm: number | null; windspeed_max: number }[]
|
|
}
|
|
|
|
const chatStore = useChatStore()
|
|
const settingsStore = useSettingsStore()
|
|
|
|
// Setup wizard
|
|
const showWizard = ref(false)
|
|
const wizardChecked = ref(false)
|
|
|
|
async function checkSetup() {
|
|
const config = await getBriefingConfig()
|
|
if (!config.enabled) showWizard.value = true
|
|
wizardChecked.value = true
|
|
}
|
|
|
|
async function onWizardDone() {
|
|
showWizard.value = false
|
|
await loadAll()
|
|
}
|
|
|
|
// Conversations list for the dropdown
|
|
const conversations = ref<BriefingConversation[]>([])
|
|
const selectedConvId = ref<number | null>(null)
|
|
const todayConvId = ref<number | null>(null)
|
|
|
|
const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
|
|
|
// Weather panel
|
|
const weatherData = ref<WeatherData[]>([])
|
|
const selectedWeatherIdx = ref(0)
|
|
const tempUnit = ref<string>('C')
|
|
|
|
interface CurrentConditions {
|
|
temperature: number | null;
|
|
windspeed: number | null;
|
|
description: string;
|
|
precip_next_3h: number[];
|
|
temp_unit: string;
|
|
location: string;
|
|
}
|
|
const currentConditions = ref<CurrentConditions | null>(null)
|
|
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
async function loadCurrentConditions() {
|
|
try {
|
|
currentConditions.value = await apiGet<CurrentConditions>('/api/briefing/weather/current')
|
|
// Patch the live temperature into the WeatherCard so it stays fresh
|
|
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
|
|
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
|
|
}
|
|
} catch { /* silent — endpoint may not have locations configured */ }
|
|
}
|
|
|
|
async function loadWeather() {
|
|
try {
|
|
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
|
|
weatherData.value = data.locations ?? []
|
|
tempUnit.value = data.temp_unit ?? 'C'
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
const refreshingWeather = ref(false)
|
|
async function refreshWeather() {
|
|
refreshingWeather.value = true
|
|
try {
|
|
const data = await apiPost<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather/refresh', {})
|
|
weatherData.value = data.locations ?? []
|
|
tempUnit.value = data.temp_unit ?? 'C'
|
|
} catch { /* silent */ }
|
|
finally { refreshingWeather.value = false }
|
|
}
|
|
|
|
// Upcoming events (right column, below weather)
|
|
const upcomingEvents = ref<EventEntry[]>([])
|
|
|
|
interface GroupedDay {
|
|
label: string
|
|
dateKey: string
|
|
events: EventEntry[]
|
|
}
|
|
|
|
const groupedEvents = computed<GroupedDay[]>(() => {
|
|
const groups = new Map<string, EventEntry[]>()
|
|
const today = new Date()
|
|
today.setHours(0, 0, 0, 0)
|
|
|
|
for (const ev of upcomingEvents.value) {
|
|
const d = new Date(ev.start_dt)
|
|
const key = d.toISOString().slice(0, 10)
|
|
if (!groups.has(key)) groups.set(key, [])
|
|
groups.get(key)!.push(ev)
|
|
}
|
|
|
|
const result: GroupedDay[] = []
|
|
for (const [key, events] of groups) {
|
|
const d = new Date(key + 'T00:00:00')
|
|
const diff = Math.round((d.getTime() - today.getTime()) / 86_400_000)
|
|
let label: string
|
|
if (diff === 0) label = 'Today'
|
|
else if (diff === 1) label = 'Tomorrow'
|
|
else label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
|
result.push({ label, dateKey: key, events })
|
|
}
|
|
return result
|
|
})
|
|
|
|
function formatEventTime(ev: EventEntry): string {
|
|
if (ev.all_day) return 'All day'
|
|
const d = new Date(ev.start_dt)
|
|
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
}
|
|
|
|
async function loadEvents() {
|
|
try {
|
|
const now = new Date()
|
|
const end = new Date(now)
|
|
end.setDate(end.getDate() + 14)
|
|
upcomingEvents.value = await listEvents(now.toISOString(), end.toISOString())
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
// News panel (right column)
|
|
const newsItems = ref<NewsItem[]>([])
|
|
|
|
async function loadNews() {
|
|
try {
|
|
const data = await getNewsItems({ days: 2, limit: 40 })
|
|
newsItems.value = data.items
|
|
// Seed reactions from API response
|
|
for (const item of data.items) {
|
|
if (reactions.value[item.id] === undefined) {
|
|
reactions.value[item.id] = item.reaction
|
|
}
|
|
}
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
async function loadAll() {
|
|
const [convList, today] = await Promise.all([
|
|
getBriefingConversations(),
|
|
getBriefingToday().catch(() => null),
|
|
loadWeather(),
|
|
loadNews(),
|
|
loadCurrentConditions(),
|
|
loadEvents(),
|
|
])
|
|
conversations.value = convList
|
|
if (today) {
|
|
todayConvId.value = today.id
|
|
if (!convList.find((c) => c.id === today.id)) {
|
|
conversations.value = [
|
|
{ id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
|
|
...convList,
|
|
]
|
|
}
|
|
selectedConvId.value = today.id
|
|
await chatStore.fetchConversation(today.id)
|
|
}
|
|
}
|
|
|
|
watch(selectedConvId, async (id) => {
|
|
if (!id) return
|
|
try {
|
|
await chatStore.fetchConversation(id)
|
|
} catch {
|
|
// Historical conversation unavailable — do nothing
|
|
}
|
|
})
|
|
|
|
async function discussArticle(item: NewsItem) {
|
|
if (!todayConvId.value || chatStore.streaming) return
|
|
if (!isToday.value) selectedConvId.value = todayConvId.value
|
|
await nextTick(() => {
|
|
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
})
|
|
try {
|
|
await apiPost<{ assistant_message_id: number }>(`/api/briefing/articles/${item.id}/discuss`, { conv_id: todayConvId.value })
|
|
} catch {
|
|
return
|
|
}
|
|
await chatStore.fetchConversation(todayConvId.value)
|
|
await chatStore.reconnectIfGenerating(todayConvId.value)
|
|
}
|
|
|
|
// RSS reactions: map of rss_item_id -> 'up' | 'down' | null
|
|
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
|
|
|
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
|
|
const current = reactions.value[itemId]
|
|
reactions.value[itemId] = current === reaction ? null : reaction
|
|
try {
|
|
if (current === reaction) {
|
|
await deleteRssReaction(itemId)
|
|
} else {
|
|
await postRssReaction(itemId, reaction)
|
|
}
|
|
} catch {
|
|
reactions.value[itemId] = current ?? null
|
|
}
|
|
}
|
|
|
|
function formatRelativeDate(iso: string | null): string {
|
|
if (!iso) return ''
|
|
const d = new Date(iso)
|
|
const now = new Date()
|
|
const diffH = (now.getTime() - d.getTime()) / 3_600_000
|
|
if (diffH < 24) return `${Math.round(diffH)}h ago`
|
|
if (diffH < 48) return 'Yesterday'
|
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
// Manual trigger
|
|
const triggering = ref(false)
|
|
async function triggerNow() {
|
|
triggering.value = true
|
|
try {
|
|
await triggerBriefingSlot('compilation')
|
|
// Guard: user may have navigated away during the long compilation
|
|
if (_mounted) await loadAll()
|
|
} finally {
|
|
if (_mounted) triggering.value = false
|
|
}
|
|
}
|
|
|
|
// Dropdown label
|
|
function convLabel(c: BriefingConversation): string {
|
|
if (c.id === todayConvId.value) return 'Today'
|
|
if (c.briefing_date) {
|
|
const d = new Date(c.briefing_date)
|
|
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
|
}
|
|
return c.title || 'Briefing'
|
|
}
|
|
|
|
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
|
async function _backgroundRefreshMessages() {
|
|
try {
|
|
const today = await getBriefingToday()
|
|
if (!today) return
|
|
if (_mounted && isToday.value && chatStore.currentConversation?.id === todayConvId.value) {
|
|
await chatStore.fetchConversation(today.id)
|
|
}
|
|
await loadNews()
|
|
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
|
}
|
|
|
|
useBackgroundRefresh(
|
|
_backgroundRefreshMessages,
|
|
60_000,
|
|
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
|
|
)
|
|
|
|
let _mounted = true
|
|
onUnmounted(() => {
|
|
_mounted = false
|
|
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await checkSetup()
|
|
if (!showWizard.value) {
|
|
await loadAll()
|
|
// Poll current conditions every 30 minutes
|
|
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="briefing-root">
|
|
<!-- Setup wizard overlay -->
|
|
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
|
|
|
|
<!-- Main view -->
|
|
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
|
|
<!-- Header spans all columns -->
|
|
<header class="briefing-header">
|
|
<div class="briefing-header-left">
|
|
<h1 class="briefing-title">Briefing</h1>
|
|
<span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
|
|
</div>
|
|
<div class="briefing-header-right">
|
|
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
|
|
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
|
|
</select>
|
|
<button
|
|
class="btn-trigger"
|
|
@click="triggerNow"
|
|
:disabled="triggering"
|
|
title="Manually trigger morning briefing now"
|
|
>{{ triggering ? '…' : 'Refresh' }}</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Left column: Chat -->
|
|
<div class="briefing-center">
|
|
<ChatPanel
|
|
variant="full"
|
|
briefingMode
|
|
:readOnly="!isToday"
|
|
placeholder="Reply to your briefing…"
|
|
class="briefing-chat-panel"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Right column: Weather + News -->
|
|
<div class="briefing-right">
|
|
<!-- Weather section (sticky) -->
|
|
<div class="weather-section" v-if="weatherData.length">
|
|
<div class="weather-section-header">
|
|
<div class="weather-tabs" v-if="weatherData.length > 1">
|
|
<button
|
|
v-for="(loc, i) in weatherData"
|
|
:key="(loc as WeatherData).location"
|
|
class="weather-tab"
|
|
:class="{ active: selectedWeatherIdx === i }"
|
|
@click="selectedWeatherIdx = i"
|
|
>{{ (loc as WeatherData).location }}</button>
|
|
</div>
|
|
<button
|
|
class="weather-refresh-btn"
|
|
:class="{ spinning: refreshingWeather }"
|
|
:disabled="refreshingWeather"
|
|
@click="refreshWeather"
|
|
title="Refresh weather"
|
|
>↻</button>
|
|
</div>
|
|
<WeatherCard
|
|
:weather="weatherData[selectedWeatherIdx]"
|
|
:temp-unit="tempUnit"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Upcoming events -->
|
|
<div class="events-section" v-if="groupedEvents.length">
|
|
<div class="panel-label-row">
|
|
<div class="panel-label">Upcoming</div>
|
|
<router-link to="/calendar" class="events-cal-link">Calendar →</router-link>
|
|
</div>
|
|
<div class="events-list">
|
|
<div v-for="group in groupedEvents" :key="group.dateKey" class="events-day-group">
|
|
<div class="events-day-label">{{ group.label }}</div>
|
|
<div v-for="ev in group.events" :key="ev.id" class="event-row">
|
|
<span class="event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
|
|
<span class="event-body">
|
|
<span class="event-title">{{ ev.title }}</span>
|
|
<span class="event-time">{{ formatEventTime(ev) }}</span>
|
|
<span v-if="ev.location" class="event-loc">{{ ev.location }}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- News section (scrollable) -->
|
|
<div v-if="settingsStore.rssEnabled" class="news-section">
|
|
<div class="panel-label-row">
|
|
<div class="panel-label">Today's News</div>
|
|
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
|
|
</div>
|
|
<div v-if="!newsItems.length" class="panel-empty">No articles in the last 2 days</div>
|
|
<div
|
|
v-for="item in newsItems"
|
|
:key="item.id"
|
|
class="news-card"
|
|
>
|
|
<div class="news-card-meta">
|
|
<span class="news-source">{{ item.source }}</span>
|
|
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
|
|
</div>
|
|
<a
|
|
v-if="item.url"
|
|
:href="item.url"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="news-title"
|
|
>{{ item.title }}</a>
|
|
<p v-else class="news-title news-title--plain">{{ item.title }}</p>
|
|
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
|
|
<div class="news-reactions">
|
|
<button
|
|
class="reaction-btn"
|
|
:class="{ active: reactions[item.id] === 'up' }"
|
|
@click="handleReaction(item.id, 'up')"
|
|
title="Interested"
|
|
>👍</button>
|
|
<button
|
|
class="reaction-btn"
|
|
:class="{ active: reactions[item.id] === 'down' }"
|
|
@click="handleReaction(item.id, 'down')"
|
|
title="Not interested"
|
|
>👎</button>
|
|
<button
|
|
v-if="isToday && todayConvId"
|
|
class="reaction-btn discuss-btn"
|
|
@click="discussArticle(item)"
|
|
title="Discuss in briefing chat"
|
|
>💬</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.briefing-root {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.briefing-shell {
|
|
display: grid;
|
|
grid-template-columns: 1fr minmax(320px, 35%);
|
|
grid-template-rows: auto 1fr;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.briefing-header {
|
|
grid-column: 1 / -1;
|
|
grid-row: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1.25rem 1rem 1rem;
|
|
border-bottom: 1px solid var(--color-border);
|
|
flex-shrink: 0;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.briefing-header-left {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.briefing-title {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 1.3rem;
|
|
font-weight: 700;
|
|
margin: 0;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.briefing-today-badge {
|
|
font-size: 0.82rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.briefing-header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.briefing-conv-select {
|
|
padding: 0.35rem 0.6rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 6px;
|
|
background: var(--color-bg-card);
|
|
color: var(--color-text);
|
|
font-size: 0.82rem;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.btn-trigger {
|
|
padding: 0.35rem 0.8rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 6px;
|
|
background: var(--color-bg-card);
|
|
color: var(--color-text-muted);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
transition: all 0.15s;
|
|
font-family: inherit;
|
|
}
|
|
.btn-trigger:hover:not(:disabled) {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
/* ─── Left column (Chat) ─────────────────────────────────────────────────── */
|
|
|
|
.briefing-center {
|
|
grid-column: 1;
|
|
grid-row: 2;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.briefing-chat-panel {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* ─── Right column (Weather + News) ──────────────────────────────────────── */
|
|
|
|
.briefing-right {
|
|
grid-column: 2;
|
|
grid-row: 2;
|
|
border-left: 1px solid var(--color-border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.weather-section {
|
|
flex-shrink: 0;
|
|
padding: 1rem 1rem 0.5rem;
|
|
}
|
|
|
|
.weather-section :deep(.weather-card) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.weather-section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.weather-refresh-btn {
|
|
background: none;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 6px;
|
|
color: var(--color-text-muted);
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
padding: 0.2rem 0.45rem;
|
|
line-height: 1;
|
|
transition: all 0.15s;
|
|
}
|
|
.weather-refresh-btn:hover {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
.weather-refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.weather-refresh-btn.spinning {
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
.weather-tabs {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.weather-tab {
|
|
padding: 0.3rem 0.7rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 6px;
|
|
background: none;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.78rem;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.weather-tab:hover {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.weather-tab.active {
|
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* ─── Upcoming events ─────────────────────────────────────── */
|
|
.events-section {
|
|
padding: 0.75rem 1rem;
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
.events-cal-link {
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
text-decoration: none;
|
|
}
|
|
.events-cal-link:hover { color: var(--color-primary); }
|
|
.events-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.6rem;
|
|
}
|
|
.events-day-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
}
|
|
.events-day-label {
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
color: var(--color-text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
padding-bottom: 0.15rem;
|
|
}
|
|
.event-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.4rem;
|
|
padding: 0.25rem 0.4rem;
|
|
border-radius: 6px;
|
|
}
|
|
.event-row:hover {
|
|
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
|
|
}
|
|
.event-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: var(--color-primary);
|
|
flex-shrink: 0;
|
|
margin-top: 5px;
|
|
}
|
|
.event-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.05rem;
|
|
min-width: 0;
|
|
}
|
|
.event-title {
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
color: var(--color-text);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.event-time {
|
|
font-size: 0.72rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.event-loc {
|
|
font-size: 0.7rem;
|
|
color: var(--color-text-muted);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.news-section {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0.75rem 1rem 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.panel-label {
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--color-primary);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.panel-label-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.news-count {
|
|
font-size: 0.72rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
/* ── Current conditions (live) ─────────────────────────── */
|
|
|
|
.panel-empty {
|
|
font-size: 0.82rem;
|
|
color: var(--color-text-muted);
|
|
padding: 0.5rem 0;
|
|
}
|
|
|
|
.news-card {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 10px;
|
|
padding: 0.65rem 0.85rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.news-card-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.news-source {
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.news-date {
|
|
font-size: 0.72rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.news-title {
|
|
font-size: 0.88rem;
|
|
font-weight: 600;
|
|
color: var(--color-text);
|
|
line-height: 1.35;
|
|
text-decoration: none;
|
|
margin: 0;
|
|
}
|
|
|
|
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
|
|
|
.news-snippet {
|
|
font-size: 0.78rem;
|
|
color: var(--color-text-muted);
|
|
line-height: 1.45;
|
|
margin: 0;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.news-reactions {
|
|
display: flex;
|
|
gap: 0.3rem;
|
|
margin-top: 0.15rem;
|
|
}
|
|
|
|
.reaction-btn {
|
|
background: none;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 6px;
|
|
padding: 0.1rem 0.35rem;
|
|
cursor: pointer;
|
|
font-size: 0.82rem;
|
|
line-height: 1.4;
|
|
opacity: 0.55;
|
|
transition: opacity 0.15s, border-color 0.15s;
|
|
}
|
|
|
|
.reaction-btn:hover {
|
|
opacity: 1;
|
|
border-color: var(--color-primary);
|
|
}
|
|
|
|
.reaction-btn.active {
|
|
opacity: 1;
|
|
border-color: var(--color-primary);
|
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
}
|
|
|
|
/* ─── Responsive ─────────────────────────────────────────────────────────── */
|
|
|
|
@media (max-width: 900px) {
|
|
.briefing-shell {
|
|
grid-template-columns: 1fr;
|
|
grid-template-rows: auto 1fr auto;
|
|
}
|
|
.briefing-center {
|
|
grid-column: 1;
|
|
grid-row: 2;
|
|
}
|
|
.briefing-right {
|
|
grid-column: 1;
|
|
grid-row: 3;
|
|
border-left: none;
|
|
border-top: 1px solid var(--color-border);
|
|
max-height: 300px;
|
|
}
|
|
}
|
|
</style>
|