Compare commits

...

17 Commits

Author SHA1 Message Date
Psychotoxical 70c2fe2672 style(modal): align StatsExportModal body indentation 2026-06-24 16:17:50 +02:00
Psychotoxical c1583dee27 refactor(modal): migrate AnalyticsStrategySection modals onto the Modal primitive 2026-06-24 16:17:50 +02:00
Psychotoxical f1fe53a6f5 refactor(modal): migrate DeviceSyncMigrationModal onto the Modal primitive 2026-06-24 16:17:50 +02:00
Psychotoxical 1bc36f9421 refactor(modal): migrate MagicStringModal onto the Modal primitive 2026-06-24 16:17:50 +02:00
Psychotoxical d774d9a3d0 refactor(modal): migrate LoadPlaylistModal onto Modal + ConfirmDialog 2026-06-24 16:17:50 +02:00
Psychotoxical 534e366cba refactor(modal): replace ConfirmModal with the ConfirmDialog wrapper 2026-06-24 16:17:50 +02:00
Psychotoxical e4ce02cad8 refactor(modal): migrate DownloadFolderModal onto the Modal primitive 2026-06-24 16:17:50 +02:00
Psychotoxical 8741baadbf refactor(modal): migrate StatsExportModal onto the Modal primitive 2026-06-24 16:17:50 +02:00
Psychotoxical 5ad8041ea7 refactor(modal): migrate SongInfoModal onto the Modal primitive 2026-06-24 16:17:50 +02:00
Psychotoxical b1bad351d1 refactor(modal): migrate SavePlaylistModal onto the Modal primitive 2026-06-24 16:17:50 +02:00
Psychotoxical 72af9f7560 refactor(modal): migrate CsvImportReportModal onto the Modal primitive 2026-06-24 16:17:50 +02:00
Psychotoxical e9d9365110 refactor(modal): migrate DeviceSyncPreSyncModal onto the Modal primitive 2026-06-24 16:17:50 +02:00
Psychotoxical 764ad7dfe0 refactor(modal): migrate ExportPickerModal onto the Modal primitive 2026-06-24 16:17:50 +02:00
Psychotoxical 25574bed58 fix(a11y): visible keyboard focus ring on buttons 2026-06-24 16:17:50 +02:00
Psychotoxical c604a6d645 test(modal): cover Modal and ConfirmDialog primitives 2026-06-24 16:17:50 +02:00
Psychotoxical 0859e856f7 feat(modal): add ConfirmDialog wrapper on the Modal primitive 2026-06-24 16:17:50 +02:00
Psychotoxical 14f5b190b8 feat(modal): keyboard/a11y, dismiss guards and size/variant options in Modal primitive 2026-06-24 16:17:50 +02:00
25 changed files with 1253 additions and 948 deletions
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent, within } from '@testing-library/react';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import ConfirmDialog from './ConfirmDialog';
/** Footer-scoped button query — the header X shares the cancel label by design. */
const footer = () => within(document.querySelector('.ui-modal-footer') as HTMLElement);
describe('ConfirmDialog', () => {
it('renders title, message and both buttons', () => {
renderWithProviders(
<ConfirmDialog
open
title="Delete playlist?"
message="This cannot be undone."
confirmLabel="Delete"
cancelLabel="Cancel"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByText('Delete playlist?')).toBeInTheDocument();
expect(screen.getByText('This cannot be undone.')).toBeInTheDocument();
expect(footer().getByRole('button', { name: 'Delete' })).toBeInTheDocument();
expect(footer().getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});
it('fires onConfirm and onCancel from their buttons', () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
renderWithProviders(
<ConfirmDialog
open
title="t"
message="m"
confirmLabel="OK"
cancelLabel="No"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
fireEvent.click(footer().getByRole('button', { name: 'OK' }));
expect(onConfirm).toHaveBeenCalledOnce();
fireEvent.click(footer().getByRole('button', { name: 'No' }));
expect(onCancel).toHaveBeenCalledOnce();
});
it('gives the confirm button initial focus', () => {
renderWithProviders(
<ConfirmDialog
open
title="t"
message="m"
confirmLabel="Confirm"
cancelLabel="Cancel"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Confirm' }));
});
it('applies danger styling to the confirm button', () => {
renderWithProviders(
<ConfirmDialog open title="t" message="m" confirmLabel="Delete" danger onConfirm={vi.fn()} />,
);
expect(footer().getByRole('button', { name: 'Delete' })).toHaveStyle({
background: 'var(--danger)',
});
});
it('renders a single button and resolves via onConfirm on Escape', () => {
const onConfirm = vi.fn();
renderWithProviders(
<ConfirmDialog open title="t" message="m" confirmLabel="Got it" onConfirm={onConfirm} />,
);
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
fireEvent.keyDown(window, { key: 'Escape' });
expect(onConfirm).toHaveBeenCalledOnce();
});
it('disables buttons and blocks dismissal while busy', () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
renderWithProviders(
<ConfirmDialog
open
busy
title="t"
message="m"
confirmLabel="OK"
cancelLabel="Cancel"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
expect(footer().getByRole('button', { name: 'OK' })).toBeDisabled();
expect(footer().getByRole('button', { name: 'Cancel' })).toBeDisabled();
fireEvent.keyDown(window, { key: 'Escape' });
fireEvent.click(screen.getByRole('dialog').parentElement!);
expect(onCancel).not.toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { type ReactNode, useRef } from 'react';
import Modal from './Modal';
interface ConfirmDialogProps {
open: boolean;
title: string;
message: ReactNode;
confirmLabel: string;
/**
* Cancel button label. Omit (together with `onCancel`) to render a
* single-button info dialog — Esc / backdrop / X then resolve via `onConfirm`.
*/
cancelLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel?: () => void;
/**
* While busy (e.g. a delete in flight) the dialog can't be dismissed via
* backdrop/Escape and the buttons are disabled.
*/
busy?: boolean;
}
/**
* Confirm/cancel (or single-button info) dialog on top of {@link Modal}. The
* confirm button takes initial focus so Enter confirms natively (no Enter
* hijack). Used for ConfirmModal, the LoadPlaylist delete prompt, the analytics
* clear-confirm and the Orbit exit prompt.
*/
export default function ConfirmDialog({
open,
title,
message,
confirmLabel,
cancelLabel,
danger,
onConfirm,
onCancel,
busy,
}: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null);
const dismiss = onCancel ?? onConfirm;
const confirmStyle = danger
? { background: 'var(--danger)', borderColor: 'var(--danger)', color: '#fff' }
: undefined;
return (
<Modal
open={open}
onClose={dismiss}
title={title}
size="sm"
closeLabel={cancelLabel ?? confirmLabel}
initialFocusRef={confirmRef}
closeOnBackdrop={!busy}
closeOnEscape={!busy}
bodyClassName="ui-modal-body--padded"
footer={
<>
{cancelLabel && onCancel && (
<button type="button" className="btn btn-ghost" onClick={onCancel} disabled={busy}>
{cancelLabel}
</button>
)}
<button
type="button"
ref={confirmRef}
className="btn btn-primary"
style={confirmStyle}
onClick={onConfirm}
disabled={busy}
>
{confirmLabel}
</button>
</>
}
>
<p className="confirm-dialog-message">{message}</p>
</Modal>
);
}
-83
View File
@@ -1,83 +0,0 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
interface ConfirmModalProps {
open: boolean;
title: string;
message: string;
confirmLabel: string;
/**
* Cancel button label. Omit (together with `onCancel`) to render the
* modal as a single-button info dialog — Esc / outside-click / X then
* also resolve via `onConfirm`.
*/
cancelLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel?: () => void;
}
export default function ConfirmModal({
open,
title,
message,
confirmLabel,
cancelLabel,
danger,
onConfirm,
onCancel,
}: ConfirmModalProps) {
const dismiss = onCancel ?? onConfirm;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') dismiss();
else if (e.key === 'Enter') onConfirm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, dismiss, onConfirm]);
if (!open) return null;
const confirmStyle = danger
? { background: 'var(--danger)', borderColor: 'var(--danger)', color: '#fff' }
: undefined;
return createPortal(
<div
className="modal-overlay"
onClick={dismiss}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '380px' }}
>
<button className="modal-close" onClick={dismiss} aria-label={cancelLabel ?? confirmLabel}>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>{title}</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
{message}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
{cancelLabel && onCancel && (
<button className="btn btn-ghost" onClick={onCancel} autoFocus>
{cancelLabel}
</button>
)}
<button className="btn btn-primary" style={confirmStyle} onClick={onConfirm} autoFocus={!cancelLabel}>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
+26 -28
View File
@@ -3,6 +3,7 @@ import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { useAuthStore } from '../store/authStore';
import Modal from './Modal';
export default function DownloadFolderModal() {
const { isOpen, folder, remember, setFolder, setRemember, confirm, cancel } = useDownloadModalStore();
@@ -14,33 +15,16 @@ export default function DownloadFolderModal() {
if (selected && typeof selected === 'string') setFolder(selected);
};
if (!isOpen) return null;
return (
<>
<div className="eq-popup-backdrop" onClick={cancel} style={{ zIndex: 210 }} />
<div className="eq-popup" style={{ zIndex: 211, width: 'min(480px, 92vw)', gap: 0 }}>
<div className="eq-popup-header">
<span className="eq-popup-title">{t('common.chooseDownloadFolder')}</span>
</div>
<div style={{ padding: '16px 0 12px' }}>
<div className="download-folder-pick-row">
<span className="download-folder-path">
{folder || t('common.noFolderSelected')}
</span>
<button className="btn btn-ghost" onClick={handleBrowse} style={{ flexShrink: 0 }}>
<FolderOpen size={15} /> {t('settings.pickFolder')}
</button>
</div>
<label className="download-remember-row">
<input type="checkbox" checked={remember} onChange={e => setRemember(e.target.checked)} />
<span>{t('common.rememberDownloadFolder')}</span>
</label>
</div>
<div className="download-modal-actions">
<Modal
open={isOpen}
onClose={cancel}
title={t('common.chooseDownloadFolder')}
size="md"
closeLabel={t('common.cancel')}
bodyClassName="ui-modal-body--padded"
footer={
<>
<button className="btn btn-ghost" onClick={cancel}>{t('common.cancel')}</button>
<button
className="btn btn-primary"
@@ -49,8 +33,22 @@ export default function DownloadFolderModal() {
>
{t('common.download')}
</button>
</div>
</>
}
>
<div className="download-folder-pick-row">
<span className="download-folder-path">
{folder || t('common.noFolderSelected')}
</span>
<button className="btn btn-ghost" onClick={handleBrowse} style={{ flexShrink: 0 }}>
<FolderOpen size={15} /> {t('settings.pickFolder')}
</button>
</div>
</>
<label className="download-remember-row">
<input type="checkbox" checked={remember} onChange={e => setRemember(e.target.checked)} />
<span>{t('common.rememberDownloadFolder')}</span>
</label>
</Modal>
);
}
+41 -63
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { X, Download } from 'lucide-react';
import { Download } from 'lucide-react';
import Modal from './Modal';
interface Props {
onConfirm: (since: number) => void;
@@ -16,75 +17,52 @@ export default function ExportPickerModal({ onConfirm, onClose }: Props) {
};
return (
<div
style={{
position: 'fixed', inset: 0, zIndex: 99998,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
>
<div style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-subtle)',
borderRadius: '14px',
padding: '28px 32px',
width: '340px',
boxShadow: '0 8px 40px rgba(0,0,0,0.5)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '20px' }}>
<h2 style={{ margin: 0, fontSize: '16px', fontWeight: 700, color: 'var(--text-primary)' }}>
Alben exportieren
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)', padding: '4px', display: 'flex' }}
>
<X size={18} />
</button>
</div>
<p style={{ margin: '0 0 16px', fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
Alle Alben exportieren, die seit diesem Datum hinzugekommen sind:
</p>
<input
type="date"
value={date}
max={today}
onChange={e => {
setDate(e.target.value);
e.target.blur();
}}
style={{
width: '100%',
padding: '9px 12px',
borderRadius: '8px',
border: '1px solid var(--border-subtle)',
background: 'var(--bg-app)',
color: 'var(--text-primary)',
fontSize: '14px',
boxSizing: 'border-box',
outline: 'none',
colorScheme: 'dark',
}}
/>
<div style={{ display: 'flex', gap: '10px', marginTop: '20px' }}>
<button className="btn btn-surface" onClick={onClose} style={{ flex: 1 }}>
Abbrechen
</button>
<Modal
open
onClose={onClose}
title="Alben exportieren"
size="sm"
closeLabel="Abbrechen"
bodyClassName="ui-modal-body--padded"
footer={
<>
<button className="btn btn-surface" onClick={onClose}>Abbrechen</button>
<button
className="btn btn-primary"
onClick={handleConfirm}
disabled={!date}
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<Download size={15} />
Exportieren
</button>
</div>
</div>
</div>
</>
}
>
<p style={{ margin: '0 0 16px', fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
Alle Alben exportieren, die seit diesem Datum hinzugekommen sind:
</p>
<input
type="date"
value={date}
max={today}
onChange={e => {
setDate(e.target.value);
e.target.blur();
}}
style={{
width: '100%',
padding: '9px 12px',
borderRadius: '8px',
border: '1px solid var(--border-subtle)',
background: 'var(--bg-app)',
color: 'var(--text-primary)',
fontSize: '14px',
boxSizing: 'border-box',
outline: 'none',
colorScheme: 'dark',
}}
/>
</Modal>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { useConfirmModalStore } from '../store/confirmModalStore';
import ConfirmModal from './ConfirmModal';
import ConfirmDialog from './ConfirmDialog';
/**
* App-level singleton renderer for the global confirm modal. Mount once
@@ -11,7 +11,7 @@ export default function GlobalConfirmModal() {
useConfirmModalStore();
return (
<ConfirmModal
<ConfirmDialog
open={isOpen}
title={title}
message={message}
+180
View File
@@ -0,0 +1,180 @@
import { describe, it, expect, vi } from 'vitest';
import { createRef } from 'react';
import { screen, fireEvent, render } from '@testing-library/react';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import Modal from './Modal';
describe('Modal', () => {
it('renders nothing when closed', () => {
renderWithProviders(
<Modal open={false} onClose={vi.fn()} title="Hidden">
body
</Modal>,
);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('renders title, subtitle, icon, body and footer when open', () => {
renderWithProviders(
<Modal
open
onClose={vi.fn()}
title="My title"
subtitle="My subtitle"
icon={<span data-testid="icon" />}
footer={<button>Do it</button>}
>
<p>body content</p>
</Modal>,
);
expect(screen.getByText('My title')).toBeInTheDocument();
expect(screen.getByText('My subtitle')).toBeInTheDocument();
expect(screen.getByTestId('icon')).toBeInTheDocument();
expect(screen.getByText('body content')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Do it' })).toBeInTheDocument();
});
it('portals into document.body', () => {
renderWithProviders(
<Modal open onClose={vi.fn()} title="Portaled">
body
</Modal>,
);
const dialog = screen.getByRole('dialog');
expect(dialog.closest('.ui-modal-backdrop')?.parentElement).toBe(document.body);
});
it('labels the dialog with the title element id', () => {
renderWithProviders(
<Modal open onClose={vi.fn()} title="Accessible">
body
</Modal>,
);
const dialog = screen.getByRole('dialog');
const labelledBy = dialog.getAttribute('aria-labelledby');
expect(labelledBy).toBeTruthy();
expect(document.getElementById(labelledBy!)).toHaveTextContent('Accessible');
});
it('calls onClose on Escape, backdrop click and the X button', async () => {
const onClose = vi.fn();
renderWithProviders(
<Modal open onClose={onClose} title="Closable">
body
</Modal>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('dialog').parentElement!);
expect(onClose).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(3);
});
it('does not close on Escape when closeOnEscape is false', () => {
const onClose = vi.fn();
renderWithProviders(
<Modal open onClose={onClose} title="Guarded" closeOnEscape={false}>
body
</Modal>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
});
it('does not close on backdrop click when closeOnBackdrop is false', () => {
const onClose = vi.fn();
renderWithProviders(
<Modal open onClose={onClose} title="Guarded" closeOnBackdrop={false}>
body
</Modal>,
);
fireEvent.click(screen.getByRole('dialog').parentElement!);
expect(onClose).not.toHaveBeenCalled();
});
it('hides the close button when hideClose is set', () => {
renderWithProviders(
<Modal open onClose={vi.fn()} title="No X" hideClose>
body
</Modal>,
);
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
});
it('focuses the initialFocusRef target on open', () => {
const ref = createRef<HTMLButtonElement>();
renderWithProviders(
<Modal open onClose={vi.fn()} title="Focus" initialFocusRef={ref} footer={<button ref={ref}>Primary</button>}>
body
</Modal>,
);
expect(document.activeElement).toBe(ref.current);
});
it('focuses the first body field when no initialFocusRef is given', () => {
renderWithProviders(
<Modal open onClose={vi.fn()} title="Form">
<input aria-label="name" />
</Modal>,
);
expect(document.activeElement).toBe(screen.getByLabelText('name'));
});
it('traps Tab focus inside the dialog', () => {
renderWithProviders(
<Modal open onClose={vi.fn()} title="Trap" footer={<button>Last</button>}>
body
</Modal>,
);
const closeBtn = screen.getByRole('button', { name: 'Close' });
const lastBtn = screen.getByRole('button', { name: 'Last' });
lastBtn.focus();
fireEvent.keyDown(window, { key: 'Tab' });
expect(document.activeElement).toBe(closeBtn);
closeBtn.focus();
fireEvent.keyDown(window, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(lastBtn);
});
it('restores focus to the opening element on close', () => {
const onClose = vi.fn();
const { rerender } = render(
<>
<button data-testid="opener">opener</button>
<Modal open={false} onClose={onClose} title="Restore">
body
</Modal>
</>,
);
const opener = screen.getByTestId('opener');
opener.focus();
expect(document.activeElement).toBe(opener);
rerender(
<>
<button data-testid="opener">opener</button>
<Modal open onClose={onClose} title="Restore">
body
</Modal>
</>,
);
// Modal grabbed focus.
expect(document.activeElement).not.toBe(opener);
rerender(
<>
<button data-testid="opener">opener</button>
<Modal open={false} onClose={onClose} title="Restore">
body
</Modal>
</>,
);
expect(document.activeElement).toBe(opener);
});
});
+127 -12
View File
@@ -1,7 +1,27 @@
import { type ReactNode, useEffect } from 'react';
import { type ReactNode, type Ref, type RefObject, useEffect, useId, useRef } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
/** Selector for tabbable elements used by the focus trap. */
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' +
'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* Body scroll-lock with a module-level ref count so nested modals (e.g.
* LoadPlaylist → delete-confirm, Join → AccountPicker) don't release the lock
* while an outer modal is still open.
*/
let scrollLockCount = 0;
function lockBodyScroll() {
if (scrollLockCount === 0) document.body.style.overflow = 'hidden';
scrollLockCount += 1;
}
function unlockBodyScroll() {
scrollLockCount = Math.max(0, scrollLockCount - 1);
if (scrollLockCount === 0) document.body.style.overflow = '';
}
interface ModalProps {
open: boolean;
/** Called on Escape, backdrop click, and the header close button. */
@@ -13,47 +33,140 @@ interface ModalProps {
icon?: ReactNode;
/** Action row pinned to the bottom, above the modal edge. */
footer?: ReactNode;
size?: 'sm' | 'md' | 'lg';
size?: 'sm' | 'md' | 'lg' | 'xl';
/** Hide the header close (X) button. */
hideClose?: boolean;
/** Accessible label / tooltip for the close button. */
closeLabel?: string;
/**
* Focus target when the modal opens. Falls back to the first focusable
* element, then the dialog card. Use it for the primary action (Confirm/OK)
* or the first form field so Enter activates the right control natively.
*/
initialFocusRef?: RefObject<HTMLElement | null>;
/** Backdrop click closes the modal. Default `true`. */
closeOnBackdrop?: boolean;
/** Escape closes the modal. Default `true`. */
closeOnEscape?: boolean;
/** `perf` = opaque backdrop, no enter/backdrop animation (sidebar perf probe). */
variant?: 'perf';
/** Extra class(es) on `.ui-modal-body` (e.g. `ui-modal-body--padded`). */
bodyClassName?: string;
/** Forwarded to `.ui-modal-body` (scroll root for IntersectionObserver / keyboard nav). */
bodyRef?: Ref<HTMLDivElement>;
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.
* X (each guardable via `closeOnEscape` / `closeOnBackdrop`). Deliberately uses
* **no `backdrop-filter`** — on WebKitGTK (some GPU stacks) the blur bleeds onto
* the modal content, making text look unfocused.
*
* Keyboard / a11y is handled here once so every consumer inherits it: focus
* trap (Tab/Shift+Tab cycle inside the card), initial focus, focus restore to
* the opening element, `aria-labelledby` on the title, and a ref-counted body
* scroll-lock. Enter is **not** hijacked — it activates the focused control
* natively; roving lists keep their own Arrow/Enter handlers in `children`.
*/
export default function Modal({
open, onClose, title, subtitle, icon, footer, size = 'md', hideClose, closeLabel, children,
open,
onClose,
title,
subtitle,
icon,
footer,
size = 'md',
hideClose,
closeLabel,
initialFocusRef,
closeOnBackdrop = true,
closeOnEscape = true,
variant,
bodyClassName,
bodyRef,
children,
}: ModalProps) {
const cardRef = useRef<HTMLDivElement>(null);
const titleId = useId();
// Ref-counted body scroll-lock while open.
useEffect(() => {
if (!open) return;
lockBodyScroll();
return unlockBodyScroll;
}, [open]);
// Initial focus on open + restore focus to the opening element on close.
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const card = cardRef.current;
const body = card?.querySelector<HTMLElement>('.ui-modal-body');
// Prefer the first focusable inside the body (e.g. a form field) over the
// header close button; explicit initialFocusRef always wins.
const target =
initialFocusRef?.current ??
body?.querySelector<HTMLElement>(FOCUSABLE) ??
card?.querySelector<HTMLElement>(FOCUSABLE) ??
card;
target?.focus();
return () => previouslyFocused?.focus?.();
}, [open, initialFocusRef]);
// Escape to close + Tab focus trap.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Escape') {
if (closeOnEscape) onClose();
return;
}
if (e.key !== 'Tab') return;
const card = cardRef.current;
if (!card) return;
const focusables = Array.from(card.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusables.length === 0) {
e.preventDefault();
card.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || !card.contains(active))) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (active === last || !card.contains(active))) {
e.preventDefault();
first.focus();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
}, [open, closeOnEscape, onClose]);
if (!open) return null;
return createPortal(
<div className="ui-modal-backdrop" onClick={onClose}>
<div
className={`ui-modal-backdrop${variant === 'perf' ? ' ui-modal-backdrop--perf' : ''}`}
onClick={closeOnBackdrop ? onClose : undefined}
>
<div
className={`ui-modal ui-modal--${size}`}
ref={cardRef}
className={`ui-modal ui-modal--${size}${variant === 'perf' ? ' ui-modal--perf' : ''}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
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>
<span className="ui-modal-title" id={titleId}>{title}</span>
{subtitle != null && <span className="ui-modal-subtitle">{subtitle}</span>}
</div>
{!hideClose && (
@@ -69,7 +182,9 @@ export default function Modal({
</button>
)}
</div>
<div className="ui-modal-body">{children}</div>
<div className={`ui-modal-body${bodyClassName ? ` ${bodyClassName}` : ''}`} ref={bodyRef}>
{children}
</div>
{footer && <div className="ui-modal-footer">{footer}</div>}
</div>
</div>,
+2 -2
View File
@@ -4,7 +4,7 @@ import { Crown, User, UserMinus, ShieldOff, Mic, MicOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { kickOrbitParticipant, removeOrbitParticipant, setOrbitSuggestionBlocked } from '../utils/orbit';
import ConfirmModal from './ConfirmModal';
import ConfirmDialog from './ConfirmDialog';
interface Props {
/** Anchor — we position the popover directly below its bottom-right. */
@@ -139,7 +139,7 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
);
})}
</div>
<ConfirmModal
<ConfirmDialog
open={!!confirm}
title={confirm?.mode === 'ban'
? t('orbit.confirmBanTitle')
+2 -2
View File
@@ -18,7 +18,7 @@ import OrbitExitModal from './OrbitExitModal';
import OrbitSettingsPopover from './OrbitSettingsPopover';
import OrbitSharePopover from './OrbitSharePopover';
import OrbitDiagnosticsPopover from './OrbitDiagnosticsPopover';
import ConfirmModal from './ConfirmModal';
import ConfirmDialog from './ConfirmDialog';
import { formatTrackTime } from '../utils/format/formatDuration';
/**
@@ -354,7 +354,7 @@ export default function OrbitSessionBar() {
/>
)}
<OrbitExitModal />
<ConfirmModal
<ConfirmDialog
open={confirmLeave}
title={role === 'host'
? t('orbit.confirmEndTitle')
+3 -3
View File
@@ -19,7 +19,7 @@ import {
} from '../utils/orbit';
import { switchActiveServer } from '../utils/server/switchActiveServer';
import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
import ConfirmModal from './ConfirmModal';
import ConfirmDialog from './ConfirmDialog';
const ORBIT_JOIN_ERROR_KEYS: Record<string, string> = {
'not-found': 'orbit.joinErrNotFound',
@@ -233,7 +233,7 @@ export default function PasteClipboardHandler() {
confirmBusyLabel={t('sharePaste.playQueueing')}
/>
)}
<ConfirmModal
<ConfirmDialog
open={!!orbitConfirm}
title={t('orbit.confirmJoinTitle')}
message={t('orbit.confirmJoinBody', {
@@ -249,7 +249,7 @@ export default function PasteClipboardHandler() {
}}
onCancel={() => setOrbitConfirm(null)}
/>
<ConfirmModal
<ConfirmDialog
open={orbitInvalid}
title={t('orbit.invalidLinkTitle')}
message={t('orbit.invalidLinkBody')}
+56 -73
View File
@@ -2,8 +2,6 @@ import { getSong } from '../api/subsonicLibrary';
import { libraryGetFacts } from '../api/library';
import type { SubsonicSong } from '../api/subsonicTypes';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { ndGetSongPath } from '../api/navidromeAdmin';
@@ -15,6 +13,7 @@ import { showToast } from '../utils/ui/toast';
import { formatTrackTime } from '../utils/format/formatDuration';
import { formatLastSeen } from '../utils/componentHelpers/userMgmtHelpers';
import { libraryIsReady } from '../utils/library/libraryReady';
import Modal from './Modal';
import {
formatQueueMoodLabels,
parseTrackEnrichmentFacts,
@@ -134,15 +133,6 @@ export default function SongInfoModal() {
return () => { cancelled = true; };
}, [songInfoModal.isOpen, songInfoModal.songId]);
useEffect(() => {
if (!songInfoModal.isOpen) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') closeSongInfo(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [songInfoModal.isOpen, closeSongInfo]);
if (!songInfoModal.isOpen) return null;
const channels = song?.channelCount === 1
? t('songInfo.mono')
: song?.channelCount === 2
@@ -171,74 +161,67 @@ export default function SongInfoModal() {
: null;
const displayMood = enrichment ? formatQueueMoodLabels(enrichment.moodLabels, t) : null;
return createPortal(
<>
<div className="song-info-backdrop" onClick={closeSongInfo} />
<div className="song-info-modal" role="dialog" aria-modal="true" aria-label={t('songInfo.title')}>
<div className="song-info-header">
<span className="song-info-title">{t('songInfo.title')}</span>
<button className="btn btn-ghost song-info-close" onClick={closeSongInfo} aria-label="Close">
<X size={16} />
</button>
</div>
return (
<Modal
open={songInfoModal.isOpen}
onClose={closeSongInfo}
title={t('songInfo.title')}
size="md"
bodyClassName="song-info-body"
>
{loading && <div className="song-info-loading">{t('common.loading')}</div>}
<div className="song-info-body">
{loading && <div className="song-info-loading">{t('common.loading')}</div>}
{!loading && song && (
<table className="song-info-table">
<tbody>
<CopyableFieldRow label={t('songInfo.songTitle')} text={song.title} />
<CopyableFieldRow label={t('songInfo.artist')} text={song.artist} />
<CopyableFieldRow label={t('songInfo.album')} text={song.album} />
{song.albumArtist && song.albumArtist !== song.artist && (
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
)}
<Row label={t('songInfo.year')} value={song.year} />
<Row label={t('songInfo.genre')} value={song.genre} />
<Row label={t('songInfo.duration')} value={formatTrackTime(song.duration)} />
<Row label={t('songInfo.track')} value={trackLabel} />
<Row label={t('songInfo.bpm')} value={displayBpm} />
<Row label={t('songInfo.mood')} value={displayMood} />
<Row label={t('songInfo.playCount')} value={song.playCount} />
<Row label={t('songInfo.lastPlayed')} value={song.played ? formatLastSeen(song.played, i18n.language, '—') : null} />
{!loading && song && (
<table className="song-info-table">
<tbody>
<CopyableFieldRow label={t('songInfo.songTitle')} text={song.title} />
<CopyableFieldRow label={t('songInfo.artist')} text={song.artist} />
<CopyableFieldRow label={t('songInfo.album')} text={song.album} />
{song.albumArtist && song.albumArtist !== song.artist && (
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
)}
<Row label={t('songInfo.year')} value={song.year} />
<Row label={t('songInfo.genre')} value={song.genre} />
<Row label={t('songInfo.duration')} value={formatTrackTime(song.duration)} />
<Row label={t('songInfo.track')} value={trackLabel} />
<Row label={t('songInfo.bpm')} value={displayBpm} />
<Row label={t('songInfo.mood')} value={displayMood} />
<Row label={t('songInfo.playCount')} value={song.playCount} />
<Row label={t('songInfo.lastPlayed')} value={song.played ? formatLastSeen(song.played, i18n.language, '—') : null} />
<Divider />
<Row label={t('songInfo.format')} value={[song.suffix?.toUpperCase(), song.contentType].filter(Boolean).join(' · ') || null} />
<Row label={t('songInfo.bitrate')} value={song.bitRate ? `${song.bitRate} kbps` : null} />
<Row label={t('songInfo.sampleRate')} value={song.samplingRate ? `${(song.samplingRate / 1000).toFixed(1)} kHz` : null} />
<Row label={t('songInfo.bitDepth')} value={song.bitDepth ? `${song.bitDepth} bit` : null} />
<Row label={t('songInfo.channels')} value={channels} />
<Row label={t('songInfo.fileSize')} value={formatSize(song.size)} />
{(absolutePath || song.path) && (
<>
<Divider />
<Row label={t('songInfo.path')} value={<span className="song-info-path">{absolutePath ?? song.path}</span>} />
</>
)}
<Row label={t('songInfo.format')} value={[song.suffix?.toUpperCase(), song.contentType].filter(Boolean).join(' · ') || null} />
<Row label={t('songInfo.bitrate')} value={song.bitRate ? `${song.bitRate} kbps` : null} />
<Row label={t('songInfo.sampleRate')} value={song.samplingRate ? `${(song.samplingRate / 1000).toFixed(1)} kHz` : null} />
<Row label={t('songInfo.bitDepth')} value={song.bitDepth ? `${song.bitDepth} bit` : null} />
<Row label={t('songInfo.channels')} value={channels} />
<Row label={t('songInfo.fileSize')} value={formatSize(song.size)} />
{(absolutePath || song.path) && (
<>
<Divider />
<Row label={t('songInfo.path')} value={<span className="song-info-path">{absolutePath ?? song.path}</span>} />
</>
{hasReplayGain && (
<>
<Divider />
{song.replayGain!.trackGain !== undefined && (
<Row label={t('songInfo.replayGainTrack')} value={`${song.replayGain!.trackGain >= 0 ? '+' : ''}${song.replayGain!.trackGain.toFixed(2)} dB`} />
)}
{hasReplayGain && (
<>
<Divider />
{song.replayGain!.trackGain !== undefined && (
<Row label={t('songInfo.replayGainTrack')} value={`${song.replayGain!.trackGain >= 0 ? '+' : ''}${song.replayGain!.trackGain.toFixed(2)} dB`} />
)}
{song.replayGain!.albumGain !== undefined && (
<Row label={t('songInfo.replayGainAlbum')} value={`${song.replayGain!.albumGain >= 0 ? '+' : ''}${song.replayGain!.albumGain.toFixed(2)} dB`} />
)}
{song.replayGain!.trackPeak !== undefined && (
<Row label={t('songInfo.replayGainPeak')} value={song.replayGain!.trackPeak.toFixed(6)} />
)}
</>
{song.replayGain!.albumGain !== undefined && (
<Row label={t('songInfo.replayGainAlbum')} value={`${song.replayGain!.albumGain >= 0 ? '+' : ''}${song.replayGain!.albumGain.toFixed(2)} dB`} />
)}
</tbody>
</table>
)}
</div>
</div>
</>,
document.body
{song.replayGain!.trackPeak !== undefined && (
<Row label={t('songInfo.replayGainPeak')} value={song.replayGain!.trackPeak.toFixed(6)} />
)}
</>
)}
</tbody>
</table>
)}
</Modal>
);
}
+106 -134
View File
@@ -1,12 +1,11 @@
import { getAlbumList } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react';
import { save } from '@tauri-apps/plugin-dialog';
import { writeFile } from '@tauri-apps/plugin-fs';
import { showToast } from '../utils/ui/toast';
import Modal from './Modal';
import {
exportAlbumCardBlob,
renderAlbumCardCanvas,
@@ -113,16 +112,6 @@ export default function StatsExportModal({ open, albums, meta, onClose }: Props)
};
}, [open, format, gridSize, effectiveAlbums, enoughAlbums, title, meta]);
// Esc-to-close.
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;
const onSave = async () => {
if (saving || !enoughAlbums) return;
setSaving(true);
@@ -151,135 +140,118 @@ export default function StatsExportModal({ open, albums, meta, onClose }: Props)
}
};
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '720px', width: 'min(720px, 92vw)' }}
>
<button className="modal-close" onClick={onClose} aria-label={t('statistics.exportCancel')}>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.25rem', fontFamily: 'var(--font-display)' }}>
{t('statistics.exportTitle')}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1rem', fontSize: '0.875rem' }}>
{t('statistics.exportSubtitle')}
</p>
{/* Format */}
<div style={{ marginBottom: '0.875rem' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('statistics.exportFormat')}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{FORMATS.map(f => {
const active = format === f.key;
return (
<button
key={f.key}
type="button"
onClick={() => setFormat(f.key)}
className="btn btn-surface"
style={{
padding: '0.5rem 0.75rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
border: `1px solid ${active ? 'var(--accent)' : 'var(--glass-border)'}`,
background: active ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : undefined,
}}
>
<span style={{
display: 'inline-block',
width: f.ratioBox.w * 0.4,
height: f.ratioBox.h * 0.4,
background: active ? 'var(--accent)' : 'var(--text-muted)',
opacity: active ? 0.9 : 0.5,
borderRadius: 2,
}} />
{t(`statistics.exportFormat${f.key[0].toUpperCase()}${f.key.slice(1)}`)}
</button>
);
})}
</div>
</div>
{/* Grid */}
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('statistics.exportGrid')}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{GRID_SIZES.map(n => {
const active = gridSize === n;
return (
<button
key={n}
type="button"
onClick={() => setGridSize(n)}
className="btn btn-surface"
style={{
padding: '0.5rem 0.875rem',
border: `1px solid ${active ? 'var(--accent)' : 'var(--glass-border)'}`,
background: active ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : undefined,
}}
>
{t('statistics.exportGridLabel', { n })}
</button>
);
})}
</div>
</div>
{/* Preview */}
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('statistics.exportPreview')}
</div>
<PreviewFrame format={format}>
{!enoughAlbums ? (
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
color: 'var(--text-muted)',
fontSize: '0.875rem',
padding: '1rem',
}}>
{t('statistics.exportNotEnough', { count: required, n: gridSize })}
</div>
) : (
<div ref={previewRef} style={{ width: '100%' }} />
)}
</PreviewFrame>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
return (
<Modal
open={open}
onClose={onClose}
title={t('statistics.exportTitle')}
subtitle={t('statistics.exportSubtitle')}
size="lg"
closeLabel={t('statistics.exportCancel')}
bodyClassName="ui-modal-body--padded"
footer={
<>
<button className="btn btn-ghost" onClick={onClose} disabled={saving}>
{t('statistics.exportCancel')}
</button>
<button
className="btn btn-primary"
onClick={onSave}
disabled={!enoughAlbums || saving}
>
<button className="btn btn-primary" onClick={onSave} disabled={!enoughAlbums || saving}>
{saving ? t('statistics.exportSaving') : t('statistics.exportSave')}
</button>
</>
}
>
{/* Format */}
<div style={{ marginBottom: '0.875rem' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('statistics.exportFormat')}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{FORMATS.map(f => {
const active = format === f.key;
return (
<button
key={f.key}
type="button"
onClick={() => setFormat(f.key)}
className="btn btn-surface"
style={{
padding: '0.5rem 0.75rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
border: `1px solid ${active ? 'var(--accent)' : 'var(--glass-border)'}`,
background: active ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : undefined,
}}
>
<span style={{
display: 'inline-block',
width: f.ratioBox.w * 0.4,
height: f.ratioBox.h * 0.4,
background: active ? 'var(--accent)' : 'var(--text-muted)',
opacity: active ? 0.9 : 0.5,
borderRadius: 2,
}} />
{t(`statistics.exportFormat${f.key[0].toUpperCase()}${f.key.slice(1)}`)}
</button>
);
})}
</div>
</div>
</div>,
document.body,
{/* Grid */}
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('statistics.exportGrid')}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{GRID_SIZES.map(n => {
const active = gridSize === n;
return (
<button
key={n}
type="button"
onClick={() => setGridSize(n)}
className="btn btn-surface"
style={{
padding: '0.5rem 0.875rem',
border: `1px solid ${active ? 'var(--accent)' : 'var(--glass-border)'}`,
background: active ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : undefined,
}}
>
{t('statistics.exportGridLabel', { n })}
</button>
);
})}
</div>
</div>
{/* Preview */}
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('statistics.exportPreview')}
</div>
<PreviewFrame format={format}>
{!enoughAlbums ? (
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
color: 'var(--text-muted)',
fontSize: '0.875rem',
padding: '1rem',
}}>
{t('statistics.exportNotEnough', { count: required, n: gridSize })}
</div>
) : (
<div ref={previewRef} style={{ width: '100%' }} />
)}
</PreviewFrame>
</div>
</Modal>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import ConfirmModal from './ConfirmModal';
import ConfirmDialog from './ConfirmDialog';
import { readThemeMigrationNotice, clearThemeMigrationNotice } from '../utils/themes/themeMigration';
/**
@@ -24,7 +24,7 @@ export default function ThemeMigrationNotice() {
};
return (
<ConfirmModal
<ConfirmDialog
open={open}
title={t('settings.themeMigrationNoticeTitle')}
message={t('settings.themeMigrationNoticeBody', { themes: themes.join(', ') })}
@@ -1,9 +1,10 @@
import React from 'react';
import { type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
import type {
MigrationPair, MigrationPhase, MigrationResult,
} from '../../utils/deviceSync/runDeviceSyncMigration';
import Modal from '../Modal';
interface Props {
migrationPhase: MigrationPhase;
@@ -22,115 +23,127 @@ export default function DeviceSyncMigrationModal({
}: Props) {
const { t } = useTranslation();
if (migrationPhase === 'closed') return null;
// Locked while files are being renamed: no backdrop / Escape / X dismissal.
const executing = migrationPhase === 'executing';
const dismiss = () => { if (!executing) closeMigration(); };
let footer: ReactNode = null;
if (migrationPhase === 'preview') {
footer = (
<>
<button className="btn btn-ghost" onClick={closeMigration}>{t('common.cancel')}</button>
<button className="btn btn-primary" onClick={executeMigration} disabled={migrationPairs.length === 0}>
{t('deviceSync.migrateStart', { defaultValue: 'Start renaming' })}
</button>
</>
);
} else if (migrationPhase === 'done' || migrationPhase === 'nothing') {
footer = (
<button className="btn btn-primary" onClick={closeMigration}>{t('common.close')}</button>
);
}
return (
<div className="modal-overlay" onClick={migrationPhase === 'executing' ? undefined : closeMigration}>
<div className="modal-content device-sync-migrate-modal" onClick={e => e.stopPropagation()}>
<h2 className="modal-title">{t('deviceSync.migrateTitle', { defaultValue: 'Reorganize existing files' })}</h2>
<div className="device-sync-migrate-body">
{migrationPhase === 'loading' && (
<div className="device-sync-migrate-loading">
<Loader2 size={18} className="spin" />
<span>{t('deviceSync.migrateLoading', { defaultValue: 'Analyzing existing files…' })}</span>
</div>
)}
{migrationPhase === 'nothing' && (
<div className="device-sync-migrate-nothing">
{migrationOldTemplate ? (
t('deviceSync.migrateNothingToDo', { defaultValue: 'All existing files already match the new scheme — nothing to do.' })
) : (
t('deviceSync.migrateNoTemplate', { defaultValue: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.' })
)}
</div>
)}
{migrationPhase === 'preview' && (
<>
<div className="device-sync-migrate-summary">
<div>
<strong>{migrationPairs.length}</strong>{' '}
{t('deviceSync.migrateFilesToRename', { defaultValue: 'files will be renamed' })}
</div>
{migrationUnchanged > 0 && (
<div className="muted">
{t('deviceSync.migrateUnchanged', {
defaultValue: '{{n}} files are already at the correct path',
n: migrationUnchanged,
})}
</div>
)}
{migrationCollisions.length > 0 && (
<div className="device-sync-migrate-warning">
<AlertCircle size={14} />
{t('deviceSync.migrateCollisions', {
defaultValue: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.',
n: migrationCollisions.length,
})}
</div>
)}
<Modal
open={migrationPhase !== 'closed'}
onClose={dismiss}
title={t('deviceSync.migrateTitle', { defaultValue: 'Reorganize existing files' })}
size="md"
hideClose={executing}
closeOnBackdrop={!executing}
closeOnEscape={!executing}
bodyClassName="ui-modal-body--padded"
footer={footer}
>
<div className="device-sync-migrate-body">
{migrationPhase === 'loading' && (
<div className="device-sync-migrate-loading">
<Loader2 size={18} className="spin" />
<span>{t('deviceSync.migrateLoading', { defaultValue: 'Analyzing existing files' })}</span>
</div>
)}
{migrationPhase === 'nothing' && (
<div className="device-sync-migrate-nothing">
{migrationOldTemplate ? (
t('deviceSync.migrateNothingToDo', { defaultValue: 'All existing files already match the new scheme — nothing to do.' })
) : (
t('deviceSync.migrateNoTemplate', { defaultValue: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.' })
)}
</div>
)}
{migrationPhase === 'preview' && (
<>
<div className="device-sync-migrate-summary">
<div>
<strong>{migrationPairs.length}</strong>{' '}
{t('deviceSync.migrateFilesToRename', { defaultValue: 'files will be renamed' })}
</div>
<div className="device-sync-migrate-preview-note">
{t('deviceSync.migratePreviewNote', {
defaultValue: 'Old template: {{tpl}}',
tpl: migrationOldTemplate,
})}
</div>
</>
)}
{migrationPhase === 'executing' && (
<div className="device-sync-migrate-loading">
<Loader2 size={18} className="spin" />
<span>{t('deviceSync.migrateExecuting', { defaultValue: 'Renaming files…' })}</span>
</div>
)}
{migrationPhase === 'done' && migrationResult && (
<div className="device-sync-migrate-result">
<div className="device-sync-migrate-result-line">
<CheckCircle2 size={14} className="positive" />
{t('deviceSync.migrateSuccess', {
defaultValue: '{{n}} files renamed successfully',
n: migrationResult.ok,
})}
</div>
{migrationResult.failed > 0 && (
<div className="device-sync-migrate-result-line">
<AlertCircle size={14} className="danger" />
{t('deviceSync.migrateFailed', {
defaultValue: '{{n}} renames failed',
n: migrationResult.failed,
{migrationUnchanged > 0 && (
<div className="muted">
{t('deviceSync.migrateUnchanged', {
defaultValue: '{{n}} files are already at the correct path',
n: migrationUnchanged,
})}
</div>
)}
{migrationResult.errors.length > 0 && (
<details className="device-sync-migrate-errors">
<summary>{t('deviceSync.migrateShowErrors', { defaultValue: 'Show errors' })}</summary>
<ul>
{migrationResult.errors.slice(0, 50).map((err, i) => (
<li key={i}>{err}</li>
))}
{migrationResult.errors.length > 50 && (
<li> {migrationResult.errors.length - 50} more</li>
)}
</ul>
</details>
{migrationCollisions.length > 0 && (
<div className="device-sync-migrate-warning">
<AlertCircle size={14} />
{t('deviceSync.migrateCollisions', {
defaultValue: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.',
n: migrationCollisions.length,
})}
</div>
)}
</div>
)}
</div>
<div className="device-sync-migrate-footer">
{migrationPhase === 'preview' && (
<>
<button className="btn btn-ghost" onClick={closeMigration}>{t('common.cancel')}</button>
<button className="btn btn-primary" onClick={executeMigration} disabled={migrationPairs.length === 0}>
{t('deviceSync.migrateStart', { defaultValue: 'Start renaming' })}
</button>
</>
)}
{(migrationPhase === 'done' || migrationPhase === 'nothing') && (
<button className="btn btn-primary" onClick={closeMigration}>{t('common.close')}</button>
)}
</div>
<div className="device-sync-migrate-preview-note">
{t('deviceSync.migratePreviewNote', {
defaultValue: 'Old template: {{tpl}}',
tpl: migrationOldTemplate,
})}
</div>
</>
)}
{migrationPhase === 'executing' && (
<div className="device-sync-migrate-loading">
<Loader2 size={18} className="spin" />
<span>{t('deviceSync.migrateExecuting', { defaultValue: 'Renaming files…' })}</span>
</div>
)}
{migrationPhase === 'done' && migrationResult && (
<div className="device-sync-migrate-result">
<div className="device-sync-migrate-result-line">
<CheckCircle2 size={14} className="positive" />
{t('deviceSync.migrateSuccess', {
defaultValue: '{{n}} files renamed successfully',
n: migrationResult.ok,
})}
</div>
{migrationResult.failed > 0 && (
<div className="device-sync-migrate-result-line">
<AlertCircle size={14} className="danger" />
{t('deviceSync.migrateFailed', {
defaultValue: '{{n}} renames failed',
n: migrationResult.failed,
})}
</div>
)}
{migrationResult.errors.length > 0 && (
<details className="device-sync-migrate-errors">
<summary>{t('deviceSync.migrateShowErrors', { defaultValue: 'Show errors' })}</summary>
<ul>
{migrationResult.errors.slice(0, 50).map((err, i) => (
<li key={i}>{err}</li>
))}
{migrationResult.errors.length > 50 && (
<li> {migrationResult.errors.length - 50} more</li>
)}
</ul>
</details>
)}
</div>
)}
</div>
</div>
</Modal>
);
}
@@ -1,8 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AlertCircle, Loader2 } from 'lucide-react';
import type { SyncDelta } from '../../utils/deviceSync/runDeviceSyncExecution';
import { formatMb } from '../../utils/format/formatBytes';
import Modal from '../Modal';
interface Props {
preSyncOpen: boolean;
@@ -16,62 +16,59 @@ export default function DeviceSyncPreSyncModal({
preSyncOpen, preSyncLoading, syncDelta, onCancel, onProceed,
}: Props) {
const { t } = useTranslation();
if (!preSyncOpen) return null;
const overBudget = syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes;
return (
<div className="modal-overlay">
<div className="modal-content device-sync-modal">
<h2 className="modal-title">{t('deviceSync.syncSummary')}</h2>
{preSyncLoading ? (
<div className="device-sync-loading-modal" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', margin: '20px' }}>
<Loader2 size={32} className="spin" />
<p style={{ marginTop: '10px' }}>{t('deviceSync.calculating')}</p>
<Modal
open={preSyncOpen}
onClose={onCancel}
title={t('deviceSync.syncSummary')}
size="md"
closeLabel={t('deviceSync.cancel')}
bodyClassName="ui-modal-body--padded"
footer={!preSyncLoading ? (
<>
<button className="btn btn-ghost" onClick={onCancel}>
{t('deviceSync.cancel')}
</button>
<button className="btn btn-primary" onClick={onProceed} disabled={overBudget}>
{t('deviceSync.proceed')}
</button>
</>
) : undefined}
>
{preSyncLoading ? (
<div className="device-sync-loading-modal" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', margin: '20px' }}>
<Loader2 size={32} className="spin" />
<p style={{ marginTop: '10px' }}>{t('deviceSync.calculating')}</p>
</div>
) : (
<div className="device-sync-summary-stats" style={{ display: 'flex', flexDirection: 'column', gap: '8px', margin: '10px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
<span>{t('deviceSync.filesToAdd')}</span>
<span className="color-success">+{syncDelta.addCount} ({formatMb(syncDelta.addBytes)})</span>
</div>
) : (
<div className="device-sync-summary-stats" style={{ display: 'flex', flexDirection: 'column', gap: '8px', margin: '10px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
<span>{t('deviceSync.filesToAdd')}</span>
<span className="color-success">+{syncDelta.addCount} ({formatMb(syncDelta.addBytes)})</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
<span>{t('deviceSync.filesToDelete')}</span>
<span className="color-error">-{syncDelta.delCount} ({formatMb(syncDelta.delBytes)})</span>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '10px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold' }}>
<span>{t('deviceSync.netChange')}</span>
<span>{formatMb(syncDelta.addBytes - syncDelta.delBytes)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', color: syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes ? 'var(--danger)' : 'inherit', marginTop: '10px' }}>
<span>{t('deviceSync.availableSpace')}</span>
<span>{formatMb(syncDelta.availableBytes)}</span>
</div>
{syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes && (
<div className="sync-warning error" style={{ background: 'color-mix(in srgb, var(--danger) 15%, transparent)', padding: '10px', borderRadius: 'var(--radius-md)', marginTop: '15px', display: 'flex', gap: '10px', color: 'var(--danger)', alignItems: 'flex-start' }}>
<AlertCircle size={16} style={{ flexShrink: 0, marginTop: '2px' }} />
<span>{t('deviceSync.spaceWarning')}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
<span>{t('deviceSync.filesToDelete')}</span>
<span className="color-error">-{syncDelta.delCount} ({formatMb(syncDelta.delBytes)})</span>
</div>
)}
{!preSyncLoading && (
<div className="modal-actions" style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '25px' }}>
<button className="btn btn-ghost" onClick={onCancel}>
{t('deviceSync.cancel')}
</button>
<button
className="btn btn-primary"
onClick={onProceed}
disabled={syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes}
>
{t('deviceSync.proceed')}
</button>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '10px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold' }}>
<span>{t('deviceSync.netChange')}</span>
<span>{formatMb(syncDelta.addBytes - syncDelta.delBytes)}</span>
</div>
)}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', color: overBudget ? 'var(--danger)' : 'inherit', marginTop: '10px' }}>
<span>{t('deviceSync.availableSpace')}</span>
<span>{formatMb(syncDelta.availableBytes)}</span>
</div>
{overBudget && (
<div className="sync-warning error" style={{ background: 'color-mix(in srgb, var(--danger) 15%, transparent)', padding: '10px', borderRadius: 'var(--radius-md)', marginTop: '15px', display: 'flex', gap: '10px', color: 'var(--danger)', alignItems: 'flex-start' }}>
<AlertCircle size={16} style={{ flexShrink: 0, marginTop: '2px' }} />
<span>{t('deviceSync.spaceWarning')}</span>
</div>
)}
</div>
)}
</Modal>
);
}
+90 -120
View File
@@ -1,9 +1,8 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Download, X } from 'lucide-react';
import { Download } from 'lucide-react';
import { showToast } from '../../utils/ui/toast';
import type { SpotifyCsvTrack } from '../../utils/playlist/spotifyCsvImport';
import Modal from '../Modal';
interface CsvReportModalProps {
report: {
@@ -20,9 +19,6 @@ interface CsvReportModalProps {
export default function CsvImportReportModal({ report, playlistName, onClose }: CsvReportModalProps) {
const { t } = useTranslation();
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
const downloadReport = () => {
try {
@@ -59,127 +55,101 @@ export default function CsvImportReportModal({ report, playlistName, onClose }:
}
};
return createPortal(
<div
className="modal-overlay modal-overlay--csv"
onClick={handleOverlayClick}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 99999,
}}
>
<div
className="modal-content"
style={{
maxWidth: 500,
maxHeight: '80vh',
width: '90%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
onClick={e => e.stopPropagation()}
>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ position: 'absolute', top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 20, marginBottom: 16 }}>{t('playlists.csvImportReport')}</h2>
<div style={{ display: 'grid', gridTemplateColumns: report.searchErrors && report.searchErrors.length > 0 ? 'repeat(5, 1fr)' : 'repeat(4, 1fr)', gap: 12, marginBottom: 20, textAlign: 'center' }}>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text)' }}>{report.total}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportTotal')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>{report.added}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportAdded')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-muted)' }}>{report.duplicates}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicates')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: report.notFound.length > 0 ? '#ff6b6b' : 'var(--text-muted)' }}>{report.notFound.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFound')}</div>
</div>
{report.searchErrors && report.searchErrors.length > 0 && (
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: '#ffa500' }}>{report.searchErrors.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNetworkErrors')}</div>
</div>
)}
</div>
{report.duplicateTracks.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicatesTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.duplicateTracks.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
{report.notFound.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFoundTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 200, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.notFound.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
{track.albumName && <div style={{ color: 'var(--text-muted)', fontSize: 11 }}>{track.albumName}</div>}
{track.score !== undefined && (
<div style={{ fontSize: 11, marginTop: 2 }}>
<span style={{ color: 'var(--text-muted)' }}>Score: </span>
<span style={{ color: track.score >= (track.thresholdNeeded ?? 0.6) ? '#4ade80' : '#f87171' }}>
{track.score.toFixed(2)}
</span>
<span style={{ color: 'var(--text-muted)' }}> (threshold: {(track.thresholdNeeded ?? 0.6).toFixed(2)})</span>
</div>
)}
</div>
))}
</div>
</>
)}
{report.searchErrors && report.searchErrors.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: '#ffa500' }}>{t('playlists.csvImportNetworkErrorsTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.searchErrors.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
<div className="modal-actions" style={{ marginTop: 'auto', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
return (
<Modal
open
onClose={onClose}
title={t('playlists.csvImportReport')}
size="md"
closeLabel={t('playlists.csvImportClose')}
bodyClassName="ui-modal-body--padded"
footer={
<>
<button className="btn btn-surface" onClick={downloadReport}>
<Download size={14} /> {t('playlists.csvImportDownloadReport')}
</button>
<button className="btn btn-primary" onClick={onClose}>
{t('playlists.csvImportClose')}
</button>
</>
}
>
<div style={{ display: 'grid', gridTemplateColumns: report.searchErrors && report.searchErrors.length > 0 ? 'repeat(5, 1fr)' : 'repeat(4, 1fr)', gap: 12, marginBottom: 20, textAlign: 'center' }}>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text)' }}>{report.total}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportTotal')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>{report.added}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportAdded')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-muted)' }}>{report.duplicates}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicates')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: report.notFound.length > 0 ? '#ff6b6b' : 'var(--text-muted)' }}>{report.notFound.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFound')}</div>
</div>
{report.searchErrors && report.searchErrors.length > 0 && (
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: '#ffa500' }}>{report.searchErrors.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNetworkErrors')}</div>
</div>
)}
</div>
</div>,
document.body
{report.duplicateTracks.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicatesTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.duplicateTracks.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
{report.notFound.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFoundTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 200, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.notFound.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
{track.albumName && <div style={{ color: 'var(--text-muted)', fontSize: 11 }}>{track.albumName}</div>}
{track.score !== undefined && (
<div style={{ fontSize: 11, marginTop: 2 }}>
<span style={{ color: 'var(--text-muted)' }}>Score: </span>
<span style={{ color: track.score >= (track.thresholdNeeded ?? 0.6) ? '#4ade80' : '#f87171' }}>
{track.score.toFixed(2)}
</span>
<span style={{ color: 'var(--text-muted)' }}> (threshold: {(track.thresholdNeeded ?? 0.6).toFixed(2)})</span>
</div>
)}
</div>
))}
</div>
</>
)}
{report.searchErrors && report.searchErrors.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: '#ffa500' }}>{t('playlists.csvImportNetworkErrorsTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.searchErrors.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
</Modal>
);
}
+23 -25
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { Play, X, Trash2, ListPlus } from 'lucide-react';
import { Play, Trash2, ListPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getPlaylists, deletePlaylist } from '../../api/subsonicPlaylists';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import Modal from '../Modal';
import ConfirmDialog from '../ConfirmDialog';
interface Props {
onClose: () => void;
@@ -33,7 +35,7 @@ export function LoadPlaylistModal({ onClose, onLoad }: Props) {
fetchPlaylists();
}, []);
const handleDelete = async (id: string, name: string) => {
const handleDelete = (id: string, name: string) => {
setConfirmDelete({ id, name });
};
@@ -46,10 +48,14 @@ export function LoadPlaylistModal({ onClose, onLoad }: Props) {
return (
<>
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '560px', width: '90vw' }}>
<button className="modal-close" onClick={onClose}><X size={18} /></button>
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.loadPlaylist')}</h3>
<Modal
open
onClose={onClose}
title={t('queue.loadPlaylist')}
size="md"
closeLabel={t('queue.cancel')}
bodyClassName="ui-modal-body--padded"
>
{!loading && playlists.length > 0 && (
<input
type="text"
@@ -79,26 +85,18 @@ export function LoadPlaylistModal({ onClose, onLoad }: Props) {
))}
</div>
)}
</div>
</div>
</Modal>
{confirmDelete && (
<div className="modal-overlay" onClick={() => setConfirmDelete(null)} role="dialog" aria-modal="true">
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '360px' }}>
<button className="modal-close" onClick={() => setConfirmDelete(null)}><X size={18} /></button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>{t('queue.delete')}</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
{t('queue.deleteConfirm', { name: confirmDelete.name })}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>{t('queue.cancel')}</button>
<button className="btn btn-primary" style={{ background: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={confirmDeletePlaylist}>
{t('queue.delete')}
</button>
</div>
</div>
</div>
)}
<ConfirmDialog
open={!!confirmDelete}
title={t('queue.delete')}
message={confirmDelete ? t('queue.deleteConfirm', { name: confirmDelete.name }) : ''}
confirmLabel={t('queue.delete')}
cancelLabel={t('queue.cancel')}
danger
onConfirm={confirmDeletePlaylist}
onCancel={() => setConfirmDelete(null)}
/>
</>
);
}
+26 -20
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import Modal from '../Modal';
interface Props {
onClose: () => void;
@@ -10,26 +10,32 @@ interface Props {
export function SavePlaylistModal({ onClose, onSave }: Props) {
const { t } = useTranslation();
const [name, setName] = useState('');
const submit = () => { if (name.trim()) onSave(name.trim()); };
return (
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true">
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<button className="modal-close" onClick={onClose}><X size={18} /></button>
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('queue.savePlaylist')}</h3>
<input
type="text"
className="live-search-field"
placeholder={t('queue.playlistName')}
value={name}
onChange={e => setName(e.target.value)}
autoFocus
onKeyDown={e => e.key === 'Enter' && name.trim() && onSave(name.trim())}
style={{ width: '100%', marginBottom: '1rem', padding: '10px 16px' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<Modal
open
onClose={onClose}
title={t('queue.savePlaylist')}
size="sm"
closeLabel={t('queue.cancel')}
bodyClassName="ui-modal-body--padded"
footer={
<>
<button className="btn btn-ghost" onClick={onClose}>{t('queue.cancel')}</button>
<button className="btn btn-primary" onClick={() => name.trim() && onSave(name.trim())}>{t('queue.save')}</button>
</div>
</div>
</div>
<button className="btn btn-primary" onClick={submit}>{t('queue.save')}</button>
</>
}
>
<input
type="text"
className="live-search-field"
placeholder={t('queue.playlistName')}
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submit(); }}
style={{ width: '100%', padding: '10px 16px' }}
/>
</Modal>
);
}
@@ -1,7 +1,7 @@
import { AlertTriangle, BarChart3, FileDown, RefreshCcw, TriangleAlert, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { AlertTriangle, BarChart3, FileDown, RefreshCcw, TriangleAlert } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Modal from '../Modal';
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { writeFile } from '@tauri-apps/plugin-fs';
import SettingsSubSection from '../SettingsSubSection';
@@ -66,6 +66,7 @@ export default function AnalyticsStrategySection() {
const [failedModalTarget, setFailedModalTarget] = useState<FailedModalTarget | null>(null);
const [failedModalLoading, setFailedModalLoading] = useState(false);
const [failedActionBusy, setFailedActionBusy] = useState(false);
const clearCancelRef = useRef<HTMLButtonElement>(null);
const activeServerIds = useMemo(
() => new Set(servers.map(server => serverIndexKeyForProfile(server))),
@@ -437,179 +438,151 @@ export default function AnalyticsStrategySection() {
</SettingsGroup>
</div>
{clearTarget &&
createPortal(
<div
className="modal-overlay"
onClick={() => setClearTarget(null)}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '420px' }}
>
{clearTarget && (
<Modal
open
onClose={() => { if (clearingServerId !== clearTarget.serverId) setClearTarget(null); }}
title={t('settings.analyticsStrategyClearTitle')}
size="sm"
closeLabel={t('settings.analyticsStrategyClearCancel')}
initialFocusRef={clearCancelRef}
closeOnBackdrop={clearingServerId !== clearTarget.serverId}
closeOnEscape={clearingServerId !== clearTarget.serverId}
bodyClassName="ui-modal-body--padded"
footer={
<>
<button
className="modal-close"
ref={clearCancelRef}
className="btn btn-primary"
onClick={() => setClearTarget(null)}
aria-label={t('settings.analyticsStrategyClearCancel')}
disabled={clearingServerId === clearTarget.serverId}
>
<X size={18} />
{t('settings.analyticsStrategyClearCancel')}
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>
{t('settings.analyticsStrategyClearTitle')}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
{t('settings.analyticsStrategyClearDesc', { server: clearTarget.label })}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
className="btn btn-primary"
onClick={() => setClearTarget(null)}
autoFocus
disabled={clearingServerId === clearTarget.serverId}
>
{t('settings.analyticsStrategyClearCancel')}
</button>
<button
className="btn btn-surface"
onClick={handleClearAnalysis}
disabled={clearingServerId === clearTarget.serverId}
style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<AlertTriangle size={14} />
{t('settings.analyticsStrategyClearConfirm')}
</span>
</button>
</div>
</div>
</div>,
document.body,
)}
{failedModalTarget &&
createPortal(
<div
className="modal-overlay"
onClick={() => !failedActionBusy && setFailedModalTarget(null)}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '680px', width: 'min(680px, 92vw)' }}
>
<button
className="modal-close"
className="btn btn-surface"
onClick={handleClearAnalysis}
disabled={clearingServerId === clearTarget.serverId}
style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<AlertTriangle size={14} />
{t('settings.analyticsStrategyClearConfirm')}
</span>
</button>
</>
}
>
<p className="confirm-dialog-message">
{t('settings.analyticsStrategyClearDesc', { server: clearTarget.label })}
</p>
</Modal>
)}
{failedModalTarget && (
<Modal
open
onClose={() => { if (!failedActionBusy) setFailedModalTarget(null); }}
title={t('settings.analyticsFailedTracksTitle', { server: failedModalTarget.label })}
size="lg"
closeLabel={t('settings.analyticsFailedTracksClose')}
closeOnBackdrop={!failedActionBusy}
closeOnEscape={!failedActionBusy}
bodyClassName="ui-modal-body--padded"
footer={
<>
<button
className="btn btn-surface"
onClick={handleExportFailedTracks}
disabled={failedModalLoading || failedActionBusy || (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<FileDown size={14} />
{t('settings.analyticsFailedTracksExport')}
</span>
</button>
<div style={{ flex: 1 }} />
<button
className="btn btn-primary"
onClick={() => setFailedModalTarget(null)}
aria-label={t('settings.analyticsFailedTracksClose')}
disabled={failedActionBusy}
>
<X size={18} />
{t('settings.analyticsFailedTracksClose')}
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>
{t('settings.analyticsFailedTracksTitle', { server: failedModalTarget.label })}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.analyticsFailedTracksDesc')}
</p>
<button
className="btn btn-surface"
onClick={handleRescanFailedTracks}
disabled={failedModalLoading || failedActionBusy || (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0}
style={{ borderColor: 'var(--warning, #f59e0b)', color: 'var(--warning, #f59e0b)' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<RefreshCcw size={14} />
{t('settings.analyticsFailedTracksRescan')}
</span>
</button>
</>
}
>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.analyticsFailedTracksDesc')}
</p>
{failedModalLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{t('settings.analyticsFailedTracksLoading')}
</div>
) : (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{t('settings.analyticsFailedTracksEmpty')}
</div>
) : (
{failedModalLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{t('settings.analyticsFailedTracksLoading')}
</div>
) : (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{t('settings.analyticsFailedTracksEmpty')}
</div>
) : (
<div
style={{
border: '1px solid var(--border-subtle, rgba(255,255,255,0.08))',
borderRadius: 10,
maxHeight: 280,
overflowY: 'auto',
marginBottom: 12,
}}
>
{(failedTracksByServer[failedModalTarget.indexKey] ?? []).map(track => (
<div
key={`${track.trackId}:${track.md5_16kb}:${track.updatedAt}`}
style={{
border: '1px solid var(--border-subtle, rgba(255,255,255,0.08))',
borderRadius: 10,
maxHeight: 280,
overflowY: 'auto',
marginBottom: 12,
padding: '8px 10px',
borderBottom: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
fontSize: 12,
color: 'var(--text-secondary)',
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 10,
}}
>
{(failedTracksByServer[failedModalTarget.indexKey] ?? []).map(track => (
<div style={{ minWidth: 0 }}>
<div
key={`${track.trackId}:${track.md5_16kb}:${track.updatedAt}`}
style={{
padding: '8px 10px',
borderBottom: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
wordBreak: 'break-word',
color: 'var(--text-primary)',
fontFamily: 'var(--font-ui)',
fontSize: 12,
color: 'var(--text-secondary)',
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 10,
}}
>
<div style={{ minWidth: 0 }}>
<div
style={{
wordBreak: 'break-word',
color: 'var(--text-primary)',
fontFamily: 'var(--font-ui)',
fontSize: 12,
}}
>
{track.title?.trim() || track.trackId}
</div>
<div style={{ wordBreak: 'break-all', fontSize: 11, fontFamily: 'var(--font-mono)' }}>
{track.serverPath?.trim() || track.trackId}
</div>
</div>
<span style={{ whiteSpace: 'nowrap', color: 'var(--text-muted)' }}>
{new Date(track.updatedAt * 1000).toLocaleString()}
</span>
{track.title?.trim() || track.trackId}
</div>
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<button
className="btn btn-surface"
onClick={handleExportFailedTracks}
disabled={failedModalLoading || failedActionBusy || (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<FileDown size={14} />
{t('settings.analyticsFailedTracksExport')}
<div style={{ wordBreak: 'break-all', fontSize: 11, fontFamily: 'var(--font-mono)' }}>
{track.serverPath?.trim() || track.trackId}
</div>
</div>
<span style={{ whiteSpace: 'nowrap', color: 'var(--text-muted)' }}>
{new Date(track.updatedAt * 1000).toLocaleString()}
</span>
</button>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-primary"
onClick={() => setFailedModalTarget(null)}
disabled={failedActionBusy}
>
{t('settings.analyticsFailedTracksClose')}
</button>
<button
className="btn btn-surface"
onClick={handleRescanFailedTracks}
disabled={failedModalLoading || failedActionBusy || (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0}
style={{ borderColor: 'var(--warning, #f59e0b)', color: 'var(--warning, #f59e0b)' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<RefreshCcw size={14} />
{t('settings.analyticsFailedTracksRescan')}
</span>
</button>
</div>
</div>
))}
</div>
</div>,
document.body,
)}
)}
</Modal>
)}
</SettingsSubSection>
);
}
@@ -6,7 +6,7 @@ import { open } from '@tauri-apps/plugin-dialog';
import { useInstalledThemesStore } from '../../store/installedThemesStore';
import { validateThemePackage, type ValidatedTheme } from '../../utils/themes/validateThemePackage';
import { showToast } from '../../utils/ui/toast';
import ConfirmModal from '../ConfirmModal';
import ConfirmDialog from '../ConfirmDialog';
/**
* Import a community theme from a local `.zip` (manifest.json + theme.css).
@@ -129,7 +129,7 @@ export function ThemeImportSection() {
</div>
)}
<ConfirmModal
<ConfirmDialog
open={pending !== null}
title={t('settings.themeImportConfirmTitle')}
message={pending ? `${t('settings.themeImportConfirmBody', { name: pending.name, author: pending.author })} ${t('settings.themeImportConfirmRisk')}` : ''}
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RotateCcw, UserPlus, Users } from 'lucide-react';
import type { NdUser } from '../../api/navidromeAdmin';
import ConfirmModal from '../ConfirmModal';
import ConfirmDialog from '../ConfirmDialog';
import { useUserMgmtData } from '../../hooks/useUserMgmtData';
import { useUserMgmtActions } from '../../hooks/useUserMgmtActions';
import { UserForm } from './UserForm';
@@ -121,7 +121,7 @@ export function UserManagementSection({
)}
</>
)}
<ConfirmModal
<ConfirmDialog
open={!!confirmingDelete}
title={t('settings.userMgmtDelete')}
message={confirmingDelete
@@ -1,6 +1,4 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { useState } from 'react';
import type { TFunction } from 'i18next';
import { ndUpdateUser, type NdUser } from '../../../api/navidromeAdmin';
import { showToast } from '../../../utils/ui/toast';
@@ -11,6 +9,7 @@ import {
} from '../../../utils/server/serverMagicString';
import { shortHostFromServerUrl } from '../../../utils/server/serverDisplayName';
import { useAuthStore } from '../../../store/authStore';
import Modal from '../../Modal';
interface Props {
user: NdUser;
@@ -27,9 +26,6 @@ interface Props {
* APIs, so we must re-set one); on success we encode it into a server
* magic string, copy it to the clipboard, and let the parent reload the
* list.
*
* Rendered into `document.body` via portal so the modal escapes the
* settings overflow box.
*/
export function MagicStringModal({
user,
@@ -88,63 +84,18 @@ export function MagicStringModal({
})();
};
return createPortal(
<div
className="modal-overlay"
onClick={closeIfIdle}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '400px' }}
>
<button
type="button"
className="modal-close"
onClick={closeIfIdle}
aria-label={t('settings.userMgmtCancel')}
>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>
{t('settings.userMgmtMagicStringModalTitle')}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5, fontSize: 13 }}>
{t('settings.userMgmtMagicStringModalDesc', { username: user.userName })}
</p>
<p style={{ color: 'var(--text-muted)', marginBottom: '0.75rem', lineHeight: 1.45, fontSize: 12 }}>
{t('settings.userMgmtMagicStringPasswordNavHint')}
</p>
<div
role="note"
style={{
fontSize: 11,
lineHeight: 1.45,
marginBottom: '1rem',
padding: '8px 10px',
borderRadius: 6,
border: '1px solid color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent)',
background: 'color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.userMgmtMagicStringPlaintextWarning')}
</div>
<div className="form-group" style={{ marginBottom: '1.25rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.userMgmtPassword')}</label>
<input
className="input"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="off"
disabled={submitting}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
return (
<Modal
open
onClose={closeIfIdle}
title={t('settings.userMgmtMagicStringModalTitle')}
size="sm"
closeLabel={t('settings.userMgmtCancel')}
closeOnBackdrop={!submitting}
closeOnEscape={!submitting}
bodyClassName="ui-modal-body--padded"
footer={
<>
<button
type="button"
className="btn btn-ghost"
@@ -161,9 +112,41 @@ export function MagicStringModal({
>
{t('settings.userMgmtMagicStringModalConfirm')}
</button>
</div>
</>
}
>
<p style={{ color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5, fontSize: 13 }}>
{t('settings.userMgmtMagicStringModalDesc', { username: user.userName })}
</p>
<p style={{ color: 'var(--text-muted)', marginBottom: '0.75rem', lineHeight: 1.45, fontSize: 12 }}>
{t('settings.userMgmtMagicStringPasswordNavHint')}
</p>
<div
role="note"
style={{
fontSize: 11,
lineHeight: 1.45,
marginBottom: '1rem',
padding: '8px 10px',
borderRadius: 6,
border: '1px solid color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent)',
background: 'color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.userMgmtMagicStringPlaintextWarning')}
</div>
</div>,
document.body,
<div className="form-group">
<label style={{ fontSize: 13 }}>{t('settings.userMgmtPassword')}</label>
<input
className="input"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="off"
disabled={submitting}
/>
</div>
</Modal>
);
}
+29 -1
View File
@@ -7,7 +7,9 @@
.ui-modal-backdrop {
position: fixed;
inset: 0;
z-index: 3000;
/* Above toasts (~9000), below TooltipPortal (99999) matches the legacy
.modal-overlay layering so migrated modals stack as before. */
z-index: 9999;
background: var(--modal-scrim, rgba(0, 0, 0, 0.5));
display: flex;
align-items: center;
@@ -17,6 +19,11 @@
/* No backdrop-filter: on WebKitGTK (some GPU stacks) the blur bleeds onto the
modal content and makes its text look unfocused. */
}
/* perf variant: opaque backdrop + no animation (sidebar performance probe). */
.ui-modal-backdrop--perf {
background: rgba(17, 17, 27, 0.96);
animation: none;
}
.ui-modal {
position: relative;
@@ -32,6 +39,8 @@
}
.ui-modal--sm { width: min(400px, 94vw); }
.ui-modal--lg { width: min(720px, 94vw); }
.ui-modal--xl { width: min(860px, 94vw); }
.ui-modal--perf { animation: none; }
.ui-modal-header {
display: flex;
@@ -81,18 +90,37 @@
color: var(--text-primary);
background: var(--bg-glass);
}
.ui-modal-close:focus-visible {
opacity: 1;
color: var(--text-primary);
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ui-modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
/* Opt-in padding for modals whose content used the legacy .modal-content pad.
Edge-to-edge lists / tables keep the padding-less default. */
.ui-modal-body--padded {
padding: 4px 18px 16px;
}
.ui-modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
padding: 12px 18px;
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
/* ─── ConfirmDialog (src/components/ConfirmDialog.tsx) ─── */
.confirm-dialog-message {
margin: 0;
color: var(--text-secondary);
line-height: 1.5;
}
+8
View File
@@ -12,6 +12,14 @@
white-space: nowrap;
}
/* Keyboard focus ring. The offset puts a band of the real background between
the button and the ring, so it stays visible even on an accent-filled
.btn-primary (where ring and fill share the accent colour). */
.btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn-primary {
background: var(--accent);
color: var(--text-on-accent);