#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:
parent
c2f036ab21
commit
a863960266
12 changed files with 462 additions and 36 deletions
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
|
@ -10,6 +11,7 @@ from app.core.config import get_settings
|
|||
from app.db.store import Store
|
||||
|
||||
router = APIRouter(prefix="/opportunities", tags=["opportunities"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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)
|
||||
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,
|
||||
lambda s: s.create_opportunity(**body.model_dump()),
|
||||
)
|
||||
logger.info("Opportunity created: id=%s", result.get("id"))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{opportunity_id}")
|
||||
|
|
@ -105,6 +110,7 @@ async def update_opportunity(opportunity_id: int, body: OpportunityUpdate):
|
|||
async def approve_opportunity(opportunity_id: int):
|
||||
"""Mark as approved. For Reddit opportunities, returns auto-post instructions.
|
||||
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))
|
||||
if opp is None:
|
||||
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):
|
||||
"""Record that a post was successfully made (auto or manual).
|
||||
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))
|
||||
if opp is None:
|
||||
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")
|
||||
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(
|
||||
_in_thread, lambda s: s.dismiss_opportunity(opportunity_id, body.note)
|
||||
)
|
||||
|
|
|
|||
87
app/api/endpoints/stats.py
Normal file
87
app/api/endpoints/stats.py
Normal 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)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
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:
|
||||
|
|
@ -12,3 +12,4 @@ def register_routes(app: FastAPI) -> None:
|
|||
app.include_router(signals.router, prefix="/api/v1")
|
||||
app.include_router(blog.router, prefix="/api/v1")
|
||||
app.include_router(reddit.router, prefix="/api/v1")
|
||||
app.include_router(stats.router, prefix="/api/v1")
|
||||
|
|
|
|||
32
app/core/logging_config.py
Normal file
32
app/core/logging_config.py
Normal 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)
|
||||
18
app/main.py
18
app/main.py
|
|
@ -3,17 +3,20 @@ from __future__ import annotations
|
|||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.routes import register_routes
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging_config import configure_logging
|
||||
from app.db.store import Store
|
||||
from app.services.scheduler import (
|
||||
start_scheduler, stop_scheduler, sync_all_campaigns,
|
||||
start_scraper_job,
|
||||
)
|
||||
|
||||
configure_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -67,6 +70,19 @@ def create_app() -> FastAPI:
|
|||
allow_headers=["*"],
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,13 @@
|
|||
|
||||
<!-- Main -->
|
||||
<main class="main-content">
|
||||
<StatsBar />
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Global toast notifications -->
|
||||
<ToastList />
|
||||
|
||||
<!-- Bottom Nav (mobile <768px) -->
|
||||
<nav class="bottom-nav" aria-label="Main navigation">
|
||||
<router-link class="bottom-nav-item" to="/signals" active-class="active">
|
||||
|
|
@ -50,3 +54,8 @@
|
|||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToastList from './components/ToastList.vue'
|
||||
import StatsBar from './components/StatsBar.vue'
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -186,7 +186,9 @@
|
|||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
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 campaignId = Number(route.params.id)
|
||||
|
||||
|
|
@ -206,18 +208,22 @@ const variantForm = reactive({ sub_pattern: '*', title: '', body: '', flair: '',
|
|||
const subForm = reactive({ sub: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
const [c, v, s, p, allRules] = await Promise.all([
|
||||
api.campaigns.get(campaignId),
|
||||
api.variants.list(campaignId),
|
||||
api.subs.listForCampaign(campaignId),
|
||||
api.posts.list(campaignId, undefined, 20),
|
||||
api.subs.listRules(),
|
||||
])
|
||||
campaign.value = c
|
||||
variants.value = v
|
||||
campaignSubs.value = s
|
||||
recentPosts.value = p
|
||||
subRulesMap.value = Object.fromEntries(allRules.map(r => [r.sub, r]))
|
||||
try {
|
||||
const [c, v, s, p, allRules] = await Promise.all([
|
||||
api.campaigns.get(campaignId),
|
||||
api.variants.list(campaignId),
|
||||
api.subs.listForCampaign(campaignId),
|
||||
api.posts.list(campaignId, undefined, 20),
|
||||
api.subs.listRules(),
|
||||
])
|
||||
campaign.value = c
|
||||
variants.value = v
|
||||
campaignSubs.value = s
|
||||
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) {
|
||||
|
|
@ -225,6 +231,8 @@ async function triggerSub(sub: string) {
|
|||
try {
|
||||
await api.posts.trigger(campaignId, sub)
|
||||
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 {
|
||||
triggeringSub.value = null
|
||||
}
|
||||
|
|
@ -235,39 +243,57 @@ async function triggerAll() {
|
|||
try {
|
||||
await api.campaigns.trigger(campaignId)
|
||||
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 {
|
||||
triggering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addVariant() {
|
||||
const v = await api.variants.create(campaignId, {
|
||||
sub_pattern: variantForm.sub_pattern || '*',
|
||||
title: variantForm.title,
|
||||
body: variantForm.body,
|
||||
flair: variantForm.flair || null,
|
||||
notes: variantForm.notes || null,
|
||||
})
|
||||
variants.value = [...variants.value, v]
|
||||
showAddVariant.value = false
|
||||
Object.assign(variantForm, { sub_pattern: '*', title: '', body: '', flair: '', notes: '' })
|
||||
try {
|
||||
const v = await api.variants.create(campaignId, {
|
||||
sub_pattern: variantForm.sub_pattern || '*',
|
||||
title: variantForm.title,
|
||||
body: variantForm.body,
|
||||
flair: variantForm.flair || null,
|
||||
notes: variantForm.notes || null,
|
||||
})
|
||||
variants.value = [...variants.value, v]
|
||||
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) {
|
||||
await api.variants.delete(campaignId, id)
|
||||
variants.value = variants.value.filter(v => v.id !== id)
|
||||
try {
|
||||
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() {
|
||||
const s = await api.subs.add(campaignId, subForm.sub)
|
||||
campaignSubs.value = [...campaignSubs.value, s]
|
||||
showAddSub.value = false
|
||||
subForm.sub = ''
|
||||
try {
|
||||
const s = await api.subs.add(campaignId, subForm.sub)
|
||||
campaignSubs.value = [...campaignSubs.value, s]
|
||||
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) {
|
||||
await api.subs.remove(campaignId, sub)
|
||||
campaignSubs.value = campaignSubs.value.filter(s => s.sub !== sub)
|
||||
try {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -222,6 +222,9 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { api, type Opportunity, type OpportunityStatus } from '../services/api'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const opportunities = ref<Opportunity[]>([])
|
||||
const loading = ref(false)
|
||||
|
|
@ -290,6 +293,8 @@ async function saveDraft() {
|
|||
draft_title: editTitle.value || undefined,
|
||||
})
|
||||
replace(updated)
|
||||
} catch (e: unknown) {
|
||||
toast.error(`Failed to save draft: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
|
@ -305,6 +310,8 @@ async function approve() {
|
|||
}
|
||||
const result = await api.opportunities.approve(selected.value.id)
|
||||
replace(result.opportunity)
|
||||
} catch (e: unknown) {
|
||||
toast.error(`Failed to approve: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
|
@ -316,6 +323,8 @@ async function dismiss() {
|
|||
try {
|
||||
const updated = await api.opportunities.dismiss(selected.value.id)
|
||||
replace(updated)
|
||||
} catch (e: unknown) {
|
||||
toast.error(`Failed to dismiss: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
|
@ -329,6 +338,8 @@ async function markManualPosted() {
|
|||
replace(updated)
|
||||
confirmingPosted.value = false
|
||||
postedUrl.value = ''
|
||||
} catch (e: unknown) {
|
||||
toast.error(`Failed to mark as posted: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
|
@ -336,9 +347,13 @@ async function markManualPosted() {
|
|||
|
||||
async function copyDraft() {
|
||||
if (!selected.value) return
|
||||
await navigator.clipboard.writeText(selected.value.draft_body)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
try {
|
||||
await navigator.clipboard.writeText(selected.value.draft_body)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
toast.error('Could not copy to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
async function createOpp() {
|
||||
|
|
@ -353,6 +368,8 @@ async function createOpp() {
|
|||
opportunities.value.unshift(created)
|
||||
showAddModal.value = false
|
||||
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 {
|
||||
saving.value = false
|
||||
}
|
||||
|
|
|
|||
121
frontend/src/components/StatsBar.vue
Normal file
121
frontend/src/components/StatsBar.vue
Normal 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>
|
||||
77
frontend/src/components/ToastList.vue
Normal file
77
frontend/src/components/ToastList.vue
Normal 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>
|
||||
30
frontend/src/composables/useToast.ts
Normal file
30
frontend/src/composables/useToast.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -311,4 +311,6 @@ export const api = {
|
|||
updateStatus: (id: number, status: SignalStatus, notes?: string) =>
|
||||
http.patch<Signal>(`/signals/${id}/status`, { status, notes: notes ?? null }).then(r => r.data),
|
||||
},
|
||||
|
||||
stats: () => http.get('/stats').then(r => r.data),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue