mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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.
This commit is contained in:
committed by
GitHub
parent
43d75e744b
commit
e215694301
+165
-97
@@ -1,11 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench, Shuffle, WifiOff, HardDriveUpload, Mic2 } from 'lucide-react';
|
||||
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 { q: string; a: string; }
|
||||
interface FaqSection { icon: React.ReactNode; title: string; items: FaqItem[]; }
|
||||
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 }: FaqItem & { open: boolean; onToggle: () => void }) {
|
||||
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}>
|
||||
@@ -20,143 +23,208 @@ function AccordionItem({ q, a, open, onToggle }: FaqItem & { open: boolean; onTo
|
||||
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[] = [
|
||||
const sections: FaqSection[] = useMemo(() => [
|
||||
{
|
||||
id: 's1',
|
||||
icon: <Rocket size={18} />,
|
||||
title: t('help.s1'),
|
||||
items: [
|
||||
{ q: t('help.q1'), a: t('help.a1') },
|
||||
{ q: t('help.q2'), a: t('help.a2') },
|
||||
{ q: t('help.q3'), a: t('help.a3') },
|
||||
{ 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: [
|
||||
{ q: t('help.q4'), a: t('help.a4') },
|
||||
{ q: t('help.q5'), a: t('help.a5') },
|
||||
{ q: t('help.q6'), a: t('help.a6') },
|
||||
{ q: t('help.q7'), a: t('help.a7') },
|
||||
{ q: t('help.q8'), a: t('help.a8') },
|
||||
{ q: t('help.q22'), a: t('help.a22') },
|
||||
{ q: t('help.q24'), a: t('help.a24') },
|
||||
{ q: t('help.q29'), a: t('help.a29') },
|
||||
{ q: t('help.q48'), a: t('help.a48') },
|
||||
{ q: t('help.q30'), a: t('help.a30') },
|
||||
{ q: t('help.q58'), a: t('help.a58') },
|
||||
{ q: t('help.q49'), a: t('help.a49') },
|
||||
{ q: t('help.q59'), a: t('help.a59') },
|
||||
{ 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') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <LibraryBig size={18} />,
|
||||
id: 's3',
|
||||
icon: <Sliders size={18} />,
|
||||
title: t('help.s3'),
|
||||
items: [
|
||||
{ q: t('help.q9'), a: t('help.a9') },
|
||||
{ q: t('help.q10'), a: t('help.a10') },
|
||||
{ q: t('help.q37'), a: t('help.a37') },
|
||||
{ q: t('help.q38'), a: t('help.a38') },
|
||||
{ q: t('help.q39'), a: t('help.a39') },
|
||||
{ q: t('help.q40'), a: t('help.a40') },
|
||||
{ q: t('help.q50'), a: t('help.a50') },
|
||||
{ q: t('help.q11'), a: t('help.a11') },
|
||||
{ q: t('help.q25'), a: t('help.a25') },
|
||||
{ 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') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Settings2 size={18} />,
|
||||
id: 's4',
|
||||
icon: <LibraryBig size={18} />,
|
||||
title: t('help.s4'),
|
||||
items: [
|
||||
{ q: t('help.q12'), a: t('help.a12') },
|
||||
{ q: t('help.q41'), a: t('help.a41') },
|
||||
{ q: t('help.q42'), a: t('help.a42') },
|
||||
{ q: t('help.q43'), a: t('help.a43') },
|
||||
{ q: t('help.q13'), a: t('help.a13') },
|
||||
{ q: t('help.q32'), a: t('help.a32') },
|
||||
{ q: t('help.q15'), a: t('help.a15') },
|
||||
{ q: t('help.q31'), a: t('help.a31') },
|
||||
{ q: t('help.q51'), a: t('help.a51') },
|
||||
{ q: t('help.q44'), a: t('help.a44') },
|
||||
{ q: t('help.q45'), a: t('help.a45') },
|
||||
{ q: t('help.q52'), a: t('help.a52') },
|
||||
{ q: t('help.q46'), a: t('help.a46') },
|
||||
{ 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') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Radio size={18} />,
|
||||
id: 's5',
|
||||
icon: <Mic2 size={18} />,
|
||||
title: t('help.s5'),
|
||||
items: [
|
||||
{ q: t('help.q16'), a: t('help.a16') },
|
||||
{ q: t('help.q17'), a: t('help.a17') },
|
||||
{ id: 'q20', q: t('help.q20'), a: t('help.a20') },
|
||||
{ id: 'q21', q: t('help.q21'), a: t('help.a21') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Mic2 size={18} />,
|
||||
title: t('help.s10'),
|
||||
items: [
|
||||
{ q: t('help.q56'), a: t('help.a56') },
|
||||
{ q: t('help.q57'), a: t('help.a57') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Shuffle size={18} />,
|
||||
title: t('help.s7'),
|
||||
items: [
|
||||
{ q: t('help.q26'), a: t('help.a26') },
|
||||
{ q: t('help.q27'), a: t('help.a27') },
|
||||
{ q: t('help.q28'), a: t('help.a28') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <HardDriveUpload size={18} />,
|
||||
title: t('help.s9'),
|
||||
items: [
|
||||
{ q: t('help.q53'), a: t('help.a53') },
|
||||
{ q: t('help.q54'), a: t('help.a54') },
|
||||
{ q: t('help.q55'), a: t('help.a55') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <WifiOff size={18} />,
|
||||
title: t('help.s8'),
|
||||
items: [
|
||||
{ q: t('help.q34'), a: t('help.a34') },
|
||||
{ q: t('help.q35'), a: t('help.a35') },
|
||||
{ q: t('help.q47'), a: t('help.a47') },
|
||||
{ q: t('help.q36'), a: t('help.a36') },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Wrench size={18} />,
|
||||
id: 's6',
|
||||
icon: <Share2 size={18} />,
|
||||
title: t('help.s6'),
|
||||
items: [
|
||||
{ q: t('help.q18'), a: t('help.a18') },
|
||||
{ q: t('help.q19'), a: t('help.a19') },
|
||||
{ q: t('help.q20'), a: t('help.a20') },
|
||||
{ q: t('help.q21'), a: t('help.a21') },
|
||||
{ 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">
|
||||
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1>
|
||||
<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">
|
||||
{sections.map((section, si) => (
|
||||
<section key={si} className="settings-section" style={{ breakInside: 'avoid', marginBottom: '1.25rem' }}>
|
||||
{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, ii) => {
|
||||
const key = `${si}-${ii}`;
|
||||
return <AccordionItem key={key} q={item.q} a={item.a} open={openKey === key} onToggle={() => toggle(key)} />;
|
||||
{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>
|
||||
|
||||
@@ -367,6 +367,7 @@ const CONTRIBUTORS = [
|
||||
'Queue: optional "Preserve Play Next order" toggle — multiple Play Next inserts queue up behind each other instead of latest-on-top (PR #464)',
|
||||
'Library: "favorites only" filter on Albums, Artists and Advanced Search — toolbar toggle reading star overrides live (PR #466)',
|
||||
'Settings: keep current active server when adding a new one — no more auto-switch interrupting playback or library context (PR #475)',
|
||||
'Help page: full rewrite with 45 focused entries across 10 themed sections (Getting Started / Playback & Queue / Audio Tools / Library & Discovery / Lyrics / Sharing & Social / Personalization / Power User / Offline & Sync / Integrations & Troubleshooting), in-page live search with case-insensitive substring matching and auto-expand on hits, translated to all 8 locales (PR #485)',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user