mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
@@ -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' && (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user