mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(settings): results-only search, keyboard nav, flash animation, Ctrl+F (#264)
Follow-up on #263. Refines the cross-tab search into a proper command-palette-ish flow and polishes the UX around it. Results-only mode: - When a query is active, the tab content is hidden entirely; the results list is the only thing shown. Unified list (was: in-place current-tab filter + "In other tabs" panel underneath), sorted with current-tab matches first and then by score. - Removed the DOM-based hide/show logic and its CSS helper class, the dead searchOtherTabs i18n key, and the searchHits/otherTabHits state. Keyboard navigation inside the search field: - ↓/↑ moves selection through the result list (clamped, no wrap). - Enter activates the selected item (same flow as a click — clears query, switches tab, scrolls + opens + flashes the target). - Mouse hover syncs with the keyboard selection so the two input modes don't fight each other. - Selected item gets accent-border + tinted background; scrollIntoView with block:'nearest' keeps it visible when the list is long. Ctrl/Cmd+F (Settings page only): - Window keydown listener registered on mount, removed on unmount → the shortcut is live only while Settings is rendered. - Opens the search input, focuses + selects it even if already open. - preventDefault blocks the native WebKit find bar. - Placeholder now hints at the shortcut: "… (Ctrl+F)" / "… (⌘F)" on macOS, no new i18n keys needed. Target flash animation: - After navigating from a result, the target SettingsSubSection gets a 1.4s accent-colored border + glow pulse so the user immediately sees which entry the result pointed at. Reflow trick lets it retrigger when clicking the same result twice. Misc: - "Next Track Buffering" → "Buffering" in all 8 locales + the section comment in Settings.tsx. The SETTINGS_INDEX keeps "next track" as a keyword so old search habits still find it. Co-authored-by: Psychotoxical <dev@psysonic.app> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
ae7b94f190
commit
f0e5b2542b
+1
-2
@@ -708,7 +708,6 @@ export const deTranslation = {
|
||||
aboutContributorsCount_other: '{{count}} Beiträge',
|
||||
searchPlaceholder: 'Einstellungen durchsuchen…',
|
||||
searchNoResults: 'Keine Treffer für deine Suche.',
|
||||
searchOtherTabs: 'In anderen Tabs',
|
||||
integrationsPrivacyTitle: 'Hinweis zum Datenschutz',
|
||||
integrationsPrivacyBody: 'Alle Integrationen auf diesem Tab sind <strong>Opt-in</strong> und senden, sobald aktiviert, Daten an externe Dienste oder an deinen Navidrome-Server. Last.fm erhält deinen Wiedergabeverlauf, Discord zeigt den aktuell laufenden Track in deinem Profil an, Bandsintown wird pro Künstler für Tour-Daten abgefragt, und der „Jetzt läuft"-Hinweis veröffentlicht deinen aktuellen Titel für andere Benutzer deines Navidrome-Servers. Wer das nicht möchte, lässt den jeweiligen Abschnitt einfach deaktiviert.',
|
||||
homeCustomizerTitle: 'Startseite',
|
||||
@@ -795,7 +794,7 @@ export const deTranslation = {
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
preloadMode: 'Nächsten Track vorpuffern',
|
||||
preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll',
|
||||
nextTrackBufferingTitle: 'Nächster Track – Pufferung',
|
||||
nextTrackBufferingTitle: 'Pufferung',
|
||||
preloadHotCacheMutualExclusive: 'Preload und Hot Cache erfüllen denselben Zweck – nur eine Methode kann gleichzeitig aktiv sein. Das Aktivieren einer deaktiviert die andere automatisch.',
|
||||
preloadOff: 'Aus',
|
||||
preloadBalanced: 'Ausgewogen (30 s vor Ende)',
|
||||
|
||||
+1
-2
@@ -710,7 +710,6 @@ export const enTranslation = {
|
||||
aboutContributorsCount_other: '{{count}} contributions',
|
||||
searchPlaceholder: 'Search settings…',
|
||||
searchNoResults: 'No settings match your search.',
|
||||
searchOtherTabs: 'In other tabs',
|
||||
integrationsPrivacyTitle: 'Privacy notice',
|
||||
integrationsPrivacyBody: 'All integrations on this tab are <strong>opt-in</strong> and send data to external services or to your Navidrome server when enabled. Last.fm receives your listening history, Discord shows the currently playing track in your profile, Bandsintown is queried per artist to fetch tour dates, and the Now-Playing share publishes your current track to other users of your Navidrome server. If you don\'t want any of this, simply leave the corresponding section disabled.',
|
||||
homeCustomizerTitle: 'Home Page',
|
||||
@@ -797,7 +796,7 @@ export const enTranslation = {
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
preloadMode: 'Preload Next Track',
|
||||
preloadModeDesc: 'When to start buffering the next track in the queue',
|
||||
nextTrackBufferingTitle: 'Next Track Buffering',
|
||||
nextTrackBufferingTitle: 'Buffering',
|
||||
preloadHotCacheMutualExclusive: 'Preload and Hot Cache serve the same purpose — only one can be active at a time. Enabling one will automatically disable the other.',
|
||||
preloadOff: 'Off',
|
||||
preloadBalanced: 'Balanced (30 s before end)',
|
||||
|
||||
+1
-2
@@ -700,7 +700,6 @@ export const esTranslation = {
|
||||
aboutContributorsCount_other: '{{count}} contribuciones',
|
||||
searchPlaceholder: 'Buscar en ajustes…',
|
||||
searchNoResults: 'Ningún ajuste coincide con tu búsqueda.',
|
||||
searchOtherTabs: 'En otras pestañas',
|
||||
aboutMaintainersLabel: 'Mantenedores',
|
||||
integrationsPrivacyTitle: 'Aviso de privacidad',
|
||||
integrationsPrivacyBody: 'Todas las integraciones de esta pestaña son <strong>opt-in</strong> y, una vez activadas, envían datos a servicios externos o a tu servidor Navidrome. Last.fm recibe tu historial de escucha, Discord muestra la pista actual en tu perfil, Bandsintown se consulta por artista para obtener fechas de gira, y la compartición «En reproducción» publica tu pista actual para otros usuarios de tu servidor Navidrome. Si no quieres nada de esto, simplemente deja desactivada la sección correspondiente.',
|
||||
@@ -788,7 +787,7 @@ export const esTranslation = {
|
||||
gaplessDesc: 'Precarga siguiente pista para eliminar silencios entre canciones',
|
||||
preloadMode: 'Precargar Siguiente Pista',
|
||||
preloadModeDesc: 'Cuándo comenzar a cargar la siguiente pista en cola',
|
||||
nextTrackBufferingTitle: 'Buffer de Siguiente Pista',
|
||||
nextTrackBufferingTitle: 'Buffer',
|
||||
preloadHotCacheMutualExclusive: 'Precarga y Caché Activa sirven para lo mismo — solo uno puede estar activo a la vez. Habilitar uno desactivará automáticamente el otro.',
|
||||
preloadOff: 'Apagado',
|
||||
preloadBalanced: 'Balanceado (30 s antes del final)',
|
||||
|
||||
+1
-2
@@ -695,7 +695,6 @@ export const frTranslation = {
|
||||
aboutContributorsCount_other: '{{count}} contributions',
|
||||
searchPlaceholder: 'Rechercher dans les paramètres…',
|
||||
searchNoResults: 'Aucun réglage ne correspond à votre recherche.',
|
||||
searchOtherTabs: 'Dans d\'autres onglets',
|
||||
aboutMaintainersLabel: 'Mainteneurs',
|
||||
integrationsPrivacyTitle: 'Avis de confidentialité',
|
||||
integrationsPrivacyBody: 'Toutes les intégrations de cet onglet sont <strong>facultatives</strong> et, une fois activées, envoient des données à des services externes ou à votre serveur Navidrome. Last.fm reçoit votre historique d\'écoute, Discord affiche le morceau en cours dans votre profil, Bandsintown est interrogé par artiste pour récupérer les dates de tournée, et le partage « En cours de lecture » publie votre morceau actuel auprès des autres utilisateurs de votre serveur Navidrome. Si vous ne voulez rien de tout cela, laissez simplement la section correspondante désactivée.',
|
||||
@@ -783,7 +782,7 @@ export const frTranslation = {
|
||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||
preloadMode: 'Précharger la piste suivante',
|
||||
preloadModeDesc: 'Quand commencer à mettre en mémoire tampon la piste suivante',
|
||||
nextTrackBufferingTitle: 'Piste suivante – mise en mémoire tampon',
|
||||
nextTrackBufferingTitle: 'Mise en mémoire tampon',
|
||||
preloadHotCacheMutualExclusive: "Preload et Hot Cache remplissent le même rôle — un seul peut être actif à la fois. Activer l'un désactive automatiquement l'autre.",
|
||||
preloadOff: 'Désactivé',
|
||||
preloadBalanced: 'Équilibré (30 s avant la fin)',
|
||||
|
||||
+1
-2
@@ -694,7 +694,6 @@ export const nbTranslation = {
|
||||
aboutContributorsCount_other: '{{count}} bidrag',
|
||||
searchPlaceholder: 'Søk i innstillinger…',
|
||||
searchNoResults: 'Ingen innstillinger samsvarer med søket ditt.',
|
||||
searchOtherTabs: 'I andre faner',
|
||||
aboutMaintainersLabel: 'Ansvarlige',
|
||||
integrationsPrivacyTitle: 'Personvern-merknad',
|
||||
integrationsPrivacyBody: 'Alle integrasjoner på denne fanen er <strong>frivillige</strong> og sender, når aktivert, data til eksterne tjenester eller til Navidrome-serveren din. Last.fm mottar lyttehistorikken din, Discord viser gjeldende spor i profilen din, Bandsintown spørres per artist for turnédatoer, og "Spilles nå"-delingen publiserer gjeldende spor til andre brukere av Navidrome-serveren din. Hvis du ikke ønsker noe av dette, la den aktuelle seksjonen stå deaktivert.',
|
||||
@@ -785,7 +784,7 @@ export const nbTranslation = {
|
||||
experimental: 'Eksperimentell',
|
||||
preloadMode: 'Forhåndslast neste spor',
|
||||
preloadModeDesc: 'Når buffering av neste spor i køen skal starte',
|
||||
nextTrackBufferingTitle: 'Neste spor – bufring',
|
||||
nextTrackBufferingTitle: 'Bufring',
|
||||
preloadHotCacheMutualExclusive: 'Forhåndslasting og Hot Cache tjener samme formål – bare én kan være aktiv om gangen. Å aktivere den ene deaktiverer automatisk den andre.',
|
||||
preloadOff: 'Av',
|
||||
preloadBalanced: 'Balansert (30 s før slutt)',
|
||||
|
||||
+1
-2
@@ -694,7 +694,6 @@ export const nlTranslation = {
|
||||
aboutContributorsCount_other: '{{count}} bijdragen',
|
||||
searchPlaceholder: 'Instellingen zoeken…',
|
||||
searchNoResults: 'Geen instellingen komen overeen met je zoekopdracht.',
|
||||
searchOtherTabs: 'In andere tabbladen',
|
||||
aboutMaintainersLabel: 'Beheerders',
|
||||
integrationsPrivacyTitle: 'Privacyverklaring',
|
||||
integrationsPrivacyBody: 'Alle integraties op dit tabblad zijn <strong>opt-in</strong> en sturen, eenmaal ingeschakeld, gegevens naar externe diensten of naar je Navidrome-server. Last.fm ontvangt je luistergeschiedenis, Discord toont het nu spelende nummer in je profiel, Bandsintown wordt per artiest bevraagd voor tourdatums, en de "Nu aan het afspelen"-deling publiceert je huidige nummer bij andere gebruikers van je Navidrome-server. Wil je dit niet, laat dan de bijbehorende sectie gewoon uitgeschakeld.',
|
||||
@@ -782,7 +781,7 @@ export const nlTranslation = {
|
||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||
preloadMode: 'Volgend nummer vooraf laden',
|
||||
preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen',
|
||||
nextTrackBufferingTitle: 'Volgend nummer – buffering',
|
||||
nextTrackBufferingTitle: 'Buffering',
|
||||
preloadHotCacheMutualExclusive: 'Preload en Hot Cache dienen hetzelfde doel — slechts één kan tegelijk actief zijn. Het inschakelen van de ene schakelt de andere automatisch uit.',
|
||||
preloadOff: 'Uit',
|
||||
preloadBalanced: 'Gebalanceerd (30 s voor einde)',
|
||||
|
||||
+1
-2
@@ -732,7 +732,6 @@ export const ruTranslation = {
|
||||
aboutContributorsCount_other: '{{count}} вклада',
|
||||
searchPlaceholder: 'Поиск в настройках…',
|
||||
searchNoResults: 'Ничего не найдено по вашему запросу.',
|
||||
searchOtherTabs: 'В других вкладках',
|
||||
aboutMaintainersLabel: 'Сопровождающие',
|
||||
integrationsPrivacyTitle: 'Уведомление о конфиденциальности',
|
||||
integrationsPrivacyBody: 'Все интеграции на этой вкладке включаются <strong>по желанию</strong> и при активации отправляют данные во внешние сервисы или на ваш сервер Navidrome. Last.fm получает вашу историю прослушивания, Discord показывает текущую композицию в вашем профиле, Bandsintown опрашивается по артисту для получения дат гастролей, а «Сейчас играет» делится текущей композицией с другими пользователями вашего сервера Navidrome. Если вы не хотите ничего из этого, просто оставьте соответствующий раздел отключённым.',
|
||||
@@ -822,7 +821,7 @@ export const ruTranslation = {
|
||||
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
|
||||
preloadMode: 'Предзагрузка следующего трека',
|
||||
preloadModeDesc: 'Когда начинать буферизацию следующего в очереди',
|
||||
nextTrackBufferingTitle: 'Буферизация следующего трека',
|
||||
nextTrackBufferingTitle: 'Буферизация',
|
||||
preloadHotCacheMutualExclusive: 'Предзагрузка и Hot Cache выполняют одну функцию — одновременно может быть активна только одна. Включение одной автоматически отключает другую.',
|
||||
preloadOff: 'Выкл.',
|
||||
preloadBalanced: 'Умеренно (за 30 с до конца)',
|
||||
|
||||
+1
-2
@@ -689,7 +689,6 @@ export const zhTranslation = {
|
||||
aboutContributorsCount_other: '{{count}} 项贡献',
|
||||
searchPlaceholder: '搜索设置…',
|
||||
searchNoResults: '没有设置匹配您的搜索。',
|
||||
searchOtherTabs: '在其他标签页中',
|
||||
aboutMaintainersLabel: '维护者',
|
||||
integrationsPrivacyTitle: '隐私说明',
|
||||
integrationsPrivacyBody: '此标签页中的所有集成均为 <strong>选择加入</strong>,启用后会向外部服务或您的 Navidrome 服务器发送数据。Last.fm 接收您的收听历史,Discord 在您的个人资料中显示正在播放的曲目,Bandsintown 会按艺人查询以获取巡演日期,"正在播放" 分享会向您 Navidrome 服务器的其他用户公开您当前的曲目。如果您不需要这些功能,只需将相应部分保持停用即可。',
|
||||
@@ -777,7 +776,7 @@ export const zhTranslation = {
|
||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||
preloadMode: '预加载下一曲目',
|
||||
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
|
||||
nextTrackBufferingTitle: '下一曲目缓冲',
|
||||
nextTrackBufferingTitle: '缓冲',
|
||||
preloadHotCacheMutualExclusive: '预加载与热缓存用途相同——两者不能同时启用。启用其中一个会自动禁用另一个。',
|
||||
preloadOff: '关闭',
|
||||
preloadBalanced: '均衡(结束前30秒)',
|
||||
|
||||
+110
-65
@@ -1454,10 +1454,11 @@ export default function Settings() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>(resolveTab((routeState as { tab?: string } | null)?.tab));
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
// -1 bedeutet: keine aktive Suche; >= 0 ist die Trefferzahl im aktuellen Tab.
|
||||
const [searchHits, setSearchHits] = useState<number>(-1);
|
||||
const [otherTabHits, setOtherTabHits] = useState<Array<{ tab: Tab; titleKey: string; title: string; score: number }>>([]);
|
||||
const [searchResults, setSearchResults] = useState<Array<{ tab: Tab; titleKey: string; title: string; score: number }>>([]);
|
||||
const [selectedResultIdx, setSelectedResultIdx] = useState(0);
|
||||
const [pendingFocusTitle, setPendingFocusTitle] = useState<string | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchResultsListRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Server-Liste DnD
|
||||
const psyDragState = useDragDrop();
|
||||
@@ -1509,16 +1510,14 @@ export default function Settings() {
|
||||
if (st?.tab) setActiveTab(st.tab);
|
||||
}, [routeState, location.pathname, location.search, location.hash, navigate]);
|
||||
|
||||
// Settings-Suche: matcht SETTINGS_INDEX gegen den Query (Substring + Fuzzy-
|
||||
// Fallback). Aktueller Tab wird per DOM gehideded; Treffer aus anderen Tabs
|
||||
// landen in otherTabHits und werden als Liste unter dem Tab-Inhalt gerendert.
|
||||
// Settings-Suche: matcht SETTINGS_INDEX gegen den Query (Substring + Fuzzy).
|
||||
// Ergebnis ist eine flache Liste; aktueller Tab zuerst, dann nach Score. Wenn
|
||||
// eine Query aktiv ist, wird der Tab-Content gerendert-nicht und stattdessen
|
||||
// die Ergebnisliste angezeigt.
|
||||
useEffect(() => {
|
||||
const q = searchQuery.trim();
|
||||
const subs = document.querySelectorAll<HTMLElement>('[data-settings-search]');
|
||||
if (!q) {
|
||||
subs.forEach(el => el.classList.remove('settings-sub-section--hidden'));
|
||||
setSearchHits(-1);
|
||||
setOtherTabHits([]);
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
const scored = SETTINGS_INDEX.map(entry => {
|
||||
@@ -1526,39 +1525,65 @@ export default function Settings() {
|
||||
const hay = entry.keywords ? `${title} ${entry.keywords}` : title;
|
||||
return { ...entry, title, score: matchScore(hay, q) };
|
||||
}).filter(e => e.score > 0);
|
||||
|
||||
const currentTitles = new Set(scored.filter(e => e.tab === activeTab).map(e => e.title));
|
||||
let hits = 0;
|
||||
subs.forEach(el => {
|
||||
const title = el.getAttribute('data-settings-search') || '';
|
||||
if (currentTitles.has(title)) {
|
||||
el.classList.remove('settings-sub-section--hidden');
|
||||
hits++;
|
||||
} else {
|
||||
el.classList.add('settings-sub-section--hidden');
|
||||
}
|
||||
scored.sort((a, b) => {
|
||||
const aCurrent = a.tab === activeTab ? 1 : 0;
|
||||
const bCurrent = b.tab === activeTab ? 1 : 0;
|
||||
if (aCurrent !== bCurrent) return bCurrent - aCurrent;
|
||||
return b.score - a.score;
|
||||
});
|
||||
setSearchHits(hits);
|
||||
setOtherTabHits(
|
||||
scored
|
||||
.filter(e => e.tab !== activeTab)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 12),
|
||||
);
|
||||
});
|
||||
setSearchResults(scored);
|
||||
setSelectedResultIdx(0);
|
||||
}, [searchQuery, activeTab, t]);
|
||||
|
||||
// Nach Tab-Wechsel aus der "Other tabs"-Ergebnisliste: Ziel-Sub-Section
|
||||
// oeffnen und in den Viewport scrollen.
|
||||
// Selektion ins Blickfeld scrollen (nur wenn das Item out-of-view ist).
|
||||
useEffect(() => {
|
||||
if (!searchQuery || searchResults.length === 0) return;
|
||||
const list = searchResultsListRef.current;
|
||||
if (!list) return;
|
||||
const item = list.children[selectedResultIdx] as HTMLElement | undefined;
|
||||
item?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedResultIdx, searchQuery, searchResults.length]);
|
||||
|
||||
// Ctrl/Cmd+F oeffnet die Settings-Suche (nur auf der Settings-Seite — dieser
|
||||
// Effect ist ja an Settings gebunden). Fokussiert das Feld auch wenn's schon
|
||||
// offen ist. preventDefault blockt die native WebKit-Find-Bar.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'f' && e.key !== 'F') return;
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
if (e.altKey || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
setSearchOpen(true);
|
||||
window.setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
}, 0);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
// Nach Klick auf ein Ergebnis: Ziel-Sub-Section oeffnen, scrollen und kurz
|
||||
// highlighten, damit der User auf dem neuen Tab sofort weiss welcher Eintrag
|
||||
// gemeint war.
|
||||
useEffect(() => {
|
||||
if (!pendingFocusTitle) return;
|
||||
const el = document.querySelector<HTMLElement>(
|
||||
`[data-settings-search="${CSS.escape(pendingFocusTitle)}"]`,
|
||||
);
|
||||
if (el) {
|
||||
if (el instanceof HTMLDetailsElement) el.open = true;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setPendingFocusTitle(null);
|
||||
}
|
||||
if (!el) return;
|
||||
if (el instanceof HTMLDetailsElement) el.open = true;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
el.classList.remove('settings-sub-section--flash');
|
||||
// reflow, damit die Animation bei wiederholtem Klick auf dasselbe Ziel
|
||||
// erneut abspielt.
|
||||
void el.offsetWidth;
|
||||
el.classList.add('settings-sub-section--flash');
|
||||
const timer = window.setTimeout(() => {
|
||||
el.classList.remove('settings-sub-section--flash');
|
||||
}, 1500);
|
||||
setPendingFocusTitle(null);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [pendingFocusTitle, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1932,17 +1957,35 @@ export default function Settings() {
|
||||
<div className="settings-search-wrap">
|
||||
<Search size={14} className="settings-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
className="input settings-search-input"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('settings.searchPlaceholder')}
|
||||
placeholder={`${t('settings.searchPlaceholder')} (${IS_MACOS ? '⌘F' : 'Ctrl+F'})`}
|
||||
aria-label={t('settings.searchPlaceholder')}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchQuery('');
|
||||
setSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
if (searchResults.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedResultIdx(i => Math.min(i + 1, searchResults.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedResultIdx(i => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const hit = searchResults[selectedResultIdx];
|
||||
if (!hit) return;
|
||||
setSearchQuery('');
|
||||
setSearchOpen(false);
|
||||
setPendingFocusTitle(hit.title);
|
||||
setActiveTab(hit.tab);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1973,40 +2016,41 @@ export default function Settings() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{searchQuery && searchHits === 0 && otherTabHits.length === 0 && (
|
||||
{searchQuery && searchResults.length === 0 && (
|
||||
<div className="settings-search-empty" role="status">
|
||||
{t('settings.searchNoResults')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchQuery && otherTabHits.length > 0 && (
|
||||
<div className="settings-search-other-tabs" role="region" aria-label={t('settings.searchOtherTabs')}>
|
||||
<div className="settings-search-other-tabs-title">{t('settings.searchOtherTabs')}</div>
|
||||
<ul className="settings-search-other-tabs-list">
|
||||
{otherTabHits.map(hit => {
|
||||
const tabLabelKey = TAB_LABEL_KEY[hit.tab];
|
||||
return (
|
||||
<li key={`${hit.tab}:${hit.titleKey}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-search-other-tab-item"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSearchOpen(false);
|
||||
setPendingFocusTitle(hit.title);
|
||||
setActiveTab(hit.tab);
|
||||
}}
|
||||
>
|
||||
<span className="settings-search-other-tab-badge">{t(tabLabelKey as any)}</span>
|
||||
<span className="settings-search-other-tab-title">{hit.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{searchQuery && searchResults.length > 0 && (
|
||||
<ul ref={searchResultsListRef} className="settings-search-results">
|
||||
{searchResults.map((hit, idx) => {
|
||||
const tabLabelKey = TAB_LABEL_KEY[hit.tab];
|
||||
const selected = idx === selectedResultIdx;
|
||||
return (
|
||||
<li key={`${hit.tab}:${hit.titleKey}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-search-result-item"
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
onMouseEnter={() => setSelectedResultIdx(idx)}
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSearchOpen(false);
|
||||
setPendingFocusTitle(hit.title);
|
||||
setActiveTab(hit.tab);
|
||||
}}
|
||||
>
|
||||
<span className="settings-search-result-badge">{t(tabLabelKey as any)}</span>
|
||||
<span className="settings-search-result-title">{hit.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!searchQuery && <>
|
||||
{/* ── Audio ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'audio' && (
|
||||
<>
|
||||
@@ -2834,7 +2878,7 @@ export default function Settings() {
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
{/* Next Track Buffering */}
|
||||
{/* Buffering */}
|
||||
<SettingsSubSection
|
||||
title={t('settings.nextTrackBufferingTitle')}
|
||||
icon={<Download size={16} />}
|
||||
@@ -4059,6 +4103,7 @@ export default function Settings() {
|
||||
|
||||
</>
|
||||
)}
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+21
-24
@@ -2460,6 +2460,15 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes settings-sub-section-flash {
|
||||
0% { border-color: var(--border-subtle); box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
|
||||
20% { border-color: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 35%, transparent); }
|
||||
100% { border-color: var(--border-subtle); box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
|
||||
}
|
||||
.settings-sub-section--flash {
|
||||
animation: settings-sub-section-flash 1.4s ease-out;
|
||||
}
|
||||
|
||||
.settings-sub-section-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2591,10 +2600,6 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-sub-section--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-search-empty {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: 13px;
|
||||
@@ -2605,31 +2610,18 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-search-other-tabs {
|
||||
margin-top: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
.settings-search-results {
|
||||
list-style: none;
|
||||
margin: var(--space-4) 0 var(--space-6);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
.settings-search-other-tabs-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.settings-search-other-tabs-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.settings-search-other-tab-item {
|
||||
.settings-search-result-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2644,11 +2636,16 @@
|
||||
font-size: 13px;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.settings-search-other-tab-item:hover {
|
||||
.settings-search-result-item:hover,
|
||||
.settings-search-result-item[data-selected="true"] {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.settings-search-other-tab-badge {
|
||||
.settings-search-result-item[data-selected="true"] {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--bg-hover));
|
||||
}
|
||||
.settings-search-result-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
@@ -2657,7 +2654,7 @@
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.settings-search-other-tab-title {
|
||||
.settings-search-result-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user