feat(update): add a Buzz price alerts panel to the notification bell

P5a of docs/plans/paid-model-price-tracking.md: one surface that answers "what
got cheaper / became free", without a permanent button (the grid filter was
dropped by owner decision, so the panel carries the actions itself).

- price_alert_since records when an alert started, so the panel can say
  "dropped 3 d ago" and count what is new since the user last looked; it is set
  on the first sight of an already-cheap version, preserved while the alert
  stands, and cleared when the price rises back above the threshold
- get_price_alerts() compares the threshold at read time (editing it takes
  effect immediately, no refresh needed) and returns both kinds in one list;
  model_type=None covers every type, which the shared update DB makes a single
  query. "became free" needs no price data, so it is reported even while price
  tracking is off
- GET /api/lm/price-alerts, registered once in MiscRoutes rather than per model
  type, decorating rows best-effort with the local model name and file path from
  the scanner indexes (a cold cache just omits them)
- a third tab in the notification bell: segments for under-threshold and
  became-free, the three states (tracking off / nothing matching / stale), and
  per-row actions (CivitAI always, Open when the model is local)
- two non-permanent entry points share one helper: the controls-bar updates
  dropdown and the global context menu, whose label carries the unread count
- unread state stays client-side (localStorage watermark); the count is fetched
  once on init and only when price tracking is enabled
- the per-type frontend client method is removed as dead code; the per-type
  backend route stays for the companion extension
This commit is contained in:
Will Miao
2026-10-04 15:08:48 +08:00
parent ad2402724b
commit 7ed19c185c
27 changed files with 2142 additions and 86 deletions
@@ -652,3 +652,167 @@
margin-top: 5px;
}
}
/* --- Buzz price alerts panel --------------------------------------------- */
.price-alerts {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.price-alerts-disabled {
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
padding: var(--space-2);
display: flex;
flex-direction: column;
gap: var(--space-2);
align-items: flex-start;
}
.price-alerts-disabled-text {
margin: 0;
color: var(--text-color);
opacity: 0.85;
font-size: 0.9rem;
}
.price-alerts-settings-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0.4rem 0.7rem;
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--card-bg, #fff);
color: var(--text-color);
cursor: pointer;
}
.price-alerts-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: wrap;
}
.price-alerts-segments {
display: inline-flex;
gap: var(--space-1);
}
.price-alerts-segment {
padding: 0.35rem 0.65rem;
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
color: var(--text-color);
cursor: pointer;
font-size: 0.85rem;
}
.price-alerts-segment.active {
background: var(--lora-accent, #845ef7);
border-color: var(--lora-accent, #845ef7);
color: #fff;
}
.price-alerts-threshold {
font-size: 0.85rem;
opacity: 0.8;
}
.price-alerts-note {
margin: 0;
font-size: 0.8rem;
opacity: 0.75;
}
.price-alerts-empty {
margin: var(--space-2) 0 0;
opacity: 0.7;
font-size: 0.9rem;
}
.price-alerts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 46vh;
overflow-y: auto;
}
.price-alert-item {
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
background: var(--card-bg, #fff);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.price-alert-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: wrap;
}
.price-alert-model {
font-weight: 600;
}
.price-alert-price {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.price-alert-price.is-alert {
color: #2b8a3e;
}
[data-theme="dark"] .price-alert-price.is-alert {
color: #69db7c;
}
.price-alert-was {
text-decoration: line-through;
opacity: 0.6;
font-weight: 400;
margin-left: 0.35rem;
}
.price-alert-meta {
font-size: 0.8rem;
opacity: 0.75;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.price-alert-actions {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.price-alert-action {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.55rem;
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
color: var(--text-color);
cursor: pointer;
font-size: 0.8rem;
}