feat: structured logging, frontend error toasts, stats bar (#14 #15 #16)

#14 — Structured logging
- app/core/logging_config.py: configure_logging() sets stdout handler
  with timestamped format; called at import time in main.py
- Global FastAPI exception_handler logs 500s with full traceback
- opportunities.py: logger added; create/approve/mark-posted/dismiss
  each emit an info line so failures are traceable

#15 — Frontend error handling
- frontend/src/composables/useToast.ts: shared toast composable
  (error/success/info, auto-dismiss, module-level singleton)
- frontend/src/components/ToastList.vue: fixed-position overlay,
  theme-aware, accessible (role=alert, aria-live=polite)
- OpportunitiesView: all 6 async actions have catch + toast.error()
- CampaignDetail: onMounted + all 6 mutation functions wrapped

#16 — Aggregate stats
- app/api/endpoints/stats.py: GET /api/v1/stats — single DB pass
  via GROUP BY; returns posts totals, 7-day count, top communities,
  platform breakdown, and opportunity queue counts
- frontend/src/components/StatsBar.vue: slim header bar above
  router-view; chips for posts ok/failed/week, queue pending/approved/
  posted, top community; hides gracefully on API error
This commit is contained in:
Alan Weinstock 2026-05-25 15:02:15 -07:00
parent c2f036ab21
commit a863960266
12 changed files with 462 additions and 36 deletions

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
from typing import Literal from typing import Literal
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
@ -10,6 +11,7 @@ from app.core.config import get_settings
from app.db.store import Store from app.db.store import Store
router = APIRouter(prefix="/opportunities", tags=["opportunities"]) router = APIRouter(prefix="/opportunities", tags=["opportunities"])
logger = logging.getLogger(__name__)
VALID_STATUSES = {"pending_review", "approved", "posted", "manual_posted", "dismissed"} VALID_STATUSES = {"pending_review", "approved", "posted", "manual_posted", "dismissed"}
@ -74,10 +76,13 @@ async def list_opportunities(status: str | None = None):
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_opportunity(body: OpportunityCreate): async def create_opportunity(body: OpportunityCreate):
return await asyncio.to_thread( logger.info("Creating opportunity: platform=%s community=%s", body.platform, body.community)
result = await asyncio.to_thread(
_in_thread, _in_thread,
lambda s: s.create_opportunity(**body.model_dump()), lambda s: s.create_opportunity(**body.model_dump()),
) )
logger.info("Opportunity created: id=%s", result.get("id"))
return result
@router.get("/{opportunity_id}") @router.get("/{opportunity_id}")
@ -105,6 +110,7 @@ async def update_opportunity(opportunity_id: int, body: OpportunityUpdate):
async def approve_opportunity(opportunity_id: int): async def approve_opportunity(opportunity_id: int):
"""Mark as approved. For Reddit opportunities, returns auto-post instructions. """Mark as approved. For Reddit opportunities, returns auto-post instructions.
For other platforms, returns a manual handoff payload.""" For other platforms, returns a manual handoff payload."""
logger.info("Approving opportunity id=%s", opportunity_id)
opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id)) opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id))
if opp is None: if opp is None:
raise HTTPException(404, "Opportunity not found") raise HTTPException(404, "Opportunity not found")
@ -136,6 +142,7 @@ async def approve_opportunity(opportunity_id: int):
async def mark_posted(opportunity_id: int, body: MarkPostedBody = MarkPostedBody(), manual: bool = False): async def mark_posted(opportunity_id: int, body: MarkPostedBody = MarkPostedBody(), manual: bool = False):
"""Record that a post was successfully made (auto or manual). """Record that a post was successfully made (auto or manual).
When manual=True, also writes a row to the posts table for history tracking.""" When manual=True, also writes a row to the posts table for history tracking."""
logger.info("mark-posted: id=%s manual=%s url=%r", opportunity_id, manual, body.url)
opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id)) opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id))
if opp is None: if opp is None:
raise HTTPException(404, "Opportunity not found") raise HTTPException(404, "Opportunity not found")
@ -161,6 +168,7 @@ async def mark_posted(opportunity_id: int, body: MarkPostedBody = MarkPostedBody
@router.post("/{opportunity_id}/dismiss") @router.post("/{opportunity_id}/dismiss")
async def dismiss_opportunity(opportunity_id: int, body: DismissBody): async def dismiss_opportunity(opportunity_id: int, body: DismissBody):
logger.info("Dismissing opportunity id=%s note=%r", opportunity_id, body.note)
result = await asyncio.to_thread( result = await asyncio.to_thread(
_in_thread, lambda s: s.dismiss_opportunity(opportunity_id, body.note) _in_thread, lambda s: s.dismiss_opportunity(opportunity_id, body.note)
) )

View file

@ -0,0 +1,87 @@
"""
Aggregate stats endpoint counts across posts and opportunities.
Returns a single payload with everything the StatsBar needs.
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter
from app.core.config import get_settings
from app.db.store import Store
router = APIRouter(prefix="/stats", tags=["stats"])
logger = logging.getLogger(__name__)
def _fetch_stats(db_path: str) -> dict:
store = Store(db_path)
try:
# --- Post counts ---
# All-time by status
post_rows = store._fetchall(
"SELECT status, COUNT(*) AS n FROM posts GROUP BY status"
)
posts_by_status: dict[str, int] = {r["status"]: r["n"] for r in post_rows}
# Past 7 days total (any status)
posts_7d = store._fetchone(
"SELECT COUNT(*) AS n FROM posts WHERE posted_at >= datetime('now', '-7 days')"
)
# Top 5 communities by successful post count
top_communities = store._fetchall(
"""
SELECT target, COUNT(*) AS n
FROM posts
WHERE status = 'success'
GROUP BY target
ORDER BY n DESC
LIMIT 5
"""
)
# Platform breakdown (success only)
platform_rows = store._fetchall(
"SELECT platform, COUNT(*) AS n FROM posts WHERE status = 'success' GROUP BY platform"
)
# --- Opportunity counts ---
opp_rows = store._fetchall(
"SELECT status, COUNT(*) AS n FROM opportunities GROUP BY status"
)
opps_by_status: dict[str, int] = {r["status"]: r["n"] for r in opp_rows}
return {
"posts": {
"total": sum(posts_by_status.values()),
"success": posts_by_status.get("success", 0),
"failed": posts_by_status.get("failed", 0),
"skipped": posts_by_status.get("skipped", 0),
"last_7_days": (posts_7d or {}).get("n", 0),
"by_platform": {r["platform"]: r["n"] for r in platform_rows},
"top_communities": [
{"community": r["target"], "count": r["n"]} for r in top_communities
],
},
"opportunities": {
"total": sum(opps_by_status.values()),
"pending_review": opps_by_status.get("pending_review", 0),
"approved": opps_by_status.get("approved", 0),
"posted": opps_by_status.get("posted", 0),
"manual_posted": opps_by_status.get("manual_posted", 0),
"dismissed": opps_by_status.get("dismissed", 0),
},
}
finally:
store.close()
@router.get("")
async def get_stats() -> dict:
"""Aggregate stats across posts and opportunities."""
db_path = get_settings().db_path
logger.debug("Fetching aggregate stats")
return await asyncio.to_thread(_fetch_stats, db_path)

View file

@ -1,6 +1,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from app.api.endpoints import blog, campaigns, opportunities, posts, reddit, scheduler, signals, subs from app.api.endpoints import blog, campaigns, opportunities, posts, reddit, scheduler, signals, stats, subs
def register_routes(app: FastAPI) -> None: def register_routes(app: FastAPI) -> None:
@ -12,3 +12,4 @@ def register_routes(app: FastAPI) -> None:
app.include_router(signals.router, prefix="/api/v1") app.include_router(signals.router, prefix="/api/v1")
app.include_router(blog.router, prefix="/api/v1") app.include_router(blog.router, prefix="/api/v1")
app.include_router(reddit.router, prefix="/api/v1") app.include_router(reddit.router, prefix="/api/v1")
app.include_router(stats.router, prefix="/api/v1")

View file

@ -0,0 +1,32 @@
"""
Logging configuration for Magpie.
Call configure_logging() once at app startup (in lifespan).
Writes to stdout uvicorn captures stdout to $LOG_API via manage.sh.
Format: timestamp [LEVEL] module: message
"""
from __future__ import annotations
import logging
import sys
def configure_logging(level: str = "INFO") -> None:
"""Apply a consistent log format across all loggers."""
fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
datefmt = "%Y-%m-%d %H:%M:%S"
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt))
root = logging.getLogger()
# Avoid double-adding if called more than once (e.g. --reload)
if not root.handlers:
root.addHandler(handler)
root.setLevel(getattr(logging, level.upper(), logging.INFO))
# Quiet noisy third-party loggers
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("apscheduler").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)

View file

@ -3,17 +3,20 @@ from __future__ import annotations
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api.routes import register_routes from app.api.routes import register_routes
from app.core.config import get_settings from app.core.config import get_settings
from app.core.logging_config import configure_logging
from app.db.store import Store from app.db.store import Store
from app.services.scheduler import ( from app.services.scheduler import (
start_scheduler, stop_scheduler, sync_all_campaigns, start_scheduler, stop_scheduler, sync_all_campaigns,
start_scraper_job, start_scraper_job,
) )
configure_logging()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -67,6 +70,19 @@ def create_app() -> FastAPI:
allow_headers=["*"], allow_headers=["*"],
) )
register_routes(app) register_routes(app)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception(
"Unhandled exception on %s %s",
request.method,
request.url.path,
)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)
return app return app

View file

@ -22,9 +22,13 @@
<!-- Main --> <!-- Main -->
<main class="main-content"> <main class="main-content">
<StatsBar />
<router-view /> <router-view />
</main> </main>
<!-- Global toast notifications -->
<ToastList />
<!-- Bottom Nav (mobile <768px) --> <!-- Bottom Nav (mobile <768px) -->
<nav class="bottom-nav" aria-label="Main navigation"> <nav class="bottom-nav" aria-label="Main navigation">
<router-link class="bottom-nav-item" to="/signals" active-class="active"> <router-link class="bottom-nav-item" to="/signals" active-class="active">
@ -50,3 +54,8 @@
</nav> </nav>
</div> </div>
</template> </template>
<script setup lang="ts">
import ToastList from './components/ToastList.vue'
import StatsBar from './components/StatsBar.vue'
</script>

View file

@ -186,7 +186,9 @@
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { api, type Campaign, type Variant, type CampaignSub, type Post, type SubRules } from '@/services/api' import { api, type Campaign, type Variant, type CampaignSub, type Post, type SubRules } from '@/services/api'
import { useToast } from '../composables/useToast'
const toast = useToast()
const route = useRoute() const route = useRoute()
const campaignId = Number(route.params.id) const campaignId = Number(route.params.id)
@ -206,18 +208,22 @@ const variantForm = reactive({ sub_pattern: '*', title: '', body: '', flair: '',
const subForm = reactive({ sub: '' }) const subForm = reactive({ sub: '' })
onMounted(async () => { onMounted(async () => {
const [c, v, s, p, allRules] = await Promise.all([ try {
api.campaigns.get(campaignId), const [c, v, s, p, allRules] = await Promise.all([
api.variants.list(campaignId), api.campaigns.get(campaignId),
api.subs.listForCampaign(campaignId), api.variants.list(campaignId),
api.posts.list(campaignId, undefined, 20), api.subs.listForCampaign(campaignId),
api.subs.listRules(), api.posts.list(campaignId, undefined, 20),
]) api.subs.listRules(),
campaign.value = c ])
variants.value = v campaign.value = c
campaignSubs.value = s variants.value = v
recentPosts.value = p campaignSubs.value = s
subRulesMap.value = Object.fromEntries(allRules.map(r => [r.sub, r])) recentPosts.value = p
subRulesMap.value = Object.fromEntries(allRules.map(r => [r.sub, r]))
} catch (e: unknown) {
toast.error(`Failed to load campaign: ${e instanceof Error ? e.message : 'Unknown error'}`)
}
}) })
async function triggerSub(sub: string) { async function triggerSub(sub: string) {
@ -225,6 +231,8 @@ async function triggerSub(sub: string) {
try { try {
await api.posts.trigger(campaignId, sub) await api.posts.trigger(campaignId, sub)
recentPosts.value = await api.posts.list(campaignId, undefined, 20) recentPosts.value = await api.posts.list(campaignId, undefined, 20)
} catch (e: unknown) {
toast.error(`Trigger failed for ${sub}: ${e instanceof Error ? e.message : 'Unknown error'}`)
} finally { } finally {
triggeringSub.value = null triggeringSub.value = null
} }
@ -235,39 +243,57 @@ async function triggerAll() {
try { try {
await api.campaigns.trigger(campaignId) await api.campaigns.trigger(campaignId)
recentPosts.value = await api.posts.list(campaignId, undefined, 20) recentPosts.value = await api.posts.list(campaignId, undefined, 20)
} catch (e: unknown) {
toast.error(`Trigger all failed: ${e instanceof Error ? e.message : 'Unknown error'}`)
} finally { } finally {
triggering.value = false triggering.value = false
} }
} }
async function addVariant() { async function addVariant() {
const v = await api.variants.create(campaignId, { try {
sub_pattern: variantForm.sub_pattern || '*', const v = await api.variants.create(campaignId, {
title: variantForm.title, sub_pattern: variantForm.sub_pattern || '*',
body: variantForm.body, title: variantForm.title,
flair: variantForm.flair || null, body: variantForm.body,
notes: variantForm.notes || null, flair: variantForm.flair || null,
}) notes: variantForm.notes || null,
variants.value = [...variants.value, v] })
showAddVariant.value = false variants.value = [...variants.value, v]
Object.assign(variantForm, { sub_pattern: '*', title: '', body: '', flair: '', notes: '' }) showAddVariant.value = false
Object.assign(variantForm, { sub_pattern: '*', title: '', body: '', flair: '', notes: '' })
} catch (e: unknown) {
toast.error(`Failed to add variant: ${e instanceof Error ? e.message : 'Unknown error'}`)
}
} }
async function deleteVariant(id: number) { async function deleteVariant(id: number) {
await api.variants.delete(campaignId, id) try {
variants.value = variants.value.filter(v => v.id !== id) await api.variants.delete(campaignId, id)
variants.value = variants.value.filter(v => v.id !== id)
} catch (e: unknown) {
toast.error(`Failed to delete variant: ${e instanceof Error ? e.message : 'Unknown error'}`)
}
} }
async function addSub() { async function addSub() {
const s = await api.subs.add(campaignId, subForm.sub) try {
campaignSubs.value = [...campaignSubs.value, s] const s = await api.subs.add(campaignId, subForm.sub)
showAddSub.value = false campaignSubs.value = [...campaignSubs.value, s]
subForm.sub = '' showAddSub.value = false
subForm.sub = ''
} catch (e: unknown) {
toast.error(`Failed to add sub: ${e instanceof Error ? e.message : 'Unknown error'}`)
}
} }
async function removeSub(sub: string) { async function removeSub(sub: string) {
await api.subs.remove(campaignId, sub) try {
campaignSubs.value = campaignSubs.value.filter(s => s.sub !== sub) await api.subs.remove(campaignId, sub)
campaignSubs.value = campaignSubs.value.filter(s => s.sub !== sub)
} catch (e: unknown) {
toast.error(`Failed to remove ${sub}: ${e instanceof Error ? e.message : 'Unknown error'}`)
}
} }
function resolveVariant(sub: string): Variant | null { function resolveVariant(sub: string): Variant | null {

View file

@ -222,6 +222,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { api, type Opportunity, type OpportunityStatus } from '../services/api' import { api, type Opportunity, type OpportunityStatus } from '../services/api'
import { useToast } from '../composables/useToast'
const toast = useToast()
const opportunities = ref<Opportunity[]>([]) const opportunities = ref<Opportunity[]>([])
const loading = ref(false) const loading = ref(false)
@ -290,6 +293,8 @@ async function saveDraft() {
draft_title: editTitle.value || undefined, draft_title: editTitle.value || undefined,
}) })
replace(updated) replace(updated)
} catch (e: unknown) {
toast.error(`Failed to save draft: ${e instanceof Error ? e.message : 'Unknown error'}`)
} finally { } finally {
saving.value = false saving.value = false
} }
@ -305,6 +310,8 @@ async function approve() {
} }
const result = await api.opportunities.approve(selected.value.id) const result = await api.opportunities.approve(selected.value.id)
replace(result.opportunity) replace(result.opportunity)
} catch (e: unknown) {
toast.error(`Failed to approve: ${e instanceof Error ? e.message : 'Unknown error'}`)
} finally { } finally {
saving.value = false saving.value = false
} }
@ -316,6 +323,8 @@ async function dismiss() {
try { try {
const updated = await api.opportunities.dismiss(selected.value.id) const updated = await api.opportunities.dismiss(selected.value.id)
replace(updated) replace(updated)
} catch (e: unknown) {
toast.error(`Failed to dismiss: ${e instanceof Error ? e.message : 'Unknown error'}`)
} finally { } finally {
saving.value = false saving.value = false
} }
@ -329,6 +338,8 @@ async function markManualPosted() {
replace(updated) replace(updated)
confirmingPosted.value = false confirmingPosted.value = false
postedUrl.value = '' postedUrl.value = ''
} catch (e: unknown) {
toast.error(`Failed to mark as posted: ${e instanceof Error ? e.message : 'Unknown error'}`)
} finally { } finally {
saving.value = false saving.value = false
} }
@ -336,9 +347,13 @@ async function markManualPosted() {
async function copyDraft() { async function copyDraft() {
if (!selected.value) return if (!selected.value) return
await navigator.clipboard.writeText(selected.value.draft_body) try {
copied.value = true await navigator.clipboard.writeText(selected.value.draft_body)
setTimeout(() => { copied.value = false }, 2000) copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
toast.error('Could not copy to clipboard')
}
} }
async function createOpp() { async function createOpp() {
@ -353,6 +368,8 @@ async function createOpp() {
opportunities.value.unshift(created) opportunities.value.unshift(created)
showAddModal.value = false showAddModal.value = false
newOpp.value = { platform: 'reddit', community: '', thread_url: '', thread_title: '', signal_reason: '', product: '', draft_body: '', post_type: 'reply_to_thread' } newOpp.value = { platform: 'reddit', community: '', thread_url: '', thread_title: '', signal_reason: '', product: '', draft_body: '', post_type: 'reply_to_thread' }
} catch (e: unknown) {
toast.error(`Failed to create opportunity: ${e instanceof Error ? e.message : 'Unknown error'}`)
} finally { } finally {
saving.value = false saving.value = false
} }

View file

@ -0,0 +1,121 @@
<template>
<div v-if="stats" class="stats-bar" aria-label="Activity summary">
<div class="stats-group">
<span class="stats-label">Posts</span>
<span class="stats-chip stats-chip--success">{{ stats.posts.success }} ok</span>
<span v-if="stats.posts.failed" class="stats-chip stats-chip--danger">{{ stats.posts.failed }} failed</span>
<span class="stats-chip">{{ stats.posts.last_7_days }} this week</span>
</div>
<div class="stats-sep" aria-hidden="true">|</div>
<div class="stats-group">
<span class="stats-label">Queue</span>
<span v-if="stats.opportunities.pending_review" class="stats-chip stats-chip--warn">
{{ stats.opportunities.pending_review }} pending
</span>
<span v-if="stats.opportunities.approved" class="stats-chip stats-chip--accent">
{{ stats.opportunities.approved }} approved
</span>
<span class="stats-chip">{{ stats.opportunities.posted + stats.opportunities.manual_posted }} posted</span>
</div>
<div v-if="topCommunity" class="stats-sep" aria-hidden="true">|</div>
<div v-if="topCommunity" class="stats-group stats-group--community">
<span class="stats-label">Top</span>
<span class="stats-chip">{{ topCommunity.community }} ({{ topCommunity.count }})</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { api } from '../services/api'
interface StatsPayload {
posts: {
total: number
success: number
failed: number
skipped: number
last_7_days: number
by_platform: Record<string, number>
top_communities: { community: string; count: number }[]
}
opportunities: {
total: number
pending_review: number
approved: number
posted: number
manual_posted: number
dismissed: number
}
}
const stats = ref<StatsPayload | null>(null)
const topCommunity = computed(() =>
stats.value?.posts.top_communities[0] ?? null
)
onMounted(async () => {
try {
stats.value = await api.stats()
} catch {
// Non-critical stats bar is informational; fail silently
}
})
</script>
<style scoped>
.stats-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 6px 16px;
background: var(--color-bg-card);
border-bottom: 1px solid var(--color-border);
font-size: 0.78rem;
color: var(--color-text-muted, #888);
min-height: 36px;
}
.stats-group {
display: flex;
align-items: center;
gap: 6px;
}
.stats-group--community {
/* hide on very narrow screens */
}
.stats-label {
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.72rem;
}
.stats-sep {
color: var(--color-border);
user-select: none;
}
.stats-chip {
padding: 2px 8px;
border-radius: 12px;
background: var(--color-bg, #1e1e1e);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
white-space: nowrap;
}
.stats-chip--success { border-color: var(--color-success, #4caf7d); color: var(--color-success, #4caf7d); }
.stats-chip--danger { border-color: var(--color-danger, #e05252); color: var(--color-danger, #e05252); }
.stats-chip--warn { border-color: var(--color-warn, #d4a017); color: var(--color-warn, #d4a017); }
.stats-chip--accent { border-color: var(--color-accent, #5b7fa6); color: var(--color-accent, #5b7fa6); }
@media (max-width: 480px) {
.stats-group--community { display: none; }
.stats-sep:last-of-type { display: none; }
}
</style>

View file

@ -0,0 +1,77 @@
<template>
<div class="toast-list" aria-live="polite" aria-atomic="false">
<div
v-for="t in toasts"
:key="t.id"
:class="['toast', `toast--${t.type}`]"
role="alert"
>
<span class="toast-icon">{{ icons[t.type] }}</span>
<span class="toast-message">{{ t.message }}</span>
<button class="toast-close" @click="dismiss(t.id)" aria-label="Dismiss"></button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from '../composables/useToast'
const { toasts, dismiss } = useToast()
const icons = { error: '⚠️', success: '✓', info: '' }
</script>
<style scoped>
.toast-list {
position: fixed;
bottom: calc(var(--bottom-nav-height, 60px) + 12px);
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 9999;
width: min(420px, 92vw);
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: 8px;
font-size: 0.875rem;
line-height: 1.4;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
pointer-events: all;
color: var(--color-text);
background: var(--color-bg-card);
border-left: 4px solid var(--color-border);
}
.toast--error { border-color: var(--color-danger, #e05252); }
.toast--success{ border-color: var(--color-success, #4caf7d); }
.toast--info { border-color: var(--color-accent, #5b7fa6); }
.toast-icon { flex-shrink: 0; font-size: 1rem; }
.toast-message { flex: 1; }
.toast-close {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted, #888);
font-size: 0.8rem;
padding: 0 2px;
line-height: 1;
}
.toast-close:hover { color: var(--color-text); }
@media (min-width: 768px) {
.toast-list {
bottom: 20px;
left: unset;
right: 20px;
transform: none;
}
}
</style>

View file

@ -0,0 +1,30 @@
import { ref } from 'vue'
export type ToastType = 'error' | 'success' | 'info'
export interface Toast {
id: number
type: ToastType
message: string
}
let _nextId = 1
const toasts = ref<Toast[]>([])
export function useToast() {
function show(message: string, type: ToastType = 'info', durationMs = 4000) {
const id = _nextId++
toasts.value = [...toasts.value, { id, type, message }]
setTimeout(() => dismiss(id), durationMs)
}
function dismiss(id: number) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
const error = (msg: string) => show(msg, 'error', 6000)
const success = (msg: string) => show(msg, 'success', 3000)
const info = (msg: string) => show(msg, 'info', 4000)
return { toasts, show, dismiss, error, success, info }
}

View file

@ -311,4 +311,6 @@ export const api = {
updateStatus: (id: number, status: SignalStatus, notes?: string) => updateStatus: (id: number, status: SignalStatus, notes?: string) =>
http.patch<Signal>(`/signals/${id}/status`, { status, notes: notes ?? null }).then(r => r.data), http.patch<Signal>(`/signals/${id}/status`, { status, notes: notes ?? null }).then(r => r.data),
}, },
stats: () => http.get('/stats').then(r => r.data),
} }