mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
f9df918c72
* feat(themes): add semantic tokens for the theme-store contract (B0 P1) Additive: define --highlight, --accent-2, --bg-deep, --bg-elevated and --text-on-accent on the :root base as --ctp-* mappings. They resolve per-theme automatically and nothing consumes them yet (zero behaviour change) — groundwork for replacing direct --ctp-* use in components. * refactor(themes): components consume semantic tokens, not --ctp-* (B0 P2) Replace every direct --ctp-* reference in component/layout/track CSS and TSX inline styles with the readable semantic token (--bg-app, --accent, --highlight, --text-on-accent, …). --ctp-* now survives only as the Catppuccin palette layer the base maps from, and as the deliberate categorical rainbow in Composers/Genres/artistsHelpers (left untouched). This is the readable contract surface for the community theme store. Divergences (theme set a semantic var != its --ctp- source) are corrections — the element now uses the theme's real semantic colour. * feat(themes): add player-bar title/artist color tokens New optional --player-title / --player-artist, defaulting to --text-primary / --text-secondary so nothing changes unless a theme overrides them. Lets a theme give the now-playing readout its own colour as a plain token. * refactor(themes): token-only theme library (flatten, whitelist, one file per theme) Turn every built-in theme into a single self-contained [data-theme] var block of semantic whitelist tokens (plus the internal --ctp-* palette layer): - Flatten all themes: drop structural override rules, @keyframes, and global-token overrides (radius / shadow-elevation / transition / spacing / font / focus-ring). Signature player-bar readout colours are preserved via the new --player-title / --player-artist tokens. - Normalize the var blocks to the semantic whitelist: drop the alternate token vocabulary (nav-active / scrollbar / bg-input / success / border-default / ...); rename --success -> --positive and --border-default -> --border where no whitelist equivalent was set. Migrate the few components that read those tokens to the whitelist equivalents. - Split multi-theme files so each theme ships as its own file, making the built-in set 1:1 with the per-theme store packaging. Kept as-is (built-in, not flattened): the two colour-blind-safe accessibility themes, plus the two curated core skins. * chore(themes): remove seven themes retired after the token refactor These themes leaned on heavy structural overrides and were dropped rather than flattened. Full removal each: the CSS file(s), the index.css import, the Theme type union, and the ThemePicker entry. * feat(themes): granular tokens — track lists Wire track rows to per-region tokens: row hover (--row-hover), the now-playing row + indicator (--row-playing-bg / --row-playing-text), track title/artist/ number/duration text, column-header text, row dividers, and the resize-handle active colour. Covers the desktop tracklist, the shared song-row (Tracks hub / search), and the mobile tracklist. Drop a baked border fallback. Visual no-op. * feat(themes): granular tokens — cards Wire album and artist cards to per-region tokens (--card-hover-border, --card-title, --card-subtitle, --card-placeholder-bg). Visual no-op. * fix(themes): drop undefined/baked colour aliases Replace the undefined --bg-surface (resolved to nothing — broken placeholder backgrounds and filter input) with --card-placeholder-bg / --input-bg, and the baked-hex aliases --color-error / --color-warning with --danger / --warning so themes can actually recolour them. * feat(themes): Spectrum demo theme + trim unused cascade tokens Add a loud built-in demo theme that gives each region its own hue (sidebar green, player pink, lists cyan, cards gold, menus red, controls blue) so the per-region granularity is obvious when you switch to it. Drop two unused cascade tokens (--sidebar-text-active, --row-active-bg) and the unused on-media block (those media surfaces stay static by design). * style(themes): make Spectrum demo brutally loud Full-saturation neon per region (toxic green sidebar, magenta player, cyan lists, acid-yellow cards, blood-red menus, electric-blue controls, purple scrollbar) so the per-region separation is unmistakable. The earlier soft tints were too subtle to read. * feat(themes): name the granular demo theme Braindead * feat(themes): granular per-region tokens — cascade layer + sidebar Add an optional per-region token layer (semantic-cascade.css) so a theme can recolour individual regions — sidebar hover, player controls, list rows, menus, inputs, on-media surfaces — independently of the global tokens. Every token defaults to its base token (or a media-safe literal), so this is a visual no-op until a theme overrides one; it only adds control points. Wire the sidebar region as the first consumer and drop the baked grey fallbacks (--bg-tertiary, etc.) that no theme could reach. * feat(themes): granular tokens — controls, menus, scrollbar Wire inputs, buttons, sliders, the custom-select, context menus, submenus and modals to per-region tokens, and tokenise the scrollbar. Complete B0 by dropping the last direct --ctp-* references in the input/button/progress/ scrollbar utility CSS. Fix three undefined-token bugs that fell back to nothing (so no theme could reach them): --surface-2 (context-menu hover had no highlight), --bg-surface (submenu create-input had no background), and the baked grey fallbacks in the custom-select. Every other new token defaults to today's value — a visual no-op that only adds override points. * feat(themes): granular tokens — player bar Wire the desktop player bar's transport controls, time toggle, and overflow menu to per-region tokens (--player-control, --player-time-toggle-*, etc.). Fix the undefined --surface-hover/--surface-active grey fallbacks on the time toggle. Visual no-op; defaults match today's values. * chore(themes): remove empty theme stub files Six theme CSS files were reduced to comment-only stubs by the flatten sweep but their files and @import lines remained. Five are empty structural companions of now-flattened themes (morpheus, p-dvd, aero-glass, luna-teal) and two are orphans of cut themes (order-of-the-phoenix, pandora). Removed the files and their imports. * feat(themes): runtime injection foundation for the theme store Plumbing for installed community themes ahead of the in-app store UI, nothing user-visible yet: - installedThemesStore: persisted (localStorage) record of installed community themes incl. their CSS text, so an active community theme is available synchronously at startup (no flash, fully offline). - themeInjection: reconcile <head> <style data-installed-theme> elements with the store; lightweight defense-in-depth sanitize on top of CI. - themeRegistry: jsDelivr registry client with a 12h localStorage cache and stale-on-error fallback. - App: inject installed themes before applying data-theme, in both webviews. - themeStore: widen the Theme type to accept dynamic installed ids. * feat(themes): dedicated Themes settings tab Move theme selection and the day/night scheduler out of Appearance into a new dedicated Themes tab — the future home of the community Theme Store. Appearance keeps grid columns, visual options, UI scale, font and seekbar. - ThemesTab: theme picker + scheduler (relocated verbatim). - AppearanceTab: drop the two relocated sections + now-unused imports. - Register the tab in settingsTabs (Tab union, resolveTab, search index) and Settings (tab bar, render, label map). - i18n: settings.tabThemes in all 9 locales. * feat(themes): community Theme Store browse + install Add the Theme Store section to the Themes tab: - Fetch the jsDelivr registry (12h cache, stale-on-error fallback). - Search by name/author/description + filter by light/dark + refresh. - Per-row CDN thumbnail, name, author, description and actions: Install / Apply / Update / Uninstall. Installing fetches the CSS, persists it (localStorage) and the runtime injection applies it; uninstalling the active theme falls back to the matching core. - Rating slot left reserved (deferred). - i18n: themeStore* keys in all 9 locales. * feat(themes): slim bundle to fixed cores + flat Themes tab Remove the 86 store palettes (incl. braindead) from the app bundle — the CSS files, their index.css imports, the Theme union and the picker data — leaving only the six fixed cores (Catppuccin Mocha/Latte, Kanagawa Wave, Stark HUD, Vision Dark/Navy). Everything else installs from the store. Themes tab is rebuilt flat (no collapsible accordions): - "Your Themes": one card grid of the fixed cores + installed community themes; click to apply, uninstall on community ones (active theme falls back to the matching core). Catppuccin prefix on Mocha/Latte; a CVD-safe pill on the colour-blind-safe Vision themes. - Scheduler day/night options include installed themes. - Theme Store: alphabetical order, thumbnail lightbox, and a submit hint above the search linking to the themes repository. - Nav order: Servers, Library, Audio, Themes, Appearance, Lyrics, … - ThemePicker accordion removed; fixed-theme data moved to fixedThemes.ts. - i18n for all new strings across 9 locales. * feat(themes): reset removed-from-bundle themes to a bundled fallback After slimming the bundle to the six fixed core themes, a profile upgraded from an older build may have an active or scheduler theme that is now store-only and not installed — it has no [data-theme] block and would render as unstyled :root. Reset any theme/themeDay/themeNight that is neither bundled nor installed to a bundled fallback: Mocha for the main + night slots, Latte for the day slot. Runs synchronously in runPreReactBootstrap, rewriting the persisted selection in localStorage before React mounts (no flash; Zustand rehydrates after first paint). No auto-install and no network — the fallback is always a bundled theme, so it works offline. * feat(themes): floating back-to-top button on the Themes tab The Themes tab can get long (theme grid + scheduler + full store list), so add a floating back-to-top affordance that appears once the page is scrolled and smooth-scrolls to the top. It is portalled into the route host and positioned absolute against it — the main scroll viewport sets contain: paint, which would otherwise make position: fixed resolve against the scrolling box and drift with the content. Reusable component (scroll viewport id + threshold props); i18n common.backToTop added in all nine locales. * feat(themes): accessibility + state polish for the Theme Store - Reuse the shared CoverLightbox for the thumbnail preview instead of a second inline dialog — gains a visible close button and a focus-managed, portalled dialog, and drops duplicated markup. - Theme cards expose aria-pressed so assistive tech announces the selected theme, not just the visual check. - Transient store messages get live-region roles (loading/empty/install failure = status, fetch error = alert). - Thumbnails degrade gracefully when offline/missing (hide the broken-image glyph; the thumbnail button no longer stretches with the row, so its background can't show as letterbox bars). * feat(themes): larger store-row thumbnails (120x75 -> 200x125) The list previews were too small to make a theme out; bump the display size (same 1.6 aspect). Thumbnails are now served at 720x450, so the larger display stays crisp. * fix(themes): bust thumbnail cache on registry change jsDelivr serves theme thumbnails with a 7-day max-age, so when a thumbnail is updated the webview keeps showing its cached old image (the path is unchanged). Append the registry's generatedAt as a cache-busting query to the thumbnail URLs (list + lightbox); it changes on every themes push, so a registry refresh makes the webview re-fetch and reflect the current CDN image instead of a stale one. * docs(themes): changelog + credits for the Theme Store Add the 1.48.0 "Themes — community Theme Store" changelog entry (PR #1009) and the matching line in the Psychotoxical credits. * fix(themes): address PR review (uninstall hygiene, validation, polish) Uninstall/scheduler & validation: - uninstallTheme() repairs every selection slot (active + day + night), not just the manual one, and is shared by both uninstall buttons (dedup). - Validate theme CSS at install time and skip persisting CSS that won't inject (no more "installed/active but renders nothing" with no feedback). - Harden the runtime validator: exactly one rule, scoped exactly to the theme's [data-theme='<id>'] selector (no unscoped/foreign selectors), no at-rules, url() only data:, no expression()/javascript:, size-capped. Tokens & polish: - Fix three dangling undefined tokens (--surface-2 x2, --bg-surface). - Finish the warning/success token sweep (--warning / --positive, themeable). - Apply the active theme synchronously before React mounts (no first-frame flash) and inject installed themes up front. - One-time, dismissible notice when the slim-bundle migration reset a theme. - Update badge uses semver, not string inequality. - Offline/stale indicator in the store; cross-window theme sync; drop the now dead REMOVED_THEME_REMAP and Card.mode field. Tests: themeInjection (validator + sync), themeRegistry (cache/force/stale/ malformed), uninstallTheme (slot repair), migration notice. i18n in all nine locales. Full suite green (1755 tests). * test(bootstrap): cover startup theme apply + cross-window sync The review fixes added applyThemeAtStartup / installCrossWindowThemeSync to bootstrap.ts (a hot-path file) without tests, dropping its coverage to 68.3% and failing the frontend hot-path coverage gate (>=70%). Add unit tests for both (and the no-op / malformed-storage paths); bootstrap.ts is back to ~98%.
579 lines
26 KiB
TypeScript
579 lines
26 KiB
TypeScript
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, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
|
import { libraryDeleteServerData, librarySyncClearSession } from '../../api/library';
|
|
import { bootstrapIndexedServer } from '../../utils/library/librarySession';
|
|
import { useLibraryIndexSync } from '../../hooks/useLibraryIndexSync';
|
|
import ServerLibraryIndexControls from './ServerLibraryIndexControls';
|
|
import type { ServerProfile } from '../../store/authStoreTypes';
|
|
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
|
|
import { useDragDrop } from '../../contexts/DragDropContext';
|
|
import { type ServerMagicPayload } from '../../utils/server/serverMagicString';
|
|
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint';
|
|
import {
|
|
verifySameServerEndpoints,
|
|
type VerifySameServerResult,
|
|
} from '../../utils/server/serverFingerprint';
|
|
import {
|
|
indexKeyRemapForUrlChange,
|
|
runIndexKeyRemigration,
|
|
} from '../../utils/server/serverUrlRemigration';
|
|
import { useConfirmModalStore } from '../../store/confirmModalStore';
|
|
import { showToast } from '../../utils/ui/toast';
|
|
import { showAudiomuseNavidromeServerSetting } from '../../utils/server/subsonicServerIdentity';
|
|
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
|
|
import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
|
|
import { switchActiveServer } from '../../utils/server/switchActiveServer';
|
|
import { AddServerForm } from './AddServerForm';
|
|
import { ServerGripHandle } from './ServerGripHandle';
|
|
|
|
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
|
|
|
|
type ServerDropTarget = { idx: number; before: boolean } | null;
|
|
|
|
export function ServersTab({
|
|
initialInvite,
|
|
}: {
|
|
initialInvite: ServerMagicPayload | null;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const auth = useAuthStore();
|
|
const psyDragState = useDragDrop();
|
|
const librarySync = useLibraryIndexSync();
|
|
|
|
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
|
const [showAddForm, setShowAddForm] = useState<boolean>(initialInvite != null);
|
|
const [editingServerId, setEditingServerId] = useState<string | null>(null);
|
|
const [pastedServerInvite, setPastedServerInvite] = useState<ServerMagicPayload | null>(initialInvite);
|
|
const [serverContainerEl, setServerContainerEl] = useState<HTMLDivElement | null>(null);
|
|
const [serverDropTarget, setServerDropTarget] = useState<ServerDropTarget>(null);
|
|
const serverDropTargetRef = useRef<ServerDropTarget>(null);
|
|
const serversRef = useRef(auth.servers);
|
|
serversRef.current = auth.servers;
|
|
const addServerInviteAnchorRef = useRef<HTMLDivElement>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!showAddForm || !pastedServerInvite) return;
|
|
addServerInviteAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}, [showAddForm, pastedServerInvite]);
|
|
|
|
// Pick up later invites that arrive via the parent route handler while
|
|
// ServersTab is already mounted (initial mount is handled via useState).
|
|
useEffect(() => {
|
|
if (initialInvite) {
|
|
setPastedServerInvite(initialInvite);
|
|
setShowAddForm(true);
|
|
}
|
|
}, [initialInvite]);
|
|
|
|
// Clear drop target when drag ends
|
|
useEffect(() => {
|
|
if (!psyDragState.isDragging) {
|
|
serverDropTargetRef.current = null;
|
|
setServerDropTarget(null);
|
|
}
|
|
}, [psyDragState.isDragging]);
|
|
|
|
// psy-drop listener for server reorder
|
|
useEffect(() => {
|
|
if (!serverContainerEl) return;
|
|
const onPsyDrop = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail;
|
|
if (!detail?.data) return;
|
|
let parsed: { type?: string; index?: number };
|
|
try { parsed = JSON.parse(detail.data as string); } catch { return; }
|
|
if (parsed.type !== 'server_reorder' || parsed.index == null) return;
|
|
|
|
const fromIdx = parsed.index;
|
|
const target = serverDropTargetRef.current;
|
|
serverDropTargetRef.current = null; setServerDropTarget(null);
|
|
if (!target) return;
|
|
|
|
const insertBefore = target.before ? target.idx : target.idx + 1;
|
|
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
|
|
|
|
const next = [...serversRef.current];
|
|
const [moved] = next.splice(fromIdx, 1);
|
|
next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
|
|
auth.setServers(next);
|
|
};
|
|
serverContainerEl.addEventListener('psy-drop', onPsyDrop);
|
|
return () => serverContainerEl.removeEventListener('psy-drop', onPsyDrop);
|
|
}, [serverContainerEl, auth]);
|
|
|
|
const handleServerDragMove = (e: React.MouseEvent) => {
|
|
if (!psyDragState.isDragging || !serverContainerEl) return;
|
|
const rows = serverContainerEl.querySelectorAll<HTMLElement>('[data-server-idx]');
|
|
let target: ServerDropTarget = null;
|
|
for (const row of rows) {
|
|
const rect = row.getBoundingClientRect();
|
|
const idx = Number(row.dataset.serverIdx);
|
|
if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
|
|
target = { idx, before: false };
|
|
}
|
|
serverDropTargetRef.current = target;
|
|
setServerDropTarget(target);
|
|
};
|
|
|
|
const testConnection = async (server: ServerProfile) => {
|
|
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
|
try {
|
|
// Dual-address: probe through the connect layer so the test reflects
|
|
// whichever endpoint the app would actually use right now (LAN at home,
|
|
// public elsewhere). probe.baseUrl also feeds the AudioMuse probe so
|
|
// that one hits the same endpoint.
|
|
const probe = await ensureConnectUrlResolved(server);
|
|
if (probe.ok) {
|
|
const identity = {
|
|
type: probe.ping.type,
|
|
serverVersion: probe.ping.serverVersion,
|
|
openSubsonic: probe.ping.openSubsonic,
|
|
};
|
|
auth.setSubsonicServerIdentity(server.id, identity);
|
|
scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity);
|
|
}
|
|
setConnStatus(s => ({ ...s, [server.id]: probe.ok ? 'ok' : 'error' }));
|
|
} catch {
|
|
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
|
}
|
|
};
|
|
|
|
const switchToServer = async (server: ServerProfile) => {
|
|
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
|
const ok = await switchActiveServer(server);
|
|
if (ok) {
|
|
setConnStatus(s => ({ ...s, [server.id]: 'ok' }));
|
|
// Auf der Servers-Seite bleiben, damit der User seinen Switch hier
|
|
// sofort visuell bestaetigt sieht (gruener Check, aktiv-Badge).
|
|
} else {
|
|
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
|
}
|
|
};
|
|
|
|
const deleteServer = async (server: ServerProfile) => {
|
|
if (!confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) {
|
|
return;
|
|
}
|
|
// §5.6: when a local library index exists for this server, let the
|
|
// user keep the cached rows (offline use) or delete them. OK =
|
|
// delete the cache, Cancel = keep it.
|
|
const hadIndex = useLibraryIndexStore.getState().isIndexEnabled(server.id);
|
|
const purgeLibrary = hadIndex && confirm(t('settings.confirmDeleteServerLibrary'));
|
|
|
|
auth.removeServer(server.id);
|
|
try {
|
|
await librarySyncClearSession(server.id);
|
|
if (purgeLibrary) {
|
|
await libraryDeleteServerData(server.id);
|
|
}
|
|
} catch {
|
|
/* best-effort — server already removed from the store */
|
|
}
|
|
};
|
|
|
|
const closeAddServerForm = () => {
|
|
setShowAddForm(false);
|
|
setPastedServerInvite(null);
|
|
};
|
|
|
|
/**
|
|
* Surface a dual-address verify failure as a toast (mismatch /
|
|
* insufficient / unreachable). Returns true when the result is `ok` and
|
|
* the caller should proceed; false when the user must fix something
|
|
* before save.
|
|
*/
|
|
const announceVerifyResult = (result: VerifySameServerResult): boolean => {
|
|
if (result.ok) return true;
|
|
if (result.reason === 'unreachable') {
|
|
showToast(
|
|
t('settings.dualAddressUnreachable', { host: result.unreachableHost ?? '' }),
|
|
6000,
|
|
'error',
|
|
);
|
|
} else if (result.reason === 'mismatch') {
|
|
showToast(t('settings.dualAddressMismatch'), 6000, 'error');
|
|
} else {
|
|
showToast(t('settings.dualAddressInsufficient'), 6000, 'error');
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const handleAddServer = async (data: Omit<ServerProfile, 'id'>) => {
|
|
setShowAddForm(false);
|
|
setPastedServerInvite(null);
|
|
const tempId = '_new';
|
|
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
|
|
try {
|
|
// Dual-address: confirm both addresses point at the same server
|
|
// before persisting anything. Single-address adds skip verify and go
|
|
// straight to the legacy ping (which is also the connect-test).
|
|
if (data.alternateUrl) {
|
|
const verify = await verifySameServerEndpoints(
|
|
{ url: data.url, alternateUrl: data.alternateUrl },
|
|
data.username,
|
|
data.password,
|
|
);
|
|
if (!announceVerifyResult(verify)) {
|
|
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
|
return;
|
|
}
|
|
}
|
|
const ping = await pingWithCredentials(data.url, data.username, data.password);
|
|
if (ping.ok) {
|
|
const id = auth.addServer(data);
|
|
const identity = {
|
|
type: ping.type,
|
|
serverVersion: ping.serverVersion,
|
|
openSubsonic: ping.openSubsonic,
|
|
};
|
|
auth.setSubsonicServerIdentity(id, identity);
|
|
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity);
|
|
setConnStatus(s => ({ ...s, [id]: 'ok' }));
|
|
const added = useAuthStore.getState().servers.find(s => s.id === id);
|
|
if (added) void bootstrapIndexedServer(added);
|
|
} else {
|
|
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
|
}
|
|
} catch {
|
|
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
|
}
|
|
};
|
|
|
|
// Edit normally saves unconditionally — ping result becomes a post-save
|
|
// status indicator (analog zum existing Test-Button) rather than blocking
|
|
// the save. Lets users update a profile even when the server is currently
|
|
// unreachable.
|
|
//
|
|
// **Dual-address exception:** when the edit introduces or changes the
|
|
// second address (or changes the primary url while a second address is
|
|
// already saved), verify both addresses are the same server *before*
|
|
// persisting. A mismatch here would silently bind library / cover / queue
|
|
// data to two unrelated boxes — the spec blocks save in v1.
|
|
const handleEditServer = async (id: string, data: Omit<ServerProfile, 'id'>) => {
|
|
const previous = auth.servers.find(s => s.id === id);
|
|
|
|
// URL-change remigration — runs BEFORE everything else when the edit
|
|
// changes the derived index key. User confirms first; on failure the
|
|
// edit is aborted with a stage-specific toast. Spec §8.
|
|
const remap = previous ? indexKeyRemapForUrlChange(previous, data) : null;
|
|
if (remap) {
|
|
const confirmed = await useConfirmModalStore.getState().request({
|
|
title: t('settings.urlRemigrationTitle'),
|
|
message: t('settings.urlRemigrationMessage', {
|
|
oldKey: remap.oldKey,
|
|
newKey: remap.newKey,
|
|
}),
|
|
confirmLabel: t('settings.urlRemigrationConfirm'),
|
|
cancelLabel: t('common.cancel'),
|
|
danger: true,
|
|
});
|
|
if (!confirmed) return;
|
|
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
|
const result = await runIndexKeyRemigration(remap);
|
|
if (!result.ok) {
|
|
const failureKey =
|
|
result.failure.stage === 'inspect'
|
|
? 'settings.urlRemigrationFailureInspect'
|
|
: result.failure.stage === 'run'
|
|
? 'settings.urlRemigrationFailureRun'
|
|
: 'settings.urlRemigrationFailureCoverRename';
|
|
showToast(t(failureKey), 8000, 'error');
|
|
setConnStatus(s => ({ ...s, [id]: 'error' }));
|
|
return;
|
|
}
|
|
}
|
|
|
|
const dualAddressChanged =
|
|
data.alternateUrl != null &&
|
|
data.alternateUrl !== '' &&
|
|
(data.alternateUrl !== previous?.alternateUrl ||
|
|
data.url !== previous?.url ||
|
|
data.username !== previous?.username ||
|
|
data.password !== previous?.password);
|
|
|
|
if (dualAddressChanged) {
|
|
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
|
const verify = await verifySameServerEndpoints(
|
|
{ url: data.url, alternateUrl: data.alternateUrl },
|
|
data.username,
|
|
data.password,
|
|
);
|
|
if (!announceVerifyResult(verify)) {
|
|
setConnStatus(s => ({ ...s, [id]: 'error' }));
|
|
return;
|
|
}
|
|
}
|
|
|
|
setEditingServerId(null);
|
|
auth.updateServer(id, data);
|
|
// Profile edited → any cached sticky connect URL for this id may now be
|
|
// stale (credentials may have changed, alternate may have been added).
|
|
invalidateReachableEndpointCache(id);
|
|
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
|
try {
|
|
const ping = await pingWithCredentials(data.url, data.username, data.password);
|
|
if (ping.ok) {
|
|
const identity = {
|
|
type: ping.type,
|
|
serverVersion: ping.serverVersion,
|
|
openSubsonic: ping.openSubsonic,
|
|
};
|
|
auth.setSubsonicServerIdentity(id, identity);
|
|
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity);
|
|
}
|
|
setConnStatus(s => ({ ...s, [id]: ping.ok ? 'ok' : 'error' }));
|
|
} catch {
|
|
setConnStatus(s => ({ ...s, [id]: 'error' }));
|
|
}
|
|
};
|
|
|
|
const handleLogout = () => {
|
|
auth.logout();
|
|
navigate('/login');
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<section className="settings-section">
|
|
<div className="settings-section-header">
|
|
<Server size={18} />
|
|
<h2>{t('settings.servers')}</h2>
|
|
</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
|
{t('settings.serverCompatible')}
|
|
</div>
|
|
|
|
{auth.servers.length === 0 && !showAddForm ? (
|
|
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
|
|
{t('settings.noServers')}
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={setServerContainerEl}
|
|
onMouseMove={handleServerDragMove}
|
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
|
>
|
|
{auth.servers.map((srv, srvIdx) => {
|
|
if (editingServerId === srv.id) {
|
|
return (
|
|
<AddServerForm
|
|
key={srv.id}
|
|
editingServer={srv}
|
|
onSave={(data) => handleEditServer(srv.id, data)}
|
|
onCancel={() => setEditingServerId(null)}
|
|
/>
|
|
);
|
|
}
|
|
const isActive = srv.id === auth.activeServerId;
|
|
const status = connStatus[srv.id];
|
|
const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before;
|
|
const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before;
|
|
return (
|
|
<div
|
|
key={srv.id}
|
|
data-server-idx={srvIdx}
|
|
className="settings-card"
|
|
style={{
|
|
border: isActive ? '1px solid var(--accent)' : undefined,
|
|
background: isActive ? 'color-mix(in srgb, var(--accent) 10%, var(--bg-card))' : undefined,
|
|
// Drop-target indicator via inset shadow rather than
|
|
// borderTop/borderBottom: mixing the `border` shorthand with
|
|
// border-side longhands in one inline style object makes React
|
|
// clear the unset sides, which drops the top/bottom border on
|
|
// the active card (only left/right remain).
|
|
boxShadow: isBefore
|
|
? 'inset 0 2px 0 0 var(--accent)'
|
|
: isAfter
|
|
? 'inset 0 -2px 0 0 var(--accent)'
|
|
: undefined,
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'stretch', gap: '0.75rem' }}>
|
|
<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>
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
|
{srv.url.startsWith('https://') && (
|
|
<Lock size={11} style={{ color: 'var(--positive)', flexShrink: 0 }} />
|
|
)}
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{srv.url.replace(/^https?:\/\//, '')}
|
|
</span>
|
|
</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginTop: 1 }}>
|
|
<User size={11} />
|
|
{srv.username}
|
|
</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' }}
|
|
onClick={() => {
|
|
setShowAddForm(false);
|
|
setPastedServerInvite(null);
|
|
setEditingServerId(srv.id);
|
|
}}
|
|
data-tooltip={t('settings.editServer')}
|
|
id={`settings-edit-server-${srv.id}`}
|
|
>
|
|
<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}`}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ServerLibraryIndexControls
|
|
status={librarySync.statusByServer[serverIndexKeyForProfile(srv)] ?? null}
|
|
connection={librarySync.connectionByServer[serverIndexKeyForProfile(srv)] ?? 'unknown'}
|
|
progressLabel={librarySync.progressByServer[serverIndexKeyForProfile(srv)] ?? null}
|
|
busy={librarySync.busyServerId === serverIndexKeyForProfile(srv)}
|
|
actionsDisabled={librarySync.globalBusy && librarySync.busyServerId !== serverIndexKeyForProfile(srv)}
|
|
onFullSync={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'full')}
|
|
onDeltaSync={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'delta')}
|
|
onVerify={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'verify')}
|
|
onCancel={() => void librarySync.handleCancel()}
|
|
/>
|
|
{showAudiomuseNavidromeServerSetting(
|
|
auth.subsonicServerIdentityByServer[srv.id],
|
|
auth.instantMixProbeByServer[srv.id],
|
|
) && (
|
|
<div
|
|
className="settings-toggle-row"
|
|
data-settings-search={t('settings.audiomuseTitle')}
|
|
style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)' }}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', minWidth: 0 }}>
|
|
<Sparkles size={16} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 2 }} />
|
|
<div>
|
|
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
{t('settings.audiomuseTitle')}
|
|
{!!auth.audiomuseNavidromeByServer[srv.id] && 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>
|
|
<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>
|
|
<label className="toggle-switch" aria-label={t('settings.audiomuseTitle')}>
|
|
<input
|
|
type="checkbox"
|
|
checked={!!auth.audiomuseNavidromeByServer[srv.id]}
|
|
onChange={e => auth.setAudiomuseNavidromeEnabled(srv.id, e.target.checked)}
|
|
/>
|
|
<span className="toggle-track" />
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={addServerInviteAnchorRef}
|
|
id="settings-add-server-anchor"
|
|
style={{ scrollMarginTop: '12px' }}
|
|
>
|
|
{showAddForm ? (
|
|
<AddServerForm
|
|
initialInvite={pastedServerInvite}
|
|
onSave={handleAddServer}
|
|
onCancel={closeAddServerForm}
|
|
/>
|
|
) : (
|
|
<button
|
|
className="btn btn-surface"
|
|
style={{ marginTop: '0.75rem' }}
|
|
onClick={() => {
|
|
setPastedServerInvite(null);
|
|
setShowAddForm(true);
|
|
}}
|
|
id="settings-add-server-btn"
|
|
>
|
|
<Plus size={16} /> {t('settings.addServer')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="settings-section">
|
|
<button className="btn btn-danger" onClick={handleLogout} id="settings-logout-btn">
|
|
<LogOut size={16} /> {t('settings.logout')}
|
|
</button>
|
|
</section>
|
|
</>
|
|
);
|
|
}
|