feat: daily briefing frontend — view, setup wizard, settings tab, nav
- BriefingView: soft chat UI with conversation history dropdown, SSE streaming via chat store, manual refresh trigger, today/past conv toggle - BriefingSetupWizard: 4-step first-time setup overlay (welcome → locations → office days → RSS feeds) - SettingsView: Briefing tab with enable toggle, location geocoding, office day picker, slot toggles, RSS feed management, notifications - AppHeader + router: /briefing route and nav link (desktop + mobile) - client.ts: briefing types and API functions (config, feeds, convs, geocode, trigger) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -300,6 +300,122 @@ export function apiSSEStream(
|
|||||||
return { close: () => controller.abort(), done };
|
return { close: () => controller.abort(), done };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Briefing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface BriefingLocation {
|
||||||
|
label: string;
|
||||||
|
address: string;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BriefingFeed {
|
||||||
|
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;
|
||||||
|
message_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BriefingMessage {
|
||||||
|
id: number;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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> {
|
||||||
|
await apiPut('/api/briefing/config', config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
|
||||||
|
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBriefingFeed(url: string): Promise<BriefingFeed> {
|
||||||
|
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', { url });
|
||||||
|
return { ...data, last_fetched_at: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBriefingFeed(id: number): Promise<void> {
|
||||||
|
await apiDelete(`/api/briefing/feeds/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
|
||||||
|
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
|
||||||
|
return data.conversations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
|
||||||
|
return apiGet('/api/briefing/conversations/today');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 triggerBriefingSlot(slot: string): Promise<void> {
|
||||||
|
await apiPost('/api/briefing/trigger', { slot });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
return { lat: r.lat, lon: r.lon, display_name: r.label };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiStreamPost(
|
export async function apiStreamPost(
|
||||||
path: string,
|
path: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ router.afterEach(() => {
|
|||||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
<router-link to="/tasks" class="nav-link">Tasks</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="/graph" class="nav-link">Graph</router-link>
|
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||||
|
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ router.afterEach(() => {
|
|||||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
<router-link to="/tasks" class="nav-link">Tasks</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="/graph" class="nav-link">Graph</router-link>
|
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||||
|
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<div class="mobile-divider"></div>
|
||||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import {
|
||||||
|
saveBriefingConfig,
|
||||||
|
geocodeAddress,
|
||||||
|
createBriefingFeed,
|
||||||
|
type BriefingConfig,
|
||||||
|
} from '@/api/client'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ done: [] }>()
|
||||||
|
|
||||||
|
const step = ref(1)
|
||||||
|
const TOTAL_STEPS = 4
|
||||||
|
|
||||||
|
const config = reactive<BriefingConfig>({
|
||||||
|
enabled: true,
|
||||||
|
locations: {},
|
||||||
|
use_caldav_event_locations: false,
|
||||||
|
work_days: [1, 2, 3, 4, 5],
|
||||||
|
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||||
|
notifications: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 2 — locations
|
||||||
|
const homeAddress = ref('')
|
||||||
|
const workAddress = ref('')
|
||||||
|
const geocodingHome = ref(false)
|
||||||
|
const geocodingWork = ref(false)
|
||||||
|
const homeConfirmed = ref<string | null>(null)
|
||||||
|
const workConfirmed = ref<string | null>(null)
|
||||||
|
const homeError = ref('')
|
||||||
|
const workError = ref('')
|
||||||
|
|
||||||
|
async function lookupHome() {
|
||||||
|
if (!homeAddress.value.trim()) return
|
||||||
|
geocodingHome.value = true
|
||||||
|
homeError.value = ''
|
||||||
|
try {
|
||||||
|
const r = await geocodeAddress(homeAddress.value.trim())
|
||||||
|
if (r) {
|
||||||
|
config.locations.home = { label: 'Home', address: homeAddress.value.trim(), lat: r.lat, lon: r.lon }
|
||||||
|
homeConfirmed.value = r.display_name
|
||||||
|
} else {
|
||||||
|
homeError.value = 'Location not found'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
geocodingHome.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupWork() {
|
||||||
|
if (!workAddress.value.trim()) return
|
||||||
|
geocodingWork.value = true
|
||||||
|
workError.value = ''
|
||||||
|
try {
|
||||||
|
const r = await geocodeAddress(workAddress.value.trim())
|
||||||
|
if (r) {
|
||||||
|
config.locations.work = { label: 'Work', address: workAddress.value.trim(), lat: r.lat, lon: r.lon }
|
||||||
|
workConfirmed.value = r.display_name
|
||||||
|
} else {
|
||||||
|
workError.value = 'Location not found'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
geocodingWork.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 — work days
|
||||||
|
function toggleDay(d: number) {
|
||||||
|
const idx = config.work_days.indexOf(d)
|
||||||
|
if (idx === -1) config.work_days.push(d)
|
||||||
|
else config.work_days.splice(idx, 1)
|
||||||
|
config.work_days.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 — RSS feeds
|
||||||
|
const newFeedUrl = ref('')
|
||||||
|
const pendingFeeds = ref<Array<{ url: string }>>([])
|
||||||
|
|
||||||
|
function addPendingFeed() {
|
||||||
|
if (!newFeedUrl.value.trim()) return
|
||||||
|
pendingFeeds.value.push({ url: newFeedUrl.value.trim() })
|
||||||
|
newFeedUrl.value = ''
|
||||||
|
}
|
||||||
|
function removePendingFeed(i: number) { pendingFeeds.value.splice(i, 1) }
|
||||||
|
|
||||||
|
// Finish
|
||||||
|
const finishing = ref(false)
|
||||||
|
async function finish() {
|
||||||
|
finishing.value = true
|
||||||
|
try {
|
||||||
|
await saveBriefingConfig(config)
|
||||||
|
for (const f of pendingFeeds.value) {
|
||||||
|
try { await createBriefingFeed(f.url) } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
emit('done')
|
||||||
|
} finally {
|
||||||
|
finishing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="wizard-overlay">
|
||||||
|
<div class="wizard-card" role="dialog" aria-label="Briefing Setup">
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="wizard-progress">
|
||||||
|
<div class="wizard-progress-fill" :style="{ width: `${(step / TOTAL_STEPS) * 100}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-step-label">Step {{ step }} of {{ TOTAL_STEPS }}</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Welcome -->
|
||||||
|
<div v-if="step === 1" class="wizard-body">
|
||||||
|
<h2 class="wizard-title">Good morning.</h2>
|
||||||
|
<p class="wizard-text">
|
||||||
|
The Briefing is a daily conversation that summarises your day — tasks, calendar,
|
||||||
|
weather, and news — and checks in a few times throughout the day.
|
||||||
|
</p>
|
||||||
|
<p class="wizard-text">
|
||||||
|
It learns from your responses over time and adapts to your schedule.
|
||||||
|
Let's take a minute to set it up.
|
||||||
|
</p>
|
||||||
|
<div class="wizard-actions">
|
||||||
|
<button class="btn-wizard-primary" @click="step = 2">Get started</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Locations -->
|
||||||
|
<div v-else-if="step === 2" class="wizard-body">
|
||||||
|
<h2 class="wizard-title">Where are you?</h2>
|
||||||
|
<p class="wizard-text">
|
||||||
|
Enter your home and work addresses. The briefing uses these to fetch weather
|
||||||
|
for the right places. You can skip either one.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label class="wizard-label">Home address</label>
|
||||||
|
<div class="wizard-input-row">
|
||||||
|
<input v-model="homeAddress" class="wizard-input" placeholder="e.g. 123 Main St, Springfield" @keydown.enter="lookupHome" />
|
||||||
|
<button class="btn-wizard-secondary" @click="lookupHome" :disabled="geocodingHome">
|
||||||
|
{{ geocodingHome ? '…' : 'Look up' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="homeConfirmed" class="wizard-confirmed">✓ {{ homeConfirmed }}</div>
|
||||||
|
<div v-if="homeError" class="wizard-error">{{ homeError }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label class="wizard-label">Work address</label>
|
||||||
|
<div class="wizard-input-row">
|
||||||
|
<input v-model="workAddress" class="wizard-input" placeholder="e.g. 456 Office Ave, Portland" @keydown.enter="lookupWork" />
|
||||||
|
<button class="btn-wizard-secondary" @click="lookupWork" :disabled="geocodingWork">
|
||||||
|
{{ geocodingWork ? '…' : 'Look up' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="workConfirmed" class="wizard-confirmed">✓ {{ workConfirmed }}</div>
|
||||||
|
<div v-if="workError" class="wizard-error">{{ workError }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wizard-actions">
|
||||||
|
<button class="btn-wizard-ghost" @click="step = 1">Back</button>
|
||||||
|
<button class="btn-wizard-primary" @click="step = 3">Continue</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Work schedule -->
|
||||||
|
<div v-else-if="step === 3" class="wizard-body">
|
||||||
|
<h2 class="wizard-title">When do you go to the office?</h2>
|
||||||
|
<p class="wizard-text">
|
||||||
|
The 8am slot is labelled "you're at the office" on days you have marked as office days.
|
||||||
|
Toggle any days you typically commute.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="wizard-days">
|
||||||
|
<button
|
||||||
|
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||||
|
:key="idx"
|
||||||
|
:class="['wizard-day-btn', { active: config.work_days.includes(idx) }]"
|
||||||
|
@click="toggleDay(idx)"
|
||||||
|
type="button"
|
||||||
|
>{{ day }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wizard-actions">
|
||||||
|
<button class="btn-wizard-ghost" @click="step = 2">Back</button>
|
||||||
|
<button class="btn-wizard-primary" @click="step = 4">Continue</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: RSS feeds -->
|
||||||
|
<div v-else-if="step === 4" class="wizard-body">
|
||||||
|
<h2 class="wizard-title">What do you follow?</h2>
|
||||||
|
<p class="wizard-text">
|
||||||
|
Add RSS or Atom feeds and the briefing will summarise recent items each morning.
|
||||||
|
You can add or remove feeds any time in Settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="pendingFeeds.length" class="wizard-feeds-list">
|
||||||
|
<div v-for="(f, i) in pendingFeeds" :key="i" class="wizard-feed-row">
|
||||||
|
<span class="wizard-feed-url">{{ f.url }}</span>
|
||||||
|
<button class="btn-wizard-remove" @click="removePendingFeed(i)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wizard-add-feed">
|
||||||
|
<input v-model="newFeedUrl" class="wizard-input" placeholder="https://…/feed.xml" style="flex: 1" />
|
||||||
|
<button class="btn-wizard-secondary" @click="addPendingFeed" :disabled="!newFeedUrl.trim()">Add</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wizard-actions" style="margin-top: 1.5rem">
|
||||||
|
<button class="btn-wizard-ghost" @click="step = 3">Back</button>
|
||||||
|
<button class="btn-wizard-ghost" @click="finish" :disabled="finishing">Skip</button>
|
||||||
|
<button class="btn-wizard-primary" @click="finish" :disabled="finishing">
|
||||||
|
{{ finishing ? 'Setting up…' : 'Enable Briefing' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wizard-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
.wizard-card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg, 18px);
|
||||||
|
width: 520px;
|
||||||
|
max-width: 94vw;
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.wizard-progress {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
.wizard-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #6366f1, #4f46e5);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.wizard-step-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: right;
|
||||||
|
padding: 0.5rem 1.5rem 0;
|
||||||
|
}
|
||||||
|
.wizard-body {
|
||||||
|
padding: 1.5rem 2rem 2rem;
|
||||||
|
}
|
||||||
|
.wizard-title {
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.wizard-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.wizard-field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.wizard-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.wizard-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.wizard-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.wizard-input:focus { border-color: var(--color-primary); }
|
||||||
|
.wizard-confirmed {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-success, #22c55e);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.wizard-error {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.wizard-days {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
.wizard-day-btn {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.wizard-day-btn.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.wizard-feeds-list {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.wizard-feed-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.wizard-feed-url {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-wizard-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.btn-wizard-remove:hover { color: var(--color-danger, #ef4444); }
|
||||||
|
.wizard-add-feed {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.wizard-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.btn-wizard-primary {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-wizard-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-wizard-secondary {
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-wizard-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-wizard-ghost {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-wizard-ghost:hover { background: var(--color-bg-secondary); }
|
||||||
|
.btn-wizard-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
</style>
|
||||||
@@ -110,6 +110,11 @@ const router = createRouter({
|
|||||||
name: "shared-with-me",
|
name: "shared-with-me",
|
||||||
component: () => import("@/views/SharedWithMeView.vue"),
|
component: () => import("@/views/SharedWithMeView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/briefing",
|
||||||
|
name: "briefing",
|
||||||
|
component: () => import("@/views/BriefingView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
name: "settings",
|
name: "settings",
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import ChatMessage from '@/components/ChatMessage.vue'
|
||||||
|
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
||||||
|
import {
|
||||||
|
getBriefingConfig,
|
||||||
|
getBriefingConversations,
|
||||||
|
getBriefingToday,
|
||||||
|
getBriefingConvMessages,
|
||||||
|
triggerBriefingSlot,
|
||||||
|
type BriefingConversation,
|
||||||
|
type BriefingMessage,
|
||||||
|
} from '@/api/client'
|
||||||
|
import type { Message } from '@/types/chat'
|
||||||
|
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
|
||||||
|
// Setup wizard
|
||||||
|
const showWizard = ref(false)
|
||||||
|
const wizardChecked = ref(false)
|
||||||
|
|
||||||
|
async function checkSetup() {
|
||||||
|
const config = await getBriefingConfig()
|
||||||
|
if (!config.enabled) showWizard.value = true
|
||||||
|
wizardChecked.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onWizardDone() {
|
||||||
|
showWizard.value = false
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversations list for the dropdown
|
||||||
|
const conversations = ref<BriefingConversation[]>([])
|
||||||
|
const selectedConvId = ref<number | null>(null)
|
||||||
|
const todayConvId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
||||||
|
|
||||||
|
// Messages for selected conversation
|
||||||
|
const messages = ref<BriefingMessage[]>([])
|
||||||
|
const loadingMessages = ref(false)
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
const [convList, today] = await Promise.all([
|
||||||
|
getBriefingConversations(),
|
||||||
|
getBriefingToday().catch(() => null),
|
||||||
|
])
|
||||||
|
conversations.value = convList
|
||||||
|
if (today) {
|
||||||
|
todayConvId.value = today.id
|
||||||
|
// Ensure today is in the list
|
||||||
|
if (!convList.find((c) => c.id === today.id)) {
|
||||||
|
conversations.value = [
|
||||||
|
{ id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
|
||||||
|
...convList,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
selectedConvId.value = today.id
|
||||||
|
messages.value = today.messages
|
||||||
|
// Load into chatStore so we can stream
|
||||||
|
await chatStore.fetchConversation(today.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedConvId, async (id) => {
|
||||||
|
if (!id) return
|
||||||
|
if (id === todayConvId.value) {
|
||||||
|
await chatStore.fetchConversation(id)
|
||||||
|
messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingMessages.value = true
|
||||||
|
try {
|
||||||
|
messages.value = await getBriefingConvMessages(id)
|
||||||
|
} finally {
|
||||||
|
loadingMessages.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh messages after streaming ends
|
||||||
|
watch(() => chatStore.streaming, async (streaming) => {
|
||||||
|
if (!streaming && selectedConvId.value === todayConvId.value && todayConvId.value) {
|
||||||
|
const today = await getBriefingToday().catch(() => null)
|
||||||
|
if (today) messages.value = today.messages
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Input
|
||||||
|
const input = ref('')
|
||||||
|
const sending = ref(false)
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const text = input.value.trim()
|
||||||
|
if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
|
||||||
|
// Ensure today's conv is loaded in chatStore
|
||||||
|
if (chatStore.currentConversation?.id !== todayConvId.value) {
|
||||||
|
await chatStore.fetchConversation(todayConvId.value)
|
||||||
|
}
|
||||||
|
input.value = ''
|
||||||
|
sending.value = true
|
||||||
|
try {
|
||||||
|
await chatStore.sendMessage(text)
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual trigger
|
||||||
|
const triggering = ref(false)
|
||||||
|
async function triggerNow() {
|
||||||
|
triggering.value = true
|
||||||
|
try {
|
||||||
|
await triggerBriefingSlot('compilation')
|
||||||
|
// Reload
|
||||||
|
await loadAll()
|
||||||
|
} finally {
|
||||||
|
triggering.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown label
|
||||||
|
function convLabel(c: BriefingConversation): string {
|
||||||
|
if (c.id === todayConvId.value) return 'Today'
|
||||||
|
if (c.briefing_date) {
|
||||||
|
const d = new Date(c.briefing_date)
|
||||||
|
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
return c.title || 'Briefing'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert BriefingMessage to Message for ChatMessage component
|
||||||
|
function toMsg(m: BriefingMessage): Message {
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
conversation_id: -1,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
context_note_id: null,
|
||||||
|
context_note_title: null,
|
||||||
|
created_at: m.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await checkSetup()
|
||||||
|
if (!showWizard.value) await loadAll()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="briefing-root">
|
||||||
|
<!-- Setup wizard overlay -->
|
||||||
|
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
|
||||||
|
|
||||||
|
<!-- Main view (shown after wizard check and only if not showing wizard) -->
|
||||||
|
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="briefing-header">
|
||||||
|
<div class="briefing-header-left">
|
||||||
|
<h1 class="briefing-title">Briefing</h1>
|
||||||
|
<span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="briefing-header-right">
|
||||||
|
<!-- Conversation history dropdown -->
|
||||||
|
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
|
||||||
|
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="btn-trigger"
|
||||||
|
@click="triggerNow"
|
||||||
|
:disabled="triggering"
|
||||||
|
title="Manually trigger morning briefing now"
|
||||||
|
>{{ triggering ? '…' : 'Refresh' }}</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<div class="briefing-messages-wrap">
|
||||||
|
<div v-if="loadingMessages" class="briefing-loading">Loading…</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="!messages.length" class="briefing-empty">
|
||||||
|
<p>No briefing yet for today.</p>
|
||||||
|
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="briefing-messages">
|
||||||
|
<ChatMessage
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:message="toMsg(msg)"
|
||||||
|
:is-streaming="false"
|
||||||
|
/>
|
||||||
|
<!-- Live streaming bubble for today -->
|
||||||
|
<ChatMessage
|
||||||
|
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
|
||||||
|
:message="{
|
||||||
|
id: -1,
|
||||||
|
conversation_id: todayConvId ?? -1,
|
||||||
|
role: 'assistant',
|
||||||
|
content: chatStore.streamingContent,
|
||||||
|
context_note_id: null,
|
||||||
|
context_note_title: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}"
|
||||||
|
:is-streaming="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input bar (today only) -->
|
||||||
|
<div v-if="isToday" class="briefing-input-bar">
|
||||||
|
<textarea
|
||||||
|
v-model="input"
|
||||||
|
class="briefing-input"
|
||||||
|
placeholder="Reply to your briefing…"
|
||||||
|
rows="1"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
class="btn-send"
|
||||||
|
@click="send"
|
||||||
|
:disabled="!input.trim() || chatStore.streaming || sending"
|
||||||
|
aria-label="Send"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.briefing-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.25rem 0 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-title {
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-today-badge {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-conv-select {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-trigger {
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-trigger:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.briefing-messages-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-loading,
|
||||||
|
.briefing-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-empty-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-input-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0.75rem 0 1rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.briefing-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.4;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.briefing-input:focus { border-color: var(--color-primary); }
|
||||||
|
|
||||||
|
.btn-send {
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.btn-send:hover:not(:disabled) { opacity: 0.9; }
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,7 @@ import { ref, watch, onMounted } from "vue";
|
|||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, type GroupEntry, type GroupMember, type UserSearchResult } from "@/api/client";
|
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, geocodeAddress, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client";
|
||||||
import { usePushStore } from "@/stores/push";
|
import { usePushStore } from "@/stores/push";
|
||||||
import type { User } from "@/types/auth";
|
import type { User } from "@/types/auth";
|
||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
@@ -32,7 +32,7 @@ const appVersion = ref('dev');
|
|||||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||||
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "config", "users", "logs", "groups"]);
|
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "config", "users", "logs", "groups"]);
|
||||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||||
watch(activeTab, (v) => {
|
watch(activeTab, (v) => {
|
||||||
@@ -40,6 +40,7 @@ watch(activeTab, (v) => {
|
|||||||
if (v === "users" && authStore.isAdmin) loadUsersPanel();
|
if (v === "users" && authStore.isAdmin) loadUsersPanel();
|
||||||
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
|
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
|
||||||
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
|
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
|
||||||
|
if (v === "briefing") loadBriefingTab();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Groups management
|
// Groups management
|
||||||
@@ -117,6 +118,94 @@ async function removeMemberFromGroup(groupId: number, userId: number) {
|
|||||||
await loadGroupsPanel();
|
await loadGroupsPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Briefing settings
|
||||||
|
const briefingConfig = ref<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,
|
||||||
|
});
|
||||||
|
const briefingFeeds = ref<BriefingFeed[]>([]);
|
||||||
|
const briefingSaving = ref(false);
|
||||||
|
const briefingSaved = ref(false);
|
||||||
|
const briefingGeocoding = ref<Record<string, boolean>>({});
|
||||||
|
const briefingGeoError = ref<Record<string, string>>({});
|
||||||
|
const newFeedUrl = ref('');
|
||||||
|
const addingFeed = ref(false);
|
||||||
|
|
||||||
|
async function loadBriefingTab() {
|
||||||
|
briefingConfig.value = await getBriefingConfig();
|
||||||
|
briefingFeeds.value = await getBriefingFeeds();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function geocodeLocation(key: 'home' | 'work') {
|
||||||
|
const loc = briefingConfig.value.locations[key];
|
||||||
|
if (!loc?.address?.trim()) return;
|
||||||
|
briefingGeocoding.value[key] = true;
|
||||||
|
briefingGeoError.value[key] = '';
|
||||||
|
try {
|
||||||
|
const result = await geocodeAddress(loc.address.trim());
|
||||||
|
if (result) {
|
||||||
|
briefingConfig.value.locations[key] = {
|
||||||
|
...loc,
|
||||||
|
lat: result.lat,
|
||||||
|
lon: result.lon,
|
||||||
|
label: key.charAt(0).toUpperCase() + key.slice(1),
|
||||||
|
};
|
||||||
|
briefingGeoError.value[key] = '';
|
||||||
|
} else {
|
||||||
|
briefingGeoError.value[key] = 'Location not found — check the address';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
briefingGeoError.value[key] = 'Geocoding failed';
|
||||||
|
} finally {
|
||||||
|
briefingGeocoding.value[key] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWorkDay(day: number) {
|
||||||
|
const days = briefingConfig.value.work_days;
|
||||||
|
const idx = days.indexOf(day);
|
||||||
|
if (idx === -1) days.push(day);
|
||||||
|
else days.splice(idx, 1);
|
||||||
|
days.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBriefingSettings() {
|
||||||
|
briefingSaving.value = true;
|
||||||
|
briefingSaved.value = false;
|
||||||
|
try {
|
||||||
|
await saveBriefingConfig(briefingConfig.value);
|
||||||
|
briefingSaved.value = true;
|
||||||
|
setTimeout(() => (briefingSaved.value = false), 2000);
|
||||||
|
} catch {
|
||||||
|
toastStore.show('Failed to save briefing settings', 'error');
|
||||||
|
} finally {
|
||||||
|
briefingSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFeed() {
|
||||||
|
if (!newFeedUrl.value.trim() || addingFeed.value) return;
|
||||||
|
addingFeed.value = true;
|
||||||
|
try {
|
||||||
|
const feed = await createBriefingFeed(newFeedUrl.value.trim());
|
||||||
|
briefingFeeds.value.push(feed);
|
||||||
|
newFeedUrl.value = '';
|
||||||
|
} catch {
|
||||||
|
toastStore.show('Failed to add feed', 'error');
|
||||||
|
} finally {
|
||||||
|
addingFeed.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFeed(id: number) {
|
||||||
|
await deleteBriefingFeed(id);
|
||||||
|
briefingFeeds.value = briefingFeeds.value.filter((f) => f.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
// Chat retention
|
// Chat retention
|
||||||
const chatRetentionDays = ref(90);
|
const chatRetentionDays = ref(90);
|
||||||
const savingRetention = ref(false);
|
const savingRetention = ref(false);
|
||||||
@@ -774,7 +863,7 @@ function formatUserDate(iso: string): string {
|
|||||||
<div class="sidebar-group">
|
<div class="sidebar-group">
|
||||||
<div class="sidebar-group-label">User</div>
|
<div class="sidebar-group-label">User</div>
|
||||||
<button
|
<button
|
||||||
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data']"
|
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing']"
|
||||||
:key="tab"
|
:key="tab"
|
||||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||||
@click="activeTab = tab"
|
@click="activeTab = tab"
|
||||||
@@ -1172,6 +1261,147 @@ function formatUserDate(iso: string): string {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Briefing ── -->
|
||||||
|
<div v-show="activeTab === 'briefing'" class="settings-grid">
|
||||||
|
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Daily Briefing</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
Configure your daily briefing — a conversation that summarises your day,
|
||||||
|
weather, and RSS feeds on a schedule.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Enable -->
|
||||||
|
<div class="checkbox-field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="briefingConfig.enabled" />
|
||||||
|
Enable Daily Briefing
|
||||||
|
</label>
|
||||||
|
<p class="field-hint">Start daily briefings at the configured slots.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Locations -->
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Locations</h2>
|
||||||
|
<p class="section-desc">Enter addresses for weather lookup. Click "Look up" to geocode.</p>
|
||||||
|
|
||||||
|
<div v-for="key in (['home', 'work'] as const)" :key="key" class="briefing-location-row">
|
||||||
|
<label class="field-label">{{ key.charAt(0).toUpperCase() + key.slice(1) }}</label>
|
||||||
|
<div class="briefing-input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
:placeholder="key === 'home' ? 'e.g. 123 Main St, Springfield' : 'e.g. 456 Office Ave, Portland'"
|
||||||
|
:value="briefingConfig.locations[key]?.address ?? ''"
|
||||||
|
@input="(e) => {
|
||||||
|
if (!briefingConfig.locations[key]) briefingConfig.locations[key] = { label: key, address: '' };
|
||||||
|
briefingConfig.locations[key]!.address = (e.target as HTMLInputElement).value;
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<button class="btn-secondary" @click="geocodeLocation(key)" :disabled="briefingGeocoding[key]">
|
||||||
|
{{ briefingGeocoding[key] ? 'Looking up…' : 'Look up' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="briefingConfig.locations[key]?.lat" class="briefing-geo-confirmed">
|
||||||
|
✓ {{ briefingConfig.locations[key]!.lat?.toFixed(4) }}, {{ briefingConfig.locations[key]!.lon?.toFixed(4) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="briefingGeoError[key]" class="briefing-geo-error">{{ briefingGeoError[key] }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-field" style="margin-top: 0.75rem">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="briefingConfig.use_caldav_event_locations" />
|
||||||
|
Also check CalDAV event locations
|
||||||
|
</label>
|
||||||
|
<p class="field-hint">Look up weather for locations in today's calendar events.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Work schedule -->
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Office Days</h2>
|
||||||
|
<p class="section-desc">Which days do you go into the office? Used to decide whether to include the 8am check-in slot.</p>
|
||||||
|
<div class="briefing-day-toggles">
|
||||||
|
<button
|
||||||
|
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||||
|
:key="idx"
|
||||||
|
:class="['briefing-day-btn', { active: briefingConfig.work_days.includes(idx) }]"
|
||||||
|
@click="toggleWorkDay(idx)"
|
||||||
|
type="button"
|
||||||
|
>{{ day }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Slots -->
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Scheduled Slots</h2>
|
||||||
|
<p class="section-desc">Each active slot will post an update into your briefing conversation.</p>
|
||||||
|
<div class="briefing-slot-list">
|
||||||
|
<div
|
||||||
|
v-for="[key, label, time] in ([
|
||||||
|
['compilation', 'Morning briefing', '4:00 am'],
|
||||||
|
['morning', 'Office check-in', '8:00 am'],
|
||||||
|
['midday', 'Midday update', '12:00 pm'],
|
||||||
|
['afternoon', 'End of day', '4:00 pm'],
|
||||||
|
] as const)"
|
||||||
|
:key="key"
|
||||||
|
class="briefing-slot-row"
|
||||||
|
>
|
||||||
|
<div class="briefing-slot-info">
|
||||||
|
<span class="briefing-slot-label">{{ label }}</span>
|
||||||
|
<span class="briefing-slot-time">{{ time }}</span>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="briefingConfig.slots[key]" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- RSS Feeds -->
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>RSS Feeds</h2>
|
||||||
|
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
|
||||||
|
<div class="briefing-feeds-list" v-if="briefingFeeds.length">
|
||||||
|
<div class="briefing-feed-row" v-for="feed in briefingFeeds" :key="feed.id">
|
||||||
|
<div class="briefing-feed-info">
|
||||||
|
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
|
||||||
|
<span class="briefing-feed-url">{{ feed.url }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="briefing-btn-remove" @click="removeFeed(feed.id)" aria-label="Remove feed">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="field-hint">No feeds yet.</p>
|
||||||
|
<div class="briefing-add-feed">
|
||||||
|
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" style="flex: 1" />
|
||||||
|
<button class="btn-secondary" @click="addFeed" :disabled="addingFeed || !newFeedUrl.trim()">
|
||||||
|
{{ addingFeed ? 'Adding…' : 'Add Feed' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Notifications -->
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Notifications</h2>
|
||||||
|
<div class="checkbox-field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="briefingConfig.notifications" />
|
||||||
|
Push notification when each briefing slot is ready
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-save" @click="saveBriefingSettings" :disabled="briefingSaving">
|
||||||
|
{{ briefingSaved ? 'Saved ✓' : briefingSaving ? 'Saving…' : 'Save Briefing Settings' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Admin ── -->
|
<!-- ── Admin ── -->
|
||||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
||||||
|
|
||||||
@@ -2441,4 +2671,122 @@ function formatUserDate(iso: string): string {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Briefing tab */
|
||||||
|
.briefing-location-row {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.briefing-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.briefing-geo-confirmed {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-success, #22c55e);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.briefing-geo-error {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.briefing-day-toggles {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.briefing-day-btn {
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.briefing-day-btn.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.briefing-slot-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.briefing-slot-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.briefing-slot-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.briefing-slot-label {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.briefing-slot-time {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.briefing-feeds-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.briefing-feed-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.briefing-feed-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.briefing-feed-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.briefing-feed-url {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.briefing-btn-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.briefing-btn-remove:hover { color: var(--color-danger, #ef4444); }
|
||||||
|
.briefing-add-feed {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user