refactor(update)!: make obtainability a property of updates, not a surface

The owner could not tell from the UI what "Buzz Price Tracking" enabled, what the
"Price alert threshold" number meant, or what "Price Alerts" was alerting about.
That was not a copy problem: the implementation exposed our mechanism (a page
scrape) and our SQL predicates as the user's concepts. Two concrete defects came
from the same root:

- the alert population included versions the user already owns (neither the event
  generator nor the query filtered on is_in_library; in the owner's library 28 of
  52 gated versions were already downloaded, so most "alerts" were about files
  already on disk, which cannot become cheaper *for them*);
- a threshold-filtered state list lived in a notification surface, so an empty
  panel had three indistinguishable causes and read as a broken feature.

The information model is now the version plus ownership: cost is shown only where
a decision exists. Owned -> nothing. Not owned and free -> nothing. Not owned and
gated -> the price when it is known, `Paid` without a number when it is not, and
early access keeps its countdown because "free on <date>" decides between waiting
and paying. The numeric threshold has no place in that model: every decision is
categorical (wait / pay / skip), so the setting, the comparison and the whole
alert-state machine are gone.

- both alert-state columns are removed from the schema rather than left dead; a
  database created by an unreleased build has them dropped on open (native
  ALTER TABLE ... DROP COLUMN, guarded), which is a no-op for everyone else
- gate events are emitted only for versions the user does not have, and the
  price-drop event goes with the threshold it belonged to
- both alert endpoints, PriceAlertsHandler and the service-registry adapter field
  it needed are removed: events already reach the UI through the refresh response
- the bell tab, panel, CSS, both entry points, the unread watermark and their
  locales are removed; the setting keeps only the enable flag and the refresh
  interval and is framed as plumbing
- "Price unavailable" is replaced by `Paid`: the gate is certain from the public
  API, only the number is best-effort, and that is our plumbing, not the user's
  problem

Verified against a copy of the owner's real database: 52 gated versions ->
28 owned (now silent) + 24 the feature is actually about; the drop migration ran
and both removed endpoints 404.
This commit is contained in:
Will Miao
2026-10-05 08:54:08 +08:00
parent 156d2e5eb9
commit e42d649df0
35 changed files with 403 additions and 2494 deletions
@@ -652,167 +652,3 @@
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;
}
@@ -4,7 +4,6 @@ import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js';
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { openPriceAlertsPanel, updateService } from '../../managers/UpdateService.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { showRematchSummary } from '../RematchSummaryModal.js';
@@ -28,7 +27,6 @@ export class GlobalContextMenu extends BaseContextMenu {
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
const rematchRecipesItem = this.menu.querySelector('[data-action="rematch-recipes"]');
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
const priceAlertsItem = this.menu.querySelector('[data-action="price-alerts"]');
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
// Update check indicator for group-by-model
@@ -37,16 +35,6 @@ export class GlobalContextMenu extends BaseContextMenu {
groupByModelCheck.style.display = isEnabled ? 'block' : 'none';
}
// Append the unread count when it is already known (loaded once on init,
// and only when price tracking is on), so the label stays a plain label
// for everyone else.
const priceAlertsLabel = priceAlertsItem?.querySelector('#globalPriceAlertsLabel');
if (priceAlertsLabel) {
const unread = updateService?.getUnreadPriceAlertCount?.() || 0;
const base = translate('globalContextMenu.priceAlerts.label', {}, 'Price alerts');
priceAlertsLabel.textContent = unread > 0 ? `${base} (${unread})` : base;
}
if (isRecipesPage) {
modelUpdateItem?.classList.add('hidden');
licenseRefreshItem?.classList.add('hidden');
@@ -54,7 +42,6 @@ export class GlobalContextMenu extends BaseContextMenu {
cleanupExamplesItem?.classList.add('hidden');
excludedModelsItem?.classList.add('hidden');
groupByModelItem?.classList.add('hidden');
priceAlertsItem?.classList.add('hidden');
rematchRecipesItem?.classList.remove('hidden');
} else {
modelUpdateItem?.classList.remove('hidden');
@@ -63,7 +50,6 @@ export class GlobalContextMenu extends BaseContextMenu {
cleanupExamplesItem?.classList.remove('hidden');
excludedModelsItem?.classList.remove('hidden');
groupByModelItem?.classList.remove('hidden');
priceAlertsItem?.classList.remove('hidden');
rematchRecipesItem?.classList.add('hidden');
}
@@ -103,9 +89,6 @@ export class GlobalContextMenu extends BaseContextMenu {
console.error('Failed to check model updates:', error);
});
break;
case 'price-alerts':
openPriceAlertsPanel();
break;
case 'fetch-missing-licenses':
this.fetchMissingLicenses(menuItem).catch((error) => {
console.error('Failed to refresh missing license metadata:', error);
@@ -4,7 +4,6 @@ import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setS
import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/uiHelpers.js';
import { eventManager } from '../../utils/EventManager.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { openPriceAlertsPanel } from '../../managers/UpdateService.js';
import { sidebarManager } from '../SidebarManager.js';
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
@@ -257,17 +256,6 @@ export class PageControls {
});
}
// Opens the notification bell on the price alerts tab (same action as the
// global context menu entry).
const priceAlertsOption = document.getElementById('priceAlertsMenuItem');
if (priceAlertsOption) {
priceAlertsOption.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelector('.dropdown-group.active')?.classList.remove('active');
openPriceAlertsPanel();
});
}
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.dropdown-group')) {
+37 -44
View File
@@ -569,65 +569,58 @@ function renderRow(version, options) {
}));
}
if (isEarlyAccess) {
// Obtainability, only where there is a decision to make: a version already in
// the library cannot become cheaper *for this user*, so it gets no cost badge.
// The download price is the only price axis that matters here — the generation
// fee is what CivitAI charges to generate on its own site, which a local model
// never incurs.
const priceLabel = formatBuzzPrice(version.priceBuzz);
if (!version.isInLibrary && isEarlyAccess) {
// Early access is the one state with a real countdown: "free on <date>"
// decides between waiting and paying.
const eaTime = formatEarlyAccessTime(version.earlyAccessEndsAt);
badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', {
title: buildPriceTooltip(
version,
translate(
'modals.model.versions.badges.earlyAccessTooltip',
{},
'This version currently requires Civitai early access'
)
eaTime
? translate(
'modals.model.versions.badges.earlyAccessTooltipUntil',
{ date: eaTime },
`Early access until ${eaTime}`
)
: translate(
'modals.model.versions.badges.earlyAccessTooltip',
{},
'This version currently requires Civitai early access'
)
),
}));
}
if (isPaidPermanent(version)) {
badges.push(buildBadge(paidBadgeLabel, 'paid', {
title: buildPriceTooltip(
version,
translate(
if (!version.isInLibrary && isGated(version)) {
if (priceLabel) {
// The number already says "paid", so a separate Paid badge would be
// noise next to it.
badges.push(buildBadge(priceLabel, isEarlyAccess ? 'early-access' : 'paid', {
title: buildPriceTooltip(version, paidBadgeLabel),
}));
} else if (!isEarlyAccess) {
// Paid, but no readable price: the gate is certain (it comes from the
// public API), only the number is best-effort. Saying nothing here
// would read as "free", which is the one thing this version is not.
badges.push(buildBadge(paidBadgeLabel, 'paid', {
title: translate(
'modals.model.versions.badges.paidTooltip',
{},
'This version requires payment to download'
)
),
}));
}
// Known price for a gated version. The price is only captured when the
// optional price tracking is enabled, so this stays hidden otherwise.
const priceLabel = isGated(version) ? formatBuzzPrice(version.priceBuzz) : '';
if (priceLabel) {
const alertsEnabled = !!version.priceAlert;
badges.push(buildBadge(priceLabel, alertsEnabled ? 'price-alert' : 'paid', {
title: alertsEnabled
? translate(
'modals.model.versions.badges.priceAlertTooltip',
{ price: priceLabel },
`${priceLabel} - below your price alert threshold`
)
: buildPriceTooltip(version, paidBadgeLabel),
}));
} else if (isGated(version) && version.priceAttemptedAt) {
// We looked and could not read a price. Staying silent would read as
// "free", which is the one thing this version is not.
badges.push(buildBadge(
translate('modals.model.versions.badges.priceUnavailable', {}, 'Price unavailable'),
'muted',
{
title: translate(
'modals.model.versions.badges.priceUnavailableTooltip',
{},
'CivitAI does not publish this price in its public API, and mature model pages can only be read in a browser'
),
}
));
}));
}
}
// A version that used to be gated and no longer is. `gateLapsedAt` is
// persisted, so the marker survives long after the refresh that saw it.
if (!isGated(version) && version.gateLapsedAt) {
if (!version.isInLibrary && !isGated(version) && version.gateLapsedAt) {
const freeBadgeLabel = translate('modals.model.versions.badges.freeNow', {}, 'Free Now');
badges.push(buildBadge(freeBadgeLabel, 'success', {
title: translate(
-5
View File
@@ -1169,11 +1169,6 @@ export class SettingsManager {
priceTrackingEnabledCheckbox.checked = state.global.settings.price_tracking_enabled || false;
}
const priceAlertThresholdInput = document.getElementById('priceAlertThresholdBuzz');
if (priceAlertThresholdInput) {
priceAlertThresholdInput.value = state.global.settings.price_alert_threshold_buzz ?? 0;
}
const priceCheckTtlInput = document.getElementById('priceCheckTtlHours');
if (priceCheckTtlInput) {
priceCheckTtlInput.value = state.global.settings.price_check_ttl_hours ?? 24;
-443
View File
@@ -9,79 +9,6 @@ import {
import { state } from '../state/index.js';
import { bannerService } from './BannerService.js';
import { translate } from '../utils/i18nHelpers.js';
import { showModelModal } from '../components/shared/ModelModal.js';
/** Global endpoint: the alerts panel spans every model type. */
const PRICE_ALERTS_ENDPOINT = '/api/lm/price-alerts';
/** localStorage watermark of the newest alert the user has already seen. */
const PRICE_ALERTS_VIEWED_KEY = 'lm_price_alerts_viewed_at';
async function fetchPriceAlerts(limit = 200) {
const response = await fetch(
`${PRICE_ALERTS_ENDPOINT}?limit=${encodeURIComponent(limit)}`
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || response.statusText || 'Failed to load price alerts');
}
return {
alerts: Array.isArray(payload.alerts) ? payload.alerts : [],
enabled: payload.enabled === true,
thresholdBuzz: payload.thresholdBuzz ?? 0,
newestCheckedAt: payload.newestCheckedAt ?? null,
unavailableCount: payload.unavailableCount ?? 0,
pricedCount: payload.pricedCount ?? 0
};
}
/** POSIX seconds of the moment an alert started, for unread/sorting comparisons. */
function priceAlertTimestamp(alert) {
if (typeof alert?.priceAlertSince === 'number') {
return alert.priceAlertSince;
}
if (alert?.gateLapsedAt) {
const parsed = Date.parse(alert.gateLapsedAt);
if (!Number.isNaN(parsed)) {
return parsed / 1000;
}
}
return 0;
}
function formatRelativeTime(timestampSeconds) {
if (!timestampSeconds) return '';
const elapsedMs = Date.now() - timestampSeconds * 1000;
const minutes = Math.floor(elapsedMs / 60000);
if (minutes < 1) return translate('update.priceAlerts.justNow', {}, 'just now');
if (minutes < 60) {
return translate('update.priceAlerts.minutesAgo', { count: minutes }, `${minutes} min ago`);
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return translate('update.priceAlerts.hoursAgo', { count: hours }, `${hours} h ago`);
}
const days = Math.floor(hours / 24);
return translate('update.priceAlerts.daysAgo', { count: days }, `${days} d ago`);
}
function formatShortDate(value) {
if (!value) return '';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '';
return parsed.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
/**
* Open the notification bell on the Buzz price alerts tab.
* Shared by the controls dropdown item and the global context menu item.
*/
export function openPriceAlertsPanel() {
updateService.openPriceAlertsTab();
}
export class UpdateService {
constructor() {
@@ -108,18 +35,6 @@ export class UpdateService {
this.activeNotificationTab = 'updates';
this.handleBannerHistoryUpdated = this.handleBannerHistoryUpdated.bind(this);
this.handleNotificationTabKeydown = this.handleNotificationTabKeydown.bind(this);
// Buzz price alerts (panel data, loaded on demand; the count is cached so
// the bell badge and the context-menu label can share it).
this.priceAlerts = null;
this.priceAlertsEnabled = false;
this.priceAlertsThreshold = 0;
this.priceAlertsNewestCheckedAt = null;
this.priceAlertsUnavailableCount = 0;
this.priceAlertsPricedCount = 0;
this.priceAlertsLoading = false;
this.priceAlertSegment = 'below_threshold';
this.unreadPriceAlertCount = 0;
this.handlePriceAlertSegmentClick = this.handlePriceAlertSegmentClick.bind(this);
}
initialize() {
@@ -335,349 +250,7 @@ export class UpdateService {
});
this.renderRecentBanners();
this.wirePriceAlertsPanel();
this.switchNotificationTab(this.activeNotificationTab);
// One request, and only for users who turned price tracking on. It feeds
// the tab badge and the global-context-menu count.
if (state?.global?.settings?.price_tracking_enabled) {
this.loadPriceAlerts().catch((error) => {
console.warn('Failed to preload price alerts:', error);
});
}
}
wirePriceAlertsPanel() {
const modal = document.getElementById('updateModal');
if (!modal) return;
this.priceAlertSegments = Array.from(
modal.querySelectorAll('[data-price-alert-segment]')
);
this.priceAlertSegments.forEach((segment) => {
segment.addEventListener('click', this.handlePriceAlertSegmentClick);
});
const settingsBtn = document.getElementById('priceAlertsSettingsBtn');
if (settingsBtn) {
settingsBtn.addEventListener('click', () => {
this.toggleUpdateModal();
if (window.settingsManager) {
window.settingsManager.toggleSettings();
}
});
}
}
handlePriceAlertSegmentClick(event) {
const segment = event?.currentTarget?.getAttribute('data-price-alert-segment');
if (!segment) return;
this.priceAlertSegment = segment;
this.renderPriceAlerts();
}
/**
* Open the bell on the price alerts tab.
*
* ``toggleUpdateModal()`` *closes* an open bell, so an entry point that called
* it unconditionally would dismiss the modal instead of switching to the tab.
*/
openPriceAlertsTab() {
const modal = modalManager.getModal('updateModal');
if (!modal || !modal.isOpen) {
this.toggleUpdateModal();
}
this.switchNotificationTab('priceAlerts', { markRead: true });
}
/**
* Load the global alerts payload (all model types in one query).
* @param {{force?: boolean}} [options]
*/
async loadPriceAlerts({ force = false } = {}) {
if (this.priceAlertsLoading) return this.priceAlerts;
if (!force && this.priceAlerts) {
this.renderPriceAlerts();
return this.priceAlerts;
}
this.priceAlertsLoading = true;
try {
const payload = await fetchPriceAlerts();
this.priceAlerts = payload.alerts;
this.priceAlertsEnabled = payload.enabled;
this.priceAlertsThreshold = payload.thresholdBuzz;
this.priceAlertsNewestCheckedAt = payload.newestCheckedAt;
this.priceAlertsUnavailableCount = payload.unavailableCount || 0;
this.priceAlertsPricedCount = payload.pricedCount || 0;
this.refreshUnreadPriceAlertCount();
this.renderPriceAlerts();
return this.priceAlerts;
} catch (error) {
console.error('Failed to load price alerts:', error);
// Keep whatever we already had: a stale list beats an empty panel.
this.renderPriceAlerts({ error: true });
return this.priceAlerts;
} finally {
this.priceAlertsLoading = false;
}
}
/** Alerts newer than the last time the user opened the panel. */
refreshUnreadPriceAlertCount() {
const alerts = Array.isArray(this.priceAlerts) ? this.priceAlerts : [];
const viewedAt = Number(getStorageItem(PRICE_ALERTS_VIEWED_KEY, 0)) || 0;
this.unreadPriceAlertCount = alerts.filter(
(alert) => priceAlertTimestamp(alert) > viewedAt
).length;
this.updateTabBadges();
return this.unreadPriceAlertCount;
}
getUnreadPriceAlertCount() {
return this.unreadPriceAlertCount || 0;
}
markPriceAlertsViewed() {
const alerts = Array.isArray(this.priceAlerts) ? this.priceAlerts : [];
const newest = alerts.reduce(
(latest, alert) => Math.max(latest, priceAlertTimestamp(alert)),
0
);
if (newest > 0) {
setStorageItem(PRICE_ALERTS_VIEWED_KEY, newest);
}
this.unreadPriceAlertCount = 0;
this.updateTabBadges();
}
renderPriceAlerts({ error = false } = {}) {
const list = document.getElementById('priceAlertsList');
const empty = document.getElementById('priceAlertsEmpty');
const disabled = document.getElementById('priceAlertsDisabled');
const stale = document.getElementById('priceAlertsStale');
const thresholdLabel = document.getElementById('priceAlertsThreshold');
if (!list || !empty) return;
if (disabled) {
disabled.classList.toggle('hidden', this.priceAlertsEnabled !== false);
}
if (thresholdLabel) {
thresholdLabel.textContent = this.priceAlertsEnabled
? translate(
'update.priceAlerts.thresholdLabel',
{ buzz: this.priceAlertsThreshold },
`Alert threshold: ${this.priceAlertsThreshold} Buzz`
)
: '';
}
if (stale) {
const checkedAt = this.priceAlertsNewestCheckedAt;
let staleText = error
? translate(
'update.priceAlerts.loadFailed',
{},
'Could not refresh the alerts; showing the last known list.'
)
: checkedAt
? translate(
'update.priceAlerts.lastChecked',
{ when: formatRelativeTime(checkedAt) },
`Prices last checked ${formatRelativeTime(checkedAt)}`
)
: '';
const unavailable = this.priceAlertsUnavailableCount;
if (unavailable > 0) {
const unavailableText = translate(
'update.priceAlerts.unavailable',
{ count: unavailable },
`${unavailable} paid version(s) have no readable price (mature models can only be read in a browser)`
);
staleText = staleText ? `${staleText} · ${unavailableText}` : unavailableText;
}
stale.textContent = staleText;
stale.classList.toggle('hidden', !staleText);
}
if (Array.isArray(this.priceAlertSegments)) {
this.priceAlertSegments.forEach((segment) => {
const isActive =
segment.getAttribute('data-price-alert-segment') ===
this.priceAlertSegment;
segment.classList.toggle('active', isActive);
segment.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
}
const alerts = (Array.isArray(this.priceAlerts) ? this.priceAlerts : []).filter(
(alert) => (alert.kind || 'below_threshold') === this.priceAlertSegment
);
list.innerHTML = '';
if (!alerts.length) {
empty.textContent = this.buildPriceAlertsEmptyText();
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
alerts.forEach((alert) => {
list.appendChild(this.buildPriceAlertItem(alert));
});
}
/**
* The empty state has to explain a 0 Buzz threshold: "nothing is under your
* threshold" is technically true but useless when dozens of paid versions
* already have a known price.
*/
buildPriceAlertsEmptyText() {
if (this.priceAlertSegment === 'became_free') {
return translate(
'update.priceAlerts.emptyFree',
{},
'No version has become free yet.'
);
}
if (!this.priceAlertsThreshold && this.priceAlertsPricedCount > 0) {
return translate(
'update.priceAlerts.emptyZeroThreshold',
{ count: this.priceAlertsPricedCount },
`${this.priceAlertsPricedCount} paid version(s) have a known price, but your threshold is 0 Buzz so only free versions are listed. Set a threshold in Settings - Library to catch price drops.`
);
}
return translate(
'update.priceAlerts.empty',
{},
'Nothing is under your price threshold right now.'
);
}
buildPriceAlertItem(alert) {
const item = document.createElement('li');
item.className = 'price-alert-item';
const head = document.createElement('div');
head.className = 'price-alert-head';
const name = document.createElement('span');
name.className = 'price-alert-model';
const modelLabel = alert.modelName || alert.versionName || `#${alert.modelId}`;
name.textContent = alert.versionName
? `${modelLabel} · ${alert.versionName}`
: modelLabel;
head.appendChild(name);
if (alert.kind === 'below_threshold') {
const price = document.createElement('span');
price.className = 'price-alert-price is-alert';
price.textContent = `${Number(alert.priceBuzz).toLocaleString()} Buzz`;
if (
typeof alert.listPriceBuzz === 'number' &&
alert.listPriceBuzz > alert.priceBuzz
) {
const was = document.createElement('span');
was.className = 'price-alert-was';
was.textContent = `${alert.listPriceBuzz.toLocaleString()} Buzz`;
price.appendChild(was);
}
head.appendChild(price);
} else {
const free = document.createElement('span');
free.className = 'price-alert-price is-alert';
free.textContent = translate('update.priceAlerts.freeNow', {}, 'Free now');
head.appendChild(free);
}
item.appendChild(head);
const meta = document.createElement('div');
meta.className = 'price-alert-meta';
const metaParts = [alert.modelType];
if (alert.acceptsBlueBuzz) {
metaParts.push(translate('update.priceAlerts.blueBuzz', {}, 'Blue Buzz OK'));
}
if (alert.earlyAccessEndsAt) {
metaParts.push(
translate(
'update.priceAlerts.earlyAccessUntil',
{ date: formatShortDate(alert.earlyAccessEndsAt) },
`Early access until ${formatShortDate(alert.earlyAccessEndsAt)}`
)
);
}
metaParts.push(
alert.isInLibrary
? translate('update.priceAlerts.inLibrary', {}, 'In library')
: translate('update.priceAlerts.notInLibrary', {}, 'Not in library')
);
const since = priceAlertTimestamp(alert);
if (since > 0) {
metaParts.push(
translate(
'update.priceAlerts.droppedAgo',
{ when: formatRelativeTime(since) },
`dropped ${formatRelativeTime(since)}`
)
);
}
meta.textContent = metaParts.filter(Boolean).join(' · ');
item.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'price-alert-actions';
if (alert.civitaiUrl) {
actions.appendChild(
this.buildPriceAlertAction(
'civitai',
translate('update.priceAlerts.openCivitai', {}, 'Open on CivitAI'),
'fa-external-link-alt',
() => window.open(alert.civitaiUrl, '_blank', 'noopener')
)
);
}
// Only possible for a model that is actually in the library: the modal
// reads the local metadata by file path.
if (alert.filePath) {
actions.appendChild(
this.buildPriceAlertAction(
'open',
translate('update.priceAlerts.openLocal', {}, 'Open'),
'fa-folder-open',
() => {
showModelModal(
{
model_name: alert.modelName || alert.versionName,
file_path: alert.filePath,
civitai: {},
},
alert.modelType
);
}
)
);
}
item.appendChild(actions);
return item;
}
buildPriceAlertAction(name, label, icon, onClick) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'price-alert-action';
button.dataset.priceAlertAction = name;
const iconElement = document.createElement('i');
iconElement.className = `fas ${icon}`;
button.appendChild(iconElement);
const text = document.createElement('span');
text.textContent = label;
button.appendChild(text);
button.addEventListener('click', onClick);
return button;
}
switchNotificationTab(tabName, { markRead = false } = {}) {
@@ -710,14 +283,6 @@ export class UpdateService {
}
}
if (tabName === 'priceAlerts') {
this.loadPriceAlerts().then(() => {
if (markRead) {
this.markPriceAlertsViewed();
}
});
}
this.updateTabBadges();
}
@@ -778,12 +343,10 @@ export class UpdateService {
updateTabBadges() {
const updatesBadge = document.getElementById('updatesTabBadge');
const bannerBadge = document.getElementById('bannerTabBadge');
const priceAlertsBadge = document.getElementById('priceAlertsTabBadge');
const hasUpdate = this.updateNotificationsEnabled && this.updateAvailable;
const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function'
? bannerService.getUnreadBannerCount()
: 0;
const unreadAlerts = this.getUnreadPriceAlertCount();
if (updatesBadge) {
updatesBadge.classList.toggle('visible', hasUpdate);
@@ -791,12 +354,6 @@ export class UpdateService {
updatesBadge.textContent = '';
}
if (priceAlertsBadge) {
priceAlertsBadge.textContent = unreadAlerts > 9 ? '9+' : unreadAlerts ? String(unreadAlerts) : '';
priceAlertsBadge.classList.toggle('visible', unreadAlerts > 0);
priceAlertsBadge.classList.remove('is-dot');
}
if (bannerBadge) {
if (unreadBanners > 0) {
bannerBadge.textContent = unreadBanners > 9 ? '9+' : unreadBanners.toString();
-1
View File
@@ -58,7 +58,6 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
hide_early_access_updates: false,
hide_paid_updates: false,
price_tracking_enabled: false,
price_alert_threshold_buzz: 0,
price_check_ttl_hours: 24,
auto_organize_exclusions: [],
metadata_refresh_skip_paths: [],
+3 -13
View File
@@ -18,7 +18,6 @@ export function summarizeGateEvents(events) {
return {
becameFree: list.filter((event) => event?.kind === 'became_free').length,
newGate: list.filter((event) => event?.kind === 'new_gate').length,
priceDrop: list.filter((event) => event?.kind === 'price_drop').length,
total: list.length
};
}
@@ -29,8 +28,8 @@ export function summarizeGateEvents(events) {
* @param {Array} events
*/
export function showGateEventsToast(events) {
const { becameFree, newGate, priceDrop } = summarizeGateEvents(events);
if (!becameFree && !newGate && !priceDrop) {
const { becameFree, newGate } = summarizeGateEvents(events);
if (!becameFree && !newGate) {
return;
}
@@ -44,15 +43,6 @@ export function showGateEventsToast(events) {
)
);
}
if (priceDrop) {
parts.push(
translate(
'globalContextMenu.checkModelUpdates.gateEvents.priceDrop',
{ count: priceDrop },
`${priceDrop} version(s) dropped below your price threshold`
)
);
}
if (newGate) {
parts.push(
translate(
@@ -63,7 +53,7 @@ export function showGateEventsToast(events) {
);
}
showToast(parts.join(' · '), {}, becameFree || priceDrop ? 'success' : 'info');
showToast(parts.join(' · '), {}, becameFree ? 'success' : 'info');
}
/**