mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 09:35:31 -03:00
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:
@@ -98,7 +98,9 @@ export function getApiEndpoints(modelType) {
|
||||
modelUpdateVersions: `/api/lm/${modelType}/updates/versions`,
|
||||
ignoreModelUpdate: `/api/lm/${modelType}/updates/ignore`,
|
||||
ignoreVersionUpdate: `/api/lm/${modelType}/updates/ignore-version`,
|
||||
priceAlerts: `/api/lm/${modelType}/updates/price-alerts`,
|
||||
|
||||
// Price alerts are app-wide (all model types in one query), so they are
|
||||
// not part of this per-type endpoint map; see UpdateService.js.
|
||||
|
||||
// Preview management
|
||||
replacePreview: `/api/lm/${modelType}/replace-preview`,
|
||||
|
||||
@@ -1086,33 +1086,6 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Versions whose stored price currently crosses the alert threshold.
|
||||
* Backed by the persisted alert state, so it matches the version badges.
|
||||
* @param {{limit?: number}} [options]
|
||||
* @returns {Promise<{alerts: Array, thresholdBuzz: number, enabled: boolean}>}
|
||||
*/
|
||||
async getPriceAlerts({ limit = 200 } = {}) {
|
||||
const endpoint = this.apiConfig?.endpoints?.priceAlerts;
|
||||
if (!endpoint) {
|
||||
return { alerts: [], thresholdBuzz: 0, enabled: false };
|
||||
}
|
||||
|
||||
const response = await fetch(`${endpoint}?limit=${encodeURIComponent(limit)}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
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 : [],
|
||||
thresholdBuzz: payload.thresholdBuzz ?? 0,
|
||||
enabled: payload.enabled === true
|
||||
};
|
||||
}
|
||||
|
||||
async refreshUpdatesForModels(modelIds, { force = false } = {}) {
|
||||
if (!Array.isArray(modelIds) || modelIds.length === 0) {
|
||||
throw new Error('No model IDs provided');
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
|
||||
@@ -27,6 +28,7 @@ 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
|
||||
@@ -35,6 +37,16 @@ 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');
|
||||
@@ -42,6 +54,7 @@ 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');
|
||||
@@ -50,6 +63,7 @@ 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');
|
||||
}
|
||||
|
||||
@@ -89,6 +103,9 @@ 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,6 +4,7 @@ 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';
|
||||
|
||||
@@ -256,6 +257,17 @@ 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')) {
|
||||
|
||||
@@ -9,6 +9,77 @@ 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
|
||||
};
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
@@ -35,6 +106,16 @@ 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.priceAlertsLoading = false;
|
||||
this.priceAlertSegment = 'below_threshold';
|
||||
this.unreadPriceAlertCount = 0;
|
||||
this.handlePriceAlertSegmentClick = this.handlePriceAlertSegmentClick.bind(this);
|
||||
}
|
||||
|
||||
initialize() {
|
||||
@@ -250,7 +331,321 @@ 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.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;
|
||||
const 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)}`
|
||||
)
|
||||
: '';
|
||||
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.priceAlertSegment === 'became_free'
|
||||
? translate(
|
||||
'update.priceAlerts.emptyFree',
|
||||
{},
|
||||
'No version has become free yet.'
|
||||
)
|
||||
: translate(
|
||||
'update.priceAlerts.empty',
|
||||
{},
|
||||
'Nothing is under your price threshold right now.'
|
||||
);
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
empty.classList.add('hidden');
|
||||
alerts.forEach((alert) => {
|
||||
list.appendChild(this.buildPriceAlertItem(alert));
|
||||
});
|
||||
}
|
||||
|
||||
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 } = {}) {
|
||||
@@ -283,6 +678,14 @@ export class UpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
if (tabName === 'priceAlerts') {
|
||||
this.loadPriceAlerts().then(() => {
|
||||
if (markRead) {
|
||||
this.markPriceAlertsViewed();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.updateTabBadges();
|
||||
}
|
||||
|
||||
@@ -343,10 +746,12 @@ 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);
|
||||
@@ -354,6 +759,12 @@ 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();
|
||||
|
||||
Reference in New Issue
Block a user