fix(updater): rebuild the update modal on a reusable Modal (#1142)

* feat(ui): add reusable Modal component

Portal + dimmed backdrop (no backdrop-filter — on WebKitGTK the blur bleeds
onto modal content on some GPU stacks), Escape/backdrop/X to close, header
(icon/title/subtitle) + scrollable body + footer slot. First consumer is the
update modal; the other hand-rolled modals migrate onto it over time.

* fix(updater): rebuild the update modal on the reusable Modal

Fixes a user report (Manjaro / AMD / X11): blurry modal text (the eq-popup
backdrop-filter bled onto the content), the version arrow sitting between the
two header rows, and the unclear Skip / Remind buttons. Now uses the no-blur
Modal shell; the header icon aligns with the title; Skip is a clear button and
Remind me later is the accent action when there is no in-app install. Drops the
now-dead update-modal shell CSS.

* docs(changelog): update notification popup fix (PR #1142)
This commit is contained in:
Psychotoxical
2026-06-21 00:48:26 +02:00
committed by GitHub
parent 538dbe3db1
commit 30ccaf51a8
6 changed files with 382 additions and 263 deletions
+198 -211
View File
@@ -1,10 +1,11 @@
import { createPortal } from 'react-dom';
import { type ReactNode } from 'react';
import { open } from '@tauri-apps/plugin-shell';
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { version as currentVersion } from '../../package.json';
import { formatBytes } from '../utils/format/formatBytes';
import { useAppUpdater } from '../hooks/useAppUpdater';
import Modal from './Modal';
import Changelog from './appUpdater/Changelog';
export default function AppUpdater() {
@@ -18,222 +19,208 @@ export default function AppUpdater() {
if (!release || dismissed) return null;
return createPortal(
<>
<div className="eq-popup-backdrop" onClick={() => setDismissed(true)} style={{ zIndex: 3000 }} />
<div
className="eq-popup update-modal"
style={{ zIndex: 3001 }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="eq-popup-header update-modal-header">
<ArrowUpCircle size={16} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<span className="eq-popup-title">{t('common.updaterModalTitle')}</span>
<span className="update-modal-versions">
v{currentVersion} <strong>v{release.version}</strong>
</span>
</div>
<button
className="app-updater-dismiss"
onClick={() => setDismissed(true)}
data-tooltip={t('common.updaterRemindBtn')}
data-tooltip-pos="bottom"
>
<X size={14} />
// Footer actions — state-dependent. Downloading has no actions (no footer).
// When there is no in-app install (AUR / from-source), "Remind me later" is the
// primary action, so it gets the accent button; Skip stays a clear button.
let footer: ReactNode = null;
if (dlState === 'idle') {
footer = (
<>
<button className="btn btn-surface" onClick={handleSkip}>
{t('common.updaterSkipBtn')}
</button>
<div style={{ flex: 1 }} />
<button
className={`btn ${showInstallBtn ? 'btn-surface' : 'btn-primary'}`}
onClick={() => setDismissed(true)}
>
{t('common.updaterRemindBtn')}
</button>
{showInstallBtn && (
<button className="btn btn-primary" onClick={handleDownload}>
<Download size={14} />
{useTauriUpdater
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
: t('common.updaterDownloadBtn')}
</button>
</div>
)}
</>
);
} else if (dlState === 'done' && useTauriUpdater) {
footer = (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleRestartNow}>
<RefreshCw size={14} />
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
</button>
</>
);
} else if (dlState === 'done') {
footer = (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
</>
);
} else if (dlState === 'error') {
footer = (
<>
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleDownload}>
{t('common.updaterRetryBtn')}
</button>
</>
);
}
{/* Scrollable body: changelog + download area — single overflow container */}
<div className="update-modal-body">
{/* Collapsible Changelog */}
{release.body && (
<div className="update-modal-changelog">
<button
type="button"
className="update-modal-changelog-toggle"
onClick={() => setChangelogOpen(v => !v)}
>
<ChevronDown
size={13}
style={{
transform: changelogOpen ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s',
flexShrink: 0,
}}
/>
{t('common.updaterChangelog')}
</button>
{changelogOpen && (
<div className="update-modal-changelog-body">
<Changelog body={release.body} />
</div>
)}
</div>
)}
{/* Download / AUR area */}
<div className="update-modal-download-area">
{showAurHint ? (
<div className="update-modal-aur">
<div className="update-modal-aur-title">{t('common.updaterAurHint')}</div>
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
</div>
) : useTauriUpdater ? (
<>
{dlState === 'idle' && (
<div className="update-modal-mac-info">
<div className="update-modal-mac-info-main">
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
</div>
<div className="update-modal-mac-info-sub">
{t('common.updaterMacReady', {
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
})}
</div>
<div className="update-modal-trust-badges">
<span className="update-modal-trust-badge">
<ShieldCheck size={12} />
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
</span>
<span className="update-modal-trust-badge">
<CheckCircle2 size={12} />
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
</span>
</div>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<CheckCircle2 size={32} className="update-modal-done-icon" />
<div className="update-modal-done-title">
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
</div>
<div className="update-modal-done-countdown">
{countdown !== null
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
</div>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : asset ? (
<>
{dlState === 'idle' && (
<div className="update-modal-asset">
<span className="update-modal-asset-name">{asset.name}</span>
<span className="update-modal-asset-size">{formatBytes(asset.size)}</span>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<div className="update-modal-done-title">{t('common.updaterDone')}</div>
<div className="update-modal-done-hint">{t('common.updaterInstallHint')}</div>
<button className="btn btn-surface update-modal-folder-btn" onClick={handleShowFolder}>
<FolderOpen size={14} />
{t('common.updaterShowFolder')}
</button>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : (
<div className="update-modal-asset-none">
<button
className="app-updater-btn-primary"
onClick={() => open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
>
{t('common.updaterOpenGitHub')}
</button>
return (
<Modal
open
onClose={() => setDismissed(true)}
icon={<ArrowUpCircle size={18} />}
title={t('common.updaterModalTitle')}
subtitle={<>v{currentVersion} <strong>v{release.version}</strong></>}
closeLabel={t('common.updaterRemindBtn')}
footer={footer}
>
{/* Collapsible changelog */}
{release.body && (
<div className="update-modal-changelog">
<button
type="button"
className="update-modal-changelog-toggle"
onClick={() => setChangelogOpen(v => !v)}
>
<ChevronDown
size={13}
style={{
transform: changelogOpen ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s',
flexShrink: 0,
}}
/>
{t('common.updaterChangelog')}
</button>
{changelogOpen && (
<div className="update-modal-changelog-body">
<Changelog body={release.body} />
</div>
)}
</div>
</div>{/* end update-modal-body */}
)}
{/* Footer buttons — state-dependent to avoid redundant/jumping buttons */}
<div className="update-modal-footer">
{dlState === 'idle' && (
<>
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
{t('common.updaterSkipBtn')}
</button>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
{showInstallBtn && (
<button className="btn btn-primary" onClick={handleDownload}>
<Download size={14} />
{useTauriUpdater
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
: t('common.updaterDownloadBtn')}
{/* Download / AUR area */}
<div className="update-modal-download-area">
{showAurHint ? (
<div className="update-modal-aur">
<div className="update-modal-aur-title">{t('common.updaterAurHint')}</div>
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
</div>
) : useTauriUpdater ? (
<>
{dlState === 'idle' && (
<div className="update-modal-mac-info">
<div className="update-modal-mac-info-main">
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
</div>
<div className="update-modal-mac-info-sub">
{t('common.updaterMacReady', {
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
})}
</div>
<div className="update-modal-trust-badges">
<span className="update-modal-trust-badge">
<ShieldCheck size={12} />
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
</span>
<span className="update-modal-trust-badge">
<CheckCircle2 size={12} />
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
</span>
</div>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<CheckCircle2 size={32} className="update-modal-done-icon" />
<div className="update-modal-done-title">
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
</div>
<div className="update-modal-done-countdown">
{countdown !== null
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
</div>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : asset ? (
<>
{dlState === 'idle' && (
<div className="update-modal-asset">
<span className="update-modal-asset-name">{asset.name}</span>
<span className="update-modal-asset-size">{formatBytes(asset.size)}</span>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<div className="update-modal-done-title">{t('common.updaterDone')}</div>
<div className="update-modal-done-hint">{t('common.updaterInstallHint')}</div>
<button className="btn btn-surface update-modal-folder-btn" onClick={handleShowFolder}>
<FolderOpen size={14} />
{t('common.updaterShowFolder')}
</button>
)}
</>
)}
{dlState === 'downloading' && <div style={{ flex: 1 }} />}
{dlState === 'done' && useTauriUpdater && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleRestartNow}>
<RefreshCw size={14} />
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
</button>
</>
)}
{dlState === 'done' && !useTauriUpdater && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
</>
)}
{dlState === 'error' && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
<button className="btn btn-primary" onClick={handleDownload}>
{t('common.updaterRetryBtn')}
</button>
</>
)}
</div>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : (
<div className="update-modal-asset-none">
<button
className="app-updater-btn-primary"
onClick={() => open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
>
{t('common.updaterOpenGitHub')}
</button>
</div>
)}
</div>
</>,
document.body
</Modal>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { type ReactNode, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
interface ModalProps {
open: boolean;
/** Called on Escape, backdrop click, and the header close button. */
onClose: () => void;
title: ReactNode;
/** Smaller line under the title (e.g. a version range). */
subtitle?: ReactNode;
/** Leading icon, aligned with the title line. */
icon?: ReactNode;
/** Action row pinned to the bottom, above the modal edge. */
footer?: ReactNode;
size?: 'sm' | 'md' | 'lg';
/** Hide the header close (X) button. */
hideClose?: boolean;
/** Accessible label / tooltip for the close button. */
closeLabel?: string;
children: ReactNode;
}
/**
* Reusable modal shell: a portal to `document.body` with an opaque, centered
* card over a dimmed backdrop. Closes on Escape, backdrop click and the header
* X. Deliberately uses **no `backdrop-filter`** — on WebKitGTK (some GPU stacks)
* the blur bleeds onto the modal content, making text look unfocused. The other
* hand-rolled modals migrate onto this over time.
*/
export default function Modal({
open, onClose, title, subtitle, icon, footer, size = 'md', hideClose, closeLabel, children,
}: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="ui-modal-backdrop" onClick={onClose}>
<div
className={`ui-modal ui-modal--${size}`}
role="dialog"
aria-modal="true"
onClick={e => e.stopPropagation()}
>
<div className="ui-modal-header">
{icon && <span className="ui-modal-icon">{icon}</span>}
<div className="ui-modal-titles">
<span className="ui-modal-title">{title}</span>
{subtitle != null && <span className="ui-modal-subtitle">{subtitle}</span>}
</div>
{!hideClose && (
<button
type="button"
className="ui-modal-close"
onClick={onClose}
aria-label={closeLabel ?? 'Close'}
data-tooltip={closeLabel}
data-tooltip-pos="bottom"
>
<X size={16} />
</button>
)}
</div>
<div className="ui-modal-body">{children}</div>
{footer && <div className="ui-modal-footer">{footer}</div>}
</div>
</div>,
document.body,
);
}
+1
View File
@@ -17,6 +17,7 @@
@import './share-queue-preview-modal.css';
@import './tracklist.css';
@import './modal.css';
@import './ui-modal.css';
@import './playback-delay-sleep-delayed-start-modal.css';
@import './lucky-mix-pips-shared-by-the-inline-queue-lucky-cube-indicator.css';
@import './playback-buffering-overlay.css';
+98
View File
@@ -0,0 +1,98 @@
/* ─── Reusable Modal shell (src/components/Modal.tsx) ─── */
@keyframes ui-modal-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.ui-modal-backdrop {
position: fixed;
inset: 0;
z-index: 3000;
background: var(--modal-scrim, rgba(0, 0, 0, 0.5));
display: flex;
align-items: center;
justify-content: center;
padding: 6vh 16px;
animation: fadeIn 150ms ease both;
/* No backdrop-filter: on WebKitGTK (some GPU stacks) the blur bleeds onto the
modal content and makes its text look unfocused. */
}
.ui-modal {
position: relative;
display: flex;
flex-direction: column;
width: min(520px, 94vw);
max-height: 88vh;
background: var(--bg-sidebar);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
animation: ui-modal-in 200ms ease both;
}
.ui-modal--sm { width: min(400px, 94vw); }
.ui-modal--lg { width: min(720px, 94vw); }
.ui-modal-header {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px 16px 12px 18px;
}
.ui-modal-icon {
display: flex;
align-items: center;
margin-top: 1px;
color: var(--accent);
flex-shrink: 0;
}
.ui-modal-titles {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.ui-modal-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
}
.ui-modal-subtitle {
font-size: 12px;
color: var(--text-muted);
}
.ui-modal-close {
flex-shrink: 0;
display: flex;
align-items: center;
padding: 2px;
background: none;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-muted);
opacity: 0.7;
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
}
.ui-modal-close:hover {
opacity: 1;
color: var(--text-primary);
background: var(--bg-glass);
}
.ui-modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.ui-modal-footer {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 12px 18px;
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
+1 -52
View File
@@ -34,18 +34,6 @@
text-transform: uppercase;
flex: 1;
}
.app-updater-dismiss {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
padding: 0;
display: flex;
align-items: center;
opacity: 0.6;
transition: opacity var(--transition-fast);
}
.app-updater-dismiss:hover { opacity: 1; }
.app-updater-version {
font-size: 12px;
color: var(--text-secondary);
@@ -136,30 +124,7 @@
font-style: italic;
}
/* ── Update Modal ────────────────────────────────────────────────────────── */
.update-modal {
width: min(520px, 94vw);
gap: 0;
padding: 0;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.update-modal-header {
padding: 14px 16px 14px 18px;
}
.update-modal-versions {
font-size: 12px;
color: var(--text-muted);
display: block;
margin-top: 2px;
}
/* Scrollable middle section (changelog + download area combined) */
.update-modal-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* ── Update Modal — content styles (the shell is the reusable Modal) ── */
/* Changelog accordion */
.update-modal-changelog {
border-top: 1px solid var(--border-subtle);
@@ -318,22 +283,6 @@
.update-modal-asset-none {
padding: 4px 0;
}
/* Footer */
.update-modal-footer {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 12px 18px;
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.update-modal-skip {
font-size: 12px;
color: var(--text-muted);
padding: var(--space-1) var(--space-2);
}
.update-modal-skip:hover { color: var(--text-secondary); }
.update-toast {
margin: 0 var(--space-1) var(--space-2);
padding: var(--space-3) var(--space-3);