mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
chore(servers): remove quick/full scan buttons from server cards (#843)
Drops the Quick/Full scan actions and all supporting logic — they are no longer needed. Removes the ServerScanActions component (incl. the unused compact variant), the subsonicScan API, the scanStore, and the app-root useScanPolling hook (no more background scan polling). Cleans up the .server-scan-* CSS and the settings.scan i18n block across all 9 locales. 440 deletions, no new code; tsc + bundle clean.
This commit is contained in:
committed by
GitHub
parent
f9f96f024f
commit
1a7a2a0bfc
@@ -1,23 +0,0 @@
|
||||
import { apiForServer } from './subsonicClient';
|
||||
|
||||
export interface ScanStatus {
|
||||
scanning: boolean;
|
||||
count: number;
|
||||
folderCount?: number;
|
||||
lastScan?: string;
|
||||
}
|
||||
|
||||
export async function startScan(serverId: string, fullScan: boolean): Promise<void> {
|
||||
await apiForServer(serverId, 'startScan.view', fullScan ? { fullScan: 'true' } : {}, 10000);
|
||||
}
|
||||
|
||||
export async function getScanStatus(serverId: string): Promise<ScanStatus> {
|
||||
const data = await apiForServer<{ scanStatus?: ScanStatus }>(serverId, 'getScanStatus.view', {}, 8000);
|
||||
const s = data.scanStatus;
|
||||
return {
|
||||
scanning: !!s?.scanning,
|
||||
count: typeof s?.count === 'number' ? s.count : 0,
|
||||
folderCount: typeof s?.folderCount === 'number' ? s.folderCount : undefined,
|
||||
lastScan: typeof s?.lastScan === 'string' ? s.lastScan : undefined,
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
|
||||
import { initHotCachePrefetch } from '../hotCachePrefetch';
|
||||
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
|
||||
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
|
||||
import { useScanPolling } from '../hooks/useScanPolling';
|
||||
import { IS_WINDOWS } from '../utils/platform';
|
||||
import TauriEventBridge from './TauriEventBridge';
|
||||
import AppShell from './AppShell';
|
||||
@@ -35,8 +34,6 @@ export default function MainApp() {
|
||||
// Advanced Mode toggle. Idempotent — flagged in localStorage.
|
||||
useEffect(() => { runAdvancedModeMigration(); }, []);
|
||||
|
||||
useScanPolling();
|
||||
|
||||
// Push playback state to mini window + handle control events.
|
||||
useEffect(() => {
|
||||
return initMiniPlayerBridgeOnMain();
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, DatabaseZap, Loader2, RefreshCw, WifiOff } from 'lucide-react';
|
||||
import { startScan } from '../../api/subsonicScan';
|
||||
import { useScanStore } from '../../store/scanStore';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
|
||||
const CONFIRM_TIMEOUT_MS = 3000;
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
/** `compact` for the server switcher dropdown (icon-only), `card` for the settings cards. */
|
||||
variant: 'compact' | 'card';
|
||||
}
|
||||
|
||||
export default function ServerScanActions({ serverId, variant }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const entry = useScanStore(s => s.byServer[serverId]);
|
||||
const setEntry = useScanStore(s => s.set);
|
||||
const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const phase = entry?.phase ?? 'idle';
|
||||
const count = entry?.count ?? 0;
|
||||
const confirmingFull = entry?.confirmingFull ?? false;
|
||||
const busy = phase === 'starting' || phase === 'running';
|
||||
|
||||
useEffect(() => () => {
|
||||
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const armConfirmTimeout = () => {
|
||||
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
|
||||
confirmTimerRef.current = setTimeout(() => {
|
||||
setEntry(serverId, { confirmingFull: false });
|
||||
}, CONFIRM_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const launch = async (full: boolean) => {
|
||||
setEntry(serverId, { phase: 'starting', type: full ? 'full' : 'quick', count: 0, confirmingFull: false });
|
||||
try {
|
||||
await startScan(serverId, full);
|
||||
setEntry(serverId, { phase: 'running' });
|
||||
} catch (err) {
|
||||
setEntry(serverId, { phase: 'error' });
|
||||
showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Scan failed', 4000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onQuickClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (busy) return;
|
||||
void launch(false);
|
||||
};
|
||||
|
||||
const onFullClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (busy) return;
|
||||
if (!confirmingFull) {
|
||||
setEntry(serverId, { confirmingFull: true });
|
||||
armConfirmTimeout();
|
||||
return;
|
||||
}
|
||||
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
|
||||
void launch(true);
|
||||
};
|
||||
|
||||
// ── Status slot content ──────────────────────────────────────────────────
|
||||
const status = (() => {
|
||||
if (phase === 'running' || phase === 'starting') {
|
||||
const label = count > 0 ? formatCount(count) : '';
|
||||
return (
|
||||
<span className="server-scan-status" data-tooltip={t('settings.scan.scanning')}>
|
||||
<Loader2 size={13} className="spin-slow" />
|
||||
{label && <span className="server-scan-status-count">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (phase === 'just-finished') {
|
||||
return (
|
||||
<span className="server-scan-status server-scan-status--done" data-tooltip={t('settings.scan.done')}>
|
||||
<Check size={13} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (phase === 'error') {
|
||||
return (
|
||||
<span className="server-scan-status server-scan-status--error" data-tooltip={t('settings.scan.error')}>
|
||||
<WifiOff size={13} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<span className="server-scan-actions server-scan-actions--compact" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={onQuickClick}
|
||||
disabled={busy}
|
||||
data-tooltip={t('settings.scan.quickTip')}
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label={t('settings.scan.quick')}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`player-btn player-btn-sm${confirmingFull ? ' server-scan-btn--confirm' : ''}`}
|
||||
onClick={onFullClick}
|
||||
disabled={busy}
|
||||
data-tooltip={confirmingFull ? t('settings.scan.confirmFull') : t('settings.scan.fullTip')}
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label={t('settings.scan.full')}
|
||||
>
|
||||
<DatabaseZap size={13} />
|
||||
</button>
|
||||
<span className="server-scan-slot" aria-live="polite">{status}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Card variant — matches the existing `btn btn-surface` chrome in ServersTab actions.
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={onQuickClick}
|
||||
disabled={busy}
|
||||
data-tooltip={t('settings.scan.quickTip')}
|
||||
aria-label={t('settings.scan.quick')}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
<span className="server-card-btn-label">{t('settings.scan.quick')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-surface${confirmingFull ? ' btn-confirm-danger' : ''}`}
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={onFullClick}
|
||||
disabled={busy}
|
||||
data-tooltip={confirmingFull ? t('settings.scan.confirmFull') : t('settings.scan.fullTip')}
|
||||
aria-label={t('settings.scan.full')}
|
||||
>
|
||||
<DatabaseZap size={13} />
|
||||
<span className="server-card-btn-label">
|
||||
{confirmingFull ? t('settings.scan.confirmFullShort') : t('settings.scan.full')}
|
||||
</span>
|
||||
</button>
|
||||
{status && <span className="server-scan-slot">{status}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
|
||||
import { switchActiveServer } from '../../utils/server/switchActiveServer';
|
||||
import { AddServerForm } from './AddServerForm';
|
||||
import { ServerGripHandle } from './ServerGripHandle';
|
||||
import ServerScanActions from './ServerScanActions';
|
||||
|
||||
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
|
||||
|
||||
@@ -277,7 +276,6 @@ export function ServersTab({
|
||||
{status === 'ok' && <CheckCircle2 size={16} style={{ color: 'var(--positive)' }} />}
|
||||
{status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />}
|
||||
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />}
|
||||
<ServerScanActions serverId={srv.id} variant="card" />
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { getScanStatus } from '../api/subsonicScan';
|
||||
import { useScanStore } from '../store/scanStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import i18n from '../i18n';
|
||||
|
||||
const POLL_MS = 2000;
|
||||
const JUST_FINISHED_HOLD_MS = 2500;
|
||||
|
||||
/**
|
||||
* Polls `getScanStatus` on each server whose scan was triggered from the app.
|
||||
* Lives at the app root so dismissing the dropdown doesn't stop the poll.
|
||||
*/
|
||||
export function useScanPolling() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const inFlight = new Set<string>();
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
const { byServer, set } = useScanStore.getState();
|
||||
const runningIds = Object.entries(byServer)
|
||||
.filter(([, e]) => e.phase === 'starting' || e.phase === 'running')
|
||||
.map(([id]) => id);
|
||||
|
||||
await Promise.all(runningIds.map(async serverId => {
|
||||
if (inFlight.has(serverId)) return;
|
||||
inFlight.add(serverId);
|
||||
try {
|
||||
const status = await getScanStatus(serverId);
|
||||
if (cancelled) return;
|
||||
const current = useScanStore.getState().byServer[serverId];
|
||||
if (!current) return;
|
||||
|
||||
if (status.scanning) {
|
||||
set(serverId, { phase: 'running', count: status.count });
|
||||
} else {
|
||||
// Scan finished — show check briefly, then auto-clear.
|
||||
set(serverId, { phase: 'just-finished', count: status.count });
|
||||
const servers = useAuthStore.getState().servers;
|
||||
const srv = servers.find(s => s.id === serverId);
|
||||
const name = srv ? serverListDisplayLabel(srv, servers) : serverId;
|
||||
showToast(i18n.t('settings.scan.toast', { name, count: status.count }), 4000, 'success');
|
||||
setTimeout(() => {
|
||||
const after = useScanStore.getState().byServer[serverId];
|
||||
if (after?.phase === 'just-finished') {
|
||||
useScanStore.getState().clear(serverId);
|
||||
}
|
||||
}, JUST_FINISHED_HOLD_MS);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) set(serverId, { phase: 'error' });
|
||||
} finally {
|
||||
inFlight.delete(serverId);
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const interval = setInterval(tick, POLL_MS);
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, []);
|
||||
}
|
||||
@@ -470,16 +470,4 @@ export const settings = {
|
||||
floatingPlayerBarSub: 'Player-Leiste über dem Inhalt schweben lassen',
|
||||
uiScaleTitle: 'Interface-Skalierung',
|
||||
uiScaleLabel: 'Zoom',
|
||||
scan: {
|
||||
quick: 'Quick-Scan',
|
||||
full: 'Full-Scan',
|
||||
quickTip: 'Quick-Scan — nur neue/geänderte Dateien indexieren',
|
||||
fullTip: 'Full-Scan — alles neu indexieren',
|
||||
confirmFull: 'Nochmal klicken zum Bestätigen',
|
||||
confirmFullShort: 'Full-Scan bestätigen',
|
||||
scanning: 'Scannt…',
|
||||
done: 'Scan fertig',
|
||||
error: 'Scan fehlgeschlagen',
|
||||
toast: '{{name}}: Scan fertig — {{count}} Tracks indexiert',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -473,16 +473,4 @@ export const settings = {
|
||||
floatingPlayerBarSub: 'Keep the player bar floating above content',
|
||||
uiScaleTitle: 'Interface Scale',
|
||||
uiScaleLabel: 'Zoom',
|
||||
scan: {
|
||||
quick: 'Quick scan',
|
||||
full: 'Full scan',
|
||||
quickTip: 'Quick scan — index new/changed files',
|
||||
fullTip: 'Full scan — re-index everything',
|
||||
confirmFull: 'Click again to confirm full scan',
|
||||
confirmFullShort: 'Confirm full scan',
|
||||
scanning: 'Scanning…',
|
||||
done: 'Scan finished',
|
||||
error: 'Scan failed',
|
||||
toast: '{{name}}: scan finished — {{count}} tracks indexed',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -470,16 +470,4 @@ export const settings = {
|
||||
floatingPlayerBarSub: 'Mantener la barra del reproductor flotando sobre el contenido',
|
||||
uiScaleTitle: 'Escala de Interfaz',
|
||||
uiScaleLabel: 'Zoom',
|
||||
scan: {
|
||||
quick: 'Escaneo rápido',
|
||||
full: 'Escaneo completo',
|
||||
quickTip: 'Escaneo rápido — indexar archivos nuevos/modificados',
|
||||
fullTip: 'Escaneo completo — reindexar todo',
|
||||
confirmFull: 'Haz clic de nuevo para confirmar',
|
||||
confirmFullShort: 'Confirmar escaneo completo',
|
||||
scanning: 'Escaneando…',
|
||||
done: 'Escaneo finalizado',
|
||||
error: 'Error en el escaneo',
|
||||
toast: '{{name}}: escaneo finalizado — {{count}} pistas indexadas',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -468,16 +468,4 @@ export const settings = {
|
||||
floatingPlayerBarSub: 'Garder la barre du lecteur flottante au-dessus du contenu',
|
||||
uiScaleTitle: "Mise à l'échelle de l'interface",
|
||||
uiScaleLabel: 'Zoom',
|
||||
scan: {
|
||||
quick: 'Analyse rapide',
|
||||
full: 'Analyse complète',
|
||||
quickTip: 'Analyse rapide — indexer les fichiers nouveaux/modifiés',
|
||||
fullTip: 'Analyse complète — tout réindexer',
|
||||
confirmFull: 'Cliquer à nouveau pour confirmer',
|
||||
confirmFullShort: "Confirmer l'analyse complète",
|
||||
scanning: 'Analyse en cours…',
|
||||
done: 'Analyse terminée',
|
||||
error: "Échec de l'analyse",
|
||||
toast: '{{name}} : analyse terminée — {{count}} pistes indexées',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -467,16 +467,4 @@ export const settings = {
|
||||
floatingPlayerBarSub: 'Hold spillerlinjen flytende over innholdet',
|
||||
uiScaleTitle: 'Grensesnittskala',
|
||||
uiScaleLabel: 'Zoom',
|
||||
scan: {
|
||||
quick: 'Hurtigskanning',
|
||||
full: 'Full skanning',
|
||||
quickTip: 'Hurtigskanning — indekser nye/endrede filer',
|
||||
fullTip: 'Full skanning — reindekser alt',
|
||||
confirmFull: 'Klikk igjen for å bekrefte',
|
||||
confirmFullShort: 'Bekreft full skanning',
|
||||
scanning: 'Skanner…',
|
||||
done: 'Skanning ferdig',
|
||||
error: 'Skanning mislyktes',
|
||||
toast: '{{name}}: skanning ferdig — {{count}} spor indeksert',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -468,16 +468,4 @@ export const settings = {
|
||||
floatingPlayerBarSub: 'Houd de spelerbalk zwevend boven de inhoud',
|
||||
uiScaleTitle: 'Interface schaal',
|
||||
uiScaleLabel: 'Zoom',
|
||||
scan: {
|
||||
quick: 'Snel scannen',
|
||||
full: 'Volledige scan',
|
||||
quickTip: 'Snel scannen — nieuwe/gewijzigde bestanden indexeren',
|
||||
fullTip: 'Volledige scan — alles opnieuw indexeren',
|
||||
confirmFull: 'Klik nogmaals om te bevestigen',
|
||||
confirmFullShort: 'Volledige scan bevestigen',
|
||||
scanning: 'Scannen…',
|
||||
done: 'Scan voltooid',
|
||||
error: 'Scan mislukt',
|
||||
toast: '{{name}}: scan voltooid — {{count}} tracks geïndexeerd',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -473,16 +473,4 @@ export const settings = {
|
||||
floatingPlayerBarSub: 'Păstrează bara playerului plutind deasupra conținutului',
|
||||
uiScaleTitle: 'Scara interfaței',
|
||||
uiScaleLabel: 'Zoom',
|
||||
scan: {
|
||||
quick: 'Scanare rapidă',
|
||||
full: 'Scanare completă',
|
||||
quickTip: 'Scanare rapidă — indexează fișierele noi/modificate',
|
||||
fullTip: 'Scanare completă — reindexează totul',
|
||||
confirmFull: 'Apasă din nou pentru a confirma',
|
||||
confirmFullShort: 'Confirmă scanarea completă',
|
||||
scanning: 'Se scanează…',
|
||||
done: 'Scanare finalizată',
|
||||
error: 'Scanare eșuată',
|
||||
toast: '{{name}}: scanare finalizată — {{count}} piese indexate',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -487,16 +487,4 @@ export const settings = {
|
||||
floatingPlayerBarSub: 'Держать панель плеера плавающей над содержимым',
|
||||
uiScaleTitle: 'Масштаб интерфейса',
|
||||
uiScaleLabel: 'Масштаб',
|
||||
scan: {
|
||||
quick: 'Быстрое сканирование',
|
||||
full: 'Полное сканирование',
|
||||
quickTip: 'Быстрое сканирование — индексация новых/изменённых файлов',
|
||||
fullTip: 'Полное сканирование — переиндексировать всё',
|
||||
confirmFull: 'Нажмите ещё раз для подтверждения',
|
||||
confirmFullShort: 'Подтвердить полное сканирование',
|
||||
scanning: 'Сканирование…',
|
||||
done: 'Сканирование завершено',
|
||||
error: 'Ошибка сканирования',
|
||||
toast: '{{name}}: сканирование завершено — проиндексировано треков: {{count}}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -467,16 +467,4 @@ export const settings = {
|
||||
floatingPlayerBarSub: '保持播放栏悬浮在内容上方',
|
||||
uiScaleTitle: '界面缩放',
|
||||
uiScaleLabel: '缩放',
|
||||
scan: {
|
||||
quick: '快速扫描',
|
||||
full: '完整扫描',
|
||||
quickTip: '快速扫描 — 仅索引新增/变更文件',
|
||||
fullTip: '完整扫描 — 重新索引全部',
|
||||
confirmFull: '再次点击确认',
|
||||
confirmFullShort: '确认完整扫描',
|
||||
scanning: '扫描中…',
|
||||
done: '扫描完成',
|
||||
error: '扫描失败',
|
||||
toast: '{{name}}: 扫描完成 — 已索引 {{count}} 首曲目',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type ScanPhase = 'idle' | 'starting' | 'running' | 'just-finished' | 'error';
|
||||
|
||||
export interface ScanEntry {
|
||||
phase: ScanPhase;
|
||||
type: 'quick' | 'full';
|
||||
count: number;
|
||||
/** Set while user is in "press again to confirm" state for a full scan. */
|
||||
confirmingFull: boolean;
|
||||
}
|
||||
|
||||
const EMPTY: ScanEntry = { phase: 'idle', type: 'quick', count: 0, confirmingFull: false };
|
||||
|
||||
interface ScanState {
|
||||
byServer: Record<string, ScanEntry>;
|
||||
get: (serverId: string) => ScanEntry;
|
||||
set: (serverId: string, patch: Partial<ScanEntry>) => void;
|
||||
clear: (serverId: string) => void;
|
||||
}
|
||||
|
||||
export const useScanStore = create<ScanState>((set, getState) => ({
|
||||
byServer: {},
|
||||
get: (serverId) => getState().byServer[serverId] ?? EMPTY,
|
||||
set: (serverId, patch) => set(s => ({
|
||||
byServer: { ...s.byServer, [serverId]: { ...(s.byServer[serverId] ?? EMPTY), ...patch } },
|
||||
})),
|
||||
clear: (serverId) => set(s => {
|
||||
const next = { ...s.byServer };
|
||||
delete next[serverId];
|
||||
return { byServer: next };
|
||||
}),
|
||||
}));
|
||||
@@ -83,49 +83,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─ Server scan actions (used in server switcher + settings card) ─ */
|
||||
.server-scan-actions--compact {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.server-scan-btn--confirm {
|
||||
background: var(--danger, #dc3c3c) !important;
|
||||
color: #fff !important;
|
||||
animation: server-scan-confirm-pulse 0.7s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes server-scan-confirm-pulse {
|
||||
from { filter: brightness(1); }
|
||||
to { filter: brightness(1.25); }
|
||||
}
|
||||
|
||||
.server-scan-slot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.server-scan-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.server-scan-status--done { color: var(--positive, #4ade80); }
|
||||
.server-scan-status--error { color: var(--danger, #dc3c3c); }
|
||||
|
||||
.server-scan-status-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Server card actions collapse to icon-only on narrow viewports — labels
|
||||
* remain available as native tooltips via the existing `data-tooltip` /
|
||||
* `aria-label` attributes. Breakpoint is tuned to where the action row
|
||||
|
||||
Reference in New Issue
Block a user