feat: persist station sync catalogs and status
This commit is contained in:
+73
-4
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// This value is rewritten automatically before each build so deployed clients
|
// This value is rewritten automatically before each build so deployed clients
|
||||||
// refresh to the newest shell and cached assets.
|
// refresh to the newest shell and cached assets.
|
||||||
const CACHE_NAME = 'radioplayer-pwa-v5-1777473175316';
|
const CACHE_NAME = 'radioplayer-pwa-v5-1777474420392';
|
||||||
const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1';
|
const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1';
|
||||||
const MANAGED_CATALOG_CACHE_NAME = 'radioplayer-managed-catalog-v1';
|
const MANAGED_CATALOG_CACHE_NAME = 'radioplayer-managed-catalog-v1';
|
||||||
const RADIO_BROWSER_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search';
|
const RADIO_BROWSER_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search';
|
||||||
@@ -377,10 +377,23 @@ async function refreshManagedCatalogCache() {
|
|||||||
const response = await fetch(url, { cache: 'no-store' });
|
const response = await fetch(url, { cache: 'no-store' });
|
||||||
if (isCacheableResponse(response)) {
|
if (isCacheableResponse(response)) {
|
||||||
await managedCatalogCache.put(url, response);
|
await managedCatalogCache.put(url, response);
|
||||||
|
return { updated: true, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updated: false,
|
||||||
|
error: `Unexpected managed catalog response: ${response.status} ${response.statusText}`.trim(),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Network unavailable — keep existing cache as-is.
|
// Network unavailable — keep existing cache as-is.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { updated: false, error: 'Managed catalog request failed.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyClients(message) {
|
||||||
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
|
await Promise.allSettled(clients.map((client) => client.postMessage(message)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function respondWithManagedCatalog(request) {
|
async function respondWithManagedCatalog(request) {
|
||||||
@@ -530,7 +543,23 @@ self.addEventListener('activate', (event) => {
|
|||||||
|
|
||||||
self.addEventListener('sync', (event) => {
|
self.addEventListener('sync', (event) => {
|
||||||
if (event.tag !== STATION_SYNC_TAG) return;
|
if (event.tag !== STATION_SYNC_TAG) return;
|
||||||
event.waitUntil(syncRadioStations('background-sync'));
|
event.waitUntil(
|
||||||
|
syncRadioStations('background-sync')
|
||||||
|
.then((meta) => notifyClients({
|
||||||
|
type: 'station-catalog-updated',
|
||||||
|
reason: 'background-sync',
|
||||||
|
meta,
|
||||||
|
}))
|
||||||
|
.catch(async (error) => {
|
||||||
|
await notifyClients({
|
||||||
|
type: 'station-catalog-update-failed',
|
||||||
|
reason: 'background-sync',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
failedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
@@ -544,6 +573,14 @@ self.addEventListener('message', (event) => {
|
|||||||
? await syncRadioStations('country-selection-update', syncSettings.selectedCountryCodes)
|
? await syncRadioStations('country-selection-update', syncSettings.selectedCountryCodes)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (syncResult) {
|
||||||
|
await notifyClients({
|
||||||
|
type: 'station-catalog-updated',
|
||||||
|
reason: 'country-selection-update',
|
||||||
|
meta: syncResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
event.ports?.[0]?.postMessage({
|
event.ports?.[0]?.postMessage({
|
||||||
ok: true,
|
ok: true,
|
||||||
selectedCountryCodes: syncSettings.selectedCountryCodes,
|
selectedCountryCodes: syncSettings.selectedCountryCodes,
|
||||||
@@ -560,11 +597,43 @@ self.addEventListener('message', (event) => {
|
|||||||
|
|
||||||
self.addEventListener('periodicsync', (event) => {
|
self.addEventListener('periodicsync', (event) => {
|
||||||
if (event.tag === STATION_PERIODIC_SYNC_TAG) {
|
if (event.tag === STATION_PERIODIC_SYNC_TAG) {
|
||||||
event.waitUntil(syncRadioStations('periodic-sync'));
|
event.waitUntil(
|
||||||
|
syncRadioStations('periodic-sync')
|
||||||
|
.then((meta) => notifyClients({
|
||||||
|
type: 'station-catalog-updated',
|
||||||
|
reason: 'periodic-sync',
|
||||||
|
meta,
|
||||||
|
}))
|
||||||
|
.catch(async (error) => {
|
||||||
|
await notifyClients({
|
||||||
|
type: 'station-catalog-update-failed',
|
||||||
|
reason: 'periodic-sync',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
failedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.tag === MANAGED_CATALOG_PERIODIC_SYNC_TAG) {
|
if (event.tag === MANAGED_CATALOG_PERIODIC_SYNC_TAG) {
|
||||||
event.waitUntil(refreshManagedCatalogCache());
|
event.waitUntil(
|
||||||
|
refreshManagedCatalogCache().then((result) => {
|
||||||
|
if (!result.updated) {
|
||||||
|
return notifyClients({
|
||||||
|
type: 'managed-catalog-update-failed',
|
||||||
|
reason: 'periodic-sync',
|
||||||
|
error: result.error || 'Unknown error',
|
||||||
|
failedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return notifyClients({
|
||||||
|
type: 'managed-catalog-updated',
|
||||||
|
reason: 'periodic-sync',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,11 @@ function TrackInfo() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="managed-catalog-status" className="managed-catalog-status hidden" aria-live="polite" />
|
<div id="managed-catalog-status" className="managed-catalog-status hidden" aria-live="polite" />
|
||||||
|
<div id="catalog-sync-status" className="catalog-sync-status hidden" aria-live="polite">
|
||||||
|
<span id="catalog-sync-status-text" />
|
||||||
|
<time id="catalog-sync-status-time" className="catalog-sync-status-time" />
|
||||||
|
</div>
|
||||||
|
<div id="catalog-sync-history" className="catalog-sync-history hidden" aria-live="polite" />
|
||||||
<div id="cast-output-row" className="cast-output-row hidden" aria-live="polite">
|
<div id="cast-output-row" className="cast-output-row hidden" aria-live="polite">
|
||||||
<span className="cast-output-label">Output:</span>
|
<span className="cast-output-label">Output:</span>
|
||||||
<button id="cast-output-btn" className="cast-output-toggle" aria-pressed="false"
|
<button id="cast-output-btn" className="cast-output-toggle" aria-pressed="false"
|
||||||
|
|||||||
+304
-52
@@ -1,6 +1,10 @@
|
|||||||
import { loadRadioStations } from './radio/loadRadioStations.ts';
|
import { loadPersistedRadioStations, loadRadioStations } from './radio/loadRadioStations.ts';
|
||||||
import { defaultSelectedRadioCountryCodes, managedCountryCode, radioCountries } from './radio/radioCountries.ts';
|
import { defaultSelectedRadioCountryCodes, managedCountryCode, radioCountries } from './radio/radioCountries.ts';
|
||||||
import { getLastManagedCatalogSource, loadManagedStations } from './radio/loadManagedStations.ts';
|
import {
|
||||||
|
getLastManagedCatalogSource,
|
||||||
|
loadManagedStations,
|
||||||
|
loadPersistedManagedStations,
|
||||||
|
} from './radio/loadManagedStations.ts';
|
||||||
import {
|
import {
|
||||||
clearPlayerPersistence,
|
clearPlayerPersistence,
|
||||||
deletePersistedUserStationById,
|
deletePersistedUserStationById,
|
||||||
@@ -83,6 +87,7 @@ let stationCountryFilterOpen = false;
|
|||||||
let artworkShineTimeoutId = null;
|
let artworkShineTimeoutId = null;
|
||||||
let artworkShineClearTimeoutId = null;
|
let artworkShineClearTimeoutId = null;
|
||||||
let stationTitleRafId = null;
|
let stationTitleRafId = null;
|
||||||
|
let catalogSyncStatusTimeoutId = null;
|
||||||
|
|
||||||
const STATION_LIBRARY_PAGE_SIZE = 24;
|
const STATION_LIBRARY_PAGE_SIZE = 24;
|
||||||
const RECENT_STATION_HISTORY_LIMIT = 40;
|
const RECENT_STATION_HISTORY_LIMIT = 40;
|
||||||
@@ -98,6 +103,10 @@ const stationSubtitleEl = document.getElementById('station-subtitle');
|
|||||||
const stationHealthSummaryEl = document.getElementById('station-health-summary');
|
const stationHealthSummaryEl = document.getElementById('station-health-summary');
|
||||||
const stationHealthDetailEl = document.getElementById('station-health-detail');
|
const stationHealthDetailEl = document.getElementById('station-health-detail');
|
||||||
const managedCatalogStatusEl = document.getElementById('managed-catalog-status');
|
const managedCatalogStatusEl = document.getElementById('managed-catalog-status');
|
||||||
|
const catalogSyncStatusEl = document.getElementById('catalog-sync-status');
|
||||||
|
const catalogSyncStatusTextEl = document.getElementById('catalog-sync-status-text');
|
||||||
|
const catalogSyncStatusTimeEl = document.getElementById('catalog-sync-status-time');
|
||||||
|
const catalogSyncHistoryEl = document.getElementById('catalog-sync-history');
|
||||||
const nowPlayingEl = document.getElementById('now-playing');
|
const nowPlayingEl = document.getElementById('now-playing');
|
||||||
const nowArtistEl = document.getElementById('now-artist');
|
const nowArtistEl = document.getElementById('now-artist');
|
||||||
const nowTitleEl = document.getElementById('now-title');
|
const nowTitleEl = document.getElementById('now-title');
|
||||||
@@ -200,6 +209,11 @@ const IMPORT_EXPORT_VERSION = 1;
|
|||||||
const PLAYBACK_START_TIMEOUT_MS = 12000;
|
const PLAYBACK_START_TIMEOUT_MS = 12000;
|
||||||
const RADIO_BROWSER_COUNTRIES_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/countries';
|
const RADIO_BROWSER_COUNTRIES_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/countries';
|
||||||
const SERVICE_WORKER_SYNC_COUNTRIES_MESSAGE = 'set-sync-countries';
|
const SERVICE_WORKER_SYNC_COUNTRIES_MESSAGE = 'set-sync-countries';
|
||||||
|
const SERVICE_WORKER_STATION_CATALOG_UPDATED_MESSAGE = 'station-catalog-updated';
|
||||||
|
const SERVICE_WORKER_STATION_CATALOG_UPDATE_FAILED_MESSAGE = 'station-catalog-update-failed';
|
||||||
|
const SERVICE_WORKER_MANAGED_CATALOG_UPDATED_MESSAGE = 'managed-catalog-updated';
|
||||||
|
const SERVICE_WORKER_MANAGED_CATALOG_UPDATE_FAILED_MESSAGE = 'managed-catalog-update-failed';
|
||||||
|
const CATALOG_SYNC_HISTORY_STORAGE_KEY = 'radioplayer.catalogSyncHistory';
|
||||||
|
|
||||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1666,6 +1680,151 @@ function updateManagedCatalogStatus(source = 'unknown') {
|
|||||||
managedCatalogStatusEl.classList.remove('hidden');
|
managedCatalogStatusEl.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCatalogSyncTime(value) {
|
||||||
|
const parsed = value ? new Date(value) : new Date();
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCatalogSyncHistoryTime(value) {
|
||||||
|
const parsed = value ? new Date(value) : null;
|
||||||
|
if (!parsed || Number.isNaN(parsed.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCatalogSyncHistory() {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(CATALOG_SYNC_HISTORY_STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = parsed.kind === 'managed' ? 'managed' : parsed.kind === 'stations' ? 'stations' : null;
|
||||||
|
const syncedAt = typeof parsed.syncedAt === 'string' ? parsed.syncedAt : null;
|
||||||
|
if (!kind || !syncedAt || Number.isNaN(new Date(syncedAt).getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind, syncedAt };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCatalogSyncHistory(record) {
|
||||||
|
if (!catalogSyncHistoryEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedTime = formatCatalogSyncHistoryTime(record?.syncedAt ?? null);
|
||||||
|
if (!record || !formattedTime) {
|
||||||
|
catalogSyncHistoryEl.textContent = '';
|
||||||
|
catalogSyncHistoryEl.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = record.kind === 'managed' ? 'Managed catalog' : 'Stations';
|
||||||
|
catalogSyncHistoryEl.textContent = `Last background refresh: ${label} · ${formattedTime}`;
|
||||||
|
catalogSyncHistoryEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistCatalogSyncHistory(kind, syncedAt) {
|
||||||
|
const normalizedSyncedAt = syncedAt && !Number.isNaN(new Date(syncedAt).getTime())
|
||||||
|
? new Date(syncedAt).toISOString()
|
||||||
|
: new Date().toISOString();
|
||||||
|
const record = { kind, syncedAt: normalizedSyncedAt };
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(CATALOG_SYNC_HISTORY_STORAGE_KEY, JSON.stringify(record));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures and still render the in-memory state.
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCatalogSyncHistory(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactCatalogSyncError(error) {
|
||||||
|
if (typeof error !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const compact = error.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!compact) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return compact.length > 72 ? `${compact.slice(0, 69)}...` : compact;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCatalogSyncStatus(message, syncedAt = null, tone = 'info', detail = '') {
|
||||||
|
if (!catalogSyncStatusEl || !message) return;
|
||||||
|
|
||||||
|
if (catalogSyncStatusTimeoutId) {
|
||||||
|
clearTimeout(catalogSyncStatusTimeoutId);
|
||||||
|
catalogSyncStatusTimeoutId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactDetail = compactCatalogSyncError(detail);
|
||||||
|
const visibleMessage = compactDetail ? `${message}: ${compactDetail}` : message;
|
||||||
|
const accessibleMessage = detail ? `${message}: ${detail}` : message;
|
||||||
|
|
||||||
|
if (catalogSyncStatusTextEl) {
|
||||||
|
catalogSyncStatusTextEl.textContent = visibleMessage;
|
||||||
|
} else {
|
||||||
|
catalogSyncStatusEl.textContent = visibleMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogSyncStatusEl.title = accessibleMessage;
|
||||||
|
catalogSyncStatusEl.setAttribute('aria-label', accessibleMessage);
|
||||||
|
|
||||||
|
if (catalogSyncStatusTimeEl) {
|
||||||
|
const formattedTime = formatCatalogSyncTime(syncedAt);
|
||||||
|
catalogSyncStatusTimeEl.textContent = formattedTime;
|
||||||
|
catalogSyncStatusTimeEl.dateTime = syncedAt && !Number.isNaN(new Date(syncedAt).getTime())
|
||||||
|
? new Date(syncedAt).toISOString()
|
||||||
|
: new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogSyncStatusEl.classList.toggle('is-warning', tone === 'warning');
|
||||||
|
catalogSyncStatusEl.classList.remove('hidden');
|
||||||
|
|
||||||
|
catalogSyncStatusTimeoutId = window.setTimeout(() => {
|
||||||
|
if (catalogSyncStatusTextEl) {
|
||||||
|
catalogSyncStatusTextEl.textContent = '';
|
||||||
|
} else {
|
||||||
|
catalogSyncStatusEl.textContent = '';
|
||||||
|
}
|
||||||
|
if (catalogSyncStatusTimeEl) {
|
||||||
|
catalogSyncStatusTimeEl.textContent = '';
|
||||||
|
catalogSyncStatusTimeEl.removeAttribute('datetime');
|
||||||
|
}
|
||||||
|
catalogSyncStatusEl.removeAttribute('title');
|
||||||
|
catalogSyncStatusEl.removeAttribute('aria-label');
|
||||||
|
catalogSyncStatusEl.classList.remove('is-warning');
|
||||||
|
catalogSyncStatusEl.classList.add('hidden');
|
||||||
|
catalogSyncStatusTimeoutId = null;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
function getStationHealthBadge(station, stationHealth = loadStationHealth()) {
|
function getStationHealthBadge(station, stationHealth = loadStationHealth()) {
|
||||||
const health = stationHealth?.[station?.id];
|
const health = stationHealth?.[station?.id];
|
||||||
if (!health || Number(health.attempts) <= 0) {
|
if (!health || Number(health.attempts) <= 0) {
|
||||||
@@ -2603,6 +2762,63 @@ async function activateStationByIndex(idx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildMergedStations(raw, managedRaw) {
|
||||||
|
const normalizedRaw = raw
|
||||||
|
.filter((station) => isEnabledRadioCountryStation(station))
|
||||||
|
.map((station) => normalizeStationRecord(station))
|
||||||
|
.filter((station) => station.enabled !== false && station.url && station.url.length > 0);
|
||||||
|
|
||||||
|
const normalizedManaged = managedRaw
|
||||||
|
.map((station) => normalizeStationRecord(station))
|
||||||
|
.filter((station) => station.enabled !== false && station.url && station.url.length > 0);
|
||||||
|
|
||||||
|
const userNormalized = loadUserStations()
|
||||||
|
.map((station) => normalizeStationRecord(station, true))
|
||||||
|
.filter((station) => station.url && station.url.length > 0);
|
||||||
|
|
||||||
|
const mergedStations = [];
|
||||||
|
const seenStationIds = new Set();
|
||||||
|
const seenStreamUrls = new Set();
|
||||||
|
|
||||||
|
const pushStation = (station) => {
|
||||||
|
if (!station?.id || !station?.url) return;
|
||||||
|
if (seenStationIds.has(station.id) || seenStreamUrls.has(station.url)) return;
|
||||||
|
seenStationIds.add(station.id);
|
||||||
|
seenStreamUrls.add(station.url);
|
||||||
|
mergedStations.push(station);
|
||||||
|
};
|
||||||
|
|
||||||
|
normalizedManaged.forEach(pushStation);
|
||||||
|
normalizedRaw.forEach(pushStation);
|
||||||
|
userNormalized.forEach(pushStation);
|
||||||
|
|
||||||
|
return mergedStations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLoadedStations(mergedStations, { preferredStationId = null } = {}) {
|
||||||
|
stations = mergedStations;
|
||||||
|
updateManagedCatalogStatus(getLastManagedCatalogSource());
|
||||||
|
stationCatalogState = stations.length > 0 ? 'ready' : 'empty';
|
||||||
|
|
||||||
|
if (stations.length > 0) {
|
||||||
|
const fallbackStationId = preferredStationId || getLastStationId();
|
||||||
|
if (fallbackStationId) {
|
||||||
|
const foundIndex = stations.findIndex((station) => station.id === fallbackStationId);
|
||||||
|
currentIndex = foundIndex >= 0 ? foundIndex : 0;
|
||||||
|
} else {
|
||||||
|
currentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStation(currentIndex);
|
||||||
|
renderCoverflow();
|
||||||
|
renderStationLibrary();
|
||||||
|
startCurrentSongPollers();
|
||||||
|
} else {
|
||||||
|
renderCoverflow();
|
||||||
|
renderStationLibrary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Load stations ────────────────────────────────────────────────────────────
|
// ── Load stations ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadStations() {
|
async function loadStations() {
|
||||||
@@ -2612,60 +2828,39 @@ async function loadStations() {
|
|||||||
resetStationLibraryPage();
|
resetStationLibraryPage();
|
||||||
renderStationLibrary();
|
renderStationLibrary();
|
||||||
stopCurrentSongPollers();
|
stopCurrentSongPollers();
|
||||||
const raw = await loadRadioStations();
|
const persistedRadioPromise = loadPersistedRadioStations().catch(() => null);
|
||||||
const managedRaw = await loadManagedStations().catch(() => []);
|
const persistedManagedPromise = loadPersistedManagedStations().catch(() => null);
|
||||||
|
const liveRadioPromise = loadRadioStations();
|
||||||
|
const liveManagedPromise = loadManagedStations().catch(() => []);
|
||||||
|
|
||||||
const normalizedRaw = raw
|
const [persistedRaw, persistedManaged] = await Promise.all([
|
||||||
.filter((station) => isEnabledRadioCountryStation(station))
|
persistedRadioPromise,
|
||||||
.map((s) => normalizeStationRecord(s))
|
persistedManagedPromise,
|
||||||
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
|
]);
|
||||||
|
|
||||||
const normalizedManaged = managedRaw
|
const hasPersistedCatalog = (persistedRaw?.length ?? 0) > 0 || (persistedManaged?.length ?? 0) > 0;
|
||||||
.map((s) => normalizeStationRecord(s))
|
if (hasPersistedCatalog) {
|
||||||
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
|
applyLoadedStations(buildMergedStations(persistedRaw ?? [], persistedManaged ?? []));
|
||||||
|
console.debug('loadStations: hydrated from persisted station catalog');
|
||||||
|
}
|
||||||
|
|
||||||
const userNormalized = loadUserStations()
|
try {
|
||||||
.map((s) => normalizeStationRecord(s, true))
|
const [raw, managedRaw] = await Promise.all([liveRadioPromise, liveManagedPromise]);
|
||||||
.filter((s) => s.url && s.url.length > 0);
|
const preferredStationId = hasPersistedCatalog ? stations[currentIndex]?.id ?? null : null;
|
||||||
|
applyLoadedStations(buildMergedStations(raw, managedRaw), { preferredStationId });
|
||||||
const mergedStations = [];
|
lastManagedCatalogLoadTime = Date.now();
|
||||||
const seenStationIds = new Set();
|
console.debug('loadStations: loaded', stations.length, 'stations');
|
||||||
const seenStreamUrls = new Set();
|
console.debug('managed catalog source:', getLastManagedCatalogSource());
|
||||||
|
} catch (refreshError) {
|
||||||
const pushStation = (station) => {
|
if (!hasPersistedCatalog) {
|
||||||
if (!station?.id || !station?.url) return;
|
throw refreshError;
|
||||||
if (seenStationIds.has(station.id) || seenStreamUrls.has(station.url)) return;
|
}
|
||||||
seenStationIds.add(station.id);
|
|
||||||
seenStreamUrls.add(station.url);
|
console.warn('Failed to refresh station catalogs, continuing with persisted data.', refreshError);
|
||||||
mergedStations.push(station);
|
stationCatalogError = '';
|
||||||
};
|
if (statusTextEl) {
|
||||||
|
statusTextEl.textContent = 'Ready';
|
||||||
normalizedManaged.forEach(pushStation);
|
|
||||||
normalizedRaw.forEach(pushStation);
|
|
||||||
userNormalized.forEach(pushStation);
|
|
||||||
|
|
||||||
stations = mergedStations;
|
|
||||||
updateManagedCatalogStatus(getLastManagedCatalogSource());
|
|
||||||
lastManagedCatalogLoadTime = Date.now();
|
|
||||||
stationCatalogState = stations.length > 0 ? 'ready' : 'empty';
|
|
||||||
console.debug('loadStations: loaded', stations.length, 'stations');
|
|
||||||
console.debug('managed catalog source:', getLastManagedCatalogSource());
|
|
||||||
|
|
||||||
if (stations.length > 0) {
|
|
||||||
const lastId = getLastStationId();
|
|
||||||
if (lastId) {
|
|
||||||
const found = stations.findIndex((s) => s.id === lastId);
|
|
||||||
currentIndex = found >= 0 ? found : 0;
|
|
||||||
} else {
|
|
||||||
currentIndex = 0;
|
|
||||||
}
|
}
|
||||||
loadStation(currentIndex);
|
|
||||||
renderCoverflow();
|
|
||||||
renderStationLibrary();
|
|
||||||
startCurrentSongPollers();
|
|
||||||
} else {
|
|
||||||
renderCoverflow();
|
|
||||||
renderStationLibrary();
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load stations', e);
|
console.error('Failed to load stations', e);
|
||||||
@@ -3053,6 +3248,61 @@ function loadStation(index) {
|
|||||||
window.setTimeout(triggerSparkleOnSelected, 120);
|
window.setTimeout(triggerSparkleOnSelected, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupServiceWorkerCatalogRefreshListener() {
|
||||||
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||||
|
const messageType = event.data?.type;
|
||||||
|
if (
|
||||||
|
messageType !== SERVICE_WORKER_STATION_CATALOG_UPDATED_MESSAGE
|
||||||
|
&& messageType !== SERVICE_WORKER_STATION_CATALOG_UPDATE_FAILED_MESSAGE
|
||||||
|
&& messageType !== SERVICE_WORKER_MANAGED_CATALOG_UPDATED_MESSAGE
|
||||||
|
&& messageType !== SERVICE_WORKER_MANAGED_CATALOG_UPDATE_FAILED_MESSAGE
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType === SERVICE_WORKER_STATION_CATALOG_UPDATE_FAILED_MESSAGE) {
|
||||||
|
showCatalogSyncStatus(
|
||||||
|
'Background station sync failed',
|
||||||
|
event.data?.failedAt ?? null,
|
||||||
|
'warning',
|
||||||
|
event.data?.error ?? '',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageType === SERVICE_WORKER_MANAGED_CATALOG_UPDATE_FAILED_MESSAGE) {
|
||||||
|
showCatalogSyncStatus(
|
||||||
|
'Managed catalog refresh failed',
|
||||||
|
event.data?.failedAt ?? null,
|
||||||
|
'warning',
|
||||||
|
event.data?.error ?? '',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stationCatalogState === 'loading') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadStations().then(() => {
|
||||||
|
if (messageType === SERVICE_WORKER_MANAGED_CATALOG_UPDATED_MESSAGE) {
|
||||||
|
persistCatalogSyncHistory('managed', new Date().toISOString());
|
||||||
|
showCatalogSyncStatus('Managed catalog refreshed in background');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistCatalogSyncHistory('stations', event.data?.meta?.syncedAt ?? null);
|
||||||
|
showCatalogSyncStatus('Stations updated in background', event.data?.meta?.syncedAt ?? null);
|
||||||
|
}).catch(() => {
|
||||||
|
// Keep current UI if the refresh fails.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Google Cast initialisation ────────────────────────────────────────────────
|
// ── Google Cast initialisation ────────────────────────────────────────────────
|
||||||
|
|
||||||
// Called by the Cast SDK once it has loaded (window.__onGCastApiAvailable callback).
|
// Called by the Cast SDK once it has loaded (window.__onGCastApiAvailable callback).
|
||||||
@@ -3793,8 +4043,10 @@ async function init() {
|
|||||||
restoreCastBothMode();
|
restoreCastBothMode();
|
||||||
restoreLastStationCountry();
|
restoreLastStationCountry();
|
||||||
restoreSelectedRadioCountryCodes();
|
restoreSelectedRadioCountryCodes();
|
||||||
|
renderCatalogSyncHistory(readCatalogSyncHistory());
|
||||||
await loadStations();
|
await loadStations();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
setupServiceWorkerCatalogRefreshListener();
|
||||||
void sendSelectedCountriesToServiceWorker();
|
void sendSelectedCountriesToServiceWorker();
|
||||||
lockPortraitOrientation();
|
lockPortraitOrientation();
|
||||||
ensureArtworkPointerFallback();
|
ensureArtworkPointerFallback();
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
const MANAGED_CATALOG_CACHE_PREFIX = 'radioplayer-managed-catalog-';
|
const MANAGED_CATALOG_CACHE_PREFIX = 'radioplayer-managed-catalog-';
|
||||||
const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source';
|
const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source';
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadPersistedManagedStationCatalog,
|
||||||
|
persistManagedStationCatalog,
|
||||||
|
} from '../storage/stationCatalogPersistence.js';
|
||||||
|
|
||||||
export type ManagedCatalogSource = 'remote' | 'cached-remote' | 'bundled' | 'unknown';
|
export type ManagedCatalogSource = 'remote' | 'cached-remote' | 'bundled' | 'unknown';
|
||||||
|
|
||||||
let lastManagedCatalogSource: ManagedCatalogSource = 'unknown';
|
let lastManagedCatalogSource: ManagedCatalogSource = 'unknown';
|
||||||
@@ -44,10 +49,26 @@ function setLastManagedCatalogSource(source: ManagedCatalogSource) {
|
|||||||
lastManagedCatalogSource = source;
|
lastManagedCatalogSource = source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeManagedCatalogSource(source: unknown): ManagedCatalogSource {
|
||||||
|
return source === 'remote' || source === 'cached-remote' || source === 'bundled'
|
||||||
|
? source
|
||||||
|
: 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
export function getLastManagedCatalogSource(): ManagedCatalogSource {
|
export function getLastManagedCatalogSource(): ManagedCatalogSource {
|
||||||
return lastManagedCatalogSource;
|
return lastManagedCatalogSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadPersistedManagedStations(): Promise<unknown[] | null> {
|
||||||
|
const persisted = await loadPersistedManagedStationCatalog();
|
||||||
|
if (!persisted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastManagedCatalogSource(normalizeManagedCatalogSource(persisted.source));
|
||||||
|
return normalizeManagedStationsPayload(persisted.stations);
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadManagedStations(): Promise<unknown[]> {
|
export async function loadManagedStations(): Promise<unknown[]> {
|
||||||
const remoteCatalogUrl = `${import.meta.env.BASE_URL}api/managed-stations.json`;
|
const remoteCatalogUrl = `${import.meta.env.BASE_URL}api/managed-stations.json`;
|
||||||
const bundledCatalogUrl = `${import.meta.env.BASE_URL}stations.json`;
|
const bundledCatalogUrl = `${import.meta.env.BASE_URL}stations.json`;
|
||||||
@@ -92,6 +113,7 @@ export async function loadManagedStations(): Promise<unknown[]> {
|
|||||||
throw new Error(`Failed to load managed stations: ${response.status}`);
|
throw new Error(`Failed to load managed stations: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stations = await response.json();
|
const stations = normalizeManagedStationsPayload(await response.json());
|
||||||
return normalizeManagedStationsPayload(stations);
|
await persistManagedStationCatalog(stations, getLastManagedCatalogSource());
|
||||||
|
return stations;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { RadioStation } from './radioTypes.js';
|
import type { RadioStation } from './radioTypes.js';
|
||||||
|
import {
|
||||||
|
loadPersistedRadioStationCatalog,
|
||||||
|
persistRadioStationCatalog,
|
||||||
|
} from '../storage/stationCatalogPersistence.js';
|
||||||
|
|
||||||
const STATION_SYNC_CACHE_PREFIX = 'radioplayer-station-sync-';
|
const STATION_SYNC_CACHE_PREFIX = 'radioplayer-station-sync-';
|
||||||
|
|
||||||
@@ -21,6 +25,11 @@ async function loadSyncedCatalogFromCache(catalogUrl: string): Promise<Response
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadPersistedRadioStations(): Promise<RadioStation[] | null> {
|
||||||
|
const persisted = await loadPersistedRadioStationCatalog();
|
||||||
|
return Array.isArray(persisted) ? persisted as RadioStation[] : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadRadioStations(): Promise<RadioStation[]> {
|
export async function loadRadioStations(): Promise<RadioStation[]> {
|
||||||
const syncedCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations-sync.json`;
|
const syncedCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations-sync.json`;
|
||||||
const bundledCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations.json`;
|
const bundledCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations.json`;
|
||||||
@@ -43,5 +52,7 @@ export async function loadRadioStations(): Promise<RadioStation[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stations = (await response.json()) as RadioStation[];
|
const stations = (await response.json()) as RadioStation[];
|
||||||
return Array.isArray(stations) ? stations : [];
|
const normalizedStations = Array.isArray(stations) ? stations : [];
|
||||||
|
await persistRadioStationCatalog(normalizedStations);
|
||||||
|
return normalizedStations;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
||||||
|
|
||||||
|
type StationCatalogKey = 'radio-browser' | 'managed';
|
||||||
|
|
||||||
|
type StationCatalogRecord = {
|
||||||
|
key: StationCatalogKey;
|
||||||
|
stations: unknown[];
|
||||||
|
source: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StationCatalogDb extends DBSchema {
|
||||||
|
catalogs: {
|
||||||
|
key: StationCatalogKey;
|
||||||
|
value: StationCatalogRecord;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_NAME = 'radioplayer-station-catalogs';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const LOCAL_STORAGE_KEYS = {
|
||||||
|
radioBrowser: 'radioplayer.stationCatalog.radioBrowser',
|
||||||
|
managed: 'radioplayer.stationCatalog.managed',
|
||||||
|
};
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBPDatabase<StationCatalogDb> | null> | null = null;
|
||||||
|
|
||||||
|
function getDb() {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = openDB<StationCatalogDb>(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db) {
|
||||||
|
if (!db.objectStoreNames.contains('catalogs')) {
|
||||||
|
db.createObjectStore('catalogs', { keyPath: 'key' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn('Station catalog IndexedDB unavailable, using localStorage fallback.', error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalStorage(): Storage | null {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalStorageKey(key: StationCatalogKey) {
|
||||||
|
return key === 'managed' ? LOCAL_STORAGE_KEYS.managed : LOCAL_STORAGE_KEYS.radioBrowser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeStations(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSource(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeCatalogRecord(key: StationCatalogKey, value: unknown): StationCatalogRecord | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Partial<StationCatalogRecord>;
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
stations: sanitizeStations(record.stations),
|
||||||
|
source: sanitizeSource(record.source),
|
||||||
|
updatedAt: typeof record.updatedAt === 'string' && record.updatedAt.trim().length > 0
|
||||||
|
? record.updatedAt
|
||||||
|
: new Date(0).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCatalogFromLocalStorage(key: StationCatalogKey): StationCatalogRecord | null {
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (!storage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = storage.getItem(getLocalStorageKey(key));
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return sanitizeCatalogRecord(key, JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCatalogToLocalStorage(record: StationCatalogRecord) {
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (!storage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.setItem(getLocalStorageKey(record.key), JSON.stringify(record));
|
||||||
|
} catch {
|
||||||
|
// Ignore quota/write failures and rely on IndexedDB when available.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCatalog(key: StationCatalogKey): Promise<StationCatalogRecord | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) {
|
||||||
|
return readCatalogFromLocalStorage(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await db.get('catalogs', key);
|
||||||
|
return sanitizeCatalogRecord(key, record);
|
||||||
|
} catch {
|
||||||
|
return readCatalogFromLocalStorage(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCatalog(key: StationCatalogKey, stations: unknown[], source: string | null = null) {
|
||||||
|
const record: StationCatalogRecord = {
|
||||||
|
key,
|
||||||
|
stations: sanitizeStations(stations),
|
||||||
|
source: sanitizeSource(source),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
writeCatalogToLocalStorage(record);
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.put('catalogs', record);
|
||||||
|
} catch {
|
||||||
|
// Keep the localStorage mirror if IndexedDB writes fail.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPersistedRadioStationCatalog(): Promise<unknown[] | null> {
|
||||||
|
const record = await readCatalog('radio-browser');
|
||||||
|
return record ? record.stations : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistRadioStationCatalog(stations: unknown[]) {
|
||||||
|
await writeCatalog('radio-browser', stations);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPersistedManagedStationCatalog(): Promise<{ stations: unknown[]; source: string | null } | null> {
|
||||||
|
const record = await readCatalog('managed');
|
||||||
|
return record
|
||||||
|
? {
|
||||||
|
stations: record.stations,
|
||||||
|
source: record.source,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistManagedStationCatalog(stations: unknown[], source: string | null) {
|
||||||
|
await writeCatalog('managed', stations, source);
|
||||||
|
}
|
||||||
@@ -1585,6 +1585,61 @@ header {
|
|||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-sync-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-3-rgb), 0.1));
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-sync-status.is-warning {
|
||||||
|
border-color: rgba(255, 112, 135, 0.34);
|
||||||
|
background: linear-gradient(135deg, rgba(255, 112, 135, 0.18), rgba(255, 180, 92, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-sync-status-time {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-sync-status-time:not(:empty)::before {
|
||||||
|
content: "·";
|
||||||
|
margin-right: 8px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-sync-status::before {
|
||||||
|
content: "";
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-sync-status.is-warning::before {
|
||||||
|
background: var(--danger);
|
||||||
|
box-shadow: 0 0 10px rgba(255, 112, 135, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-sync-history {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user