- {/* Collapsible Changelog */}
- {release.body && (
-
- {showAurHint ? (
-
-
{t('common.updaterAurHint')}
-
yay -S psysonic-bin
-
sudo pacman -Syu psysonic-bin
-
- ) : useTauriUpdater ? (
- <>
- {dlState === 'idle' && (
-
-
- {t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
-
-
- {t('common.updaterMacReady', {
- defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
- })}
-
-
-
-
- {t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
-
-
-
- {t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
-
-
-
- )}
- {dlState === 'downloading' && (
-
-
-
{pct}%
-
- {formatBytes(dlProgress.bytes)}
- {dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
-
-
- )}
- {dlState === 'done' && (
-
-
-
- {t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
-
-
- {countdown !== null
- ? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
- : t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
-
-
- )}
- {dlState === 'error' && (
-
{dlError || t('common.updaterErrorMsg')}
- )}
- >
- ) : asset ? (
- <>
- {dlState === 'idle' && (
-
- {asset.name}
- {formatBytes(asset.size)}
-
- )}
- {dlState === 'downloading' && (
-
-
-
{pct}%
-
- {formatBytes(dlProgress.bytes)}
- {dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
-
-
- )}
- {dlState === 'done' && (
-
-
{t('common.updaterDone')}
-
{t('common.updaterInstallHint')}
-
-
- {t('common.updaterShowFolder')}
-
-
- )}
- {dlState === 'error' && (
-
{dlError || t('common.updaterErrorMsg')}
- )}
- >
- ) : (
-
-
open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
- >
- {t('common.updaterOpenGitHub')}
-
+ return (
+
setDismissed(true)}
+ icon={ }
+ title={t('common.updaterModalTitle')}
+ subtitle={<>v{currentVersion} → v{release.version} >}
+ closeLabel={t('common.updaterRemindBtn')}
+ footer={footer}
+ >
+ {/* Collapsible changelog */}
+ {release.body && (
+
+
setChangelogOpen(v => !v)}
+ >
+
+ {t('common.updaterChangelog')}
+
+ {changelogOpen && (
+
+
)}
- {/* end update-modal-body */}
+ )}
- {/* Footer buttons — state-dependent to avoid redundant/jumping buttons */}
-
- {dlState === 'idle' && (
- <>
-
- {t('common.updaterSkipBtn')}
-
-
-
setDismissed(true)}>
- {t('common.updaterRemindBtn')}
-
- {showInstallBtn && (
-
-
- {useTauriUpdater
- ? t('common.updaterInstallNow', { defaultValue: 'Install now' })
- : t('common.updaterDownloadBtn')}
+ {/* Download / AUR area */}
+
+ {showAurHint ? (
+
+
{t('common.updaterAurHint')}
+
yay -S psysonic-bin
+
sudo pacman -Syu psysonic-bin
+
+ ) : useTauriUpdater ? (
+ <>
+ {dlState === 'idle' && (
+
+
+ {t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
+
+
+ {t('common.updaterMacReady', {
+ defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
+ })}
+
+
+
+
+ {t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
+
+
+
+ {t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
+
+
+
+ )}
+ {dlState === 'downloading' && (
+
+
+
{pct}%
+
+ {formatBytes(dlProgress.bytes)}
+ {dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
+
+
+ )}
+ {dlState === 'done' && (
+
+
+
+ {t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
+
+
+ {countdown !== null
+ ? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
+ : t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
+
+
+ )}
+ {dlState === 'error' && (
+
{dlError || t('common.updaterErrorMsg')}
+ )}
+ >
+ ) : asset ? (
+ <>
+ {dlState === 'idle' && (
+
+ {asset.name}
+ {formatBytes(asset.size)}
+
+ )}
+ {dlState === 'downloading' && (
+
+
+
{pct}%
+
+ {formatBytes(dlProgress.bytes)}
+ {dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
+
+
+ )}
+ {dlState === 'done' && (
+
+
{t('common.updaterDone')}
+
{t('common.updaterInstallHint')}
+
+
+ {t('common.updaterShowFolder')}
- )}
- >
- )}
- {dlState === 'downloading' &&
}
- {dlState === 'done' && useTauriUpdater && (
- <>
-
-
-
- {t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
-
- >
- )}
- {dlState === 'done' && !useTauriUpdater && (
- <>
-
-
setDismissed(true)}>
- {t('common.updaterRemindBtn')}
-
- >
- )}
- {dlState === 'error' && (
- <>
-
-
setDismissed(true)}>
- {t('common.updaterRemindBtn')}
-
-
- {t('common.updaterRetryBtn')}
-
- >
- )}
-
+
+ )}
+ {dlState === 'error' && (
+ {dlError || t('common.updaterErrorMsg')}
+ )}
+ >
+ ) : (
+
+ open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
+ >
+ {t('common.updaterOpenGitHub')}
+
+
+ )}
- >,
- document.body
+
);
}
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
new file mode 100644
index 00000000..bf1a78fe
--- /dev/null
+++ b/src/components/Modal.tsx
@@ -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(
+
+
e.stopPropagation()}
+ >
+
+ {icon &&
{icon} }
+
+ {title}
+ {subtitle != null && {subtitle} }
+
+ {!hideClose && (
+
+
+
+ )}
+
+
{children}
+ {footer &&
{footer}
}
+
+
,
+ document.body,
+ );
+}
diff --git a/src/styles/components/index.css b/src/styles/components/index.css
index 582c13aa..b29817e5 100644
--- a/src/styles/components/index.css
+++ b/src/styles/components/index.css
@@ -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';
diff --git a/src/styles/components/ui-modal.css b/src/styles/components/ui-modal.css
new file mode 100644
index 00000000..1f673ab9
--- /dev/null
+++ b/src/styles/components/ui-modal.css
@@ -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;
+}
diff --git a/src/styles/layout/update-toast.css b/src/styles/layout/update-toast.css
index e66403ac..a1a00e1f 100644
--- a/src/styles/layout/update-toast.css
+++ b/src/styles/layout/update-toast.css
@@ -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);