mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -101,6 +101,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Changed
|
||||
|
||||
### Settings → Servers — compact server cards
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1054](https://github.com/Psychotoxical/psysonic/pull/1054)**
|
||||
|
||||
* Two-line header: custom entry name plus `user@host`, HTTPS lock, and a clickable version info tooltip (hover or tap).
|
||||
* Navidrome **0.62+**: green **AudioMuse-AI** inline badge when the plugin is detected; older Navidrome keeps the manual toggle row below the card.
|
||||
* **Use** and **Active** share one rightmost slot (green badge vs primary button); card actions are edit → test → use/active. Delete lives in the edit form footer.
|
||||
* **TooltipPortal**: `data-tooltip-click` for click-pinned tooltips (touch and explicit open without the 1s hover delay).
|
||||
|
||||
|
||||
|
||||
### Dependencies — npm and Rust refresh
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#997](https://github.com/Psychotoxical/psysonic/pull/997)**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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': '' } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Artist page: Top Tracks play when album list is empty on the page (PR #1031)',
|
||||
'PsyLab Connections tab with server capability readout, Navidrome admin-role probe, and tab-bar layout fix; server-capability framework with AudioMuse detection via OpenSubsonic sonicSimilarity and sonic Instant Mix routing on Navidrome ≥0.62 (PR #1033)',
|
||||
'Library DB: named slow-write op labels for macOS playback-stall diagnosis (PR #1043)',
|
||||
'Settings → Servers: compact two-line cards, capability header badges, unified use/active action, delete in edit form, click-pinned version tooltip (PR #1054)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -47,6 +47,7 @@ export const settings = {
|
||||
deleteServer: 'Löschen',
|
||||
noServers: 'Keine Server gespeichert.',
|
||||
serverActive: 'Aktiv',
|
||||
serverVersionUnknown: 'Serverversion unbekannt',
|
||||
confirmDeleteServer: 'Server „{{name}}" löschen?',
|
||||
confirmDeleteServerLibrary: 'Auch den lokalen Bibliotheksindex dieses Servers löschen? Auf Abbrechen klicken, um die zwischengespeicherte Kopie für die Offline-Nutzung zu behalten.',
|
||||
serverConnecting: 'Verbinde…',
|
||||
@@ -106,6 +107,7 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Subsonic-Passwort für „{{username}}" eingeben — es wird in den kopierten Magic-String übernommen.',
|
||||
userMgmtMagicStringModalConfirm: 'String kopieren',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseBadge: 'AudioMuse-AI',
|
||||
audiomuseStatusActive: 'Aktiv',
|
||||
audiomuseStatusChecking: 'Wird geprüft…',
|
||||
audiomuseStatusNotDetected: 'Nicht erkannt',
|
||||
|
||||
@@ -47,6 +47,7 @@ export const settings = {
|
||||
deleteServer: 'Delete',
|
||||
noServers: 'No servers saved.',
|
||||
serverActive: 'Active',
|
||||
serverVersionUnknown: 'Server version unknown',
|
||||
confirmDeleteServer: 'Delete server "{{name}}"?',
|
||||
confirmDeleteServerLibrary: 'Also delete this server\'s local library index? Click Cancel to keep the cached copy for offline use.',
|
||||
serverConnecting: 'Connecting…',
|
||||
@@ -106,6 +107,7 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Enter the Subsonic password for "{{username}}". It is included in the copied magic string.',
|
||||
userMgmtMagicStringModalConfirm: 'Copy string',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseBadge: 'AudioMuse-AI',
|
||||
audiomuseDesc:
|
||||
'Turn on if this server has the <pluginLink>AudioMuse-AI Navidrome plugin</pluginLink> configured. Enables Instant Mix from tracks and uses server-side similar artists instead of Last.fm on artist pages.',
|
||||
audiomuseStatusActive: 'Active',
|
||||
|
||||
@@ -47,6 +47,7 @@ export const settings = {
|
||||
deleteServer: 'Eliminar',
|
||||
noServers: 'No hay servidores guardados.',
|
||||
serverActive: 'Activo',
|
||||
serverVersionUnknown: 'Versión del servidor desconocida',
|
||||
confirmDeleteServer: '¿Eliminar servidor "{{name}}"?',
|
||||
serverConnecting: 'Conectando…',
|
||||
serverConnected: '¡Conectado!',
|
||||
@@ -105,6 +106,7 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Introduce la contraseña Subsonic de «{{username}}». Se incluirá en la cadena mágica copiada.',
|
||||
userMgmtMagicStringModalConfirm: 'Copiar cadena',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseBadge: 'AudioMuse-AI',
|
||||
audiomuseStatusActive: 'Activo',
|
||||
audiomuseStatusChecking: 'Comprobando…',
|
||||
audiomuseStatusNotDetected: 'No detectado',
|
||||
|
||||
@@ -47,6 +47,7 @@ export const settings = {
|
||||
deleteServer: 'Supprimer',
|
||||
noServers: 'Aucun serveur enregistré.',
|
||||
serverActive: 'Actif',
|
||||
serverVersionUnknown: 'Version du serveur inconnue',
|
||||
confirmDeleteServer: 'Supprimer le serveur « {{name}} » ?',
|
||||
serverConnecting: 'Connexion…',
|
||||
serverConnected: 'Connecté !',
|
||||
@@ -105,6 +106,7 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Saisissez le mot de passe Subsonic de « {{username}} ». Il est inclus dans la chaîne magique copiée.',
|
||||
userMgmtMagicStringModalConfirm: 'Copier la chaîne',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseBadge: 'AudioMuse-AI',
|
||||
audiomuseStatusActive: 'Actif',
|
||||
audiomuseStatusChecking: 'Vérification…',
|
||||
audiomuseStatusNotDetected: 'Non détecté',
|
||||
|
||||
@@ -47,6 +47,7 @@ export const settings = {
|
||||
deleteServer: 'Slett',
|
||||
noServers: 'Ingen tjenere lagret.',
|
||||
serverActive: 'Aktiv',
|
||||
serverVersionUnknown: 'Serverversjon ukjent',
|
||||
confirmDeleteServer: 'Slett tjener "{{name}}"?',
|
||||
serverConnecting: 'Kobler til…',
|
||||
serverConnected: 'Tilkoblet!',
|
||||
@@ -105,6 +106,7 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Skriv inn Subsonic-passordet for «{{username}}». Det tas med i den kopierte magic string-en.',
|
||||
userMgmtMagicStringModalConfirm: 'Kopier streng',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseBadge: 'AudioMuse-AI',
|
||||
audiomuseStatusActive: 'Aktiv',
|
||||
audiomuseStatusChecking: 'Sjekker…',
|
||||
audiomuseStatusNotDetected: 'Ikke oppdaget',
|
||||
|
||||
@@ -47,6 +47,7 @@ export const settings = {
|
||||
deleteServer: 'Verwijderen',
|
||||
noServers: 'Geen servers opgeslagen.',
|
||||
serverActive: 'Actief',
|
||||
serverVersionUnknown: 'Serverversie onbekend',
|
||||
confirmDeleteServer: 'Server "{{name}}" verwijderen?',
|
||||
serverConnecting: 'Verbinden…',
|
||||
serverConnected: 'Verbonden!',
|
||||
@@ -105,6 +106,7 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Voer het Subsonic-wachtwoord voor „{{username}}" in. Het wordt opgenomen in de gekopieerde magic string.',
|
||||
userMgmtMagicStringModalConfirm: 'String kopiëren',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseBadge: 'AudioMuse-AI',
|
||||
audiomuseStatusActive: 'Actief',
|
||||
audiomuseStatusChecking: 'Controleren…',
|
||||
audiomuseStatusNotDetected: 'Niet gedetecteerd',
|
||||
|
||||
@@ -47,6 +47,7 @@ export const settings = {
|
||||
deleteServer: 'Șterge',
|
||||
noServers: 'Niciun server salvat.',
|
||||
serverActive: 'Activ',
|
||||
serverVersionUnknown: 'Versiunea serverului necunoscută',
|
||||
confirmDeleteServer: 'Șterge server "{{name}}"?',
|
||||
serverConnecting: 'Se conectează…',
|
||||
serverConnected: 'Conectat!',
|
||||
@@ -105,6 +106,7 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Introdu parola Subsonic pentru "{{username}}". Este inclusă în string-ul magic copiat.',
|
||||
userMgmtMagicStringModalConfirm: 'Copiază string-ul',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseBadge: 'AudioMuse-AI',
|
||||
audiomuseStatusActive: 'Activ',
|
||||
audiomuseStatusChecking: 'Se verifică…',
|
||||
audiomuseStatusNotDetected: 'Nedetectat',
|
||||
|
||||
@@ -47,6 +47,7 @@ export const settings = {
|
||||
deleteServer: 'Удалить',
|
||||
noServers: 'Нет сохранённых серверов.',
|
||||
serverActive: 'Активен',
|
||||
serverVersionUnknown: 'Версия сервера неизвестна',
|
||||
confirmDeleteServer: 'Удалить сервер «{{name}}»?',
|
||||
serverConnecting: 'Подключение…',
|
||||
serverConnected: 'Подключено!',
|
||||
@@ -105,6 +106,7 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: 'Введите пароль Subsonic для «{{username}}» — он попадёт в копируемую magic string.',
|
||||
userMgmtMagicStringModalConfirm: 'Скопировать строку',
|
||||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||||
audiomuseBadge: 'AudioMuse-AI',
|
||||
audiomuseStatusActive: 'Активно',
|
||||
audiomuseStatusChecking: 'Проверка…',
|
||||
audiomuseStatusNotDetected: 'Не обнаружено',
|
||||
|
||||
@@ -47,6 +47,7 @@ export const settings = {
|
||||
deleteServer: '删除',
|
||||
noServers: '未保存任何服务器。',
|
||||
serverActive: '当前使用',
|
||||
serverVersionUnknown: '服务器版本未知',
|
||||
confirmDeleteServer: '删除服务器 "{{name}}"?',
|
||||
serverConnecting: '正在连接…',
|
||||
serverConnected: '已连接!',
|
||||
@@ -105,6 +106,7 @@ export const settings = {
|
||||
userMgmtMagicStringModalDesc: '请输入用户「{{username}}」的 Subsonic 密码,它会包含在复制的魔法字符串中。',
|
||||
userMgmtMagicStringModalConfirm: '复制字符串',
|
||||
audiomuseTitle: 'AudioMuse-AI(Navidrome)',
|
||||
audiomuseBadge: 'AudioMuse-AI',
|
||||
audiomuseStatusActive: '已启用',
|
||||
audiomuseStatusChecking: '检测中…',
|
||||
audiomuseStatusNotDetected: '未检测到',
|
||||
|
||||
@@ -26,6 +26,7 @@ export const SERVER_CAPABILITY_CATALOG: CapabilityDefinition[] = [
|
||||
{
|
||||
feature: FEATURE_AUDIOMUSE_SIMILAR_TRACKS,
|
||||
labelKey: 'settings.audiomuseTitle',
|
||||
badgeLabelKey: 'settings.audiomuseBadge',
|
||||
strategies: [
|
||||
{
|
||||
// Navidrome ≥ 0.62: AudioMuse plugin advertised via OpenSubsonic.
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface CapabilityStrategy {
|
||||
export interface CapabilityDefinition {
|
||||
feature: string;
|
||||
labelKey: string;
|
||||
/** Shorter label for inline header badges in Settings → Servers. */
|
||||
badgeLabelKey?: string;
|
||||
strategies: CapabilityStrategy[];
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,55 @@
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
|
||||
.settings-server-inline-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-server-inline-badge--positive {
|
||||
background: var(--positive);
|
||||
color: var(--text-on-accent, #111);
|
||||
}
|
||||
|
||||
.settings-server-version-info-btn {
|
||||
padding: 2px;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
height: auto;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
cursor: help;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-server-use-active-slot {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
min-height: 28px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button.settings-server-use-active-slot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
span.settings-server-use-active-slot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── In-Page-Suche fuer Settings ─────────────────────────────────────────── */
|
||||
.settings-header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { serverListDisplayLabel, shortHostFromServerUrl } from './serverDisplayName';
|
||||
import { serverIdentityLabel, serverListDisplayLabel, serverSettingsEntryTitle, shortHostFromServerUrl } from './serverDisplayName';
|
||||
|
||||
function srv(p: Partial<ServerProfile> & Pick<ServerProfile, 'id'>): ServerProfile {
|
||||
return {
|
||||
@@ -21,6 +21,28 @@ describe('shortHostFromServerUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('serverIdentityLabel', () => {
|
||||
it('formats username@host', () => {
|
||||
const a = srv({ id: '1', url: 'https://music.shstk.ru', username: 'cucadmuh', password: 'p', name: 'Home' });
|
||||
expect(serverIdentityLabel(a)).toBe('cucadmuh@music.shstk.ru');
|
||||
});
|
||||
it('keeps port in host', () => {
|
||||
const a = srv({ id: '1', url: 'http://127.0.0.1:4533', username: 'admin', password: 'p' });
|
||||
expect(serverIdentityLabel(a)).toBe('admin@127.0.0.1:4533');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serverSettingsEntryTitle', () => {
|
||||
it('prefers custom entry name', () => {
|
||||
const a = srv({ id: '1', url: 'https://music.shstk.ru', username: 'u', password: 'p', name: 'Home NAS' });
|
||||
expect(serverSettingsEntryTitle(a)).toBe('Home NAS');
|
||||
});
|
||||
it('falls back to short host when name empty', () => {
|
||||
const a = srv({ id: '1', url: 'https://music.shstk.ru', username: 'u', password: 'p', name: '' });
|
||||
expect(serverSettingsEntryTitle(a)).toBe('music.shstk.ru');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serverListDisplayLabel', () => {
|
||||
it('uses short host when name empty', () => {
|
||||
const a = srv({ id: '1', url: 'https://a.com', username: 'u', password: 'p', name: '' });
|
||||
|
||||
@@ -15,6 +15,20 @@ export function shortHostFromServerUrl(urlRaw?: string | null): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings server card primary line: `username@host`. */
|
||||
export function serverIdentityLabel(server: ServerProfile): string {
|
||||
const shortHost = shortHostFromServerUrl(server.url);
|
||||
const host = shortHost || (typeof server.url === 'string' ? server.url.trim() : '');
|
||||
return `${server.username}@${host}`;
|
||||
}
|
||||
|
||||
/** Settings server card title: custom entry name, or short host when unset. */
|
||||
export function serverSettingsEntryTitle(server: ServerProfile): string {
|
||||
const nameTrim = (server.name || '').trim();
|
||||
if (nameTrim) return nameTrim;
|
||||
return shortHostFromServerUrl(server.url) || (typeof server.url === 'string' ? server.url.trim() : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for server lists and chrome: if several servers share the same effective name,
|
||||
* show `username@host` so entries stay distinguishable.
|
||||
|
||||
Reference in New Issue
Block a user