diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5ae97d0..78e565a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -580,3 +580,21 @@ export async function updateEvent(id: number, payload: EventUpdatePayload): Prom export async function deleteEvent(id: number): Promise { return apiDelete(`/api/events/${id}`); } + +// ─── API Keys ───────────────────────────────────────────────────────────────── + +export interface ApiKeyEntry { + id: number + name: string + scope: string + key_prefix: string + last_used_at: string | null +} + +export const listApiKeys = () => + apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys) + +export const createApiKey = (name: string, scope: 'read' | 'write') => + apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope }) + +export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`) diff --git a/frontend/src/composables/useBackgroundRefresh.ts b/frontend/src/composables/useBackgroundRefresh.ts new file mode 100644 index 0000000..9aa2912 --- /dev/null +++ b/frontend/src/composables/useBackgroundRefresh.ts @@ -0,0 +1,29 @@ +import { onMounted, onUnmounted } from 'vue' + +/** + * Runs `refreshFn` on a recurring interval while the page is visible. + * Safe to use in any component — timer is cleared on unmount. + * + * @param refreshFn Called each tick. Should be silent (no loading state changes). + * @param intervalMs Polling interval in milliseconds. + * @param canRun Optional guard — refresh is skipped when this returns false. + */ +export function useBackgroundRefresh( + refreshFn: () => void, + intervalMs: number, + canRun?: () => boolean, +): void { + let timer: ReturnType | null = null + + onMounted(() => { + timer = setInterval(() => { + if (document.hidden) return + if (canRun && !canRun()) return + refreshFn() + }, intervalMs) + }) + + onUnmounted(() => { + if (timer !== null) clearInterval(timer) + }) +} diff --git a/frontend/src/utils/palette.ts b/frontend/src/utils/palette.ts new file mode 100644 index 0000000..e04101d --- /dev/null +++ b/frontend/src/utils/palette.ts @@ -0,0 +1,13 @@ +/** Cyclic color palette for milestone progress bars. */ +export const MILESTONE_PALETTE = [ + 'var(--color-primary)', + 'var(--color-success, #22c55e)', + '#c98a00', + '#8b5cf6', + 'var(--color-danger, #ef4444)', + '#06b6d4', +] + +export function milestoneColor(index: number): string { + return MILESTONE_PALETTE[index % MILESTONE_PALETTE.length] +} diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index 63c236d..894a868 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -1,5 +1,6 @@ @@ -263,29 +282,43 @@ onUnmounted(() => { :message="toMsg(msg)" :is-streaming="false" /> - +
- Story {{ index + 1 }} - - +
+ {{ item.source }} + {{ formatRelativeDate(item.published_at) }} +
+ {{ item.title }} +

{{ item.title }}

+

{{ item.snippet }}

+
+ + +
@@ -484,33 +517,78 @@ onUnmounted(() => { .btn-send:disabled { opacity: 0.4; cursor: not-allowed; } .btn-send:hover:not(:disabled) { opacity: 0.9; } -.rss-reactions { +.news-cards { display: flex; flex-direction: column; - gap: 0.35rem; - padding: 0.5rem 0.75rem; + gap: 0.5rem; + margin-top: 0.25rem; margin-bottom: 0.25rem; } -.rss-reaction-row { +.news-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: 10px; + padding: 0.65rem 0.85rem; display: flex; - align-items: center; - gap: 0.4rem; + flex-direction: column; + gap: 0.25rem; } -.reaction-label { +.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); - min-width: 4rem; + 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.15rem 0.4rem; + padding: 0.1rem 0.35rem; cursor: pointer; - font-size: 0.85rem; + font-size: 0.82rem; line-height: 1.4; opacity: 0.55; transition: opacity 0.15s, border-color 0.15s; diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 40664c3..ca79eda 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,6 +1,8 @@