chore(orbit): participants visible to guests, share button in session bar, guest icons

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 14:20:21 +02:00
parent 089774dc3e
commit 6c7f455e66
6 changed files with 134 additions and 47 deletions
+3 -36
View File
@@ -1,10 +1,9 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Crown, UserMinus, ShieldOff, Copy, Check } from 'lucide-react'; import { Crown, User, UserMinus, ShieldOff } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore'; import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore'; import { kickOrbitParticipant, removeOrbitParticipant } from '../utils/orbit';
import { kickOrbitParticipant, removeOrbitParticipant, buildOrbitShareLink } from '../utils/orbit';
import ConfirmModal from './ConfirmModal'; import ConfirmModal from './ConfirmModal';
interface Props { interface Props {
@@ -27,25 +26,10 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
const { t } = useTranslation(); const { t } = useTranslation();
const state = useOrbitStore(s => s.state); const state = useOrbitStore(s => s.state);
const role = useOrbitStore(s => s.role); const role = useOrbitStore(s => s.role);
const sessionId = useOrbitStore(s => s.sessionId);
const popRef = useRef<HTMLDivElement>(null); const popRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null); const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null);
const nowMs = Date.now(); const nowMs = Date.now();
const shareLink = role === 'host' && sessionId
? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId)
: null;
const onCopy = async () => {
if (!shareLink) return;
try {
await navigator.clipboard.writeText(shareLink);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ }
};
// Close on outside click / Escape — unless a confirm dialog is open // Close on outside click / Escape — unless a confirm dialog is open
// (otherwise outside-clicking the modal would dismiss the popover too, // (otherwise outside-clicking the modal would dismiss the popover too,
// and re-opening would lose the in-flight confirm context). // and re-opening would lose the in-flight confirm context).
@@ -92,24 +76,6 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
return createPortal( return createPortal(
<> <>
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu"> <div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
{shareLink && (
<div className="orbit-participants-pop__invite">
<div className="orbit-participants-pop__invite-label">{t('orbit.participantsInviteLabel')}</div>
<div className="orbit-participants-pop__invite-row">
<code className="orbit-participants-pop__invite-link">{shareLink}</code>
<button
type="button"
className="orbit-participants-pop__invite-copy"
onClick={onCopy}
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
aria-label={t('orbit.ariaCopyLink')}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
</div>
</div>
)}
<div className="orbit-participants-pop__head"> <div className="orbit-participants-pop__head">
{t('orbit.participantsCountLabel', { count: state.participants.length + 1 })} {t('orbit.participantsCountLabel', { count: state.participants.length + 1 })}
</div> </div>
@@ -126,6 +92,7 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
{state.participants.map(p => ( {state.participants.map(p => (
<div key={p.user} className="orbit-participants-pop__row"> <div key={p.user} className="orbit-participants-pop__row">
<User size={13} />
<span className="orbit-participants-pop__name">{p.user}</span> <span className="orbit-participants-pop__name">{p.user}</span>
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span> <span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
{role === 'host' && ( {role === 'host' && (
+24 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { X, RefreshCw, Shuffle, Settings2 } from 'lucide-react'; import { X, RefreshCw, Shuffle, Settings2, Share2 } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore'; import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
@@ -14,6 +14,7 @@ import { estimateLivePosition } from '../api/orbit';
import OrbitParticipantsPopover from './OrbitParticipantsPopover'; import OrbitParticipantsPopover from './OrbitParticipantsPopover';
import OrbitExitModal from './OrbitExitModal'; import OrbitExitModal from './OrbitExitModal';
import OrbitSettingsPopover from './OrbitSettingsPopover'; import OrbitSettingsPopover from './OrbitSettingsPopover';
import OrbitSharePopover from './OrbitSharePopover';
import ConfirmModal from './ConfirmModal'; import ConfirmModal from './ConfirmModal';
/** /**
@@ -46,9 +47,11 @@ export default function OrbitSessionBar() {
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
const [peopleOpen, setPeopleOpen] = useState(false); const [peopleOpen, setPeopleOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [confirmLeave, setConfirmLeave] = useState(false); const [confirmLeave, setConfirmLeave] = useState(false);
const peopleBtnRef = useRef<HTMLButtonElement>(null); const peopleBtnRef = useRef<HTMLButtonElement>(null);
const settingsBtnRef = useRef<HTMLButtonElement>(null); const settingsBtnRef = useRef<HTMLButtonElement>(null);
const shareBtnRef = useRef<HTMLButtonElement>(null);
// Second-level tick just for the shuffle countdown + drift readout — // Second-level tick just for the shuffle countdown + drift readout —
// the store itself only ticks at 2.5 s which is too coarse for a smooth // the store itself only ticks at 2.5 s which is too coarse for a smooth
@@ -179,6 +182,20 @@ export default function OrbitSessionBar() {
<Settings2 size={14} /> <Settings2 size={14} />
</button> </button>
)} )}
{role === 'host' && (
<button
ref={shareBtnRef}
type="button"
className="orbit-bar__settings"
onClick={() => setShareOpen(v => !v)}
data-tooltip={t('orbit.shareTooltip')}
aria-haspopup="menu"
aria-expanded={shareOpen || undefined}
aria-label={t('orbit.shareTooltip')}
>
<Share2 size={14} />
</button>
)}
{showCatchUp && ( {showCatchUp && (
<button <button
type="button" type="button"
@@ -213,6 +230,12 @@ export default function OrbitSessionBar() {
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}
/> />
)} )}
{shareOpen && (
<OrbitSharePopover
anchorRef={shareBtnRef}
onClose={() => setShareOpen(false)}
/>
)}
<OrbitExitModal /> <OrbitExitModal />
<ConfirmModal <ConfirmModal
open={confirmLeave} open={confirmLeave}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Copy, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { buildOrbitShareLink } from '../utils/orbit';
interface Props {
anchorRef: React.RefObject<HTMLElement | null>;
onClose: () => void;
}
/**
* Host-only popover anchored below the share button in the Orbit bar.
* Surfaces the session invite link with a copy affordance. Lives on its
* own so the participants popover can stay focused on participants.
*/
export default function OrbitSharePopover({ anchorRef, onClose }: Props) {
const { t } = useTranslation();
const sessionId = useOrbitStore(s => s.sessionId);
const popRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const shareLink = sessionId
? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId)
: null;
useEffect(() => {
const onDown = (e: MouseEvent) => {
const target = e.target as Node | null;
if (popRef.current?.contains(target)) return;
if (anchorRef.current?.contains(target)) return;
onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [anchorRef, onClose]);
const onCopy = async () => {
if (!shareLink) return;
try {
await navigator.clipboard.writeText(shareLink);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ }
};
if (!shareLink) return null;
const anchor = anchorRef.current?.getBoundingClientRect();
const style: React.CSSProperties = anchor
? {
position: 'fixed',
top: anchor.bottom + 12,
right: Math.max(8, window.innerWidth - anchor.right),
zIndex: 9999,
}
: { display: 'none' };
return createPortal(
<div ref={popRef} className="orbit-share-pop" style={style} role="menu">
<div className="orbit-share-pop__label">{t('orbit.participantsInviteLabel')}</div>
<div className="orbit-share-pop__row">
<code className="orbit-share-pop__link">{shareLink}</code>
<button
type="button"
className="orbit-share-pop__copy"
onClick={onCopy}
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
aria-label={t('orbit.ariaCopyLink')}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
</div>
</div>,
document.body,
);
}
+1
View File
@@ -1480,6 +1480,7 @@ export const deTranslation = {
hostLabel: 'Gastgeber: {{name}}', hostLabel: 'Gastgeber: {{name}}',
participantsTooltip: 'Teilnehmer', participantsTooltip: 'Teilnehmer',
settingsTooltip: 'Session-Einstellungen', settingsTooltip: 'Session-Einstellungen',
shareTooltip: 'Einladungslink teilen',
shuffleLabel: 'Warteschlange wird neu gemischt in', shuffleLabel: 'Warteschlange wird neu gemischt in',
catchUpLabel: 'aufholen', catchUpLabel: 'aufholen',
catchUpTooltip: 'Zur aktuellen Host-Position springen', catchUpTooltip: 'Zur aktuellen Host-Position springen',
+1
View File
@@ -1483,6 +1483,7 @@ export const enTranslation = {
hostLabel: 'Host: {{name}}', hostLabel: 'Host: {{name}}',
participantsTooltip: 'Participants', participantsTooltip: 'Participants',
settingsTooltip: 'Session settings', settingsTooltip: 'Session settings',
shareTooltip: 'Share invite link',
shuffleLabel: 'Queue reshuffles in', shuffleLabel: 'Queue reshuffles in',
catchUpLabel: 'catch up', catchUpLabel: 'catch up',
catchUpTooltip: "Jump to the host's current position", catchUpTooltip: "Jump to the host's current position",
+21 -10
View File
@@ -11689,22 +11689,27 @@ html[data-psy-native-hidden="true"] *::after {
box-shadow: 0 10px 30px rgba(0,0,0,0.5); box-shadow: 0 10px 30px rgba(0,0,0,0.5);
} }
.orbit-participants-pop__invite { /* Share popover (host-only) — standalone counterpart to the participants popover. */
padding: 8px 8px 10px; .orbit-share-pop {
margin-bottom: 4px; min-width: 320px;
border-bottom: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent); max-width: 380px;
padding: 12px 14px 14px;
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);
} }
.orbit-participants-pop__invite-label { .orbit-share-pop__label {
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 6px; margin-bottom: 8px;
} }
.orbit-participants-pop__invite-row { .orbit-share-pop__row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -11714,7 +11719,7 @@ html[data-psy-native-hidden="true"] *::after {
padding: 6px 6px 6px 8px; padding: 6px 6px 6px 8px;
} }
.orbit-participants-pop__invite-link { .orbit-share-pop__link {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -11729,7 +11734,7 @@ html[data-psy-native-hidden="true"] *::after {
user-select: all; user-select: all;
} }
.orbit-participants-pop__invite-copy { .orbit-share-pop__copy {
flex: 0 0 auto; flex: 0 0 auto;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -11744,7 +11749,7 @@ html[data-psy-native-hidden="true"] *::after {
cursor: pointer; cursor: pointer;
transition: filter 0.15s ease; transition: filter 0.15s ease;
} }
.orbit-participants-pop__invite-copy:hover { filter: brightness(1.1); } .orbit-share-pop__copy:hover { filter: brightness(1.1); }
.orbit-participants-pop__head { .orbit-participants-pop__head {
font-size: 10px; font-size: 10px;
@@ -11768,6 +11773,12 @@ html[data-psy-native-hidden="true"] *::after {
.orbit-participants-pop__row--host { color: var(--accent); } .orbit-participants-pop__row--host { color: var(--accent); }
.orbit-participants-pop__row--host svg { color: var(--accent); } .orbit-participants-pop__row--host svg { color: var(--accent); }
/* Guest rows give the user icon a quieter tone so the host's crown stays
the accent focal point. */
.orbit-participants-pop__row:not(.orbit-participants-pop__row--host) > svg {
color: var(--text-muted);
flex-shrink: 0;
}
.orbit-participants-pop__name { .orbit-participants-pop__name {
flex: 1; flex: 1;