Files
psysonic/src/pages/Help.tsx
T
Frank Stellmacher e215694301 feat(help): rewrite Help page — trimmed Q/A, 10 sections, live search (#485)
* feat(help): rewrite English Q/A entries — trim, consolidate, refresh

The Help page had grown to ~50 entries over time, with several that
the UI itself answers (double-click to play, click the cover for
fullscreen, click the repeat button to cycle, …) and other groups
that were better folded into a single answer (rating + Skip-to-1★,
Internet Radio basics + supported formats, Device Sync overview +
filename template + cross-platform behaviour, …).

This pass:
- drops obviously-redundant entries (q4, q7, q8, q11, q22, q24, q25,
  and the trivial Settings → X pointers q12, q13, q15, q32, q42, q43)
- consolidates the natural groupings (q5+q31, q37+q38+q39, q53+q54+q55,
  q34+q35+q47, q26+q27+q28, q12+q41, q56+q57)
- adds entries for features that did not exist yet when the previous
  Q/A list was written: Orbit (Listen Together), Magic Strings
  sharing, LUFS Smart Loudness Normalization, Mini Player + Floating
  Player Bar, Smart Playlists, Track Preview, Search and Advanced
  Search, Statistics, Tracks library hub, Genre tag-cloud browser,
  Discord Rich Presence, Bandsintown tour dates, Multi-select +
  Shift-click range selection, Sidebar / Home / Artist Page
  customization, Sleep Timer, Open Source Licenses

Result: 45 focused entries across 10 sections (Getting Started /
Playback & Queue / Audio Tools / Library & Discovery / Lyrics /
Sharing & Social / Personalization / Power User / Offline & Sync /
Integrations & Troubleshooting), each one answering something the UI
does not already answer at a glance.

* feat(help): restructure into 10 sections with live search

Page is now organised into ten focused sections (Getting Started,
Playback & Queue, Audio Tools, Library & Discovery, Lyrics, Sharing
& Social, Personalization, Power User, Offline & Sync, Integrations
& Troubleshooting) each rendered as its own column-friendly accordion
group with a Lucide icon.

A search input lives in the page header. Typing filters every Q+A
pair across all sections by case-insensitive substring; sections that
end up empty are hidden, matched items are auto-expanded so the user
sees the answer without having to click each result, and a "no
results" empty state appears when the query matches nothing. Clearing
the input restores the manual accordion behaviour. An × button next
to the input clears the query in one click.

CSS uses dedicated `.help-search`, `.help-search-icon`,
`.help-search-input`, `.help-search-clear` rules instead of leaning
on the global `.input` class — the latter brought its own focus-ring
styles that doubled with the wrapper border. Focus state highlights
the wrapper border to `--accent` via `:focus-within`.

* i18n(help): translate the new Help page to 7 locales

Updates de, fr, nl, zh, nb, ru, es to match the new English Q/A
structure (45 entries across 10 sections, plus the live-search
labels: title, searchPlaceholder, noResults).

DE / FR / NL / NB / ES were translated directly. RU and ZH are
structurally correct but written at machine-translation quality;
both could use a pass from the original locale maintainers
(@cucadmuh for RU, @jiezhuo for ZH) — none of the wording is
load-bearing for the i18n keys, so the page renders correctly today
and refinements can land as follow-up touch-ups without coupling.

* docs: changelog + contributors for PR #485

Adds the v1.46.0 "Changed" entry and the Psychotoxical contributors
line for the Help page rewrite.
2026-05-06 19:28:47 +02:00

236 lines
7.6 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import {
ChevronDown, Search, Rocket, Play, Sliders, LibraryBig, Mic2, Share2,
Palette, Wrench, WifiOff, X,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface FaqItem { id: string; q: string; a: string; }
interface FaqSection { id: string; icon: React.ReactNode; title: string; items: FaqItem[]; }
function AccordionItem({ q, a, open, onToggle }: { q: string; a: string; open: boolean; onToggle: () => void }) {
return (
<div className={`help-item${open ? ' help-item-open' : ''}`}>
<button className="help-question" onClick={onToggle} aria-expanded={open}>
<span>{q}</span>
<ChevronDown size={16} className="help-chevron" />
</button>
{open && <div className="help-answer">{a}</div>}
</div>
);
}
export default function Help() {
const { t } = useTranslation();
const [openKey, setOpenKey] = useState<string | null>(null);
const [query, setQuery] = useState('');
const toggle = (key: string) => setOpenKey(prev => prev === key ? null : key);
const sections: FaqSection[] = useMemo(() => [
{
id: 's1',
icon: <Rocket size={18} />,
title: t('help.s1'),
items: [
{ id: 'q1', q: t('help.q1'), a: t('help.a1') },
{ id: 'q2', q: t('help.q2'), a: t('help.a2') },
{ id: 'q3', q: t('help.q3'), a: t('help.a3') },
],
},
{
id: 's2',
icon: <Play size={18} />,
title: t('help.s2'),
items: [
{ id: 'q4', q: t('help.q4'), a: t('help.a4') },
{ id: 'q5', q: t('help.q5'), a: t('help.a5') },
{ id: 'q6', q: t('help.q6'), a: t('help.a6') },
{ id: 'q7', q: t('help.q7'), a: t('help.a7') },
{ id: 'q8', q: t('help.q8'), a: t('help.a8') },
],
},
{
id: 's3',
icon: <Sliders size={18} />,
title: t('help.s3'),
items: [
{ id: 'q9', q: t('help.q9'), a: t('help.a9') },
{ id: 'q10', q: t('help.q10'), a: t('help.a10') },
{ id: 'q11', q: t('help.q11'), a: t('help.a11') },
{ id: 'q12', q: t('help.q12'), a: t('help.a12') },
{ id: 'q13', q: t('help.q13'), a: t('help.a13') },
],
},
{
id: 's4',
icon: <LibraryBig size={18} />,
title: t('help.s4'),
items: [
{ id: 'q14', q: t('help.q14'), a: t('help.a14') },
{ id: 'q15', q: t('help.q15'), a: t('help.a15') },
{ id: 'q16', q: t('help.q16'), a: t('help.a16') },
{ id: 'q17', q: t('help.q17'), a: t('help.a17') },
{ id: 'q18', q: t('help.q18'), a: t('help.a18') },
{ id: 'q19', q: t('help.q19'), a: t('help.a19') },
],
},
{
id: 's5',
icon: <Mic2 size={18} />,
title: t('help.s5'),
items: [
{ id: 'q20', q: t('help.q20'), a: t('help.a20') },
{ id: 'q21', q: t('help.q21'), a: t('help.a21') },
],
},
{
id: 's6',
icon: <Share2 size={18} />,
title: t('help.s6'),
items: [
{ id: 'q22', q: t('help.q22'), a: t('help.a22') },
{ id: 'q23', q: t('help.q23'), a: t('help.a23') },
{ id: 'q24', q: t('help.q24'), a: t('help.a24') },
],
},
{
id: 's7',
icon: <Palette size={18} />,
title: t('help.s7'),
items: [
{ id: 'q25', q: t('help.q25'), a: t('help.a25') },
{ id: 'q26', q: t('help.q26'), a: t('help.a26') },
{ id: 'q27', q: t('help.q27'), a: t('help.a27') },
{ id: 'q28', q: t('help.q28'), a: t('help.a28') },
{ id: 'q29', q: t('help.q29'), a: t('help.a29') },
{ id: 'q30', q: t('help.q30'), a: t('help.a30') },
{ id: 'q31', q: t('help.q31'), a: t('help.a31') },
],
},
{
id: 's8',
icon: <Wrench size={18} />,
title: t('help.s8'),
items: [
{ id: 'q32', q: t('help.q32'), a: t('help.a32') },
{ id: 'q33', q: t('help.q33'), a: t('help.a33') },
{ id: 'q34', q: t('help.q34'), a: t('help.a34') },
{ id: 'q35', q: t('help.q35'), a: t('help.a35') },
],
},
{
id: 's9',
icon: <WifiOff size={18} />,
title: t('help.s9'),
items: [
{ id: 'q36', q: t('help.q36'), a: t('help.a36') },
{ id: 'q37', q: t('help.q37'), a: t('help.a37') },
{ id: 'q38', q: t('help.q38'), a: t('help.a38') },
],
},
{
id: 's10',
icon: <Wrench size={18} />,
title: t('help.s10'),
items: [
{ id: 'q39', q: t('help.q39'), a: t('help.a39') },
{ id: 'q40', q: t('help.q40'), a: t('help.a40') },
{ id: 'q41', q: t('help.q41'), a: t('help.a41') },
{ id: 'q42', q: t('help.q42'), a: t('help.a42') },
{ id: 'q43', q: t('help.q43'), a: t('help.a43') },
{ id: 'q44', q: t('help.q44'), a: t('help.a44') },
{ id: 'q45', q: t('help.q45'), a: t('help.a45') },
],
},
], [t]);
const trimmedQuery = query.trim().toLowerCase();
const filteredSections = useMemo(() => {
if (!trimmedQuery) return sections;
return sections
.map(s => ({
...s,
items: s.items.filter(i =>
i.q.toLowerCase().includes(trimmedQuery) ||
i.a.toLowerCase().includes(trimmedQuery),
),
}))
.filter(s => s.items.length > 0);
}, [sections, trimmedQuery]);
const totalHits = filteredSections.reduce((n, s) => n + s.items.length, 0);
const isSearching = trimmedQuery.length > 0;
return (
<div className="content-body animate-fade-in">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
marginBottom: '1.25rem',
flexWrap: 'wrap',
}}
>
<h1 className="page-title" style={{ margin: 0 }}>{t('help.title')}</h1>
<div className="help-search">
<Search size={14} className="help-search-icon" />
<input
type="text"
className="help-search-input"
placeholder={t('help.searchPlaceholder')}
value={query}
onChange={e => setQuery(e.target.value)}
/>
{query && (
<button
type="button"
className="help-search-clear"
onClick={() => setQuery('')}
aria-label={t('common.close')}
>
<X size={14} />
</button>
)}
</div>
</div>
{isSearching && totalHits === 0 && (
<div className="empty-state" style={{ padding: '2rem 0' }}>
{t('help.noResults')}
</div>
)}
<div className="help-columns">
{filteredSections.map(section => (
<section key={section.id} className="settings-section" style={{ breakInside: 'avoid', marginBottom: '1.25rem' }}>
<div className="settings-section-header">
{section.icon}
<h2>{section.title}</h2>
</div>
<div className="help-list">
{section.items.map(item => {
const key = `${section.id}-${item.id}`;
// While searching, keep matched items expanded so the user sees the answer
// without having to click each result individually.
const isOpen = isSearching ? true : openKey === key;
return (
<AccordionItem
key={key}
q={item.q}
a={item.a}
open={isOpen}
onToggle={() => toggle(key)}
/>
);
})}
</div>
</section>
))}
</div>
</div>
);
}