mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(auth): co-locate login page into features/auth
This commit is contained in:
@@ -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 {};
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 effect’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user