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 {
|
||||
label: string;
|
||||
address: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
export interface JournalConfig {
|
||||
prep_enabled: boolean;
|
||||
prep_hour: number;
|
||||
prep_minute: number;
|
||||
day_rollover_hour: number;
|
||||
morning_end_hour?: number;
|
||||
midday_end_hour?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BriefingSlots {
|
||||
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 {
|
||||
export interface JournalConversation {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
category: string | null;
|
||||
last_fetched_at: string | null;
|
||||
}
|
||||
|
||||
export interface BriefingConversation {
|
||||
id: number;
|
||||
title: string;
|
||||
briefing_date: string | null;
|
||||
model: string;
|
||||
conversation_type: string;
|
||||
day_date: string | null;
|
||||
rag_project_id: number | null;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BriefingMessage {
|
||||
export interface JournalMessage {
|
||||
id: number;
|
||||
conversation_id: number;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status: string;
|
||||
context_note_id: number | null;
|
||||
tool_calls: unknown[] | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
||||
enabled: false,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
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 interface JournalDayPayload {
|
||||
day_date: string;
|
||||
conversation: JournalConversation | null;
|
||||
messages: JournalMessage[];
|
||||
}
|
||||
|
||||
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
|
||||
await apiPut('/api/briefing/config', config);
|
||||
export interface JournalMoment {
|
||||
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[]> {
|
||||
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
|
||||
return data;
|
||||
export async function getJournalConfig(): Promise<JournalConfig> {
|
||||
return apiGet<JournalConfig>('/api/journal/config');
|
||||
}
|
||||
|
||||
export async function createBriefingFeed(url: string, category?: string): Promise<BriefingFeed> {
|
||||
const body: Record<string, string> = { url };
|
||||
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 saveJournalConfig(config: JournalConfig): Promise<void> {
|
||||
await apiPut('/api/journal/config', config);
|
||||
}
|
||||
|
||||
export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> {
|
||||
return apiPost('/api/briefing/feeds/refresh', {});
|
||||
export async function getJournalToday(): Promise<JournalDayPayload> {
|
||||
return apiGet<JournalDayPayload>('/api/journal/today');
|
||||
}
|
||||
|
||||
export async function deleteBriefingFeed(id: number): Promise<void> {
|
||||
await apiDelete(`/api/briefing/feeds/${id}`);
|
||||
export async function getJournalDay(isoDate: string): Promise<JournalDayPayload> {
|
||||
return apiGet<JournalDayPayload>(`/api/journal/day/${isoDate}`);
|
||||
}
|
||||
|
||||
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
|
||||
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
|
||||
return data.conversations;
|
||||
export async function getJournalDays(): Promise<string[]> {
|
||||
const data = await apiGet<{ days: string[] }>('/api/journal/days');
|
||||
return data.days;
|
||||
}
|
||||
|
||||
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
|
||||
return apiGet('/api/briefing/conversations/today');
|
||||
export async function triggerJournalPrep(date?: string): Promise<{ ok: boolean; message_id: number }> {
|
||||
return apiPost('/api/journal/trigger-prep', date ? { date } : {});
|
||||
}
|
||||
|
||||
export async function getBriefingConvMessages(id: number): Promise<BriefingMessage[]> {
|
||||
const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`);
|
||||
return data.messages;
|
||||
export async function listJournalMoments(params: Record<string, string | number | boolean> = {}): Promise<JournalMoment[]> {
|
||||
const qs = new URLSearchParams();
|
||||
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> {
|
||||
await apiPost('/api/briefing/trigger', { slot });
|
||||
export async function updateJournalMoment(id: number, patch: Partial<JournalMoment>): Promise<JournalMoment> {
|
||||
return apiPatch<JournalMoment>(`/api/journal/moments/${id}`, patch);
|
||||
}
|
||||
|
||||
export async function postRssReaction(
|
||||
rssItemId: number,
|
||||
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 deleteJournalMoment(id: number): Promise<void> {
|
||||
await apiDelete(`/api/journal/moments/${id}`);
|
||||
}
|
||||
|
||||
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> {
|
||||
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 };
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -76,6 +76,7 @@ router.afterEach(() => {
|
||||
<div class="nav-pill-bar">
|
||||
<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="/journal" class="nav-link">Journal</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
</div>
|
||||
@@ -123,6 +124,7 @@ router.afterEach(() => {
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<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="/journal" class="nav-link">Journal</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="/shared" class="nav-link">Shared</router-link>
|
||||
|
||||
@@ -116,6 +116,11 @@ const router = createRouter({
|
||||
name: "calendar",
|
||||
component: () => import("@/views/CalendarView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/journal",
|
||||
name: "journal",
|
||||
component: () => import("@/views/JournalView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/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