feat(journal): add JournalView UI with /journal route + nav link
Restores a working browser surface for the journal feature, which was left UI-less when Stage F of the original plan was deferred. JournalView is a fresh write (not a rename of BriefingView) — drops the briefing- specific weather panel, news cards, RSS reactions, article-discuss button, and setup wizard, since none of those have journal-backend equivalents. What it does: - Fetches /api/journal/today on mount; shows today's daily-prep card (rendered from msg_metadata.kind === 'daily_prep' sections) - Day picker via /api/journal/days lets you switch to past days - Refresh-prep button hits /api/journal/trigger-prep - Center is the existing ChatPanel pinned to today's journal conversation (so the chat input + SSE + tool-call cards inherit unchanged) - Right sidebar keeps the upcoming-events list (uses the generic /api/events endpoint, not a briefing-specific one) Also: - Replace the dead Briefing API client functions with Journal equivalents (getJournalConfig, saveJournalConfig, getJournalToday, getJournalDay, getJournalDays, triggerJournalPrep, list/update/deleteJournalMoment). - Remove NewsView.vue — it was orphaned (no route, no nav) and depended on the deleted /api/briefing/* endpoints. If a standalone news surface is wanted later it'll need to be rebuilt against new endpoints. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+65
-89
@@ -300,130 +300,106 @@ export function apiSSEStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Briefing
|
// Journal
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface BriefingLocation {
|
export interface JournalConfig {
|
||||||
label: string;
|
prep_enabled: boolean;
|
||||||
address: string;
|
prep_hour: number;
|
||||||
lat?: number;
|
prep_minute: number;
|
||||||
lon?: number;
|
day_rollover_hour: number;
|
||||||
|
morning_end_hour?: number;
|
||||||
|
midday_end_hour?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BriefingSlots {
|
export interface JournalConversation {
|
||||||
compilation: boolean;
|
|
||||||
morning: boolean;
|
|
||||||
midday: boolean;
|
|
||||||
afternoon: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BriefingConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
locations: {
|
|
||||||
home?: BriefingLocation;
|
|
||||||
work?: BriefingLocation;
|
|
||||||
};
|
|
||||||
use_caldav_event_locations: boolean;
|
|
||||||
work_days: number[];
|
|
||||||
slots: BriefingSlots;
|
|
||||||
notifications: boolean;
|
|
||||||
temp_unit: 'C' | 'F';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BriefingFeed {
|
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
model: string;
|
||||||
category: string | null;
|
conversation_type: string;
|
||||||
last_fetched_at: string | null;
|
day_date: string | null;
|
||||||
}
|
rag_project_id: number | null;
|
||||||
|
|
||||||
export interface BriefingConversation {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
briefing_date: string | null;
|
|
||||||
message_count: number;
|
message_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BriefingMessage {
|
export interface JournalMessage {
|
||||||
id: number;
|
id: number;
|
||||||
|
conversation_id: number;
|
||||||
role: 'user' | 'assistant' | 'system';
|
role: 'user' | 'assistant' | 'system';
|
||||||
content: string;
|
content: string;
|
||||||
|
status: string;
|
||||||
|
context_note_id: number | null;
|
||||||
|
tool_calls: unknown[] | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
metadata?: Record<string, unknown> | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
export interface JournalDayPayload {
|
||||||
enabled: false,
|
day_date: string;
|
||||||
locations: {},
|
conversation: JournalConversation | null;
|
||||||
use_caldav_event_locations: false,
|
messages: JournalMessage[];
|
||||||
work_days: [1, 2, 3, 4, 5],
|
|
||||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
|
||||||
notifications: true,
|
|
||||||
temp_unit: 'C',
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getBriefingConfig(): Promise<BriefingConfig> {
|
|
||||||
try {
|
|
||||||
const data = await apiGet<BriefingConfig>('/api/briefing/config');
|
|
||||||
return { ...DEFAULT_BRIEFING_CONFIG, ...data };
|
|
||||||
} catch {
|
|
||||||
return { ...DEFAULT_BRIEFING_CONFIG };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
|
export interface JournalMoment {
|
||||||
await apiPut('/api/briefing/config', config);
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
conversation_id: number | null;
|
||||||
|
source_message_id: number | null;
|
||||||
|
day_date: string;
|
||||||
|
occurred_at: string;
|
||||||
|
recorded_at: string;
|
||||||
|
content: string;
|
||||||
|
raw_excerpt: string | null;
|
||||||
|
tags: string[];
|
||||||
|
pinned: boolean;
|
||||||
|
people: { id: number; title: string }[];
|
||||||
|
places: { id: number; title: string }[];
|
||||||
|
task_ids: number[];
|
||||||
|
note_ids: number[];
|
||||||
|
score?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
|
export async function getJournalConfig(): Promise<JournalConfig> {
|
||||||
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
|
return apiGet<JournalConfig>('/api/journal/config');
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBriefingFeed(url: string, category?: string): Promise<BriefingFeed> {
|
export async function saveJournalConfig(config: JournalConfig): Promise<void> {
|
||||||
const body: Record<string, string> = { url };
|
await apiPut('/api/journal/config', config);
|
||||||
if (category?.trim()) body.category = category.trim();
|
|
||||||
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', body);
|
|
||||||
return { ...data, last_fetched_at: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> {
|
export async function getJournalToday(): Promise<JournalDayPayload> {
|
||||||
return apiPost('/api/briefing/feeds/refresh', {});
|
return apiGet<JournalDayPayload>('/api/journal/today');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBriefingFeed(id: number): Promise<void> {
|
export async function getJournalDay(isoDate: string): Promise<JournalDayPayload> {
|
||||||
await apiDelete(`/api/briefing/feeds/${id}`);
|
return apiGet<JournalDayPayload>(`/api/journal/day/${isoDate}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
|
export async function getJournalDays(): Promise<string[]> {
|
||||||
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
|
const data = await apiGet<{ days: string[] }>('/api/journal/days');
|
||||||
return data.conversations;
|
return data.days;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
|
export async function triggerJournalPrep(date?: string): Promise<{ ok: boolean; message_id: number }> {
|
||||||
return apiGet('/api/briefing/conversations/today');
|
return apiPost('/api/journal/trigger-prep', date ? { date } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBriefingConvMessages(id: number): Promise<BriefingMessage[]> {
|
export async function listJournalMoments(params: Record<string, string | number | boolean> = {}): Promise<JournalMoment[]> {
|
||||||
const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`);
|
const qs = new URLSearchParams();
|
||||||
return data.messages;
|
for (const [k, v] of Object.entries(params)) qs.set(k, String(v));
|
||||||
|
const data = await apiGet<{ moments: JournalMoment[] }>(`/api/journal/moments?${qs}`);
|
||||||
|
return data.moments;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function triggerBriefingSlot(slot: string): Promise<void> {
|
export async function updateJournalMoment(id: number, patch: Partial<JournalMoment>): Promise<JournalMoment> {
|
||||||
await apiPost('/api/briefing/trigger', { slot });
|
return apiPatch<JournalMoment>(`/api/journal/moments/${id}`, patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postRssReaction(
|
export async function deleteJournalMoment(id: number): Promise<void> {
|
||||||
rssItemId: number,
|
await apiDelete(`/api/journal/moments/${id}`);
|
||||||
reaction: 'up' | 'down'
|
|
||||||
): Promise<{ ok: boolean; action: string }> {
|
|
||||||
return apiPost('/api/briefing/rss-reactions', { rss_item_id: rssItemId, reaction });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteRssReaction(rssItemId: number): Promise<void> {
|
|
||||||
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openArticleInChat(
|
export async function openArticleInChat(
|
||||||
@@ -434,7 +410,7 @@ export async function openArticleInChat(
|
|||||||
|
|
||||||
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
|
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
|
||||||
try {
|
try {
|
||||||
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/briefing/weather/geocode', { query: address });
|
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/geocode', { query: address });
|
||||||
return { lat: r.lat, lon: r.lon, display_name: r.label };
|
return { lat: r.lat, lon: r.lon, display_name: r.label };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ router.afterEach(() => {
|
|||||||
<div class="nav-pill-bar">
|
<div class="nav-pill-bar">
|
||||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||||
|
<router-link to="/journal" class="nav-link">Journal</router-link>
|
||||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,6 +124,7 @@ router.afterEach(() => {
|
|||||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||||
|
<router-link to="/journal" class="nav-link">Journal</router-link>
|
||||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ const router = createRouter({
|
|||||||
name: "calendar",
|
name: "calendar",
|
||||||
component: () => import("@/views/CalendarView.vue"),
|
component: () => import("@/views/CalendarView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/journal",
|
||||||
|
name: "journal",
|
||||||
|
component: () => import("@/views/JournalView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
name: "settings",
|
name: "settings",
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||||
|
import ChatPanel from '@/components/ChatPanel.vue'
|
||||||
|
import {
|
||||||
|
getJournalToday,
|
||||||
|
getJournalDay,
|
||||||
|
getJournalDays,
|
||||||
|
triggerJournalPrep,
|
||||||
|
listEvents,
|
||||||
|
type EventEntry,
|
||||||
|
type JournalMessage,
|
||||||
|
} from '@/api/client'
|
||||||
|
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
|
||||||
|
const days = ref<string[]>([])
|
||||||
|
const todayDate = ref<string | null>(null)
|
||||||
|
const selectedDay = ref<string | null>(null)
|
||||||
|
const dayConvId = ref<number | null>(null)
|
||||||
|
const dayMessages = ref<JournalMessage[]>([])
|
||||||
|
const triggering = ref(false)
|
||||||
|
|
||||||
|
const isToday = computed(() => selectedDay.value !== null && selectedDay.value === todayDate.value)
|
||||||
|
|
||||||
|
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' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepMessage = computed<JournalMessage | null>(() => {
|
||||||
|
return dayMessages.value.find(
|
||||||
|
(m) => m.role === 'system' && (m.metadata as { kind?: string } | null)?.kind === 'daily_prep'
|
||||||
|
) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const prepSections = computed<Record<string, unknown>>(() => {
|
||||||
|
const meta = prepMessage.value?.metadata as { sections?: Record<string, unknown> } | null
|
||||||
|
return meta?.sections ?? {}
|
||||||
|
})
|
||||||
|
|
||||||
|
function asArray(v: unknown): unknown[] {
|
||||||
|
return Array.isArray(v) ? v : []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDay(iso: string) {
|
||||||
|
const payload = iso === todayDate.value
|
||||||
|
? await getJournalToday()
|
||||||
|
: await getJournalDay(iso)
|
||||||
|
|
||||||
|
dayMessages.value = payload.messages
|
||||||
|
if (payload.conversation) {
|
||||||
|
dayConvId.value = payload.conversation.id
|
||||||
|
await chatStore.fetchConversation(payload.conversation.id)
|
||||||
|
} else {
|
||||||
|
dayConvId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
// Today first — also creates the conversation + prep on first load.
|
||||||
|
try {
|
||||||
|
const today = await getJournalToday()
|
||||||
|
todayDate.value = today.day_date
|
||||||
|
selectedDay.value = today.day_date
|
||||||
|
dayMessages.value = today.messages
|
||||||
|
if (today.conversation) {
|
||||||
|
dayConvId.value = today.conversation.id
|
||||||
|
await chatStore.fetchConversation(today.conversation.id)
|
||||||
|
}
|
||||||
|
} catch { /* silent — backend may be unreachable */ }
|
||||||
|
|
||||||
|
// Day list
|
||||||
|
try {
|
||||||
|
days.value = await getJournalDays()
|
||||||
|
if (todayDate.value && !days.value.includes(todayDate.value)) {
|
||||||
|
days.value = [todayDate.value, ...days.value]
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
|
||||||
|
// Upcoming events
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedDay, async (iso) => {
|
||||||
|
if (!iso || iso === todayDate.value) return
|
||||||
|
try {
|
||||||
|
await loadDay(iso)
|
||||||
|
} catch { /* silent */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function triggerPrep() {
|
||||||
|
if (triggering.value) return
|
||||||
|
triggering.value = true
|
||||||
|
try {
|
||||||
|
await triggerJournalPrep()
|
||||||
|
if (_mounted) await loadAll()
|
||||||
|
} finally {
|
||||||
|
if (_mounted) triggering.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayLabel(iso: string): string {
|
||||||
|
if (iso === todayDate.value) return 'Today'
|
||||||
|
const d = new Date(iso + 'T00:00:00')
|
||||||
|
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayBadge = computed(() => {
|
||||||
|
return new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
|
||||||
|
})
|
||||||
|
|
||||||
|
let _mounted = true
|
||||||
|
onUnmounted(() => { _mounted = false })
|
||||||
|
|
||||||
|
useBackgroundRefresh(
|
||||||
|
async () => {
|
||||||
|
if (chatStore.streaming || !isToday.value || !dayConvId.value) return
|
||||||
|
await chatStore.fetchConversation(dayConvId.value)
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
() => !chatStore.streaming && isToday.value && !!dayConvId.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(loadAll)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="journal-root">
|
||||||
|
<div class="journal-shell">
|
||||||
|
<header class="journal-header">
|
||||||
|
<div class="journal-header-left">
|
||||||
|
<h1 class="journal-title">Journal</h1>
|
||||||
|
<span class="journal-today-badge">{{ todayBadge }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="journal-header-right">
|
||||||
|
<select v-if="days.length" v-model="selectedDay" class="journal-day-select">
|
||||||
|
<option v-for="d in days" :key="d" :value="d">{{ dayLabel(d) }}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="btn-trigger"
|
||||||
|
@click="triggerPrep"
|
||||||
|
:disabled="triggering || !isToday"
|
||||||
|
title="Regenerate today's daily prep"
|
||||||
|
>{{ triggering ? '…' : 'Refresh prep' }}</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Center: prep card + chat -->
|
||||||
|
<div class="journal-center">
|
||||||
|
<article v-if="prepMessage" class="daily-prep">
|
||||||
|
<header><h2>Today</h2></header>
|
||||||
|
|
||||||
|
<section v-if="asArray(prepSections.tasks).length" class="prep-section">
|
||||||
|
<h3>Tasks</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="t in (asArray(prepSections.tasks) as { id: number; title: string; due_date: string | null }[])" :key="t.id">
|
||||||
|
{{ t.title }}<span v-if="t.due_date"> · due {{ t.due_date }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="asArray(prepSections.events).length" class="prep-section">
|
||||||
|
<h3>Calendar</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(e, i) in (asArray(prepSections.events) as { id: number; title: string; start_dt: string }[])" :key="i">
|
||||||
|
{{ e.title }} — {{ e.start_dt }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="asArray(prepSections.weather).length" class="prep-section">
|
||||||
|
<h3>Weather</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(w, i) in (asArray(prepSections.weather) as { location_label?: string; location_key?: string }[])" :key="i">
|
||||||
|
{{ w.location_label || w.location_key }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="asArray(prepSections.projects).length" class="prep-section">
|
||||||
|
<h3>Active projects</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="p in (asArray(prepSections.projects) as { id: number; title: string }[])" :key="p.id">{{ p.title }}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="asArray(prepSections.recent_moments).length" class="prep-section">
|
||||||
|
<h3>Recent moments</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="m in (asArray(prepSections.recent_moments) as { id: number; content: string; day_date: string }[])" :key="m.id">
|
||||||
|
<em>{{ m.day_date }}</em> — {{ m.content }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="asArray(prepSections.open_threads).length" class="prep-section">
|
||||||
|
<h3>Open threads</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="m in (asArray(prepSections.open_threads) as { id: number; content: string; day_date: string }[])" :key="m.id">
|
||||||
|
<em>{{ m.day_date }}</em> — {{ m.content }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="asArray(prepSections.news).length" class="prep-section">
|
||||||
|
<h3>News</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(n, i) in (asArray(prepSections.news) as { title: string; source?: string }[])" :key="i">
|
||||||
|
{{ n.title }}<span v-if="n.source"> — {{ n.source }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<ChatPanel
|
||||||
|
variant="full"
|
||||||
|
briefingMode
|
||||||
|
:readOnly="!isToday"
|
||||||
|
placeholder="Tell your journal…"
|
||||||
|
class="journal-chat-panel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: upcoming events -->
|
||||||
|
<div class="journal-right">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.journal-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr minmax(280px, 30%);
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-title {
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-today-badge {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-day-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; }
|
||||||
|
|
||||||
|
.journal-center {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-chat-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-prep {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin: 1rem;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-prep > header h2 {
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-section {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-section h3 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-section ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-section li {
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-right {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-section {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.journal-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
}
|
||||||
|
.journal-center { grid-column: 1; grid-row: 2; }
|
||||||
|
.journal-right {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 3;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import {
|
|
||||||
getBriefingFeeds,
|
|
||||||
postRssReaction,
|
|
||||||
deleteRssReaction,
|
|
||||||
getNewsItems,
|
|
||||||
openArticleInChat,
|
|
||||||
type BriefingFeed,
|
|
||||||
} from '@/api/client'
|
|
||||||
import type { NewsItem } from '@/types/news'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const LIMIT = 40
|
|
||||||
|
|
||||||
const items = ref<NewsItem[]>([])
|
|
||||||
const offset = ref(0)
|
|
||||||
const hasMore = ref(true)
|
|
||||||
const loading = ref(false)
|
|
||||||
const feeds = ref<BriefingFeed[]>([])
|
|
||||||
const selectedFeedId = ref<number | null>(null)
|
|
||||||
|
|
||||||
// Reactions map: item id → current reaction
|
|
||||||
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
|
||||||
// Track which items are currently being opened in chat
|
|
||||||
const openingChat = ref<Set<number>>(new Set())
|
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
if (loading.value || !hasMore.value) return
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await getNewsItems({
|
|
||||||
days: 90,
|
|
||||||
limit: LIMIT,
|
|
||||||
offset: offset.value,
|
|
||||||
feed_id: selectedFeedId.value,
|
|
||||||
})
|
|
||||||
for (const item of data.items) {
|
|
||||||
if (reactions.value[item.id] === undefined) {
|
|
||||||
reactions.value[item.id] = item.reaction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items.value = [...items.value, ...data.items]
|
|
||||||
offset.value += data.items.length
|
|
||||||
hasMore.value = data.items.length === LIMIT
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFeedChange() {
|
|
||||||
items.value = []
|
|
||||||
offset.value = 0
|
|
||||||
hasMore.value = true
|
|
||||||
reactions.value = {}
|
|
||||||
loadMore()
|
|
||||||
}
|
|
||||||
|
|
||||||
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 < 1) return 'Just now'
|
|
||||||
if (diffH < 24) return `${Math.round(diffH)}h ago`
|
|
||||||
if (diffH < 48) return 'Yesterday'
|
|
||||||
const days = Math.floor(diffH / 24)
|
|
||||||
if (days < 7) return `${days}d ago`
|
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openInChat(itemId: number) {
|
|
||||||
if (openingChat.value.has(itemId)) return
|
|
||||||
openingChat.value.add(itemId)
|
|
||||||
try {
|
|
||||||
const result = await openArticleInChat(itemId)
|
|
||||||
router.push(`/chat/${result.conversation_id}`)
|
|
||||||
} catch {
|
|
||||||
// silently fail — button returns to enabled state
|
|
||||||
} finally {
|
|
||||||
openingChat.value.delete(itemId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
feeds.value = await getBriefingFeeds().catch(() => [])
|
|
||||||
await loadMore()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="news-root">
|
|
||||||
<div class="news-header">
|
|
||||||
<div class="news-header-left">
|
|
||||||
<h1 class="news-title">News</h1>
|
|
||||||
<span class="news-subtitle">Last 90 days</span>
|
|
||||||
</div>
|
|
||||||
<div class="news-header-right">
|
|
||||||
<select
|
|
||||||
v-model="selectedFeedId"
|
|
||||||
class="feed-select"
|
|
||||||
@change="onFeedChange"
|
|
||||||
>
|
|
||||||
<option :value="null">All feeds</option>
|
|
||||||
<option v-for="feed in feeds" :key="feed.id" :value="feed.id">
|
|
||||||
{{ feed.title }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="news-list">
|
|
||||||
<div v-if="!items.length && !loading" class="news-empty">
|
|
||||||
No articles found for the selected feed.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="item in items"
|
|
||||||
: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-card-title"
|
|
||||||
>{{ item.title }}</a>
|
|
||||||
<p v-else class="news-card-title news-card-title--plain">{{ item.title }}</p>
|
|
||||||
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
|
|
||||||
<div v-if="item.topics?.length" class="news-topics">
|
|
||||||
<span v-for="topic in item.topics" :key="topic" class="news-topic">{{ topic }}</span>
|
|
||||||
</div>
|
|
||||||
<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
|
|
||||||
class="reaction-btn open-chat-btn"
|
|
||||||
:class="{ busy: openingChat.has(item.id) }"
|
|
||||||
:disabled="openingChat.has(item.id)"
|
|
||||||
@click="openInChat(item.id)"
|
|
||||||
title="Discuss in chat"
|
|
||||||
>{{ openingChat.has(item.id) ? '…' : '💬' }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="news-footer">
|
|
||||||
<button
|
|
||||||
v-if="hasMore"
|
|
||||||
class="btn-load-more"
|
|
||||||
@click="loadMore"
|
|
||||||
:disabled="loading"
|
|
||||||
>{{ loading ? 'Loading…' : 'Load more' }}</button>
|
|
||||||
<div v-if="loading && !items.length" class="news-loading">Loading…</div>
|
|
||||||
<p v-if="!hasMore && items.length" class="news-end">All articles loaded</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.news-root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 1.25rem 1.5rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-title {
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-subtitle {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-list {
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
max-width: 860px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-empty,
|
|
||||||
.news-loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 1rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-card {
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-card-title {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
line-height: 1.4;
|
|
||||||
text-decoration: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.news-card-title:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-snippet {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-topics {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin-top: 0.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-topic {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
|
||||||
color: var(--color-primary);
|
|
||||||
border-radius: 99px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-reactions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.3rem;
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reaction-btn {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.open-chat-btn {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.open-chat-btn.busy {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1rem 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-load-more {
|
|
||||||
padding: 0.5rem 1.5rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-load-more:hover:not(:disabled) {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-load-more:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-end {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Reference in New Issue
Block a user