mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user