mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
chore(orbit): picker modal when multiple accounts match the link's server
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ import ContextMenu from './components/ContextMenu';
|
|||||||
import SongInfoModal from './components/SongInfoModal';
|
import SongInfoModal from './components/SongInfoModal';
|
||||||
import DownloadFolderModal from './components/DownloadFolderModal';
|
import DownloadFolderModal from './components/DownloadFolderModal';
|
||||||
import GlobalConfirmModal from './components/GlobalConfirmModal';
|
import GlobalConfirmModal from './components/GlobalConfirmModal';
|
||||||
|
import OrbitAccountPicker from './components/OrbitAccountPicker';
|
||||||
import { DragDropProvider } from './contexts/DragDropContext';
|
import { DragDropProvider } from './contexts/DragDropContext';
|
||||||
import TooltipPortal from './components/TooltipPortal';
|
import TooltipPortal from './components/TooltipPortal';
|
||||||
import OverlayScrollArea from './components/OverlayScrollArea';
|
import OverlayScrollArea from './components/OverlayScrollArea';
|
||||||
@@ -520,6 +521,7 @@ function AppShell() {
|
|||||||
<SongInfoModal />
|
<SongInfoModal />
|
||||||
<DownloadFolderModal />
|
<DownloadFolderModal />
|
||||||
<GlobalConfirmModal />
|
<GlobalConfirmModal />
|
||||||
|
<OrbitAccountPicker />
|
||||||
<TooltipPortal />
|
<TooltipPortal />
|
||||||
<AppUpdater />
|
<AppUpdater />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { X, User } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal shown when joining an Orbit session and the user has more than
|
||||||
|
* one account for the target server URL. Lets them pick which account
|
||||||
|
* to switch to before the join flow continues. Mount once in App.tsx —
|
||||||
|
* any caller can invoke it via `useOrbitAccountPickerStore.request(...)`.
|
||||||
|
*/
|
||||||
|
export default function OrbitAccountPicker() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isOpen, accounts, pick, cancel } = useOrbitAccountPickerStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') cancel(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [isOpen, cancel]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) cancel(); }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="orbit-account-picker-title"
|
||||||
|
style={{ alignItems: 'center', paddingTop: 0 }}
|
||||||
|
>
|
||||||
|
<div className="modal-content orbit-account-picker">
|
||||||
|
<button type="button" className="modal-close" onClick={cancel} aria-label={t('orbit.btnCancel')}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
<h3 id="orbit-account-picker-title" className="orbit-account-picker__title">
|
||||||
|
{t('orbit.accountPickerTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="orbit-account-picker__sub">
|
||||||
|
{t('orbit.accountPickerSub', { url: accounts[0]?.url ?? '' })}
|
||||||
|
</p>
|
||||||
|
<ul className="orbit-account-picker__list">
|
||||||
|
{accounts.map(a => (
|
||||||
|
<li key={a.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="orbit-account-picker__item"
|
||||||
|
onClick={() => pick(a)}
|
||||||
|
>
|
||||||
|
<User size={14} />
|
||||||
|
<span className="orbit-account-picker__user">{a.username}</span>
|
||||||
|
{a.name && <span className="orbit-account-picker__name">· {a.name}</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="orbit-account-picker__actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||||
|
{t('orbit.btnCancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
joinOrbitSession,
|
joinOrbitSession,
|
||||||
} from '../utils/orbit';
|
} from '../utils/orbit';
|
||||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||||
|
import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -49,15 +50,20 @@ export default function OrbitJoinModal({ onClose }: Props) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
// Auto-switch to the link's server if the user has an account for it.
|
// Auto-switch to the link's server if the user has an account for it.
|
||||||
// switch itself tears down any lingering orbit session.
|
// Multiple candidates → picker modal. switch tears down any lingering
|
||||||
|
// orbit session.
|
||||||
if (activeUrl !== wantUrl) {
|
if (activeUrl !== wantUrl) {
|
||||||
const targetServer = useAuthStore.getState().servers
|
const candidates = useAuthStore.getState().servers
|
||||||
.find(s => s.url.replace(/\/+$/, '') === wantUrl);
|
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
|
||||||
if (!targetServer) {
|
if (candidates.length === 0) {
|
||||||
setError(t('orbit.toastNoAccountForServer', { url: wantUrl }));
|
setError(t('orbit.toastNoAccountForServer', { url: wantUrl }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const switched = await switchActiveServer(targetServer);
|
const target = candidates.length === 1
|
||||||
|
? candidates[0]
|
||||||
|
: await useOrbitAccountPickerStore.getState().request(candidates);
|
||||||
|
if (!target) { setBusy(false); return; }
|
||||||
|
const switched = await switchActiveServer(target);
|
||||||
if (!switched) {
|
if (!switched) {
|
||||||
setError(t('orbit.toastSwitchFailed', { url: wantUrl }));
|
setError(t('orbit.toastSwitchFailed', { url: wantUrl }));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
OrbitJoinError,
|
OrbitJoinError,
|
||||||
} from '../utils/orbit';
|
} from '../utils/orbit';
|
||||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||||
|
import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
const ORBIT_JOIN_ERROR_KEYS: Record<string, string> = {
|
const ORBIT_JOIN_ERROR_KEYS: Record<string, string> = {
|
||||||
@@ -91,17 +92,22 @@ export default function PasteClipboardHandler() {
|
|||||||
const wantUrl = orbit.serverBase.replace(/\/+$/, '');
|
const wantUrl = orbit.serverBase.replace(/\/+$/, '');
|
||||||
|
|
||||||
// Auto-switch to the link's target server if the user has an
|
// Auto-switch to the link's target server if the user has an
|
||||||
// account registered for it. No account → clear error. switch
|
// account registered for it. No account → clear error. Multiple
|
||||||
// itself tears down any lingering orbit session (see
|
// accounts for the same URL → picker lets the user choose. The
|
||||||
|
// switch itself tears down any lingering orbit session (see
|
||||||
// switchActiveServer) so the join below starts clean.
|
// switchActiveServer) so the join below starts clean.
|
||||||
if (activeUrl !== wantUrl) {
|
if (activeUrl !== wantUrl) {
|
||||||
const targetServer = useAuthStore.getState().servers
|
const candidates = useAuthStore.getState().servers
|
||||||
.find(s => s.url.replace(/\/+$/, '') === wantUrl);
|
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
|
||||||
if (!targetServer) {
|
if (candidates.length === 0) {
|
||||||
showToast(t('orbit.toastNoAccountForServer', { url: wantUrl }), 5000, 'warning');
|
showToast(t('orbit.toastNoAccountForServer', { url: wantUrl }), 5000, 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const switched = await switchActiveServer(targetServer);
|
const target = candidates.length === 1
|
||||||
|
? candidates[0]
|
||||||
|
: await useOrbitAccountPickerStore.getState().request(candidates);
|
||||||
|
if (!target) return; // cancelled
|
||||||
|
const switched = await switchActiveServer(target);
|
||||||
if (!switched) {
|
if (!switched) {
|
||||||
showToast(t('orbit.toastSwitchFailed', { url: wantUrl }), 5000, 'error');
|
showToast(t('orbit.toastSwitchFailed', { url: wantUrl }), 5000, 'error');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1548,6 +1548,8 @@ export const deTranslation = {
|
|||||||
toastSwitchServer: 'Wechsle erst zu {{url}} und füge den Link dann erneut ein',
|
toastSwitchServer: 'Wechsle erst zu {{url}} und füge den Link dann erneut ein',
|
||||||
toastNoAccountForServer: 'Du hast keinen Zugang zu {{url}}. Bitte den Host um einen Einladungslink.',
|
toastNoAccountForServer: 'Du hast keinen Zugang zu {{url}}. Bitte den Host um einen Einladungslink.',
|
||||||
toastSwitchFailed: 'Wechsel zu {{url}} fehlgeschlagen',
|
toastSwitchFailed: 'Wechsel zu {{url}} fehlgeschlagen',
|
||||||
|
accountPickerTitle: 'Welcher Account?',
|
||||||
|
accountPickerSub: 'Du hast mehrere Accounts für {{url}}. Wähle aus, mit welchem du der Session beitreten willst.',
|
||||||
toastJoinFail: 'Session konnte nicht beigetreten werden',
|
toastJoinFail: 'Session konnte nicht beigetreten werden',
|
||||||
joinErrNotFound: 'Session nicht gefunden',
|
joinErrNotFound: 'Session nicht gefunden',
|
||||||
joinErrEnded: 'Session wurde beendet',
|
joinErrEnded: 'Session wurde beendet',
|
||||||
|
|||||||
@@ -1551,6 +1551,8 @@ export const enTranslation = {
|
|||||||
toastSwitchServer: 'Switch to {{url}} first, then paste again',
|
toastSwitchServer: 'Switch to {{url}} first, then paste again',
|
||||||
toastNoAccountForServer: "You don't have access to {{url}}. Ask the host for an invite.",
|
toastNoAccountForServer: "You don't have access to {{url}}. Ask the host for an invite.",
|
||||||
toastSwitchFailed: "Couldn't switch to {{url}}",
|
toastSwitchFailed: "Couldn't switch to {{url}}",
|
||||||
|
accountPickerTitle: 'Which account?',
|
||||||
|
accountPickerSub: 'You have more than one account for {{url}}. Pick the one to join the session as.',
|
||||||
toastJoinFail: "Couldn't join session",
|
toastJoinFail: "Couldn't join session",
|
||||||
joinErrNotFound: 'Session not found',
|
joinErrNotFound: 'Session not found',
|
||||||
joinErrEnded: 'Session has ended',
|
joinErrEnded: 'Session has ended',
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { ServerProfile } from './authStore';
|
||||||
|
|
||||||
|
let _resolve: ((server: ServerProfile | null) => void) | null = null;
|
||||||
|
|
||||||
|
interface OrbitAccountPickerStore {
|
||||||
|
isOpen: boolean;
|
||||||
|
accounts: ServerProfile[];
|
||||||
|
/** Open the picker with the given candidates. Resolves with the chosen
|
||||||
|
* server or null if the user cancels. */
|
||||||
|
request: (accounts: ServerProfile[]) => Promise<ServerProfile | null>;
|
||||||
|
pick: (server: ServerProfile) => void;
|
||||||
|
cancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOrbitAccountPickerStore = create<OrbitAccountPickerStore>(set => ({
|
||||||
|
isOpen: false,
|
||||||
|
accounts: [],
|
||||||
|
|
||||||
|
request: (accounts) =>
|
||||||
|
new Promise<ServerProfile | null>(resolve => {
|
||||||
|
// If another picker is already pending, treat the previous one as cancelled.
|
||||||
|
if (_resolve) _resolve(null);
|
||||||
|
_resolve = resolve;
|
||||||
|
set({ isOpen: true, accounts });
|
||||||
|
}),
|
||||||
|
|
||||||
|
pick: (server) => {
|
||||||
|
_resolve?.(server);
|
||||||
|
_resolve = null;
|
||||||
|
set({ isOpen: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: () => {
|
||||||
|
_resolve?.(null);
|
||||||
|
_resolve = null;
|
||||||
|
set({ isOpen: false });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -11918,6 +11918,70 @@ html[data-psy-native-hidden="true"] *::after {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Account picker — modal for multi-account server switch on Orbit join. */
|
||||||
|
.orbit-account-picker {
|
||||||
|
max-width: 420px;
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
padding: 22px 26px 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.orbit-account-picker__title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.orbit-account-picker__sub {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.orbit-account-picker__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.orbit-account-picker__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
.orbit-account-picker__item svg {
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.orbit-account-picker__item:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
.orbit-account-picker__user {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.orbit-account-picker__name {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.orbit-account-picker__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Start-session modal ────────────────────────────────────────── */
|
/* ── Start-session modal ────────────────────────────────────────── */
|
||||||
.orbit-start-overlay {
|
.orbit-start-overlay {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user