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