feat: add /news archive view with feed filter and pagination

- NewsView.vue: 90-day article archive, feed filter dropdown,
  load-more pagination, topic pills, thumbs up/down reactions
- Router: add /news route
- AppHeader: add News nav link (desktop + mobile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-28 00:51:24 -04:00
parent f81c38df84
commit 37e2420192
3 changed files with 378 additions and 0 deletions
+2
View File
@@ -79,6 +79,7 @@ router.afterEach(() => {
<router-link to="/graph" class="nav-link">Graph</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
</div>
@@ -129,6 +130,7 @@ router.afterEach(() => {
<router-link to="/graph" class="nav-link">Graph</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
<router-link to="/settings" class="nav-link">Settings</router-link>
+5
View File
@@ -120,6 +120,11 @@ const router = createRouter({
name: "briefing",
component: () => import("@/views/BriefingView.vue"),
},
{
path: "/news",
name: "news",
component: () => import("@/views/NewsView.vue"),
},
{
path: "/settings",
name: "settings",
+371
View File
@@ -0,0 +1,371 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
getBriefingFeeds,
postRssReaction,
deleteRssReaction,
getNewsItems,
type BriefingFeed,
} from '@/api/client'
import type { NewsItem } from '@/types/news'
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>>({})
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' })
}
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>
</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);
}
.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>