feat(settings): compact server cards with capability badges (#1054)

* feat(settings): redesign server cards with identity line and capability badges

Compact two-line server headers (entry name + user@host), HTTPS lock, and a
clickable version info tooltip. Navidrome ≥0.62 shows a green AudioMuse inline
badge; older Navidrome keeps the manual toggle row. Adds click-pinned tooltips
via data-tooltip-click on TooltipPortal.

* feat(settings): unify use/active slot and move delete into edit form

Merge Active badge and Use button into one rightmost action; Active uses
green styling. Reorder actions to edit, test, use/active. Remove the card
delete icon — deletion lives in the server edit form footer.

* docs: note compact server cards in CHANGELOG and credits (PR #1054)

* fix(credits): move PR #1054 server cards line to cucadmuh block

Was appended to Psychotoxical's contributions array by mistake; CHANGELOG
already credited cucadmuh (gh pr view author).
This commit is contained in:
cucadmuh
2026-06-10 00:25:30 +03:00
committed by GitHub
parent 36a0615dcb
commit ae9be74719
23 changed files with 374 additions and 117 deletions
+61
View File
@@ -66,4 +66,65 @@ describe('TooltipPortal open delay', () => {
fireEvent.mouseDown(btn);
expect(screen.queryByText('Play this album')).toBeNull();
});
it('shows immediately on click when data-tooltip-click is set', () => {
renderWithProviders(
<>
<TooltipPortal />
<button data-tooltip="Server version" data-tooltip-click="">info</button>
</>,
);
const btn = screen.getByText('info');
fireEvent.click(btn);
expect(screen.getByText('Server version')).toBeInTheDocument();
});
it('does not hide click-mode tooltips on mousedown', () => {
renderWithProviders(
<>
<TooltipPortal />
<button data-tooltip="Server version" data-tooltip-click="">info</button>
</>,
);
const btn = screen.getByText('info');
fireEvent.click(btn);
expect(screen.getByText('Server version')).toBeInTheDocument();
fireEvent.mouseDown(btn);
expect(screen.getByText('Server version')).toBeInTheDocument();
});
it('toggles off when the click anchor is clicked again', () => {
renderWithProviders(
<>
<TooltipPortal />
<button data-tooltip="Server version" data-tooltip-click="">info</button>
</>,
);
const btn = screen.getByText('info');
fireEvent.click(btn);
expect(screen.getByText('Server version')).toBeInTheDocument();
fireEvent.click(btn);
expect(screen.queryByText('Server version')).toBeNull();
});
it('keeps click-opened tooltips visible after mouseout', () => {
renderWithProviders(
<>
<TooltipPortal />
<button data-tooltip="Server version" data-tooltip-click="">info</button>
</>,
);
const btn = screen.getByText('info');
fireEvent.click(btn);
expect(screen.getByText('Server version')).toBeInTheDocument();
fireEvent.mouseOut(btn, { relatedTarget: document.body });
expect(screen.getByText('Server version')).toBeInTheDocument();
});
});
+34 -2
View File
@@ -21,6 +21,8 @@ export default function TooltipPortal() {
const pendingTimerRef = useRef<number | null>(null);
// Anchor whose tooltip is currently visible.
const shownAnchorRef = useRef<HTMLElement | null>(null);
/** Click-opened tooltips stay until outside click / second click, not pointer leave. */
const clickPinnedRef = useRef(false);
useEffect(() => {
const clearPending = () => {
@@ -34,13 +36,15 @@ export default function TooltipPortal() {
const hide = () => {
clearPending();
shownAnchorRef.current = null;
clickPinnedRef.current = false;
setTooltip(null);
};
const showAnchor = (anchor: HTMLElement) => {
const showAnchor = (anchor: HTMLElement, opts?: { clickPinned?: boolean }) => {
const text = anchor.getAttribute('data-tooltip');
if (!text) return;
shownAnchorRef.current = anchor;
clickPinnedRef.current = !!opts?.clickPinned;
// Fresh rect: layout may have shifted during the open delay.
setTooltip({
text,
@@ -75,16 +79,40 @@ export default function TooltipPortal() {
// Moving within the same anchor (e.g. onto a child icon) must not cancel the delay.
const to = e.relatedTarget as Node | null;
if (to && anchor.contains(to)) return;
if (clickPinnedRef.current) return;
hide();
};
const onMove = (e: MouseEvent) => {
if (!pendingAnchorRef.current && !shownAnchorRef.current) return;
if (clickPinnedRef.current) return;
const anchor = (e.target as HTMLElement).closest('[data-tooltip]');
if (!anchor) hide();
};
/** Clicking a tooltip anchor (e.g. opening a dropdown) keeps the cursor inside the element, so mouseout never runs — hide immediately. */
const onDown = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('[data-tooltip]')) hide();
const anchor = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
if (anchor?.hasAttribute('data-tooltip-click')) return;
if (anchor) hide();
};
const onClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement).closest('[data-tooltip-click]') as HTMLElement | null;
if (!anchor?.getAttribute('data-tooltip')) return;
e.preventDefault();
clearPending();
if (shownAnchorRef.current === anchor && clickPinnedRef.current) {
hide();
return;
}
showAnchor(anchor, { clickPinned: true });
};
const onDocumentClick = (e: MouseEvent) => {
window.setTimeout(() => {
const shown = shownAnchorRef.current;
if (!shown?.hasAttribute('data-tooltip-click') || !clickPinnedRef.current) return;
const target = e.target as Node;
if (shown.contains(target)) return;
hide();
}, 0);
};
/** Wheel interactions (e.g. volume on overflow button) should suppress tooltip immediately. */
const onWheel = (e: WheelEvent) => {
@@ -94,6 +122,8 @@ export default function TooltipPortal() {
document.addEventListener('mouseout', onOut);
document.addEventListener('mousemove', onMove, { passive: true });
document.addEventListener('mousedown', onDown, true);
document.addEventListener('click', onClick, true);
document.addEventListener('click', onDocumentClick);
document.addEventListener('wheel', onWheel, { capture: true, passive: true });
return () => {
clearPending();
@@ -101,6 +131,8 @@ export default function TooltipPortal() {
document.removeEventListener('mouseout', onOut);
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mousedown', onDown, true);
document.removeEventListener('click', onClick, true);
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('wheel', onWheel, true);
};
}, []);
+16 -5
View File
@@ -41,11 +41,13 @@ function hostnameForDnsHint(rawUrl: string): string | null {
export function AddServerForm({
onSave,
onCancel,
onDelete,
initialInvite = null,
editingServer = null,
}: {
onSave: (data: Omit<ServerProfile, 'id'>) => void | Promise<void>;
onCancel: () => void;
onDelete?: () => void | Promise<void>;
initialInvite?: ServerMagicPayload | null;
editingServer?: ServerProfile | null;
}) {
@@ -349,11 +351,20 @@ export function AddServerForm({
/>
</div>
)}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-ghost" onClick={onCancel}>{t('common.cancel')}</button>
<button type="submit" className="btn btn-primary">
{isEdit ? t('common.save') : t('common.add')}
</button>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'space-between', alignItems: 'center' }}>
{isEdit && onDelete ? (
<button type="button" className="btn btn-danger" onClick={() => void onDelete()}>
{t('settings.deleteServer')}
</button>
) : (
<span />
)}
<div style={{ display: 'flex', gap: '8px' }}>
<button type="button" className="btn btn-ghost" onClick={onCancel}>{t('common.cancel')}</button>
<button type="submit" className="btn btn-primary">
{isEdit ? t('common.save') : t('common.add')}
</button>
</div>
</div>
</form>
);
@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next';
import { getCapabilityDefinition } from '../../serverCapabilities/catalog';
import { isFeatureActiveForServer, resolveFeatureForServer } from '../../serverCapabilities/storeView';
/** Inline badge for auto-managed server capabilities (Settings → Servers header row). */
export function ServerCapabilityHeaderBadge({
serverId,
feature,
}: {
serverId: string;
feature: string;
}) {
const { t } = useTranslation();
const resolved = resolveFeatureForServer(serverId, feature);
if (!resolved || resolved.activation !== 'auto') return null;
if (!isFeatureActiveForServer(serverId, feature)) return null;
const def = getCapabilityDefinition(feature);
const labelKey = def?.badgeLabelKey ?? def?.labelKey;
if (!labelKey) return null;
return (
<span className="settings-server-inline-badge settings-server-inline-badge--positive">{t(labelKey)}</span>
);
}
+111 -108
View File
@@ -2,9 +2,10 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { AlertTriangle, CheckCircle2, Globe, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react';
import { AlertTriangle, CheckCircle2, Info, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Wifi, WifiOff } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { formatServerSoftware } from '../../utils/server/subsonicServerIdentity';
import { formatServerSoftware, isNavidromeAudiomuseSoftwareEligible, type InstantMixProbeResult, type SubsonicServerIdentity } from '../../utils/server/subsonicServerIdentity';
import { buildCapabilityContext } from '../../serverCapabilities/context';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { libraryDeleteServerData, librarySyncClearSession } from '../../api/library';
import { bootstrapIndexedServer } from '../../utils/library/librarySession';
@@ -25,36 +26,37 @@ import {
} from '../../utils/server/serverUrlRemigration';
import { useConfirmModalStore } from '../../store/confirmModalStore';
import { showToast } from '../../utils/ui/toast';
import PerfProbeStatusBadge, { type PerfProbeBadgeTone } from '../sidebar/perfProbe/PerfProbeStatusBadge';
import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS } from '../../serverCapabilities/catalog';
import { resolveFeatureForServer } from '../../serverCapabilities/storeView';
import type { CapabilityStatus, ResolvedCapability } from '../../serverCapabilities/types';
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import { isFeatureActiveForServer, resolveFeatureForServer } from '../../serverCapabilities/storeView';
import type { ResolvedCapability } from '../../serverCapabilities/types';
import { serverIdentityLabel, serverListDisplayLabel, serverSettingsEntryTitle } from '../../utils/server/serverDisplayName';
import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
import { switchActiveServer } from '../../utils/server/switchActiveServer';
import { AddServerForm } from './AddServerForm';
import { ServerCapabilityHeaderBadge } from './ServerCapabilityHeaderBadge';
import { ServerGripHandle } from './ServerGripHandle';
import { tooltipAttrs } from '../tooltipAttrs';
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
function audiomuseProbeBadge(
status: CapabilityStatus,
t: (key: string) => string,
): { tone: PerfProbeBadgeTone; label: string } {
switch (status) {
case 'present': return { tone: 'ok', label: t('settings.audiomuseStatusActive') };
case 'absent': return { tone: 'muted', label: t('settings.audiomuseStatusNotDetected') };
case 'error': return { tone: 'error', label: t('settings.audiomuseStatusProbeFailed') };
default: return { tone: 'warn', label: t('settings.audiomuseStatusChecking') };
}
}
/** Row visibility: hide only when a manual (legacy) strategy proves the feature absent. */
/** Row visibility: same as main — hide only when manual strategy proves the feature absent. */
function showAudiomuseRow(resolved: ResolvedCapability | null): boolean {
if (!resolved || resolved.strategyId === null || resolved.status === 'ineligible') return false;
return !(resolved.activation === 'manual' && resolved.status === 'absent');
}
/** Legacy Navidrome (< 0.62): manual toggle row below the card (not the auto header badge). */
function showLegacyAudiomuseToggleRow(
identity: SubsonicServerIdentity | undefined,
instantMixProbe: InstantMixProbeResult | undefined,
resolved: ResolvedCapability | null,
): boolean {
const ctx = buildCapabilityContext(identity);
if (ctx.isNavidrome && ctx.semverGte([0, 62, 0])) return false;
if (showAudiomuseRow(resolved)) return true;
return isNavidromeAudiomuseSoftwareEligible(identity) && instantMixProbe !== 'empty';
}
type ServerDropTarget = { idx: number; before: boolean } | null;
export function ServersTab({
@@ -388,6 +390,10 @@ export function ServersTab({
editingServer={srv}
onSave={(data) => handleEditServer(srv.id, data)}
onCancel={() => setEditingServerId(null)}
onDelete={async () => {
await deleteServer(srv);
setEditingServerId(null);
}}
/>
);
}
@@ -396,6 +402,15 @@ export function ServersTab({
const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before;
const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before;
const serverSoftware = formatServerSoftware(auth.subsonicServerIdentityByServer[srv.id]);
const serverIdentity = auth.subsonicServerIdentityByServer[srv.id];
const resolvedAudiomuse = resolveFeatureForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS);
const versionTooltip = serverSoftware ?? t('settings.serverVersionUnknown');
const audiomuseActive = isFeatureActiveForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS);
const showLegacyAudiomuseToggle = showLegacyAudiomuseToggleRow(
serverIdentity,
auth.instantMixProbeByServer[srv.id],
resolvedAudiomuse,
);
return (
<div
key={srv.id}
@@ -420,64 +435,41 @@ export function ServersTab({
<ServerGripHandle idx={srvIdx} label={serverListDisplayLabel(srv, auth.servers)} />
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px' }}>
<span style={{ fontWeight: 600 }}>{serverListDisplayLabel(srv, auth.servers)}</span>
{isActive && (
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '1px 6px', borderRadius: 'var(--radius-sm)', fontWeight: 600 }}>
{t('settings.serverActive')}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600 }}>{serverSettingsEntryTitle(srv)}</span>
<ServerCapabilityHeaderBadge
serverId={srv.id}
feature={FEATURE_AUDIOMUSE_SIMILAR_TRACKS}
/>
{resolvedAudiomuse?.activation === 'auto' && audiomuseActive && auth.audiomuseNavidromeIssueByServer[srv.id] && (
<AlertTriangle
size={16}
style={{ color: 'var(--warning, #f59e0b)', flexShrink: 0 }}
data-tooltip={t('settings.audiomuseIssueHint')}
aria-label={t('settings.audiomuseIssueHint')}
/>
)}
</div>
{serverSoftware && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginBottom: '2px', overflow: 'hidden' }}>
<Server size={10} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverSoftware}</span>
</div>
)}
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
{srv.url.startsWith('https://') ? (
<Lock size={10} style={{ color: 'var(--positive)', flexShrink: 0 }} />
) : (
<Globe size={10} style={{ flexShrink: 0 }} />
{srv.url.startsWith('https://') && (
<Lock size={10} style={{ color: 'var(--positive)', flexShrink: 0 }} aria-hidden />
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{srv.url.replace(/^https?:\/\//, '')}
{serverIdentityLabel(srv)}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginTop: 1 }}>
<User size={10} />
{srv.username}
<button
type="button"
className="btn btn-ghost settings-server-version-info-btn"
{...tooltipAttrs(versionTooltip, { click: true })}
>
<Info size={12} aria-hidden />
</button>
</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0, alignItems: 'center', marginLeft: 'auto' }}>
{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 }} />}
<button
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => testConnection(srv)}
disabled={status === 'testing'}
data-tooltip={t('settings.testBtn')}
aria-label={t('settings.testBtn')}
>
<Wifi size={13} />
<span className="server-card-btn-label">{t('settings.testBtn')}</span>
</button>
{!isActive && (
<button
className="btn btn-primary"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => switchToServer(srv)}
disabled={status === 'testing'}
id={`settings-use-server-${srv.id}`}
data-tooltip={t('settings.useServer')}
aria-label={t('settings.useServer')}
>
<Power size={13} />
<span className="server-card-btn-label">{t('settings.useServer')}</span>
</button>
)}
<button
className="btn btn-ghost"
style={{ padding: '4px 8px' }}
@@ -492,14 +484,34 @@ export function ServersTab({
<Pencil size={14} />
</button>
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)', padding: '4px 8px' }}
onClick={() => void deleteServer(srv)}
data-tooltip={t('settings.deleteServer')}
id={`settings-delete-server-${srv.id}`}
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => testConnection(srv)}
disabled={status === 'testing'}
data-tooltip={t('settings.testBtn')}
aria-label={t('settings.testBtn')}
>
<Trash2 size={14} />
<Wifi size={13} />
<span className="server-card-btn-label">{t('settings.testBtn')}</span>
</button>
{isActive ? (
<span className="settings-server-inline-badge settings-server-inline-badge--positive settings-server-use-active-slot">
{t('settings.serverActive')}
</span>
) : (
<button
className="btn btn-primary settings-server-use-active-slot"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => switchToServer(srv)}
disabled={status === 'testing'}
id={`settings-use-server-${srv.id}`}
data-tooltip={t('settings.useServer')}
aria-label={t('settings.useServer')}
>
<Power size={13} />
<span className="server-card-btn-label">{t('settings.useServer')}</span>
</button>
)}
</div>
</div>
</div>
@@ -515,11 +527,8 @@ export function ServersTab({
onCancel={() => void librarySync.handleCancel()}
/>
{(() => {
const resolved = resolveFeatureForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS);
if (!showAudiomuseRow(resolved) || !resolved) return null;
const autoManaged = resolved.activation === 'auto';
const probeBadge = audiomuseProbeBadge(resolved.status, t);
const audiomuseActive = !!auth.audiomuseNavidromeByServer[srv.id];
if (!showLegacyAudiomuseToggle) return null;
const audiomuseManualActive = !!auth.audiomuseNavidromeByServer[srv.id];
return (
<div
className="settings-toggle-row"
@@ -531,7 +540,7 @@ export function ServersTab({
<div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{t('settings.audiomuseTitle')}
{audiomuseActive && auth.audiomuseNavidromeIssueByServer[srv.id] && (
{audiomuseManualActive && auth.audiomuseNavidromeIssueByServer[srv.id] && (
<AlertTriangle
size={16}
style={{ color: 'var(--warning, #f59e0b)', flexShrink: 0 }}
@@ -540,39 +549,33 @@ export function ServersTab({
/>
)}
</div>
{!autoManaged && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>
<Trans
i18nKey="settings.audiomuseDesc"
components={{
pluginLink: (
<a
href={AUDIOMUSE_NV_PLUGIN_URL}
onClick={e => {
e.preventDefault();
void openUrl(AUDIOMUSE_NV_PLUGIN_URL);
}}
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
/>
),
}}
/>
</div>
)}
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>
<Trans
i18nKey="settings.audiomuseDesc"
components={{
pluginLink: (
<a
href={AUDIOMUSE_NV_PLUGIN_URL}
onClick={e => {
e.preventDefault();
void openUrl(AUDIOMUSE_NV_PLUGIN_URL);
}}
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
/>
),
}}
/>
</div>
</div>
</div>
{autoManaged ? (
<PerfProbeStatusBadge tone={probeBadge.tone}>{probeBadge.label}</PerfProbeStatusBadge>
) : (
<label className="toggle-switch" aria-label={t('settings.audiomuseTitle')}>
<input
type="checkbox"
checked={audiomuseActive}
onChange={e => auth.setAudiomuseNavidromeEnabled(srv.id, e.target.checked)}
/>
<span className="toggle-track" />
</label>
)}
<label className="toggle-switch" aria-label={t('settings.audiomuseTitle')}>
<input
type="checkbox"
checked={audiomuseManualActive}
onChange={e => auth.setAudiomuseNavidromeEnabled(srv.id, e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
);
})()}
+5
View File
@@ -18,4 +18,9 @@ describe('tooltipAttrs', () => {
expect('data-tooltip-wrap' in tooltipAttrs('x', { wrap: true })).toBe(true);
expect('data-tooltip-wrap' in tooltipAttrs('x')).toBe(false);
});
it('adds the click marker only when requested', () => {
expect('data-tooltip-click' in tooltipAttrs('x', { click: true })).toBe(true);
expect('data-tooltip-click' in tooltipAttrs('x')).toBe(false);
});
});
+3 -1
View File
@@ -10,15 +10,17 @@
* `pos: 'bottom'` is a viewport-edge escape hatch only (forces the tooltip
* below the anchor instead of the default auto-flip) — do not use it on
* ordinary toolbar buttons. `wrap` enables multi-line wrapping.
* `click` shows the tooltip immediately on click (for touch / explicit open).
*/
export function tooltipAttrs(
text: string,
opts?: { pos?: 'bottom'; wrap?: boolean },
opts?: { pos?: 'bottom'; wrap?: boolean; click?: boolean },
): Record<string, string> {
return {
'data-tooltip': text,
'aria-label': text,
...(opts?.pos ? { 'data-tooltip-pos': opts.pos } : {}),
...(opts?.wrap ? { 'data-tooltip-wrap': '' } : {}),
...(opts?.click ? { 'data-tooltip-click': '' } : {}),
};
}