mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70c2fe2672 | |||
| c1583dee27 | |||
| f1fe53a6f5 | |||
| 1bc36f9421 | |||
| d774d9a3d0 | |||
| 534e366cba | |||
| e4ce02cad8 | |||
| 8741baadbf | |||
| 5ad8041ea7 | |||
| b1bad351d1 | |||
| 72af9f7560 | |||
| e9d9365110 | |||
| 764ad7dfe0 | |||
| 25574bed58 | |||
| c604a6d645 | |||
| 0859e856f7 | |||
| 14f5b190b8 |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user