mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
Unify button tooltips across the app (#972)
* feat(tooltip): 2s open delay and shared tooltipAttrs helper Add a 2s hover open delay in TooltipPortal (single behaviour source) so tooltips no longer flash on quick pointer passes; hiding stays immediate. Add tooltipAttrs() to pair data-tooltip with a matching aria-label for buttons touched in the unification work. Covered by Vitest. * feat(tooltip): lower open delay to 1s 2s felt too long in testing; 1s gives the same anti-flash behaviour without making intentional hovers wait. * feat(tooltip): action tooltips on the artist overview Add tooltips describing the action to Last.fm, Wikipedia, Play All, Shuffle and Radio. Shuffle/Radio now show a tooltip on desktop too, not just mobile. Strings added to all 9 locales. * feat(tooltip): action tooltips on the album overview Add tooltips describing the action to the desktop Play, Artist Bio and Download (ZIP) buttons, matching the mobile layout. Strings added to all 9 locales. * feat(tooltip): action tooltips on the All Albums toolbar Add tooltips describing the action to the sort, year and genre filter buttons. SortDropdown gains an optional tooltip prop; the year and genre filter components carry their own, so the tooltips also appear on the other browse pages that reuse them. Strings added to all 9 locales. * feat(tooltip): action tooltips on song-list rows Add Play and Add-to-queue tooltips to the per-row icons in SongRow, used by the Tracks browse list, Search and Advanced Search. Also localizes the aria-labels, which were hardcoded English. New common.addToQueue in all 9 locales. * fix(tooltip): uniform tooltip placement on the Artists toolbar The favourite and multi-select buttons forced tooltips below while the view-mode buttons auto-flipped above, so the row looked inconsistent. Pin the view-mode buttons below too, matching the rest of the row and the Albums toolbar. * feat(tooltip): clarify and align the Advanced Search scope row Add a leading "Search in:" label and per-chip tooltips so the All/Artists/Albums/Songs row reads as a scope limiter. Drop the forced below-placement on the small star filter (used only here) so the favourites chip flips with the others instead of sitting alone below. Strings added to all 9 locales. * docs(changelog): tooltip unification (#972)
This commit is contained in:
committed by
GitHub
parent
82c414d7bc
commit
c119a32277
@@ -401,6 +401,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* Single tracks in the discovery rails now show a round, vinyl-style cover so they read as songs rather than albums — clicking one still plays it instantly.
|
||||
* A new **To album** badge under the artist jumps to the track's album, available in all 9 languages.
|
||||
|
||||
### Tooltips — consistent hints on every button
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#972](https://github.com/Psychotoxical/psysonic/pull/972)**
|
||||
|
||||
* Buttons across the app now show a short tooltip describing what they do, appearing after a 1-second hover so they never flash during quick mouse passes.
|
||||
* Tooltips that were missing — on the artist, album, All Albums, track-list and playlist actions — are filled in, and tooltips that used to point different directions within the same toolbar now line up consistently.
|
||||
* Advanced Search gains a **Search in:** label so the All / Artists / Albums / Songs row reads clearly as a scope limiter. Available in all 9 languages.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { formatLongDuration } from '../utils/format/formatDuration';
|
||||
import { formatMb } from '../utils/format/formatBytes';
|
||||
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
|
||||
/** True when the album artist label means "no single artist" — `getArtistInfo`
|
||||
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
|
||||
@@ -323,7 +324,12 @@ export default function AlbumHeader({
|
||||
) : (
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
id="album-play-all-btn"
|
||||
onClick={onPlayAll}
|
||||
{...tooltipAttrs(t('albumDetail.playTooltip'))}
|
||||
>
|
||||
<Play size={15} /> {t('common.play', 'Reproducir')}
|
||||
</button>
|
||||
{onShuffleAll && (
|
||||
@@ -361,7 +367,12 @@ export default function AlbumHeader({
|
||||
</div>
|
||||
|
||||
{showBioButton && (
|
||||
<button className="btn btn-surface" id="album-bio-btn" onClick={onBio}>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-bio-btn"
|
||||
onClick={onBio}
|
||||
{...tooltipAttrs(t('albumDetail.artistBioTooltip'))}
|
||||
>
|
||||
<Highlighter size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
)}
|
||||
@@ -375,7 +386,12 @@ export default function AlbumHeader({
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-surface" id="album-download-btn" onClick={onDownload}>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-download-btn"
|
||||
onClick={onDownload}
|
||||
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
||||
>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Check, Filter, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FilterQuickClear from './FilterQuickClear';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
|
||||
type GenreRow = GenreFilterOption;
|
||||
|
||||
@@ -153,6 +154,7 @@ export default function GenreFilterBar({
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
{...tooltipAttrs(t('common.filterGenreTooltip'), { pos: 'bottom' })}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<Filter size={14} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { enqueueAndPlay } from '../utils/playback/playSong';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
|
||||
interface Props {
|
||||
song: SubsonicSong;
|
||||
@@ -79,14 +80,14 @@ function SongRow({ song, showBpm }: Props) {
|
||||
<button
|
||||
className="song-list-row-btn song-list-row-btn--play"
|
||||
onClick={(e) => { e.stopPropagation(); handlePlay(); }}
|
||||
aria-label="Play"
|
||||
{...tooltipAttrs(t('common.play'))}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
className="song-list-row-btn"
|
||||
onClick={(e) => { e.stopPropagation(); handleEnqueue(); }}
|
||||
aria-label="Enqueue"
|
||||
{...tooltipAttrs(t('common.addToQueue'))}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowDownUp, Check } from 'lucide-react';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
|
||||
export interface SortOption<V extends string> {
|
||||
value: V;
|
||||
@@ -12,9 +13,11 @@ interface Props<V extends string> {
|
||||
options: SortOption<V>[];
|
||||
onChange: (value: V) => void;
|
||||
ariaLabel?: string;
|
||||
/** Hover tooltip describing the action (shown below the trigger). */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export default function SortDropdown<V extends string>({ value, options, onChange, ariaLabel }: Props<V>) {
|
||||
export default function SortDropdown<V extends string>({ value, options, onChange, ariaLabel, tooltip }: Props<V>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
@@ -91,6 +94,7 @@ export default function SortDropdown<V extends string>({ value, options, onChang
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
{...(tooltip ? tooltipAttrs(tooltip, { pos: 'bottom' }) : {})}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<ArrowDownUp size={14} />
|
||||
|
||||
@@ -41,7 +41,6 @@ export default function StarFilterButton({ active, onChange, size = 'default' }:
|
||||
onClick={() => onChange(!active)}
|
||||
aria-pressed={active}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{ fontSize: 12, padding: '4px 14px', display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
|
||||
>
|
||||
<Star size={12} fill={active ? 'currentColor' : 'none'} />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act, fireEvent, screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import TooltipPortal from './TooltipPortal';
|
||||
|
||||
function Fixture() {
|
||||
return (
|
||||
<>
|
||||
<TooltipPortal />
|
||||
<button data-tooltip="Play this album">play</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('TooltipPortal open delay', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows the tooltip only after the 1s open delay', () => {
|
||||
renderWithProviders(<Fixture />);
|
||||
const btn = screen.getByText('play');
|
||||
|
||||
fireEvent.mouseOver(btn);
|
||||
expect(screen.queryByText('Play this album')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(999);
|
||||
});
|
||||
expect(screen.queryByText('Play this album')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(screen.getByText('Play this album')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels the pending tooltip when the pointer leaves before the delay', () => {
|
||||
renderWithProviders(<Fixture />);
|
||||
const btn = screen.getByText('play');
|
||||
|
||||
fireEvent.mouseOver(btn);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
fireEvent.mouseOut(btn, { relatedTarget: document.body });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Play this album')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides immediately on mousedown', () => {
|
||||
renderWithProviders(<Fixture />);
|
||||
const btn = screen.getByText('play');
|
||||
|
||||
fireEvent.mouseOver(btn);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.getByText('Play this album')).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(btn);
|
||||
expect(screen.queryByText('Play this album')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,42 +8,87 @@ interface TooltipState {
|
||||
wrap: boolean;
|
||||
}
|
||||
|
||||
/** Pointer must rest on an anchor this long before the tooltip appears. */
|
||||
const TOOLTIP_OPEN_DELAY_MS = 1000;
|
||||
|
||||
export default function TooltipPortal() {
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
|
||||
|
||||
const tooltipRef = useRef<TooltipState | null>(null);
|
||||
tooltipRef.current = tooltip;
|
||||
// Anchor counting down to show, plus its pending timer.
|
||||
const pendingAnchorRef = useRef<HTMLElement | null>(null);
|
||||
const pendingTimerRef = useRef<number | null>(null);
|
||||
// Anchor whose tooltip is currently visible.
|
||||
const shownAnchorRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onOver = (e: MouseEvent) => {
|
||||
const target = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const text = target.getAttribute('data-tooltip');
|
||||
const clearPending = () => {
|
||||
if (pendingTimerRef.current !== null) {
|
||||
clearTimeout(pendingTimerRef.current);
|
||||
pendingTimerRef.current = null;
|
||||
}
|
||||
pendingAnchorRef.current = null;
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
clearPending();
|
||||
shownAnchorRef.current = null;
|
||||
setTooltip(null);
|
||||
};
|
||||
|
||||
const showAnchor = (anchor: HTMLElement) => {
|
||||
const text = anchor.getAttribute('data-tooltip');
|
||||
if (!text) return;
|
||||
shownAnchorRef.current = anchor;
|
||||
// Fresh rect: layout may have shifted during the open delay.
|
||||
setTooltip({
|
||||
text,
|
||||
anchorRect: target.getBoundingClientRect(),
|
||||
preferBottom: target.getAttribute('data-tooltip-pos') === 'bottom',
|
||||
wrap: target.hasAttribute('data-tooltip-wrap'),
|
||||
anchorRect: anchor.getBoundingClientRect(),
|
||||
preferBottom: anchor.getAttribute('data-tooltip-pos') === 'bottom',
|
||||
wrap: anchor.hasAttribute('data-tooltip-wrap'),
|
||||
});
|
||||
};
|
||||
const onOut = () => setTooltip(null);
|
||||
|
||||
const onOver = (e: MouseEvent) => {
|
||||
const anchor = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
|
||||
if (!anchor) return;
|
||||
// Already visible or already counting down for this anchor — leave it running.
|
||||
if (anchor === shownAnchorRef.current || anchor === pendingAnchorRef.current) return;
|
||||
// Moved onto a different anchor: drop old state and re-arm the delay.
|
||||
clearPending();
|
||||
if (shownAnchorRef.current) {
|
||||
shownAnchorRef.current = null;
|
||||
setTooltip(null);
|
||||
}
|
||||
pendingAnchorRef.current = anchor;
|
||||
pendingTimerRef.current = window.setTimeout(() => {
|
||||
pendingTimerRef.current = null;
|
||||
pendingAnchorRef.current = null;
|
||||
showAnchor(anchor);
|
||||
}, TOOLTIP_OPEN_DELAY_MS);
|
||||
};
|
||||
const onOut = (e: MouseEvent) => {
|
||||
const anchor = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
|
||||
if (!anchor) return;
|
||||
if (anchor !== shownAnchorRef.current && anchor !== pendingAnchorRef.current) return;
|
||||
// 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;
|
||||
hide();
|
||||
};
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!tooltipRef.current) return;
|
||||
const target = (e.target as HTMLElement).closest('[data-tooltip]');
|
||||
if (!target) setTooltip(null);
|
||||
if (!pendingAnchorRef.current && !shownAnchorRef.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) => {
|
||||
const t = (e.target as HTMLElement).closest('[data-tooltip]');
|
||||
if (t) setTooltip(null);
|
||||
if ((e.target as HTMLElement).closest('[data-tooltip]')) hide();
|
||||
};
|
||||
/** Wheel interactions (e.g. volume on overflow button) should suppress tooltip immediately. */
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const t = (e.target as HTMLElement).closest('[data-tooltip]');
|
||||
if (t) setTooltip(null);
|
||||
if ((e.target as HTMLElement).closest('[data-tooltip]')) hide();
|
||||
};
|
||||
document.addEventListener('mouseover', onOver);
|
||||
document.addEventListener('mouseout', onOut);
|
||||
@@ -51,6 +96,7 @@ export default function TooltipPortal() {
|
||||
document.addEventListener('mousedown', onDown, true);
|
||||
document.addEventListener('wheel', onWheel, { capture: true, passive: true });
|
||||
return () => {
|
||||
clearPending();
|
||||
document.removeEventListener('mouseover', onOver);
|
||||
document.removeEventListener('mouseout', onOut);
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { CalendarRange, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FilterQuickClear from './FilterQuickClear';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
import {
|
||||
ALBUM_YEAR_MAX,
|
||||
ALBUM_YEAR_MIN,
|
||||
@@ -136,6 +137,7 @@ export default function YearFilterButton({
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
{...tooltipAttrs(t('albums.yearFilterTooltip'), { pos: 'bottom' })}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
...(active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useCoverLightboxSrc } from '../../cover/lightbox';
|
||||
import type { CoverArtRef } from '../../cover/types';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import StarRating from '../StarRating';
|
||||
import { tooltipAttrs } from '../tooltipAttrs';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -142,12 +143,20 @@ export default function ArtistDetailHero({
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
{info?.lastFmUrl && (
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, 'lastfm')}>
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={() => openLink(info.lastFmUrl!, 'lastfm')}
|
||||
{...tooltipAttrs(t('artistDetail.lastfmTooltip'))}
|
||||
>
|
||||
<LastfmIcon size={14} />
|
||||
{openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'}
|
||||
</button>
|
||||
)}
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')}>
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={() => openLink(wikiUrl, 'wiki')}
|
||||
{...tooltipAttrs(t('artistDetail.wikipediaTooltip'))}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}
|
||||
</button>
|
||||
@@ -168,7 +177,12 @@ export default function ArtistDetailHero({
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{albums.length > 0 && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={playAllLoading}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handlePlayAll}
|
||||
disabled={playAllLoading}
|
||||
{...tooltipAttrs(t('artistDetail.playAllTooltip'))}
|
||||
>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Play size={16} />}
|
||||
{t('artistDetail.playAll')}
|
||||
</button>
|
||||
@@ -176,7 +190,7 @@ export default function ArtistDetailHero({
|
||||
className="btn btn-surface"
|
||||
onClick={handleShuffle}
|
||||
disabled={playAllLoading}
|
||||
data-tooltip={isMobile ? t('artistDetail.shuffle') : undefined}
|
||||
{...tooltipAttrs(t('artistDetail.shuffleTooltip'))}
|
||||
>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
|
||||
{!isMobile && t('artistDetail.shuffle')}
|
||||
@@ -187,7 +201,7 @@ export default function ArtistDetailHero({
|
||||
className="btn btn-surface"
|
||||
onClick={handleStartRadio}
|
||||
disabled={radioLoading}
|
||||
data-tooltip={isMobile ? t('artistDetail.radio') : undefined}
|
||||
{...tooltipAttrs(t('artistDetail.radioTooltip'))}
|
||||
>
|
||||
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
|
||||
{!isMobile && (radioLoading ? t('artistDetail.loading') : t('artistDetail.radio'))}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
|
||||
describe('tooltipAttrs', () => {
|
||||
it('pairs data-tooltip with a matching aria-label', () => {
|
||||
expect(tooltipAttrs('Play this album')).toEqual({
|
||||
'data-tooltip': 'Play this album',
|
||||
'aria-label': 'Play this album',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the forced position only when requested', () => {
|
||||
expect(tooltipAttrs('x', { pos: 'bottom' })['data-tooltip-pos']).toBe('bottom');
|
||||
expect('data-tooltip-pos' in tooltipAttrs('x')).toBe(false);
|
||||
});
|
||||
|
||||
it('adds the wrap marker only when requested', () => {
|
||||
expect('data-tooltip-wrap' in tooltipAttrs('x', { wrap: true })).toBe(true);
|
||||
expect('data-tooltip-wrap' in tooltipAttrs('x')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Pairs a tooltip with its accessible label so the two never drift apart.
|
||||
*
|
||||
* Spread onto any element that should show a hover tooltip rendered by
|
||||
* `TooltipPortal`. The same already-translated string becomes both the
|
||||
* `data-tooltip` (visual hover label) and the `aria-label` (screen readers).
|
||||
*
|
||||
* <button {...tooltipAttrs(t('albums.sortTooltip'))} onClick={…}>
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
export function tooltipAttrs(
|
||||
text: string,
|
||||
opts?: { pos?: 'bottom'; wrap?: boolean },
|
||||
): Record<string, string> {
|
||||
return {
|
||||
'data-tooltip': text,
|
||||
'aria-label': text,
|
||||
...(opts?.pos ? { 'data-tooltip-pos': opts.pos } : {}),
|
||||
...(opts?.wrap ? { 'data-tooltip-wrap': '' } : {}),
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,9 @@ export const albumDetail = {
|
||||
enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
|
||||
artistBio: 'Künstler-Bio',
|
||||
download: 'Download (ZIP)',
|
||||
playTooltip: 'Dieses Album abspielen',
|
||||
artistBioTooltip: 'Künstlerbiografie lesen',
|
||||
downloadTooltip: 'Dieses Album als ZIP herunterladen',
|
||||
downloading: 'Lade…',
|
||||
cacheOffline: 'Offline verfügbar machen',
|
||||
offlineCached: 'Offline verfügbar',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const albums = {
|
||||
yearTo: 'Bis',
|
||||
yearFilterClear: 'Jahresfilter zurücksetzen',
|
||||
yearFilterLabel: 'Jahr',
|
||||
sortTooltip: 'Sortierung ändern',
|
||||
yearFilterTooltip: 'Nach Erscheinungsjahr filtern',
|
||||
compilationLabel: 'Sampler',
|
||||
compilationOnly: 'Nur Sampler',
|
||||
compilationHide: 'Ohne Sampler',
|
||||
|
||||
@@ -6,6 +6,11 @@ export const artistDetail = {
|
||||
shareArtist: 'Künstler teilen',
|
||||
shuffle: 'Zufallswiedergabe',
|
||||
radio: 'Radio',
|
||||
lastfmTooltip: 'Diesen Künstler auf Last.fm öffnen',
|
||||
wikipediaTooltip: 'Diesen Künstler auf Wikipedia öffnen',
|
||||
playAllTooltip: 'Gesamte Diskografie abspielen',
|
||||
shuffleTooltip: 'Gesamte Diskografie zufällig abspielen',
|
||||
radioTooltip: 'Radio basierend auf diesem Künstler starten',
|
||||
loading: 'Lädt…',
|
||||
noRadio: 'Keine ähnlichen Titel für diesen Künstler gefunden.',
|
||||
notFound: 'Künstler nicht gefunden.',
|
||||
|
||||
@@ -21,6 +21,7 @@ export const common = {
|
||||
noFolderSelected: 'Kein Ordner ausgewählt',
|
||||
rememberDownloadFolder: 'Ordner merken',
|
||||
filterGenre: 'Genre-Filter',
|
||||
filterGenreTooltip: 'Nach Genre filtern',
|
||||
filterSearchGenres: 'Genres suchen…',
|
||||
filterNoGenres: 'Keine Genres gefunden',
|
||||
filterClear: 'Zurücksetzen',
|
||||
@@ -28,6 +29,7 @@ export const common = {
|
||||
favoritesTooltipOff: 'Nur Favoriten anzeigen',
|
||||
favoritesTooltipOn: 'Alle anzeigen',
|
||||
play: 'Abspielen',
|
||||
addToQueue: 'Zur Warteschlange hinzufügen',
|
||||
bulkSelected: '{{count}} ausgewählt',
|
||||
clearSelection: 'Auswahl aufheben',
|
||||
bulkAddToPlaylist: 'Zur Playlist hinzufügen',
|
||||
|
||||
@@ -31,6 +31,11 @@ export const search = {
|
||||
advancedMoodGroup: 'Mood',
|
||||
advancedAllMoods: 'All moods',
|
||||
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||
scopeRowLabel: 'Suchen in:',
|
||||
scopeAllTooltip: 'Alles durchsuchen',
|
||||
scopeArtistsChipTooltip: 'Ergebnisse auf Künstler beschränken',
|
||||
scopeAlbumsChipTooltip: 'Ergebnisse auf Alben beschränken',
|
||||
scopeSongsChipTooltip: 'Ergebnisse auf Titel beschränken',
|
||||
advancedAll: 'Alle',
|
||||
advancedSearch: 'Suchen',
|
||||
advancedEmpty: 'Suchbegriff eingeben oder Filter wählen, um zu beginnen.',
|
||||
|
||||
@@ -7,6 +7,9 @@ export const albumDetail = {
|
||||
enqueueTooltip: 'Add entire album to queue',
|
||||
artistBio: 'Artist Bio',
|
||||
download: 'Download (ZIP)',
|
||||
playTooltip: 'Play this album',
|
||||
artistBioTooltip: 'Read the artist biography',
|
||||
downloadTooltip: 'Download this album as a ZIP',
|
||||
downloading: 'Loading…',
|
||||
cacheOffline: 'Make available offline',
|
||||
offlineCached: 'Available offline',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const albums = {
|
||||
yearTo: 'To',
|
||||
yearFilterClear: 'Clear year filter',
|
||||
yearFilterLabel: 'Year',
|
||||
sortTooltip: 'Change sort order',
|
||||
yearFilterTooltip: 'Filter by release year',
|
||||
compilationLabel: 'Compilations',
|
||||
compilationOnly: 'Only compilations',
|
||||
compilationHide: 'Hide compilations',
|
||||
|
||||
@@ -6,6 +6,11 @@ export const artistDetail = {
|
||||
shareArtist: 'Share artist',
|
||||
shuffle: 'Shuffle',
|
||||
radio: 'Radio',
|
||||
lastfmTooltip: 'Open this artist on Last.fm',
|
||||
wikipediaTooltip: 'Open this artist on Wikipedia',
|
||||
playAllTooltip: 'Play the whole discography',
|
||||
shuffleTooltip: 'Shuffle the whole discography',
|
||||
radioTooltip: 'Start a radio based on this artist',
|
||||
loading: 'Loading…',
|
||||
noRadio: 'No similar tracks found for this artist.',
|
||||
notFound: 'Artist not found.',
|
||||
|
||||
@@ -21,6 +21,7 @@ export const common = {
|
||||
noFolderSelected: 'No folder selected',
|
||||
rememberDownloadFolder: 'Remember this folder',
|
||||
filterGenre: 'Genre Filter',
|
||||
filterGenreTooltip: 'Filter by genre',
|
||||
filterSearchGenres: 'Search genres…',
|
||||
filterNoGenres: 'No genres match',
|
||||
filterClear: 'Clear',
|
||||
@@ -28,6 +29,7 @@ export const common = {
|
||||
favoritesTooltipOff: 'Show only favorites',
|
||||
favoritesTooltipOn: 'Show all',
|
||||
play: 'Play',
|
||||
addToQueue: 'Add to queue',
|
||||
bulkSelected: '{{count}} selected',
|
||||
clearSelection: 'Clear selection',
|
||||
bulkAddToPlaylist: 'Add to Playlist',
|
||||
|
||||
@@ -31,6 +31,11 @@ export const search = {
|
||||
advancedMoodGroup: 'Mood',
|
||||
advancedAllMoods: 'All moods',
|
||||
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||
scopeRowLabel: 'Search in:',
|
||||
scopeAllTooltip: 'Search everything',
|
||||
scopeArtistsChipTooltip: 'Limit results to artists',
|
||||
scopeAlbumsChipTooltip: 'Limit results to albums',
|
||||
scopeSongsChipTooltip: 'Limit results to songs',
|
||||
advancedAll: 'All',
|
||||
advancedSearch: 'Search',
|
||||
advancedEmpty: 'Enter a search term or select a filter to begin.',
|
||||
|
||||
@@ -7,6 +7,9 @@ export const albumDetail = {
|
||||
enqueueTooltip: 'Agregar álbum completo a la cola',
|
||||
artistBio: 'Biografía del Artista',
|
||||
download: 'Descargar (ZIP)',
|
||||
playTooltip: 'Reproducir este álbum',
|
||||
artistBioTooltip: 'Leer la biografía del artista',
|
||||
downloadTooltip: 'Descargar este álbum como ZIP',
|
||||
downloading: 'Cargando…',
|
||||
cacheOffline: 'Disponible offline',
|
||||
offlineCached: 'Disponible offline',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const albums = {
|
||||
yearTo: 'Hasta',
|
||||
yearFilterClear: 'Limpiar filtro de año',
|
||||
yearFilterLabel: 'Año',
|
||||
sortTooltip: 'Cambiar el orden',
|
||||
yearFilterTooltip: 'Filtrar por año de lanzamiento',
|
||||
compilationLabel: 'Recopilatorios',
|
||||
compilationOnly: 'Solo recopilatorios',
|
||||
compilationHide: 'Ocultar recopilatorios',
|
||||
|
||||
@@ -6,6 +6,11 @@ export const artistDetail = {
|
||||
shareArtist: 'Compartir artista',
|
||||
shuffle: 'Aleatorio',
|
||||
radio: 'Radio',
|
||||
lastfmTooltip: 'Abrir este artista en Last.fm',
|
||||
wikipediaTooltip: 'Abrir este artista en Wikipedia',
|
||||
playAllTooltip: 'Reproducir toda la discografía',
|
||||
shuffleTooltip: 'Reproducir toda la discografía en aleatorio',
|
||||
radioTooltip: 'Iniciar una radio basada en este artista',
|
||||
loading: 'Cargando…',
|
||||
noRadio: 'No se encontraron pistas similares para este artista.',
|
||||
notFound: 'Artista no encontrado.',
|
||||
|
||||
@@ -20,6 +20,7 @@ export const common = {
|
||||
noFolderSelected: 'Ninguna carpeta seleccionada',
|
||||
rememberDownloadFolder: 'Recordar esta carpeta',
|
||||
filterGenre: 'Filtro de Género',
|
||||
filterGenreTooltip: 'Filtrar por género',
|
||||
filterSearchGenres: 'Buscar géneros…',
|
||||
filterNoGenres: 'Ningún género coincide',
|
||||
filterClear: 'Limpiar',
|
||||
@@ -27,6 +28,7 @@ export const common = {
|
||||
favoritesTooltipOff: 'Mostrar solo favoritos',
|
||||
favoritesTooltipOn: 'Mostrar todo',
|
||||
play: 'Reproducir',
|
||||
addToQueue: 'Añadir a la cola',
|
||||
bulkSelected: '{{count}} seleccionados',
|
||||
clearSelection: 'Limpiar selección',
|
||||
bulkAddToPlaylist: 'Agregar a Lista',
|
||||
|
||||
@@ -31,6 +31,11 @@ export const search = {
|
||||
advancedMoodGroup: 'Mood',
|
||||
advancedAllMoods: 'All moods',
|
||||
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||
scopeRowLabel: 'Buscar en:',
|
||||
scopeAllTooltip: 'Buscar en todo',
|
||||
scopeArtistsChipTooltip: 'Limitar a artistas',
|
||||
scopeAlbumsChipTooltip: 'Limitar a álbumes',
|
||||
scopeSongsChipTooltip: 'Limitar a canciones',
|
||||
advancedAll: 'Todos',
|
||||
advancedSearch: 'Buscar',
|
||||
advancedEmpty: 'Ingresa un término de búsqueda o selecciona un filtro para comenzar.',
|
||||
|
||||
@@ -7,6 +7,9 @@ export const albumDetail = {
|
||||
enqueueTooltip: 'Ajouter l\'album entier à la file d\'attente',
|
||||
artistBio: 'Biographie',
|
||||
download: 'Télécharger (ZIP)',
|
||||
playTooltip: 'Lire cet album',
|
||||
artistBioTooltip: 'Lire la biographie de l\'artiste',
|
||||
downloadTooltip: 'Télécharger cet album en ZIP',
|
||||
downloading: 'Chargement…',
|
||||
cacheOffline: 'Rendre disponible hors ligne',
|
||||
offlineCached: 'Disponible hors ligne',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const albums = {
|
||||
yearTo: 'À',
|
||||
yearFilterClear: 'Effacer le filtre année',
|
||||
yearFilterLabel: 'Année',
|
||||
sortTooltip: 'Changer l\'ordre de tri',
|
||||
yearFilterTooltip: 'Filtrer par année de sortie',
|
||||
compilationLabel: 'Compilations',
|
||||
compilationOnly: 'Uniquement compilations',
|
||||
compilationHide: 'Masquer compilations',
|
||||
|
||||
@@ -6,6 +6,11 @@ export const artistDetail = {
|
||||
shareArtist: 'Partager l\'artiste',
|
||||
shuffle: 'Aléatoire',
|
||||
radio: 'Radio',
|
||||
lastfmTooltip: 'Ouvrir cet artiste sur Last.fm',
|
||||
wikipediaTooltip: 'Ouvrir cet artiste sur Wikipédia',
|
||||
playAllTooltip: 'Lire toute la discographie',
|
||||
shuffleTooltip: 'Lire toute la discographie en aléatoire',
|
||||
radioTooltip: 'Démarrer une radio basée sur cet artiste',
|
||||
loading: 'Chargement…',
|
||||
noRadio: 'Aucun morceau similaire trouvé pour cet artiste.',
|
||||
notFound: 'Artiste introuvable.',
|
||||
|
||||
@@ -20,6 +20,7 @@ export const common = {
|
||||
noFolderSelected: 'Aucun dossier sélectionné',
|
||||
rememberDownloadFolder: 'Mémoriser ce dossier',
|
||||
filterGenre: 'Filtre genre',
|
||||
filterGenreTooltip: 'Filtrer par genre',
|
||||
filterSearchGenres: 'Rechercher des genres…',
|
||||
filterNoGenres: 'Aucun genre trouvé',
|
||||
filterClear: 'Effacer',
|
||||
@@ -27,6 +28,7 @@ export const common = {
|
||||
favoritesTooltipOff: 'Afficher uniquement les favoris',
|
||||
favoritesTooltipOn: 'Tout afficher',
|
||||
play: 'Lire',
|
||||
addToQueue: 'Ajouter à la file d\'attente',
|
||||
bulkSelected: '{{count}} sélectionné(s)',
|
||||
clearSelection: 'Effacer la sélection',
|
||||
bulkAddToPlaylist: 'Ajouter à la playlist',
|
||||
|
||||
@@ -31,6 +31,11 @@ export const search = {
|
||||
advancedMoodGroup: 'Mood',
|
||||
advancedAllMoods: 'All moods',
|
||||
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||
scopeRowLabel: 'Rechercher dans :',
|
||||
scopeAllTooltip: 'Tout rechercher',
|
||||
scopeArtistsChipTooltip: 'Limiter aux artistes',
|
||||
scopeAlbumsChipTooltip: 'Limiter aux albums',
|
||||
scopeSongsChipTooltip: 'Limiter aux titres',
|
||||
advancedAll: 'Tous',
|
||||
advancedSearch: 'Rechercher',
|
||||
advancedEmpty: 'Entrez un terme de recherche ou sélectionnez un filtre.',
|
||||
|
||||
@@ -7,6 +7,9 @@ export const albumDetail = {
|
||||
enqueueTooltip: 'Legg hele albumet i kø',
|
||||
artistBio: 'Artistbiografi',
|
||||
download: 'Last ned (ZIP)',
|
||||
playTooltip: 'Spill dette albumet',
|
||||
artistBioTooltip: 'Les artistbiografien',
|
||||
downloadTooltip: 'Last ned dette albumet som ZIP',
|
||||
downloading: 'Laster…',
|
||||
cacheOffline: 'Gjør tilgjengelig som frakoblet',
|
||||
offlineCached: 'Tilgjengelig frakoblet',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const albums = {
|
||||
yearTo: 'Til',
|
||||
yearFilterClear: 'Tøm år filteret',
|
||||
yearFilterLabel: 'År',
|
||||
sortTooltip: 'Endre sorteringsrekkefølge',
|
||||
yearFilterTooltip: 'Filtrer etter utgivelsesår',
|
||||
compilationLabel: 'Samleplater',
|
||||
compilationOnly: 'Kun samleplater',
|
||||
compilationHide: 'Skjul samleplater',
|
||||
|
||||
@@ -6,6 +6,11 @@ export const artistDetail = {
|
||||
shareArtist: 'Del artist',
|
||||
shuffle: 'Tilfeldig rekkefølge',
|
||||
radio: 'Radio',
|
||||
lastfmTooltip: 'Åpne denne artisten på Last.fm',
|
||||
wikipediaTooltip: 'Åpne denne artisten på Wikipedia',
|
||||
playAllTooltip: 'Spill hele diskografien',
|
||||
shuffleTooltip: 'Spill hele diskografien i tilfeldig rekkefølge',
|
||||
radioTooltip: 'Start en radio basert på denne artisten',
|
||||
loading: 'Laster…',
|
||||
noRadio: 'Ingen lignende spor funnet for denne artisten.',
|
||||
notFound: 'Artisten ble ikke funnet.',
|
||||
|
||||
@@ -20,6 +20,7 @@ export const common = {
|
||||
noFolderSelected: 'Ingen mappe valgt',
|
||||
rememberDownloadFolder: 'Husk denne mappen',
|
||||
filterGenre: 'Sjangerfilter',
|
||||
filterGenreTooltip: 'Filtrer etter sjanger',
|
||||
filterSearchGenres: 'Søk i sjangre…',
|
||||
filterNoGenres: 'Ingen sjangre samsvarer',
|
||||
filterClear: 'Tøm',
|
||||
@@ -27,6 +28,7 @@ export const common = {
|
||||
favoritesTooltipOff: 'Vis bare favoritter',
|
||||
favoritesTooltipOn: 'Vis alle',
|
||||
play: 'Spill',
|
||||
addToQueue: 'Legg i kø',
|
||||
bulkSelected: '{{count}} valgt',
|
||||
clearSelection: 'Fjern utvalg',
|
||||
bulkAddToPlaylist: 'Legg til i spilleliste',
|
||||
|
||||
@@ -31,6 +31,11 @@ export const search = {
|
||||
advancedMoodGroup: 'Mood',
|
||||
advancedAllMoods: 'All moods',
|
||||
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||
scopeRowLabel: 'Søk i:',
|
||||
scopeAllTooltip: 'Søk i alt',
|
||||
scopeArtistsChipTooltip: 'Begrens til artister',
|
||||
scopeAlbumsChipTooltip: 'Begrens til album',
|
||||
scopeSongsChipTooltip: 'Begrens til sanger',
|
||||
advancedAll: 'Alle',
|
||||
advancedSearch: 'Søk',
|
||||
advancedEmpty: 'Skriv inn et søkeord eller velg ett filter for å begynne.',
|
||||
|
||||
@@ -7,6 +7,9 @@ export const albumDetail = {
|
||||
enqueueTooltip: 'Volledig album aan wachtrij toevoegen',
|
||||
artistBio: 'Artiest biografie',
|
||||
download: 'Downloaden (ZIP)',
|
||||
playTooltip: 'Dit album afspelen',
|
||||
artistBioTooltip: 'Artiestbiografie lezen',
|
||||
downloadTooltip: 'Dit album als ZIP downloaden',
|
||||
downloading: 'Laden…',
|
||||
cacheOffline: 'Offline beschikbaar maken',
|
||||
offlineCached: 'Offline beschikbaar',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const albums = {
|
||||
yearTo: 'Tot',
|
||||
yearFilterClear: 'Jaarfilter wissen',
|
||||
yearFilterLabel: 'Jaar',
|
||||
sortTooltip: 'Sorteervolgorde wijzigen',
|
||||
yearFilterTooltip: 'Filteren op uitgavejaar',
|
||||
compilationLabel: 'Compilaties',
|
||||
compilationOnly: 'Alleen compilaties',
|
||||
compilationHide: 'Compilaties verbergen',
|
||||
|
||||
@@ -6,6 +6,11 @@ export const artistDetail = {
|
||||
shareArtist: 'Artiest delen',
|
||||
shuffle: 'Willekeurig',
|
||||
radio: 'Radio',
|
||||
lastfmTooltip: 'Deze artiest openen op Last.fm',
|
||||
wikipediaTooltip: 'Deze artiest openen op Wikipedia',
|
||||
playAllTooltip: 'Volledige discografie afspelen',
|
||||
shuffleTooltip: 'Volledige discografie willekeurig afspelen',
|
||||
radioTooltip: 'Radio op basis van deze artiest starten',
|
||||
loading: 'Laden…',
|
||||
noRadio: 'Geen vergelijkbare nummers gevonden voor deze artiest.',
|
||||
notFound: 'Artiest niet gevonden.',
|
||||
|
||||
@@ -20,6 +20,7 @@ export const common = {
|
||||
noFolderSelected: 'Geen map geselecteerd',
|
||||
rememberDownloadFolder: 'Deze map onthouden',
|
||||
filterGenre: 'Genre-filter',
|
||||
filterGenreTooltip: 'Filteren op genre',
|
||||
filterSearchGenres: 'Genres zoeken…',
|
||||
filterNoGenres: 'Geen genres gevonden',
|
||||
filterClear: 'Wissen',
|
||||
@@ -27,6 +28,7 @@ export const common = {
|
||||
favoritesTooltipOff: 'Alleen favorieten tonen',
|
||||
favoritesTooltipOn: 'Alles tonen',
|
||||
play: 'Afspelen',
|
||||
addToQueue: 'Aan wachtrij toevoegen',
|
||||
bulkSelected: '{{count}} geselecteerd',
|
||||
clearSelection: 'Selectie wissen',
|
||||
bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
|
||||
|
||||
@@ -31,6 +31,11 @@ export const search = {
|
||||
advancedMoodGroup: 'Mood',
|
||||
advancedAllMoods: 'All moods',
|
||||
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||
scopeRowLabel: 'Zoeken in:',
|
||||
scopeAllTooltip: 'Alles doorzoeken',
|
||||
scopeArtistsChipTooltip: 'Beperken tot artiesten',
|
||||
scopeAlbumsChipTooltip: 'Beperken tot albums',
|
||||
scopeSongsChipTooltip: 'Beperken tot nummers',
|
||||
advancedAll: 'Alle',
|
||||
advancedSearch: 'Zoeken',
|
||||
advancedEmpty: 'Voer een zoekterm in of selecteer een filter.',
|
||||
|
||||
@@ -7,6 +7,9 @@ export const albumDetail = {
|
||||
enqueueTooltip: 'Adaugă întregul album în coadă',
|
||||
artistBio: 'Biografia Artistului',
|
||||
download: 'Descarcă (ZIP)',
|
||||
playTooltip: 'Redă acest album',
|
||||
artistBioTooltip: 'Citește biografia artistului',
|
||||
downloadTooltip: 'Descarcă acest album ca ZIP',
|
||||
downloading: 'Se încarcă…',
|
||||
cacheOffline: 'Fă disponibil offline',
|
||||
offlineCached: 'Disponibil offline',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const albums = {
|
||||
yearTo: 'Până la',
|
||||
yearFilterClear: 'Golește filtrul de an',
|
||||
yearFilterLabel: 'An',
|
||||
sortTooltip: 'Schimbă ordinea de sortare',
|
||||
yearFilterTooltip: 'Filtrează după anul lansării',
|
||||
compilationLabel: 'Compilații',
|
||||
compilationOnly: 'Doar compilații',
|
||||
compilationHide: 'Ascunde compilațiile',
|
||||
|
||||
@@ -6,6 +6,11 @@ export const artistDetail = {
|
||||
shareArtist: 'Distribuie artistul',
|
||||
shuffle: 'Amestecă',
|
||||
radio: 'Radio',
|
||||
lastfmTooltip: 'Deschide acest artist pe Last.fm',
|
||||
wikipediaTooltip: 'Deschide acest artist pe Wikipedia',
|
||||
playAllTooltip: 'Redă întreaga discografie',
|
||||
shuffleTooltip: 'Redă întreaga discografie amestecat',
|
||||
radioTooltip: 'Pornește un radio bazat pe acest artist',
|
||||
loading: 'Se încarcă…',
|
||||
noRadio: 'Nicio piesă similară găsită pentru acest artist.',
|
||||
notFound: 'Artistul nu a fost găsit.',
|
||||
|
||||
@@ -21,6 +21,7 @@ export const common = {
|
||||
noFolderSelected: 'Niciun folder selectat',
|
||||
rememberDownloadFolder: 'Ține minte acest folder',
|
||||
filterGenre: 'Filtru de gen',
|
||||
filterGenreTooltip: 'Filtrează după gen',
|
||||
filterSearchGenres: 'Caută genuri…',
|
||||
filterNoGenres: 'Niciun gen nu corespunde',
|
||||
filterClear: 'Golește',
|
||||
@@ -28,6 +29,7 @@ export const common = {
|
||||
favoritesTooltipOff: 'Arată doar favorite',
|
||||
favoritesTooltipOn: 'Arată tot',
|
||||
play: 'Redă',
|
||||
addToQueue: 'Adaugă în coadă',
|
||||
bulkSelected: '{{count}} selectate',
|
||||
clearSelection: 'Golește selecția',
|
||||
bulkAddToPlaylist: 'Adaugă la Playlist',
|
||||
|
||||
@@ -31,6 +31,11 @@ export const search = {
|
||||
advancedMoodGroup: 'Mood',
|
||||
advancedAllMoods: 'All moods',
|
||||
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||
scopeRowLabel: 'Caută în:',
|
||||
scopeAllTooltip: 'Caută peste tot',
|
||||
scopeArtistsChipTooltip: 'Limitează la artiști',
|
||||
scopeAlbumsChipTooltip: 'Limitează la albume',
|
||||
scopeSongsChipTooltip: 'Limitează la piese',
|
||||
advancedAll: 'Toate',
|
||||
advancedSearch: 'Căutare',
|
||||
advancedEmpty: 'Introdu un termen de căutare sau alege un filtru pentru a începe',
|
||||
|
||||
@@ -7,6 +7,9 @@ export const albumDetail = {
|
||||
enqueueTooltip: 'Добавить весь альбом в очередь',
|
||||
artistBio: 'Биография',
|
||||
download: 'Скачать (ZIP)',
|
||||
playTooltip: 'Воспроизвести этот альбом',
|
||||
artistBioTooltip: 'Читать биографию исполнителя',
|
||||
downloadTooltip: 'Скачать этот альбом в ZIP',
|
||||
downloading: 'Загрузка…',
|
||||
cacheOffline: 'Сохранить офлайн',
|
||||
offlineCached: 'Доступно офлайн',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const albums = {
|
||||
yearTo: 'По',
|
||||
yearFilterClear: 'Сбросить год',
|
||||
yearFilterLabel: 'Год',
|
||||
sortTooltip: 'Изменить порядок сортировки',
|
||||
yearFilterTooltip: 'Фильтр по году выпуска',
|
||||
compilationLabel: 'Сборники',
|
||||
compilationOnly: 'Только сборники',
|
||||
compilationHide: 'Скрыть сборники',
|
||||
|
||||
@@ -6,6 +6,11 @@ export const artistDetail = {
|
||||
shareArtist: 'Поделиться исполнителем',
|
||||
shuffle: 'Перемешать',
|
||||
radio: 'Радио',
|
||||
lastfmTooltip: 'Открыть этого исполнителя на Last.fm',
|
||||
wikipediaTooltip: 'Открыть этого исполнителя в Википедии',
|
||||
playAllTooltip: 'Воспроизвести всю дискографию',
|
||||
shuffleTooltip: 'Воспроизвести всю дискографию вперемешку',
|
||||
radioTooltip: 'Запустить радио на основе этого исполнителя',
|
||||
loading: 'Загрузка…',
|
||||
noRadio: 'Похожие треки для этого исполнителя не найдены.',
|
||||
notFound: 'Исполнитель не найден.',
|
||||
|
||||
@@ -20,6 +20,7 @@ export const common = {
|
||||
noFolderSelected: 'Папка не выбрана',
|
||||
rememberDownloadFolder: 'Запомнить папку',
|
||||
filterGenre: 'Фильтр по жанру',
|
||||
filterGenreTooltip: 'Фильтр по жанру',
|
||||
filterSearchGenres: 'Поиск жанров…',
|
||||
filterNoGenres: 'Нет совпадений',
|
||||
filterClear: 'Сбросить',
|
||||
@@ -27,6 +28,7 @@ export const common = {
|
||||
favoritesTooltipOff: 'Показывать только избранное',
|
||||
favoritesTooltipOn: 'Показать всё',
|
||||
play: 'Воспроизвести',
|
||||
addToQueue: 'Добавить в очередь',
|
||||
bulkSelected: 'Выбрано: {{count}}',
|
||||
clearSelection: 'Сбросить выбор',
|
||||
bulkAddToPlaylist: 'В плейлист',
|
||||
|
||||
@@ -31,6 +31,11 @@ export const search = {
|
||||
advancedMoodGroup: 'Настроение',
|
||||
advancedAllMoods: 'Все настроения',
|
||||
advancedMoodLocalNote: 'Только локальный индекс и анализ треков',
|
||||
scopeRowLabel: 'Искать в:',
|
||||
scopeAllTooltip: 'Искать везде',
|
||||
scopeArtistsChipTooltip: 'Только исполнители',
|
||||
scopeAlbumsChipTooltip: 'Только альбомы',
|
||||
scopeSongsChipTooltip: 'Только треки',
|
||||
advancedAll: 'Все',
|
||||
advancedSearch: 'Найти',
|
||||
advancedEmpty: 'Введите запрос или выберите фильтр.',
|
||||
|
||||
@@ -7,6 +7,9 @@ export const albumDetail = {
|
||||
enqueueTooltip: '将整张专辑添加到播放队列',
|
||||
artistBio: '艺术家简介',
|
||||
download: '下载 (ZIP)',
|
||||
playTooltip: '播放此专辑',
|
||||
artistBioTooltip: '阅读艺术家简介',
|
||||
downloadTooltip: '将此专辑下载为 ZIP',
|
||||
downloading: '加载中…',
|
||||
cacheOffline: '设为离线可用',
|
||||
offlineCached: '已离线缓存',
|
||||
|
||||
@@ -8,6 +8,8 @@ export const albums = {
|
||||
yearTo: '到',
|
||||
yearFilterClear: '清除年份筛选',
|
||||
yearFilterLabel: '年份',
|
||||
sortTooltip: '更改排序方式',
|
||||
yearFilterTooltip: '按发行年份筛选',
|
||||
compilationLabel: '合辑',
|
||||
compilationOnly: '仅合辑',
|
||||
compilationHide: '隐藏合辑',
|
||||
|
||||
@@ -6,6 +6,11 @@ export const artistDetail = {
|
||||
shareArtist: '分享艺人',
|
||||
shuffle: '随机播放',
|
||||
radio: '电台',
|
||||
lastfmTooltip: '在 Last.fm 上打开该艺人',
|
||||
wikipediaTooltip: '在维基百科上打开该艺人',
|
||||
playAllTooltip: '播放全部作品',
|
||||
shuffleTooltip: '随机播放全部作品',
|
||||
radioTooltip: '基于该艺人开始电台',
|
||||
loading: '加载中…',
|
||||
noRadio: '未找到该艺术家的相似曲目。',
|
||||
notFound: '未找到艺术家。',
|
||||
|
||||
@@ -20,6 +20,7 @@ export const common = {
|
||||
noFolderSelected: '未选择文件夹',
|
||||
rememberDownloadFolder: '记住此文件夹',
|
||||
filterGenre: '流派筛选',
|
||||
filterGenreTooltip: '按流派筛选',
|
||||
filterSearchGenres: '搜索流派…',
|
||||
filterNoGenres: '未找到匹配流派',
|
||||
filterClear: '清除',
|
||||
@@ -27,6 +28,7 @@ export const common = {
|
||||
favoritesTooltipOff: '仅显示收藏',
|
||||
favoritesTooltipOn: '显示全部',
|
||||
play: '播放',
|
||||
addToQueue: '添加到队列',
|
||||
bulkSelected: '已选 {{count}} 首',
|
||||
clearSelection: '清除选择',
|
||||
bulkAddToPlaylist: '添加到播放列表',
|
||||
|
||||
@@ -31,6 +31,11 @@ export const search = {
|
||||
advancedMoodGroup: 'Mood',
|
||||
advancedAllMoods: 'All moods',
|
||||
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||
scopeRowLabel: '搜索范围:',
|
||||
scopeAllTooltip: '搜索全部',
|
||||
scopeArtistsChipTooltip: '仅显示艺术家',
|
||||
scopeAlbumsChipTooltip: '仅显示专辑',
|
||||
scopeSongsChipTooltip: '仅显示歌曲',
|
||||
advancedAll: '全部',
|
||||
advancedSearch: '搜索',
|
||||
advancedEmpty: '请输入搜索词或选择过滤器。',
|
||||
|
||||
@@ -384,6 +384,7 @@ export default function Albums() {
|
||||
value={sort}
|
||||
options={sortOptions}
|
||||
onChange={onSortChange}
|
||||
tooltip={t('albums.sortTooltip')}
|
||||
/>
|
||||
|
||||
<YearFilterButton
|
||||
|
||||
@@ -331,6 +331,7 @@ export default function Artists() {
|
||||
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
|
||||
data-tooltip-wrap
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Images size={20} />
|
||||
</button>
|
||||
@@ -339,6 +340,7 @@ export default function Artists() {
|
||||
onClick={() => setViewMode('grid')}
|
||||
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.gridView')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</button>
|
||||
@@ -347,6 +349,7 @@ export default function Artists() {
|
||||
onClick={() => setViewMode('list')}
|
||||
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.listView')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<List size={20} />
|
||||
</button>
|
||||
|
||||
@@ -11,6 +11,7 @@ import ArtistRow from '../components/ArtistRow';
|
||||
import PagedSongList from '../components/PagedSongList';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
import StarFilterButton from '../components/StarFilterButton';
|
||||
import { tooltipAttrs } from '../components/tooltipAttrs';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -821,11 +822,11 @@ export default function SearchBrowsePage() {
|
||||
});
|
||||
};
|
||||
|
||||
const typeOptions: { id: ResultType; label: string }[] = [
|
||||
{ id: 'all', label: t('search.advancedAll') },
|
||||
{ id: 'artists', label: t('search.artists') },
|
||||
{ id: 'albums', label: t('search.albums') },
|
||||
{ id: 'songs', label: t('search.songs') },
|
||||
const typeOptions: { id: ResultType; label: string; tooltip: string }[] = [
|
||||
{ id: 'all', label: t('search.advancedAll'), tooltip: t('search.scopeAllTooltip') },
|
||||
{ id: 'artists', label: t('search.artists'), tooltip: t('search.scopeArtistsChipTooltip') },
|
||||
{ id: 'albums', label: t('search.albums'), tooltip: t('search.scopeAlbumsChipTooltip') },
|
||||
{ id: 'songs', label: t('search.songs'), tooltip: t('search.scopeSongsChipTooltip') },
|
||||
];
|
||||
|
||||
const genreSelectOptions = [
|
||||
@@ -1059,6 +1060,11 @@ export default function SearchBrowsePage() {
|
||||
{/* Row 4: Result type + Search button */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{!trackFilterActive && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginRight: '0.15rem' }}>
|
||||
{t('search.scopeRowLabel')}
|
||||
</span>
|
||||
)}
|
||||
{!trackFilterActive && typeOptions.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
@@ -1066,6 +1072,7 @@ export default function SearchBrowsePage() {
|
||||
className={`btn ${resultType === opt.id ? 'btn-primary' : 'btn-surface'}`}
|
||||
style={{ fontSize: 12, padding: '4px 14px' }}
|
||||
onClick={() => setResultType(opt.id)}
|
||||
{...tooltipAttrs(opt.tooltip)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user