refactor(components): co-locate remaining UI/feature components (TracklistColumnPicker+LosslessModeBanner->ui, fixedThemes->utils/themes, ThemeUpdateBanner/WindowButtonPreview/AboutPsysonicLol->settings, BottomNav/MobileMoreOverlay->sidebar, MobilePlayerView->nowPlaying, WhatsNewBanner->whatsNew)

This commit is contained in:
Psychotoxical
2026-06-30 19:07:46 +02:00
parent ba9e0d6541
commit 7d2645171b
26 changed files with 32 additions and 32 deletions
@@ -0,0 +1,162 @@
import React, { useCallback, useEffect, useId, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import PsysonicLogo from '@/ui/PsysonicLogo';
const TAPS_TO_REVEAL_HINT = 10;
const TARGET_CLICKS_IN_WINDOW = 100;
const WINDOW_MS = 60_000;
/** Hardcoded About lol copy — intentionally not in locale files. */
const MSG_HINT =
'To become a developer, you need to click the Psysonic logo 100 times within one minute.';
const MSG_CONGRATS_TITLE = 'Congratulations.';
const MSG_CONGRATS_SIGN_OFF = 'Sincerely, your maintainers.';
const MSG_CONGRATS_PS = "PS: Don't forget to star the repo! ★";
/**
* About page brand row + Settings → System → About lol (logo taps + modal).
* Modal copy is English and hardcoded by design.
*/
export function AboutPsysonicBrandHeader({
appVersion,
aboutVersionLabel,
}: {
appVersion: string;
aboutVersionLabel: string;
}) {
const modalWordmarkGradSuffix = useId().replace(/:/g, '');
const [phase, setPhase] = useState<'idle' | 'hint' | 'done'>('idle');
const [, setIdleTaps] = useState(0);
const [, setHintTimestamps] = useState<number[]>([]);
const [overlayOpen, setOverlayOpen] = useState(false);
const onLogoClick = useCallback(() => {
if (phase === 'done') return;
if (phase === 'idle') {
setIdleTaps(prev => {
const next = prev + 1;
if (next >= TAPS_TO_REVEAL_HINT) queueMicrotask(() => setPhase('hint'));
return next;
});
return;
}
if (phase === 'hint') {
const now = Date.now();
setHintTimestamps(prev => {
const inWindow = prev.filter(t => t > now - WINDOW_MS);
const nextTimes = [...inWindow, now];
if (nextTimes.length >= TARGET_CLICKS_IN_WINDOW) {
queueMicrotask(() => {
setPhase('done');
setOverlayOpen(true);
});
}
return nextTimes;
});
}
}, [phase]);
const closeOverlay = useCallback(() => setOverlayOpen(false), []);
useEffect(() => {
if (!overlayOpen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prevOverflow;
};
}, [overlayOpen]);
return (
<>
<div className="settings-about-header">
<button
type="button"
onClick={onLogoClick}
className="about-psysonic-logo-lol-hit"
aria-label="Psysonic"
>
<img src="/logo-psysonic.png" width={52} height={52} alt="" decoding="async" style={{ borderRadius: 14, display: 'block' }} />
</button>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{aboutVersionLabel} {appVersion}
</div>
</div>
</div>
{phase === 'hint' && !overlayOpen && (
<p
style={{
fontSize: 12,
color: 'var(--text-secondary)',
marginTop: '0.5rem',
lineHeight: 1.5,
}}
>
{MSG_HINT}
</p>
)}
{overlayOpen &&
createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby="about-psysonic-lol-title"
className="about-psysonic-lol-overlay"
>
<button
type="button"
className="about-psysonic-lol-close"
aria-label="Close"
onClick={closeOverlay}
>
<X size={26} strokeWidth={2.25} aria-hidden />
</button>
<div className="about-psysonic-lol-panel">
<div className="about-psysonic-lol-logo-slot">
<PsysonicLogo
gradientIdSuffix={modalWordmarkGradSuffix}
className="about-psysonic-lol-logo-mark"
style={{
height: 'clamp(3.25rem, 14vw, 5.75rem)',
width: 'auto',
maxWidth: 'min(100%, 420px)',
display: 'block',
}}
/>
</div>
<div className="about-psysonic-lol-copy">
<h2 id="about-psysonic-lol-title" className="about-psysonic-lol-title">
{MSG_CONGRATS_TITLE}
</h2>
<p className="about-psysonic-lol-lede">
{"We're very much looking forward to you as a developer — join us on "}
<button
type="button"
className="about-psysonic-lol-inline-link"
onClick={() => void openUrl('https://github.com/Psychotoxical/psysonic')}
>
GitHub
</button>
{' and build great features!'}
</p>
<p className="about-psysonic-lol-signoff">{MSG_CONGRATS_SIGN_OFF}</p>
<p className="about-psysonic-lol-ps">{MSG_CONGRATS_PS}</p>
</div>
</div>
</div>,
document.body,
)}
</>
);
}
@@ -17,7 +17,7 @@ import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard, SettingsField, SettingsValue } from '@/features/settings/components/SettingsSubCard';
import { SettingsSegmented, type SegmentedOption } from '@/features/settings/components/SettingsSegmented';
import { SeekbarPreview } from '@/features/waveform';
import WindowButtonPreview from '@/components/WindowButtonPreview';
import WindowButtonPreview from '@/features/settings/components/WindowButtonPreview';
export function AppearanceTab() {
const { t } = useTranslation();
@@ -9,7 +9,7 @@ import { useThemeUpdates } from '@/hooks/useThemeUpdates';
import { useThemeAnimationRisk } from '@/hooks/useThemeAnimationRisk';
import { showToast } from '@/utils/ui/toast';
import { AnimatedThemeBadge } from '@/features/settings/components/AnimatedThemeBadge';
import { FIXED_THEMES } from '@/components/settings/fixedThemes';
import { FIXED_THEMES } from '@/utils/themes/fixedThemes';
/** Pull a 3-band swatch (bg / card / accent) out of an installed theme's CSS. */
function swatch(css: string): { bg: string; card: string; accent: string } {
@@ -11,7 +11,7 @@ import { useAuthStore } from '@/store/authStore';
import type { ClockFormat, LinuxWaylandTextRenderProfile, LoggingMode } from '@/store/authStoreTypes';
import { IS_LINUX } from '@/lib/util/platform';
import { showToast } from '@/utils/ui/toast';
import { AboutPsysonicBrandHeader } from '@/components/AboutPsysonicLol';
import { AboutPsysonicBrandHeader } from '@/features/settings/components/AboutPsysonicLol';
import CustomSelect from '@/ui/CustomSelect';
import LicensesPanel from '@/features/settings/components/LicensesPanel';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
@@ -0,0 +1,70 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Paintbrush, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import { useThemeUpdates, themeUpdateSignature } from '@/hooks/useThemeUpdates';
interface Props {
collapsed?: boolean;
}
/**
* Sidebar pill shown above Now Playing while one or more installed community
* themes have a newer version in the store. Clicking opens Settings → Themes;
* X dismisses until a new update changes the set (see {@link themeUpdateSignature}).
*
* Sibling of {@link WhatsNewBanner}; reuses its `.whats-new-banner` styling.
*/
export default function ThemeUpdateBanner({ collapsed }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const updates = useThemeUpdates();
const lastDismissed = useAuthStore(s => s.lastDismissedThemeUpdateSig);
const setDismissed = useAuthStore(s => s.setLastDismissedThemeUpdateSig);
const count = updates.length;
const sig = themeUpdateSignature(updates);
if (count === 0 || sig === lastDismissed) return null;
const open = () => navigate('/settings', { state: { tab: 'themes' } });
const dismiss = (e: React.MouseEvent) => {
e.stopPropagation();
setDismissed(sig);
};
if (collapsed) {
return (
<button
type="button"
className="whats-new-banner whats-new-banner--collapsed theme-update-banner--collapsed"
onClick={open}
data-tooltip={t('sidebar.themeUpdatesTooltip')}
data-tooltip-pos="bottom"
>
<Paintbrush size={16} />
<span className="theme-update-banner__count theme-update-banner__count--dot" aria-hidden>
{count > 9 ? '9+' : count}
</span>
</button>
);
}
return (
<button type="button" className="whats-new-banner theme-update-banner" onClick={open}>
<Paintbrush size={14} className="whats-new-banner__icon" />
<span className="whats-new-banner__text">
<span className="whats-new-banner__title">{t('sidebar.themeUpdatesTitle')}</span>
</span>
<span className="theme-update-banner__count" aria-hidden>{count}</span>
<span
className="whats-new-banner__dismiss"
role="button"
aria-label={t('sidebar.themeUpdatesDismiss')}
onClick={dismiss}
>
<X size={12} />
</span>
</button>
);
}
@@ -5,7 +5,7 @@ import { useThemeStore } from '@/store/themeStore';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
import CustomSelect from '@/ui/CustomSelect';
import BackToTopButton from '@/ui/BackToTopButton';
import { FIXED_THEMES } from '@/components/settings/fixedThemes';
import { FIXED_THEMES } from '@/utils/themes/fixedThemes';
import { InstalledThemes } from '@/features/settings/components/InstalledThemes';
import { ThemeImportSection } from '@/features/settings/components/ThemeImportSection';
import { ThemeStoreSection } from '@/features/settings/components/ThemeStoreSection';
@@ -0,0 +1,69 @@
import React from 'react';
import { Minus, Square, X } from 'lucide-react';
import type { WindowButtonStyle } from '@/store/authStoreTypes';
interface Props {
style: WindowButtonStyle;
label: string;
selected: boolean;
onClick: () => void;
}
/**
* Selection tile for the custom-title-bar window-button style picker. Renders
* the real `.titlebar-controls` / `.titlebar-btn` classes inside a mini title
* bar so the preview is exactly what the chosen style produces.
*/
export default function WindowButtonPreview({ style, label, selected, onClick }: Props) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
style={{
border: `2px solid ${selected ? 'var(--accent)' : 'var(--bg-hover)'}`,
borderRadius: 8,
background: selected
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
: 'var(--bg-card, var(--bg-app))',
padding: '10px 12px 8px',
cursor: 'pointer',
width: 130,
display: 'flex',
flexDirection: 'column',
gap: 6,
alignItems: 'stretch',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
minHeight: 34,
background: 'var(--bg-sidebar)',
border: '1px solid var(--border-subtle)',
borderRadius: 6,
overflow: 'hidden',
}}
>
<div className="titlebar-controls" data-btnstyle={style} aria-hidden>
<span className="titlebar-btn titlebar-btn-minimize"><Minus size={10} strokeWidth={2.5} /></span>
<span className="titlebar-btn titlebar-btn-maximize"><Square size={9} strokeWidth={2.5} /></span>
<span className="titlebar-btn titlebar-btn-close"><X size={10} strokeWidth={2.5} /></span>
</div>
</div>
<span
style={{
fontSize: 11,
color: selected ? 'var(--accent)' : 'var(--text-secondary)',
textAlign: 'center',
fontWeight: selected ? 600 : 400,
}}
>
{label}
</span>
</button>
);
}