refactor(auth): co-locate login page into features/auth

This commit is contained in:
Psychotoxical
2026-06-30 18:16:44 +02:00
parent 2f4303ecc8
commit 132db4e620
9 changed files with 21 additions and 10 deletions
+11
View File
@@ -0,0 +1,11 @@
/**
* Auth feature — the server login / connection page (add server, test
* credentials, custom HTTP headers, magic-string import). The page is
* lazy-loaded by the auth shell via its deep path, so it is not re-exported
* here.
*
* Stays OUT (global / shared, consumed by this feature, not owned): `authStore`
* (the cross-cutting localStorage-backed server-profile store) and the
* `utils/server/*` server-profile config helpers (also used by settings).
*/
export {};
+134
View File
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { useAuthStore } from '@/store/authStore';
import { encodeServerMagicString } from '@/utils/server/serverMagicString';
vi.mock('@/lib/api/subsonic', () => ({
pingWithCredentialsForProfile: vi.fn(async () => ({
ok: true,
type: 'navidrome',
serverVersion: '0.55.0',
openSubsonic: true,
})),
scheduleInstantMixProbeForServer: vi.fn(),
}));
vi.mock('@/utils/server/syncServerHttpContext', () => ({
syncServerHttpContextForProfile: vi.fn(async () => undefined),
}));
import { pingWithCredentialsForProfile } from '@/lib/api/subsonic';
beforeEach(() => {
resetAuthStore();
vi.mocked(pingWithCredentialsForProfile).mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
describe('Login — v2 magic string paste persistence', () => {
it('persists alternateUrl + shareUsesLocalUrl from a pasted v2 invite', async () => {
const Login = (await import('@/features/auth/pages/Login')).default;
renderWithProviders(<Login />);
const user = userEvent.setup();
const magicString = encodeServerMagicString({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10:4533',
shareUsesLocalUrl: true,
username: 'tester',
password: 'pw',
});
// Find the magic-string input by its known label/placeholder. The login
// form has multiple inputs; we use the displayed-value-after-paste path
// to keep the test stable across UI label tweaks.
const allTextboxes = screen.getAllByRole('textbox');
// The magic-string input is the one whose change handler decodes and
// prefills the rest — paste into it and the form auto-populates.
const magicInput = allTextboxes[allTextboxes.length - 1]!;
await user.type(magicInput, magicString);
// Submit the form via the Connect button (label "Connect" in en locale).
const submit = screen.getByRole('button', { name: /connect/i });
await user.click(submit);
await waitFor(() => {
expect(useAuthStore.getState().servers.length).toBeGreaterThan(0);
});
const saved = useAuthStore.getState().servers[0]!;
expect(saved.url).toBe('https://music.example.com');
expect(saved.alternateUrl).toBe('http://192.168.0.10:4533');
expect(saved.shareUsesLocalUrl).toBe(true);
expect(saved.username).toBe('tester');
});
it('persists a v1 invite as a single-address profile (no alternateUrl)', async () => {
const Login = (await import('@/features/auth/pages/Login')).default;
renderWithProviders(<Login />);
const user = userEvent.setup();
const v1MagicString = encodeServerMagicString({
url: 'https://music.example.com',
username: 'tester',
password: 'pw',
});
const allTextboxes = screen.getAllByRole('textbox');
const magicInput = allTextboxes[allTextboxes.length - 1]!;
await user.type(magicInput, v1MagicString);
const submit = screen.getByRole('button', { name: /connect/i });
await user.click(submit);
await waitFor(() => {
expect(useAuthStore.getState().servers.length).toBeGreaterThan(0);
});
const saved = useAuthStore.getState().servers[0]!;
expect(saved.url).toBe('https://music.example.com');
// v1 invite — neither dual-address field should appear on the saved
// profile so localStorage doesn't carry dangling defaults.
expect(saved.alternateUrl).toBeUndefined();
expect(saved.shareUsesLocalUrl).toBeUndefined();
});
it('persists custom HTTP headers and probes with gate profile on first connect', async () => {
const Login = (await import('@/features/auth/pages/Login')).default;
renderWithProviders(<Login />);
const user = userEvent.setup();
await user.type(screen.getByLabelText(/server url/i), 'https://music.example.com');
await user.type(screen.getByLabelText(/username/i), 'tester');
await user.type(screen.getByPlaceholderText('••••••••'), 'pw');
await user.click(screen.getByRole('button', { name: /custom http headers/i }));
const nameInputs = screen.getAllByPlaceholderText(/header name/i);
const valueInputs = screen.getAllByPlaceholderText(/header value/i);
await user.type(nameInputs[0]!, 'CF-Access-Client-Secret');
await user.type(valueInputs[0]!, 'gate-secret');
await user.click(screen.getByRole('button', { name: /connect/i }));
await waitFor(() => {
expect(useAuthStore.getState().servers.length).toBe(1);
});
const saved = useAuthStore.getState().servers[0]!;
expect(saved.customHeaders).toEqual([{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }]);
expect(saved.customHeadersApplyTo).toBe('public');
expect(vi.mocked(pingWithCredentialsForProfile)).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://music.example.com',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
}),
'https://music.example.com',
);
});
});
+470
View File
@@ -0,0 +1,470 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Wifi, WifiOff, Eye, EyeOff, Server, Globe } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import type { CustomHeaderEntry, CustomHeadersApplyTo, ServerProfile } from '@/store/authStoreTypes';
import { pingWithCredentialsForProfile, scheduleInstantMixProbeForServer } from '@/lib/api/subsonic';
import { CustomHttpHeadersEditor } from '@/features/settings';
import {
DEFAULT_CUSTOM_HEADERS_APPLY_TO,
serverCustomHeadersFromForm,
validateCustomHeaders,
} from '@/utils/server/serverHttpHeaders';
import { syncServerHttpContextForProfile } from '@/utils/server/syncServerHttpContext';
import { useTranslation } from 'react-i18next';
import i18n from '@/lib/i18n';
import CustomSelect from '@/ui/CustomSelect';
import {
decodeServerMagicString,
DECODED_PASSWORD_VISUAL_MASK,
encodeServerMagicString,
type ServerMagicPayload,
} from '@/utils/server/serverMagicString';
import { shortHostFromServerUrl, serverListDisplayLabel } from '@/utils/server/serverDisplayName';
const PsysonicLogo = () => (
<img src="/logo-psysonic.png" width="64" height="64" alt="Psysonic" style={{ borderRadius: 18 }} />
);
export default function Login() {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { addServer, updateServer, setActiveServer, setLoggedIn, setConnecting, setConnectionError, servers } = useAuthStore();
// alternateUrl / shareUsesLocalUrl are not user-editable on this page (Login
// stays single-address by design); they're populated only when a v2 magic
// string is decoded so the dual-address shape persists straight from the
// invite onto the saved profile.
const [form, setForm] = useState({
serverName: '',
url: '',
username: '',
password: '',
alternateUrl: '' as string,
shareUsesLocalUrl: false,
customHeaders: [{ name: '', value: '' }] as CustomHeaderEntry[],
customHeadersApplyTo: DEFAULT_CUSTOM_HEADERS_APPLY_TO as CustomHeadersApplyTo,
customHeadersOpen: false,
});
const [magicString, setMagicString] = useState('');
const [showPass, setShowPass] = useState(false);
/** After a valid magic string decode, do not allow revealing the password in the UI. */
const [blockPasswordReveal, setBlockPasswordReveal] = useState(false);
const [status, setStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
useEffect(() => {
const inv = (location.state as { openAddServerInvite?: ServerMagicPayload } | null)?.openAddServerInvite;
if (!inv) return;
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowPass(false);
setBlockPasswordReveal(true);
setForm(f => ({
...f,
serverName: (inv.name && inv.name.trim()) || shortHostFromServerUrl(inv.url),
url: inv.url,
username: inv.username,
password: inv.password,
alternateUrl: inv.alternateUrl ?? '',
shareUsesLocalUrl: inv.shareUsesLocalUrl ?? false,
}));
setMagicString(encodeServerMagicString(inv));
navigate('/login', { replace: true, state: {} });
}, [location.state, navigate]);
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleMagicStringChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setMagicString(v);
const trimmed = v.trim();
const decoded = decodeServerMagicString(trimmed);
if (decoded) {
setShowPass(false);
setBlockPasswordReveal(true);
setForm(f => ({
...f,
serverName: (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url),
url: decoded.url,
username: decoded.username,
password: decoded.password,
alternateUrl: decoded.alternateUrl ?? '',
shareUsesLocalUrl: decoded.shareUsesLocalUrl ?? false,
}));
if (status === 'error') {
setStatus('idle');
setTestMessage('');
}
}
};
const attemptConnect = async (profile: {
name: string;
url: string;
username: string;
password: string;
alternateUrl?: string;
shareUsesLocalUrl?: boolean;
customHeaders?: CustomHeaderEntry[];
customHeadersApplyTo?: CustomHeadersApplyTo;
}) => {
if (!profile.url.trim()) {
setTestMessage(t('login.urlRequired'));
setStatus('error');
return;
}
const headerRows = (profile.customHeaders ?? []).filter(h => h.name.trim() || h.value);
if (headerRows.length) {
const headerValidation = validateCustomHeaders(headerRows);
if (!headerValidation.ok) {
const first = headerValidation.fieldErrors[0]!;
setTestMessage(t(first.messageKey, { defaultValue: first.messageKey }));
setStatus('error');
return;
}
}
setStatus('testing');
setTestMessage(t('login.connecting'));
setConnecting(true);
setConnectionError(null);
const urlTrimmed = profile.url.trim();
const usernameTrimmed = profile.username.trim();
const headersPayload = serverCustomHeadersFromForm(
profile.customHeaders ?? [],
profile.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
);
const pingProfile: Pick<
ServerProfile,
'url' | 'alternateUrl' | 'username' | 'password' | 'customHeaders' | 'customHeadersApplyTo'
> = {
url: urlTrimmed,
username: usernameTrimmed,
password: profile.password,
alternateUrl: profile.alternateUrl?.trim() || undefined,
...headersPayload,
};
let ping: Awaited<ReturnType<typeof pingWithCredentialsForProfile>>;
try {
ping = await pingWithCredentialsForProfile(pingProfile, urlTrimmed);
} catch {
ping = { ok: false };
}
setConnecting(false);
if (ping.ok) {
// Connection succeeded — now persist to store
const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim());
// Dual-address fields persist straight off a v2 magic invite even
// though Login itself never shows the second-address field. The
// user can edit/remove them later via Settings → Servers.
const altTrimmed = profile.alternateUrl?.trim() ?? '';
const savedHeaders = serverCustomHeadersFromForm(
profile.customHeaders ?? [],
profile.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
);
let serverId: string;
if (existing) {
updateServer(existing.id, {
name: profile.name.trim() || profile.url.trim(),
password: profile.password,
...savedHeaders,
...(altTrimmed
? {
alternateUrl: altTrimmed,
shareUsesLocalUrl: profile.shareUsesLocalUrl ?? false,
}
: {}),
});
serverId = existing.id;
} else {
serverId = addServer({
name: profile.name.trim() || profile.url.trim(),
url: urlTrimmed,
username: usernameTrimmed,
password: profile.password,
...savedHeaders,
...(altTrimmed
? {
alternateUrl: altTrimmed,
shareUsesLocalUrl: profile.shareUsesLocalUrl ?? false,
}
: {}),
});
}
const identity = {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
};
useAuthStore.getState().setSubsonicServerIdentity(serverId, identity);
scheduleInstantMixProbeForServer(
serverId,
urlTrimmed,
usernameTrimmed,
profile.password,
identity,
);
const saved = useAuthStore.getState().servers.find(s => s.id === serverId);
if (saved) void syncServerHttpContextForProfile(saved);
setActiveServer(serverId);
setLoggedIn(true);
setStatus('ok');
setTestMessage(t('login.connected'));
setTimeout(() => navigate('/'), 600);
} else {
setStatus('error');
setConnectionError(t('login.error'));
setTestMessage(t('login.error'));
}
};
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const ms = magicString.trim();
if (ms) {
const decoded = decodeServerMagicString(ms);
if (!decoded) {
setStatus('error');
setTestMessage(t('login.magicStringInvalid'));
return;
}
await attemptConnect({
name: form.serverName.trim() || (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url),
url: decoded.url,
username: decoded.username,
password: decoded.password,
alternateUrl: decoded.alternateUrl,
shareUsesLocalUrl: decoded.shareUsesLocalUrl,
customHeaders: form.customHeaders,
customHeadersApplyTo: form.customHeadersApplyTo,
});
return;
}
await attemptConnect({
name: form.serverName,
url: form.url,
username: form.username,
password: form.password,
alternateUrl: form.alternateUrl,
shareUsesLocalUrl: form.shareUsesLocalUrl,
customHeaders: form.customHeaders,
customHeadersApplyTo: form.customHeadersApplyTo,
});
};
const handleQuickConnect = async (srv: typeof servers[0]) => {
setMagicString('');
setBlockPasswordReveal(false);
setShowPass(false);
setForm({
serverName: srv.name,
url: srv.url,
username: srv.username,
password: srv.password,
alternateUrl: srv.alternateUrl ?? '',
shareUsesLocalUrl: srv.shareUsesLocalUrl ?? false,
customHeaders: srv.customHeaders?.length
? srv.customHeaders.map(h => ({ ...h }))
: [{ name: '', value: '' }],
customHeadersApplyTo: srv.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
customHeadersOpen: Boolean(srv.customHeaders?.length),
});
await attemptConnect({
name: srv.name,
url: srv.url,
username: srv.username,
password: srv.password,
alternateUrl: srv.alternateUrl,
shareUsesLocalUrl: srv.shareUsesLocalUrl,
customHeaders: srv.customHeaders,
customHeadersApplyTo: srv.customHeadersApplyTo,
});
};
return (
<div className="login-page">
<div className="login-bg" aria-hidden="true" />
<div className="login-card animate-fade-in">
<div className="login-lang-picker" aria-label={t('settings.language')}>
<Globe size={14} aria-hidden="true" />
<CustomSelect
value={i18n.language}
onChange={v => i18n.changeLanguage(v)}
options={[
{ value: 'en', label: t('settings.languageEn') },
{ value: 'de', label: t('settings.languageDe') },
{ value: 'es', label: t('settings.languageEs') },
{ value: 'fr', label: t('settings.languageFr') },
{ value: 'nl', label: t('settings.languageNl') },
{ value: 'nb', label: t('settings.languageNb') },
{ value: 'ru', label: t('settings.languageRu') },
{ value: 'zh', label: t('settings.languageZh') },
{ value: 'ro', label: t('settings.languageRo') },
{ value: 'ja', label: t('settings.languageJa') },
{ value: 'hu', label: t('settings.languageHu') },
{ value: 'pl', label: t('settings.languagePl') },
]}
/>
</div>
<div className="login-logo">
<PsysonicLogo />
</div>
<h1 className="login-title">Psysonic</h1>
<p className="login-subtitle">{t('login.subtitle')}</p>
{/* Saved servers quick-connect */}
{servers.length > 0 && (
<div className="login-saved-servers">
<div className="login-saved-label">{t('login.savedServers')}</div>
{servers.map(srv => (
<button
key={srv.id}
className="btn btn-surface login-server-btn"
onClick={() => handleQuickConnect(srv)}
disabled={status === 'testing'}
>
<Server size={14} style={{ flexShrink: 0 }} />
<div style={{ textAlign: 'left', minWidth: 0 }}>
<div style={{ fontWeight: 600 }} className="truncate">{serverListDisplayLabel(srv, servers)}</div>
<div style={{ fontSize: 11, opacity: 0.7 }} className="truncate">{srv.username}@{srv.url}</div>
</div>
</button>
))}
<div className="login-divider"><span>{t('login.addNew')}</span></div>
</div>
)}
<form className="login-form" onSubmit={handleFormSubmit} noValidate>
<div className="form-group">
<label htmlFor="login-server-name">{t('login.serverName')}</label>
<input
id="login-server-name"
className="input"
type="text"
placeholder={t('login.serverNamePlaceholder')}
value={form.serverName}
onChange={update('serverName')}
autoComplete="off"
/>
</div>
<div className="form-group">
<label htmlFor="login-url">{t('login.serverUrl')}</label>
<input
id="login-url"
className="input"
type="text"
placeholder={t('login.serverUrlPlaceholder')}
value={form.url}
onChange={update('url')}
autoComplete="off"
/>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="login-username">{t('login.username')}</label>
<input
id="login-username"
className="input"
type="text"
placeholder={t('login.usernamePlaceholder')}
value={form.username}
onChange={update('username')}
readOnly={blockPasswordReveal}
autoComplete="username"
style={blockPasswordReveal ? { cursor: 'default' } : undefined}
/>
</div>
<div className="form-group">
<label htmlFor={blockPasswordReveal ? 'login-password-mask' : 'login-password'}>{t('login.password')}</label>
{blockPasswordReveal ? (
<input
id="login-password-mask"
className="input"
type="text"
readOnly
value={DECODED_PASSWORD_VISUAL_MASK}
autoComplete="off"
aria-label={t('login.password')}
style={{ letterSpacing: '0.12em', cursor: 'default' }}
/>
) : (
<div style={{ position: 'relative' }}>
<input
id="login-password"
className="input"
type={showPass ? 'text' : 'password'}
placeholder="••••••••"
value={form.password}
onChange={update('password')}
autoComplete="current-password"
style={{ paddingRight: '2.5rem' }}
/>
<button
type="button"
style={{ position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }}
onClick={() => setShowPass(v => !v)}
aria-label={showPass ? t('login.hidePassword') : t('login.showPassword')}
>
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
</div>
</div>
<CustomHttpHeadersEditor
headers={form.customHeaders}
applyTo={form.customHeadersApplyTo}
open={form.customHeadersOpen}
onOpenChange={customHeadersOpen => setForm(f => ({ ...f, customHeadersOpen }))}
onHeadersChange={customHeaders => setForm(f => ({ ...f, customHeaders }))}
onApplyToChange={customHeadersApplyTo => setForm(f => ({ ...f, customHeadersApplyTo }))}
radioGroupName="loginCustomHeadersApplyTo"
/>
<div className="form-group">
<label htmlFor="login-magic-string">{t('login.orMagicString')}</label>
<input
id="login-magic-string"
className="input"
type="text"
placeholder={t('login.magicStringPlaceholder')}
value={magicString}
onChange={handleMagicStringChange}
autoComplete="off"
/>
</div>
{testMessage && (
<div className={`login-status login-status--${status}`} role="alert">
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />}
{status === 'ok' && <Wifi size={16} />}
{status === 'error' && <WifiOff size={16} />}
<span>{testMessage}</span>
</div>
)}
<button
type="submit"
className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center', padding: '0.75rem', fontSize: '15px' }}
id="login-connect-btn"
disabled={status === 'testing'}
>
{status === 'testing' ? t('login.connecting') : t('login.connect')}
</button>
</form>
</div>
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
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>
);
}
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { version as appVersion } from '../../package.json';
import {
resolveChangelogEntry,
resolveReleaseNotes,
type ReleaseNotesSource,
} from '../utils/releaseNotes/releaseNotesResolve';
import type { ReleaseNotesEntry } from '../utils/releaseNotes/releaseNotesMatch';
export interface UseReleaseNotesResult {
loading: boolean;
whatsNewEntry: ReleaseNotesEntry | null;
changelogEntry: ReleaseNotesEntry | null;
whatsNewSource: ReleaseNotesSource;
}
export function useReleaseNotes(version: string = appVersion): UseReleaseNotesResult {
const [loading, setLoading] = useState(true);
const [whatsNewEntry, setWhatsNewEntry] = useState<ReleaseNotesEntry | null>(null);
const [changelogEntry, setChangelogEntry] = useState<ReleaseNotesEntry | null>(null);
const [whatsNewSource, setWhatsNewSource] = useState<ReleaseNotesSource>('empty');
useEffect(() => {
let cancelled = false;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
Promise.all([resolveReleaseNotes(version), resolveChangelogEntry(version)])
.then(([whatsNew, changelog]) => {
if (cancelled) return;
setWhatsNewEntry(whatsNew.entry);
setWhatsNewSource(whatsNew.source);
setChangelogEntry(changelog.entry);
})
.catch(() => {
if (cancelled) return;
setWhatsNewEntry(null);
setChangelogEntry(null);
setWhatsNewSource('empty');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [version]);
return { loading, whatsNewEntry, changelogEntry, whatsNewSource };
}
+85
View File
@@ -0,0 +1,85 @@
import React, { useState } from 'react';
import { Sparkles, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { version } from '../../package.json';
import { useReleaseNotes } from '../hooks/useReleaseNotes';
import { renderChangelogBody } from '../utils/changelog/changelogMarkdown';
type WhatsNewView = 'highlights' | 'changelog';
export default function WhatsNew() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loading, whatsNewEntry, changelogEntry } = useReleaseNotes(version);
const [view, setView] = useState<WhatsNewView>('highlights');
const close = () => {
if (window.history.length > 1) navigate(-1);
else navigate('/');
};
const activeEntry = view === 'highlights' ? whatsNewEntry : changelogEntry;
return (
<div className="whats-new">
<header className="whats-new__header">
<div className="whats-new__title-row">
<Sparkles size={20} className="whats-new__icon" />
<div>
<h1 className="whats-new__title">
{view === 'changelog' ? t('whatsNew.changelogTitle') : t('whatsNew.title')}
</h1>
<div className="whats-new__subtitle">
v{version}
{activeEntry?.date && <span className="whats-new__date"> · {activeEntry.date}</span>}
</div>
</div>
<button
type="button"
className="whats-new__close"
onClick={close}
aria-label={t('whatsNew.close')}
data-tooltip={t('whatsNew.close')}
data-tooltip-pos="bottom"
>
<X size={18} />
</button>
</div>
{changelogEntry && (
<div className="whats-new__view-tabs" role="tablist" aria-label={t('whatsNew.viewTabsLabel')}>
<button
type="button"
role="tab"
aria-selected={view === 'highlights'}
className={`whats-new__view-tab${view === 'highlights' ? ' whats-new__view-tab--active' : ''}`}
onClick={() => setView('highlights')}
>
{t('whatsNew.viewHighlights')}
</button>
<button
type="button"
role="tab"
aria-selected={view === 'changelog'}
className={`whats-new__view-tab${view === 'changelog' ? ' whats-new__view-tab--active' : ''}`}
onClick={() => setView('changelog')}
>
{t('whatsNew.viewChangelog')}
</button>
</div>
)}
</header>
<div className="whats-new__body" role="tabpanel">
{loading ? (
<p className="whats-new__empty">{t('common.loading')}</p>
) : activeEntry ? (
renderChangelogBody(activeEntry.body)
) : (
<p className="whats-new__empty">{t('whatsNew.empty')}</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { renderChangelogBody } from './changelogMarkdown';
describe('renderChangelogBody', () => {
it('renders ## section headings', () => {
render(<div>{renderChangelogBody('## Highlights\n\n### Theme Store\n- Item')}</div>);
expect(screen.getByRole('heading', { level: 2, name: 'Highlights' })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 3, name: 'Theme Store' })).toBeInTheDocument();
});
});
@@ -0,0 +1,126 @@
import React from 'react';
import { open } from '@tauri-apps/plugin-shell';
/**
* Render inline markdown segments: **bold**, *italic*, `code`, [text](url).
* External links open in the user's default browser via the Tauri shell plugin.
*/
export function renderInlineMarkdown(text: string, keyPrefix = 'i'): React.ReactNode[] {
// Tokenize — order matters: links first (no recursion), then emphasis/code.
const tokens: React.ReactNode[] = [];
const linkRe = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
let i = 0;
const pushInline = (segment: string) => {
const parts = segment.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
for (const part of parts) {
if (!part) continue;
if (part.startsWith('**') && part.endsWith('**')) {
tokens.push(<strong key={`${keyPrefix}-${i++}`}>{part.slice(2, -2)}</strong>);
} else if (part.startsWith('*') && part.endsWith('*') && part.length > 2) {
tokens.push(<em key={`${keyPrefix}-${i++}`}>{part.slice(1, -1)}</em>);
} else if (part.startsWith('`') && part.endsWith('`')) {
tokens.push(<code key={`${keyPrefix}-${i++}`} className="whats-new-code">{part.slice(1, -1)}</code>);
} else {
tokens.push(part);
}
}
};
while ((match = linkRe.exec(text)) !== null) {
if (match.index > lastIndex) pushInline(text.slice(lastIndex, match.index));
const [full, label, url] = match;
tokens.push(
<a
key={`${keyPrefix}-link-${i++}`}
href={url}
onClick={(e) => { e.preventDefault(); open(url).catch(() => {}); }}
className="whats-new-link"
>
{label}
</a>
);
lastIndex = match.index + full.length;
}
if (lastIndex < text.length) pushInline(text.slice(lastIndex));
return tokens;
}
/**
* Render a subset of GitHub-flavored Markdown used by our CHANGELOG: headings
* (## / ### / ####), bullets (- / *), blockquotes, horizontal rules, and inline
* formatting (bold/italic/code/links).
*/
export function renderChangelogBody(body: string): React.ReactNode[] {
const lines = body.split('\n');
const out: React.ReactNode[] = [];
let bulletBuffer: React.ReactNode[] = [];
let quoteBuffer: string[] = [];
const flushBullets = () => {
if (bulletBuffer.length === 0) return;
out.push(<ul key={`ul-${out.length}`} className="whats-new-list">{bulletBuffer}</ul>);
bulletBuffer = [];
};
const flushQuote = () => {
if (quoteBuffer.length === 0) return;
out.push(
<blockquote key={`q-${out.length}`} className="whats-new-quote">
{renderInlineMarkdown(quoteBuffer.join(' '), `q-${out.length}`)}
</blockquote>
);
quoteBuffer = [];
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (trimmed === '') { flushBullets(); flushQuote(); continue; }
if (trimmed === '---') {
flushBullets(); flushQuote();
out.push(<hr key={`hr-${out.length}`} className="whats-new-hr" />);
continue;
}
if (line.startsWith('## ') && !line.startsWith('### ')) {
flushBullets(); flushQuote();
out.push(<h2 key={`h2-${out.length}`} className="whats-new-h2">{renderInlineMarkdown(line.slice(3), `h2-${i}`)}</h2>);
continue;
}
if (line.startsWith('### ')) {
flushBullets(); flushQuote();
out.push(<h3 key={`h3-${out.length}`} className="whats-new-h3">{renderInlineMarkdown(line.slice(4), `h3-${i}`)}</h3>);
continue;
}
if (line.startsWith('#### ')) {
flushBullets(); flushQuote();
out.push(<h4 key={`h4-${out.length}`} className="whats-new-h4">{renderInlineMarkdown(line.slice(5), `h4-${i}`)}</h4>);
continue;
}
if (line.startsWith('> ')) {
flushBullets();
quoteBuffer.push(line.slice(2));
continue;
}
if (line.startsWith('- ') || line.startsWith('* ')) {
flushQuote();
bulletBuffer.push(
<li key={`li-${i}`}>{renderInlineMarkdown(line.slice(2), `li-${i}`)}</li>
);
continue;
}
// Paragraph / plain line
flushBullets(); flushQuote();
out.push(<p key={`p-${i}`} className="whats-new-p">{renderInlineMarkdown(line, `p-${i}`)}</p>);
}
flushBullets(); flushQuote();
return out;
}