refactor(context-menu): H.1–H.12 — extract submenus + 5 type-branch components + hooks (Phase H start) (#662)

* refactor(context-menu): H.1 — extract helpers + constants

* refactor(context-menu): H.2 — extract AddToPlaylistSubmenu component

* refactor(context-menu): H.3 — extract AlbumToPlaylistSubmenu + ArtistToPlaylistSubmenu

* refactor(context-menu): H.4 — extract MultiAlbumToPlaylistSubmenu

* refactor(context-menu): H.5 — extract MultiArtistToPlaylistSubmenu

* refactor(context-menu): H.6 — extract SinglePlaylist + MultiPlaylist submenus

* refactor(context-menu): H.7 — extract startRadio/startInstantMix/downloadAlbum/copyShareLink actions

* refactor(context-menu): H.8 — extract useContextMenuKeyboardNav hook

* refactor(context-menu): H.9 — extract useContextMenuRating hook

* refactor(context-menu): H.10 — extract ContextMenuItems (all 9 type branches)

* refactor(context-menu): H.11 — split ContextMenuItems into 5 type-branch files

ContextMenuItems.tsx (800 LOC) was just a moved 400-LOC-cap violation.
Now ContextMenuItems is a 30-LOC switch that dispatches to:
  - SongContextItems       (song + album-song + favorite-song)
  - QueueItemContextItems  (queue-item)
  - AlbumContextItems      (album + multi-album)
  - ArtistContextItems     (artist + multi-artist)
  - PlaylistContextItems   (playlist + multi-playlist)

All five branch files are now under 330 LOC; ContextMenu.tsx itself stays
at 194 LOC. Shared Props interface lives in contextMenuItemTypes.ts.

* refactor(context-menu): H.12 — strip unused imports from branch components
This commit is contained in:
Frank Stellmacher
2026-05-13 21:06:49 +02:00
committed by GitHub
parent c2b75817c4
commit ef5eda263d
17 changed files with 2468 additions and 2126 deletions
@@ -0,0 +1,301 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { usePlaylistStore } from '../../store/playlistStore';
import { showToast } from '../../utils/toast';
import { isSmartPlaylistName } from '../../utils/contextMenuHelpers';
interface SingleProps {
playlist: { id: string; name: string };
onDone: () => void;
triggerId?: string;
}
export function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: SingleProps) {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const newNameRef = useRef<HTMLInputElement>(null);
const [flipLeft, setFlipLeft] = useState(false);
const [flipUp, setFlipUp] = useState(false);
const storePlaylists = usePlaylistStore((s) => s.playlists);
const allPlaylists = useMemo(() => {
return storePlaylists.filter(
(p) => p.id !== playlist.id && !isSmartPlaylistName(p.name),
);
}, [storePlaylists, playlist.id]);
useLayoutEffect(() => {
if (subRef.current) {
const rect = subRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
if (rect.bottom > window.innerHeight - 8) setFlipUp(true);
}
}, []);
useEffect(() => {
if (creating && newNameRef.current) newNameRef.current.focus();
}, [creating]);
const handleCreate = async () => {
if (!newName.trim()) return;
const { createPlaylist } = await import('../../api/subsonicPlaylists');
try {
const newPl = await createPlaylist(newName.trim(), []);
if (newPl?.id) {
await handleAddToNewPlaylist(newPl.id, newPl.name || newName.trim());
}
setCreating(false);
setNewName('');
} catch {
showToast(t('playlists.createError'), 3000, 'error');
}
};
const handleAddToNewPlaylist = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs: sourceSongs } = await getPlaylist(playlist.id);
if (sourceSongs.length > 0) {
await updatePlaylist(targetId, sourceSongs.map((s: { id: string }) => s.id));
touchPlaylist(targetId);
showToast(t('playlists.createAndAddSuccess', { count: sourceSongs.length, playlist: targetName }), 3000, 'info');
}
onDone();
} catch {
showToast(t('playlists.addToPlaylistError'), 4000, 'error');
onDone();
}
};
const handleAdd = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs: targetSongs } = await getPlaylist(targetId);
const targetIds = new Set(targetSongs.map((s: { id: string }) => s.id));
const { songs: sourceSongs } = await getPlaylist(playlist.id);
const newSongs = sourceSongs.filter((s: { id: string }) => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
await updatePlaylist(targetId, Array.from(targetIds));
touchPlaylist(targetId);
showToast(t('playlists.addToPlaylistSuccess', { count: newSongs.length, playlist: targetName }), 3000, 'info');
} else {
showToast(t('playlists.addToPlaylistNoNew', { playlist: targetName }), 3000, 'info');
}
onDone();
} catch {
showToast(t('playlists.addToPlaylistError'), 4000, 'error');
onDone();
}
};
const subStyle: React.CSSProperties = flipLeft
? { right: 'calc(100% + 4px)', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }
: { left: 'calc(100% + 4px)', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' };
return (
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ ...subStyle, minWidth: 190 }}>
{!creating ? (
<div className="context-menu-item context-submenu-new" onClick={e => { e.stopPropagation(); setCreating(true); }}>
<Plus size={13} /> {t('playlists.newPlaylist')}
</div>
) : (
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
<input
ref={newNameRef}
className="context-submenu-input"
placeholder={t('playlists.createName')}
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="context-submenu-create-btn" onClick={handleCreate}>
<Plus size={13} />
</button>
</div>
)}
<div className="context-menu-divider" />
{allPlaylists.length === 0 && (
<div className="context-submenu-empty">{t('playlists.noOtherPlaylists')}</div>
)}
{allPlaylists.map(pl => (
<div
key={pl.id}
className="context-menu-item"
onClick={() => handleAdd(pl.id, pl.name)}
>
<ListMusic size={13} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
</div>
))}
</div>
);
}
interface MultiProps {
playlists: { id: string; name: string }[];
onDone: () => void;
triggerId?: string;
}
export function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: MultiProps) {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const newNameRef = useRef<HTMLInputElement>(null);
const [flipLeft, setFlipLeft] = useState(false);
const [flipUp, setFlipUp] = useState(false);
const storePlaylists = usePlaylistStore((s) => s.playlists);
const allPlaylists = useMemo(() => {
const selectedIds = new Set(playlists.map(p => p.id));
return storePlaylists.filter(
(p) => !selectedIds.has(p.id) && !isSmartPlaylistName(p.name),
);
}, [storePlaylists, playlists]);
useLayoutEffect(() => {
if (subRef.current) {
const rect = subRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
if (rect.bottom > window.innerHeight - 8) setFlipUp(true);
}
}, []);
useEffect(() => {
if (creating && newNameRef.current) newNameRef.current.focus();
}, [creating]);
const handleCreate = async () => {
if (!newName.trim()) return;
const { createPlaylist } = await import('../../api/subsonicPlaylists');
try {
const newPl = await createPlaylist(newName.trim(), []);
if (newPl?.id) {
await handleMergeToNewPlaylist(newPl.id, newPl.name || newName.trim());
}
setCreating(false);
setNewName('');
} catch {
showToast(t('playlists.createError'), 3000, 'error');
}
};
const handleMergeToNewPlaylist = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const targetIds = new Set<string>();
let totalAdded = 0;
for (const pl of playlists) {
const { songs } = await getPlaylist(pl.id);
const newSongs = songs.filter((s: { id: string }) => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
totalAdded += newSongs.length;
}
}
if (totalAdded > 0) {
await updatePlaylist(targetId, Array.from(targetIds));
touchPlaylist(targetId);
showToast(t('playlists.createAndAddSuccess', { count: totalAdded, playlist: targetName }), 3000, 'info');
}
onDone();
} catch {
showToast(t('playlists.mergeError'), 4000, 'error');
onDone();
}
};
const handleMerge = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs: targetSongs } = await getPlaylist(targetId);
const targetIds = new Set(targetSongs.map((s: { id: string }) => s.id));
let totalAdded = 0;
for (const pl of playlists) {
const { songs } = await getPlaylist(pl.id);
const newSongs = songs.filter((s: { id: string }) => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
totalAdded += newSongs.length;
}
}
if (totalAdded > 0) {
await updatePlaylist(targetId, Array.from(targetIds));
touchPlaylist(targetId);
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetName }), 3000, 'info');
} else {
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
}
onDone();
} catch {
showToast(t('playlists.mergeError'), 4000, 'error');
onDone();
}
};
const subStyle: React.CSSProperties = flipLeft
? { right: 'calc(100% + 4px)', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }
: { left: 'calc(100% + 4px)', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' };
return (
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ ...subStyle, minWidth: 190 }}>
{!creating ? (
<div className="context-menu-item context-submenu-new" onClick={e => { e.stopPropagation(); setCreating(true); }}>
<Plus size={13} /> {t('playlists.newPlaylist')}
</div>
) : (
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
<input
ref={newNameRef}
className="context-submenu-input"
placeholder={t('playlists.createName')}
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="context-submenu-create-btn" onClick={handleCreate}>
<Plus size={13} />
</button>
</div>
)}
<div className="context-menu-divider" />
{allPlaylists.length === 0 && (
<div className="context-submenu-empty">{t('playlists.noOtherPlaylists')}</div>
)}
{allPlaylists.map(pl => (
<div
key={pl.id}
className="context-menu-item"
onClick={() => handleMerge(pl.id, pl.name)}
>
<ListMusic size={13} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
</div>
))}
</div>
);
}