chore(orbit): help modal with 9-section walk-through

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 19:06:32 +02:00
parent d912c4293b
commit b4bb40802d
8 changed files with 240 additions and 4 deletions
+2
View File
@@ -54,6 +54,7 @@ import SongInfoModal from './components/SongInfoModal';
import DownloadFolderModal from './components/DownloadFolderModal';
import GlobalConfirmModal from './components/GlobalConfirmModal';
import OrbitAccountPicker from './components/OrbitAccountPicker';
import OrbitHelpModal from './components/OrbitHelpModal';
import { DragDropProvider } from './contexts/DragDropContext';
import TooltipPortal from './components/TooltipPortal';
import OverlayScrollArea from './components/OverlayScrollArea';
@@ -522,6 +523,7 @@ function AppShell() {
<DownloadFolderModal />
<GlobalConfirmModal />
<OrbitAccountPicker />
<OrbitHelpModal />
<TooltipPortal />
<AppUpdater />
</div>
+102
View File
@@ -0,0 +1,102 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
X, Sparkles, Users, Share2, LogIn, MousePointerClick,
ListMusic, Inbox, Sliders, LogOut,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useHelpModalStore } from '../store/helpModalStore';
import SettingsSubSection from './SettingsSubSection';
/**
* Orbit help modal. Rendered once at the app root; triggered from the
* launch popover ("How does this work?") and the in-session bar's help
* button. 9 accordion sections built on SettingsSubSection; all default
* closed so the modal opens compact. Does not touch playback.
*/
export default function OrbitHelpModal() {
const { t } = useTranslation();
const { isOpen, close } = useHelpModalStore();
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, close]);
if (!isOpen) return null;
const hostOnlyLabel = t('orbit.helpHostOnly');
return createPortal(
<div
className="modal-overlay"
onClick={e => { if (e.target === e.currentTarget) close(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-help-title"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div className="modal-content orbit-help-modal">
<button type="button" className="modal-close" onClick={close} aria-label={t('orbit.closeAria')}>
<X size={18} />
</button>
<h3 id="orbit-help-title" className="orbit-help-modal__title">
{t('orbit.helpTitle')}
</h3>
<p className="orbit-help-modal__intro">{t('orbit.helpIntro')}</p>
<div className="orbit-help-modal__body">
<SettingsSubSection title={t('orbit.helpSec1Title')} icon={<Sparkles size={16} />}>
<p>{t('orbit.helpSec1Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec2Title')} icon={<Users size={16} />}>
<p>{t('orbit.helpSec2Body')}</p>
<div className="orbit-help-modal__warn">
<strong>{t('orbit.helpSec2WarnHead')}</strong>
<span>{t('orbit.helpSec2WarnBody')}</span>
</div>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec3Title')} icon={<Share2 size={16} />}>
<p>{t('orbit.helpSec3Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec4Title')} icon={<LogIn size={16} />}>
<p>{t('orbit.helpSec4Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec5Title')} icon={<MousePointerClick size={16} />}>
<p>{t('orbit.helpSec5Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec6Title')} icon={<ListMusic size={16} />}>
<p>{t('orbit.helpSec6Body')}</p>
</SettingsSubSection>
<SettingsSubSection
title={`${t('orbit.helpSec7Title')} ${hostOnlyLabel}`}
icon={<Inbox size={16} />}
>
<p>{t('orbit.helpSec7Body')}</p>
</SettingsSubSection>
<SettingsSubSection
title={`${t('orbit.helpSec8Title')} ${hostOnlyLabel}`}
icon={<Sliders size={16} />}
>
<p>{t('orbit.helpSec8Body')}</p>
</SettingsSubSection>
<SettingsSubSection title={t('orbit.helpSec9Title')} icon={<LogOut size={16} />}>
<p>{t('orbit.helpSec9Body')}</p>
</SettingsSubSection>
</div>
</div>
</div>,
document.body,
);
}
+11 -1
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { X, RefreshCw, Shuffle, Settings2, Share2 } from 'lucide-react';
import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { useHelpModalStore } from '../store/helpModalStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { getSong } from '../api/subsonic';
import {
@@ -208,6 +209,15 @@ export default function OrbitSessionBar() {
<span>{t('orbit.catchUpLabel')}</span>
</button>
)}
<button
type="button"
className="orbit-bar__settings"
onClick={() => useHelpModalStore.getState().open()}
data-tooltip={t('orbit.helpTooltip')}
aria-label={t('orbit.helpTooltip')}
>
<HelpCircle size={14} />
</button>
<button
type="button"
className="orbit-bar__exit"
+3 -3
View File
@@ -3,6 +3,7 @@ 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 { useHelpModalStore } from '../store/helpModalStore';
import OrbitStartModal from './OrbitStartModal';
import OrbitJoinModal from './OrbitJoinModal';
@@ -54,6 +55,7 @@ export default function OrbitStartTrigger() {
const pickCreate = () => { setPopoverOpen(false); setStartOpen(true); };
const pickJoin = () => { setPopoverOpen(false); setJoinOpen(true); };
const pickHelp = () => { setPopoverOpen(false); useHelpModalStore.getState().open(); };
return (
<>
@@ -85,9 +87,7 @@ export default function OrbitStartTrigger() {
<button
type="button"
className="orbit-launch-pop__item"
disabled
data-tooltip={t('orbit.launchHelpSoon')}
data-tooltip-pos="bottom"
onClick={pickHelp}
>
<HelpCircle size={14} />
<span>{t('orbit.launchHelp')}</span>
+24
View File
@@ -1502,6 +1502,30 @@ export const deTranslation = {
hostOnline: 'Host online',
endTooltip: 'Session beenden',
leaveTooltip: 'Session verlassen',
helpTooltip: 'Wie Orbit funktioniert',
helpTitle: 'Wie Orbit funktioniert',
helpIntro: 'Orbit macht aus Psysonic einen gemeinsamen Hörraum. Der Host bestimmt die Musik; Gäste hören synchron mit und können Songs vorschlagen.',
helpHostOnly: '(Host)',
helpSec1Title: 'Was ist Orbit?',
helpSec1Body: 'Ein „Listen together"-Modus — der Host steuert die Wiedergabe, die Gäste hören mit. Alles läuft über Playlists auf deinem eigenen Navidrome-Server; keine externe Infrastruktur.',
helpSec2Title: 'Host und Gäste',
helpSec2Body: 'Der Host steuert die Wiedergabe (Play / Pause / Skip / Seek) und entscheidet, wann die Session endet. Gäste folgen der Wiedergabe des Hosts, können Songs vorschlagen und jederzeit verlassen oder wieder beitreten. Nur der Host kann Teilnehmer kicken oder dauerhaft bannen.',
helpSec2WarnHead: 'Wichtig: getrennte Accounts.',
helpSec2WarnBody: 'Jeder Teilnehmer braucht einen eigenen Navidrome-Account. Benutzen Host und Gast denselben User, kollidieren ihre Einreichungen und der Host sieht keine Vorschläge vom Gast.',
helpSec3Title: 'Session starten und einladen',
helpSec3Body: 'Klick auf den „Psy Orbit"-Button → Session erstellen → im Start-Modal siehst du einen fertigen Einladungslink und kannst optional die aktuelle Warteschlange leeren. Link teilen wie du magst. Während die Session läuft, kopiert der Share-Button in der Session-Leiste oben jederzeit den Link erneut.',
helpSec4Title: 'Beitreten',
helpSec4Body: 'Einladungslink irgendwo in Psysonic mit Ctrl+V / Cmd+V einfügen — der Beitritt-Dialog erscheint automatisch. Alternativ: „Psy Orbit" → Session beitreten → Link ins Feld pasten. Wenn der Link auf einen Server zeigt, für den du einen Account hast, wechselt Psysonic automatisch. Bei mehreren Accounts auf demselben Server fragt ein kleiner Picker, welcher genutzt werden soll.',
helpSec5Title: 'Songs vorschlagen',
helpSec5Body: 'In jeder Song-Liste: Doppelklick auf eine Zeile, oder Rechtsklick → „Zur Orbit-Session hinzufügen". Ein einfacher Klick zeigt nur einen Hinweis, statt das ganze Album in die geteilte Queue zu werfen — das ist bewusst so. Explizite Bulk-Aktionen wie „Play All" oder „Play Album" fragen zuerst nach und hängen dann an (ersetzen nie).',
helpSec6Title: 'Die geteilte Warteschlange',
helpSec6Body: 'Die Queue zeigt die kommenden Titel des Hosts — für alle sichtbar. Neu hinzugefügte Bulk-Inhalte werden immer angehängt, damit Gast-Vorschläge nicht verloren gehen. Auto-Shuffle mischt die Queue periodisch neu (konfigurierbar: 1 / 5 / 10 / 15 / 30 Min.). Wenn deine Wiedergabe vom Host abweicht, bringt dich der „Aufholen"-Button in der Session-Leiste zurück zur Live-Position des Hosts.',
helpSec7Title: 'Freigaben',
helpSec7Body: 'Standardmäßig müssen Vorschläge von Gästen manuell freigegeben werden — sie erscheinen in einer „Freigabe"-Leiste oben im Queue-Panel mit Annehmen/Ablehnen-Buttons. In den Session-Einstellungen lässt sich Auto-Übernehmen aktivieren, dann landen alle Vorschläge direkt in der Queue.',
helpSec8Title: 'Teilnehmer und Einstellungen',
helpSec8Body: 'Das Einstellungs-Icon in der Session-Leiste öffnet Shuffle-Takt, Auto-Übernehmen und die „Jetzt mischen"-Abkürzung. Klick auf die Teilnehmerzahl zeigt wer gerade dabei ist — Kick (kann über den Link wieder beitreten) oder Bann (für die Session gesperrt). Gäste sehen die Teilnehmerliste ebenfalls, aber nur lesend.',
helpSec9Title: 'Session beenden',
helpSec9Body: 'Host-X → Bestätigungsdialog → Session endet für alle und die Server-Playlists werden automatisch aufgeräumt. Gast-X → der Gast verlässt, die Session läuft weiter. Wenn der Host 5 Minuten lang nichts mehr sendet, verlässt der Gast die Session automatisch mit einer Meldung; kürzere Reconnects innerhalb dieser Zeit bleiben unsichtbar.',
participantsInviteLabel: 'Einladungslink',
participantsCountLabel: '{{count}} in Session',
participantsHost: 'Gastgeber',
+24
View File
@@ -1505,6 +1505,30 @@ export const enTranslation = {
hostOnline: 'Host online',
endTooltip: 'End session',
leaveTooltip: 'Leave session',
helpTooltip: 'How Orbit works',
helpTitle: 'How Orbit works',
helpIntro: 'Orbit turns Psysonic into a shared listening room. A host picks the music; guests listen in sync and can suggest tracks.',
helpHostOnly: '(host)',
helpSec1Title: 'What is Orbit?',
helpSec1Body: "A \"listen together\" mode — the host drives playback, guests tune in. Everything runs through playlists on your own Navidrome server; no external infrastructure.",
helpSec2Title: 'Host and guests',
helpSec2Body: 'Host controls playback (play / pause / skip / seek) and decides when the session ends. Guests follow the host\'s playback, can suggest tracks, and leave or re-join any time. Only the host can kick or permanently ban a participant.',
helpSec2WarnHead: 'Important: separate accounts.',
helpSec2WarnBody: "Every participant needs their own Navidrome account. If host and guest sign in with the same user their submissions collide and the host never sees the guest's suggestions.",
helpSec3Title: 'Starting and inviting',
helpSec3Body: 'Click the "Psy Orbit" button → Create a session → the start modal shows a ready-to-copy invite link and lets you optionally clear your current queue. Share that link however you like. While a session is running, the share button in the session bar at the top copies the link again at any time.',
helpSec4Title: 'Joining',
helpSec4Body: 'Paste the invite link anywhere in Psysonic with Ctrl+V / Cmd+V — the join prompt pops up automatically. Alternatively: "Psy Orbit" → Join a session → paste into the field. If the link points to a server you have an account on, Psysonic switches for you. When you have more than one account on that server, a small picker asks which to use.',
helpSec5Title: 'Suggesting tracks',
helpSec5Body: 'In any song list: double-click a row, or right-click → "Add to Orbit session". A single click shows a hint instead of dumping the whole album into the shared queue — that\'s intentional. Explicit bulk actions like "Play All" or "Play Album" ask for confirmation first and then append (never replace).',
helpSec6Title: 'The shared queue',
helpSec6Body: "The queue shows the host's upcoming tracks — visible to everyone. Incoming bulk additions are always appended, so guest suggestions aren't lost. Auto-shuffle reorders the queue periodically (configurable: 1 / 5 / 10 / 15 / 30 min). If your playback drifts away from the host, the \"Catch up\" button in the session bar jumps you back to the host's live position.",
helpSec7Title: 'Approvals',
helpSec7Body: 'By default, guest suggestions need manual approval — they appear in an "Approvals" strip at the top of the queue panel with accept / decline buttons. Auto-approve can be turned on in the session settings so everything lands straight away.',
helpSec8Title: 'Participants and settings',
helpSec8Body: 'Open the settings icon in the session bar for shuffle cadence, auto-approve, and the "Shuffle now" shortcut. Click the participants count to see who\'s connected — kick (can re-join via the link) or ban (locked out for the session). Guests see the participant list too, but read-only.',
helpSec9Title: 'Ending the session',
helpSec9Body: "Host X → confirm dialog → session closes for everyone and the server playlists are cleaned up automatically. Guest X → the guest leaves, the session keeps running. If the host goes quiet for 5 minutes the guest auto-leaves with a notice; short reconnects within that window are invisible.",
participantsInviteLabel: 'Invite link',
participantsCountLabel: '{{count}} in session',
participantsHost: 'host',
+19
View File
@@ -0,0 +1,19 @@
import { create } from 'zustand';
interface HelpModalStore {
isOpen: boolean;
open: () => void;
close: () => void;
}
/**
* App-wide toggle for the Orbit help modal. Two triggers — the launch
* popover "How does this work?" entry and the in-session bar help button
* — write to the same store so they share the one rendered modal. Not
* persisted; resets to closed on reload.
*/
export const useHelpModalStore = create<HelpModalStore>(set => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
}));
+55
View File
@@ -11918,6 +11918,61 @@ html[data-psy-native-hidden="true"] *::after {
cursor: not-allowed;
}
/* Orbit help modal — 9 accordions on SettingsSubSection, scrollable body. */
.orbit-help-modal {
max-width: 560px;
width: min(560px, calc(100vw - 32px));
max-height: 85vh;
padding: 22px 26px 20px;
display: flex;
flex-direction: column;
position: relative;
}
.orbit-help-modal__title {
margin: 0 0 8px;
padding-right: 1.5rem;
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.orbit-help-modal__intro {
margin: 0 0 14px;
font-size: 13px;
color: var(--text-muted);
line-height: 1.55;
}
.orbit-help-modal__body {
flex: 1;
overflow-y: auto;
margin: 0 -4px;
padding: 0 4px;
}
.orbit-help-modal__body p {
margin: 0 0 8px;
font-size: 12.5px;
line-height: 1.55;
color: var(--text-secondary, var(--text-primary));
}
.orbit-help-modal__body p:last-child { margin-bottom: 0; }
.orbit-help-modal__warn {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 10px;
padding: 10px 12px;
background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--ctp-yellow, #f9e2af) 40%, transparent);
border-radius: var(--radius-sm);
font-size: 12px;
line-height: 1.5;
color: var(--text-primary);
}
.orbit-help-modal__warn strong {
color: var(--ctp-yellow, #f9e2af);
font-weight: 700;
}
/* Account picker — modal for multi-account server switch on Orbit join. */
.orbit-account-picker {
max-width: 420px;