diff --git a/src/components/OrbitJoinModal.tsx b/src/components/OrbitJoinModal.tsx new file mode 100644 index 00000000..b0aa08bf --- /dev/null +++ b/src/components/OrbitJoinModal.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { X, LogIn, ClipboardPaste } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../store/authStore'; +import { + parseOrbitShareLink, + findSessionPlaylistId, + readOrbitState, + joinOrbitSession, +} from '../utils/orbit'; +import { showToast } from '../utils/toast'; + +interface Props { + onClose: () => void; +} + +/** + * Orbit — manual join modal. Alternative to the Ctrl+V paste shortcut for + * users who don't want to (or can't) paste the invite link into the app + * directly. Reuses the same parse + preflight pipeline the clipboard + * handler uses, so error surfaces stay consistent. + */ +export default function OrbitJoinModal({ onClose }: Props) { + const { t } = useTranslation(); + const [link, setLink] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const onPaste = async () => { + try { + const clip = await navigator.clipboard.readText(); + if (clip) setLink(clip); + } catch { /* silent — clipboard perms vary */ } + }; + + const onJoin = async () => { + setError(null); + const text = link.trim(); + if (!text) { setError(t('orbit.joinErrEmpty')); return; } + const parsed = parseOrbitShareLink(text); + if (!parsed) { setError(t('orbit.joinErrInvalid')); return; } + + const active = useAuthStore.getState().getActiveServer(); + const activeUrl = (active?.url ?? '').replace(/\/+$/, ''); + const wantUrl = parsed.serverBase.replace(/\/+$/, ''); + if (activeUrl !== wantUrl) { + setError(t('orbit.toastSwitchServer', { url: wantUrl })); + return; + } + + setBusy(true); + try { + const playlistId = await findSessionPlaylistId(parsed.sid); + if (!playlistId) { setError(t('orbit.joinErrNotFound')); return; } + const state = await readOrbitState(playlistId); + if (!state) { setError(t('orbit.joinErrNotFound')); return; } + if (state.ended) { setError(t('orbit.joinErrEnded')); return; } + await joinOrbitSession(parsed.sid); + showToast(t('orbit.toastJoined'), 2200, 'info'); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : t('orbit.toastJoinFail')); + } finally { + setBusy(false); + } + }; + + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-join-title" + > +
+ + +
+
+ +
+

+ {t('orbit.joinModalTitle')} +

+

{t('orbit.joinModalSub')}

+
+ +
+ +
+ { setLink(e.target.value); setError(null); }} + onKeyDown={e => { if (e.key === 'Enter' && !busy) void onJoin(); }} + placeholder={t('orbit.joinModalLinkPlaceholder')} + className="orbit-start-modal__input" + /> + +
+
{t('orbit.joinModalLinkHelper')}
+
+ + {error &&
{error}
} + +
+ + +
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitStartTrigger.tsx b/src/components/OrbitStartTrigger.tsx index 60c40646..a25a3889 100644 --- a/src/components/OrbitStartTrigger.tsx +++ b/src/components/OrbitStartTrigger.tsx @@ -1,34 +1,103 @@ -import { useState } from 'react'; -import { Orbit as OrbitIcon } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Orbit as OrbitIcon, Plus, LogIn, HelpCircle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; import OrbitStartModal from './OrbitStartModal'; +import OrbitJoinModal from './OrbitJoinModal'; /** - * Topbar trigger — opens the start-session modal. Hidden while a session - * is already active (role !== null) to avoid double-entry. + * Topbar trigger — opens a small launch popover offering three choices: + * create a new session, join an existing one via invite link, or open the + * Orbit help section. Hidden while a session is already active so we + * don't offer entry points while the user's session bar is already live. */ export default function OrbitStartTrigger() { const { t } = useTranslation(); const role = useOrbitStore(s => s.role); - const [open, setOpen] = useState(false); + + const [popoverOpen, setPopoverOpen] = useState(false); + const [startOpen, setStartOpen] = useState(false); + const [joinOpen, setJoinOpen] = useState(false); + const btnRef = useRef(null); + const popRef = useRef(null); + + // Close popover on outside click / Escape. + useEffect(() => { + if (!popoverOpen) return; + const onDown = (e: MouseEvent) => { + const target = e.target as Node | null; + if (popRef.current?.contains(target)) return; + if (btnRef.current?.contains(target)) return; + setPopoverOpen(false); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPopoverOpen(false); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [popoverOpen]); if (role !== null) return null; + const anchor = btnRef.current?.getBoundingClientRect(); + const popoverStyle: React.CSSProperties = anchor + ? { + position: 'fixed', + top: anchor.bottom + 8, + left: anchor.left, + zIndex: 9999, + } + : { display: 'none' }; + + const pickCreate = () => { setPopoverOpen(false); setStartOpen(true); }; + const pickJoin = () => { setPopoverOpen(false); setJoinOpen(true); }; + return ( <> - {open && setOpen(false)} />} + + {popoverOpen && createPortal( +
+ + + +
, + document.body, + )} + + {startOpen && setStartOpen(false)} />} + {joinOpen && setJoinOpen(false)} />} ); } diff --git a/src/locales/de.ts b/src/locales/de.ts index 8bdc3275..21253942 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1449,7 +1449,21 @@ export const deTranslation = { }, orbit: { triggerLabel: 'Psy Orbit', - triggerTooltip: 'Gemeinsame Hör-Session starten', + triggerTooltip: 'Gemeinsame Hör-Session starten oder beitreten', + launchCreate: 'Session erstellen', + launchJoin: 'Session beitreten', + launchHelp: 'Wie funktioniert das?', + launchHelpSoon: 'Kommt bald', + joinModalTitle: 'Einer Orbit-Session beitreten', + joinModalSub: 'Füge den Einladungslink ein, den dir der Host geschickt hat.', + joinModalLinkLabel: 'Einladungslink', + joinModalLinkPlaceholder: 'psysonic2://orbit/…', + joinModalLinkHelper: 'Akzeptiert jeden gültigen Orbit-Link. Der Link muss zu dem Server gehören, auf dem du gerade angemeldet bist.', + joinModalPasteTooltip: 'Aus Zwischenablage einfügen', + joinModalSubmit: 'Beitreten', + joinModalBusy: 'Beitreten…', + joinErrEmpty: 'Bitte Einladungslink einfügen.', + joinErrInvalid: 'Das sieht nicht nach einem Orbit-Einladungslink aus.', closeAria: 'Schließen', heroTitlePrefix: 'Gemeinsam hören mit', heroTitleBrand: 'Psy Orbit', diff --git a/src/locales/en.ts b/src/locales/en.ts index ece1410f..529318de 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1452,7 +1452,21 @@ export const enTranslation = { }, orbit: { triggerLabel: 'Psy Orbit', - triggerTooltip: 'Start a shared listening session', + triggerTooltip: 'Start or join a shared listening session', + launchCreate: 'Create a session', + launchJoin: 'Join a session', + launchHelp: 'How does this work?', + launchHelpSoon: 'Coming soon', + joinModalTitle: 'Join an Orbit session', + joinModalSub: 'Paste the invite link your host sent you.', + joinModalLinkLabel: 'Invite link', + joinModalLinkPlaceholder: 'psysonic2://orbit/…', + joinModalLinkHelper: 'Works with any valid Orbit link. Must point to the server you\'re currently signed into.', + joinModalPasteTooltip: 'Paste from clipboard', + joinModalSubmit: 'Join', + joinModalBusy: 'Joining…', + joinErrEmpty: 'Please paste an invite link.', + joinErrInvalid: 'That doesn\'t look like an Orbit invite link.', closeAria: 'Close', heroTitlePrefix: 'Listen together with', heroTitleBrand: 'Psy Orbit', diff --git a/src/styles/components.css b/src/styles/components.css index e015842a..8ffc791a 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -11878,6 +11878,46 @@ html[data-psy-native-hidden="true"] *::after { } .orbit-start-trigger:hover svg { transform: rotate(45deg); } +/* Launch popover — three-option menu anchored below the topbar trigger. */ +.orbit-launch-pop { + min-width: 220px; + padding: 6px; + background: var(--ctp-base, #1e1e2e); + border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1)); + border-radius: var(--radius-md); + box-shadow: 0 10px 30px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + gap: 2px; +} +.orbit-launch-pop__item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 9px 10px; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} +.orbit-launch-pop__item svg { + color: var(--accent); + flex-shrink: 0; +} +.orbit-launch-pop__item:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 12%, transparent); +} +.orbit-launch-pop__item:disabled { + opacity: 0.45; + cursor: not-allowed; +} + /* ── Start-session modal ────────────────────────────────────────── */ .orbit-start-overlay { align-items: center;