2ac894b5d1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
414 lines
12 KiB
Vue
414 lines
12 KiB
Vue
<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,
|
|
temp_unit: 'C',
|
|
})
|
|
|
|
// 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: var(--gradient-cta);
|
|
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: var(--gradient-cta);
|
|
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>
|