mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
c2b75817c4
commit
ef5eda263d
@@ -0,0 +1,213 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
type RatingKind = 'song' | 'album' | 'artist';
|
||||
|
||||
interface KeyboardRating {
|
||||
kind: RatingKind;
|
||||
id: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Args {
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
isOpen: boolean;
|
||||
closeContextMenu: () => void;
|
||||
keyboardRating: KeyboardRating | null;
|
||||
setKeyboardRating: React.Dispatch<React.SetStateAction<KeyboardRating | null>>;
|
||||
getRatingValueByKind: (kind: RatingKind, id: string) => number;
|
||||
commitRatingByKind: (kind: RatingKind, id: string, rating: number) => void;
|
||||
playlistSubmenuOpen: boolean;
|
||||
setPlaylistSubmenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setPlaylistSongIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
pendingSubmenuKeyboardFocus: boolean;
|
||||
setPendingSubmenuKeyboardFocus: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
onMenuKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
getMenuNavItems: (scope?: 'main' | 'submenu') => HTMLElement[];
|
||||
focusMenuItemAt: (scope: 'main' | 'submenu', index: number) => void;
|
||||
}
|
||||
|
||||
export function useContextMenuKeyboardNav({
|
||||
menuRef, isOpen, closeContextMenu,
|
||||
keyboardRating, setKeyboardRating, getRatingValueByKind, commitRatingByKind,
|
||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, setPlaylistSongIds,
|
||||
pendingSubmenuKeyboardFocus, setPendingSubmenuKeyboardFocus,
|
||||
}: Args): Result {
|
||||
const getMenuNavItems = useCallback(
|
||||
(scope: 'main' | 'submenu' = 'main') => {
|
||||
if (!menuRef.current) return [];
|
||||
if (scope === 'submenu') {
|
||||
const sub = menuRef.current.querySelector<HTMLElement>('.context-submenu');
|
||||
if (!sub || sub.offsetParent === null) return [];
|
||||
return Array.from(
|
||||
sub.querySelectorAll<HTMLElement>('.context-menu-item, .context-submenu-create-btn'),
|
||||
).filter(el => el.offsetParent !== null);
|
||||
}
|
||||
return Array.from(menuRef.current.children)
|
||||
.filter((el): el is HTMLElement =>
|
||||
el instanceof HTMLElement &&
|
||||
(el.classList.contains('context-menu-item') || el.classList.contains('context-menu-rating-row')) &&
|
||||
el.offsetParent !== null,
|
||||
);
|
||||
},
|
||||
[menuRef],
|
||||
);
|
||||
|
||||
const focusMenuItemAt = useCallback((scope: 'main' | 'submenu', index: number) => {
|
||||
const items = getMenuNavItems(scope);
|
||||
if (items.length === 0) return;
|
||||
menuRef.current
|
||||
?.querySelectorAll<HTMLElement>('.context-menu-keyboard-active')
|
||||
.forEach(el => el.classList.remove('context-menu-keyboard-active'));
|
||||
const safeIdx = ((index % items.length) + items.length) % items.length;
|
||||
const target = items[safeIdx];
|
||||
target.classList.add('context-menu-keyboard-active');
|
||||
target.tabIndex = -1;
|
||||
target.focus({ preventScroll: true });
|
||||
target.scrollIntoView({ block: 'nearest' });
|
||||
}, [getMenuNavItems, menuRef]);
|
||||
|
||||
// Focus the menu container when it opens (no row pre-highlight; keyboard outline
|
||||
// only appears after explicit arrow navigation).
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
requestAnimationFrame(() => {
|
||||
menuRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
}, [isOpen, menuRef]);
|
||||
|
||||
// Outside-click closes the menu without occluding the underlying UI.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (!target) return;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
closeContextMenu();
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [isOpen, closeContextMenu, menuRef]);
|
||||
|
||||
// After opening a submenu via keyboard, wait for it to render then focus first item.
|
||||
useEffect(() => {
|
||||
if (!pendingSubmenuKeyboardFocus || !playlistSubmenuOpen) return;
|
||||
let cancelled = false;
|
||||
const tryFocus = (attemptsLeft: number) => {
|
||||
if (cancelled) return;
|
||||
const items = getMenuNavItems('submenu');
|
||||
if (items.length > 0) {
|
||||
focusMenuItemAt('submenu', 0);
|
||||
setPendingSubmenuKeyboardFocus(false);
|
||||
return;
|
||||
}
|
||||
if (attemptsLeft <= 0) {
|
||||
setPendingSubmenuKeyboardFocus(false);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => tryFocus(attemptsLeft - 1));
|
||||
};
|
||||
requestAnimationFrame(() => tryFocus(8));
|
||||
return () => { cancelled = true; };
|
||||
}, [pendingSubmenuKeyboardFocus, playlistSubmenuOpen, getMenuNavItems, focusMenuItemAt, setPendingSubmenuKeyboardFocus]);
|
||||
|
||||
const onMenuKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const ratingRow = active?.closest('.context-menu-rating-row') as HTMLElement | null;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ratingRow) {
|
||||
const kind = ratingRow.dataset.ratingKind as RatingKind | undefined;
|
||||
const id = ratingRow.dataset.ratingId;
|
||||
if (!kind || !id) return;
|
||||
if (ratingRow.dataset.ratingDisabled === 'true') return;
|
||||
const value = keyboardRating && keyboardRating.kind === kind && keyboardRating.id === id
|
||||
? keyboardRating.value
|
||||
: getRatingValueByKind(kind, id);
|
||||
commitRatingByKind(kind, id, value);
|
||||
setKeyboardRating({ kind, id, value });
|
||||
return;
|
||||
}
|
||||
active?.click();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
if (ratingRow) {
|
||||
const kind = ratingRow.dataset.ratingKind as RatingKind | undefined;
|
||||
const id = ratingRow.dataset.ratingId;
|
||||
if (!kind || !id) return;
|
||||
if (ratingRow.dataset.ratingDisabled === 'true') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const currentValue = keyboardRating && keyboardRating.kind === kind && keyboardRating.id === id
|
||||
? keyboardRating.value
|
||||
: getRatingValueByKind(kind, id);
|
||||
const delta = e.key === 'ArrowRight' ? 1 : -1;
|
||||
const nextValue = Math.max(0, Math.min(5, currentValue + delta));
|
||||
setKeyboardRating({ kind, id, value: nextValue });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
const trigger = active?.closest('.context-menu-item--submenu') as HTMLElement | null;
|
||||
const triggerId = trigger?.dataset.playlistTriggerId;
|
||||
if (!trigger || !triggerId) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPlaylistSongIds([triggerId]);
|
||||
setPlaylistSubmenuOpen(true);
|
||||
setPendingSubmenuKeyboardFocus(true);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
const sub = active?.closest('.context-submenu') as HTMLElement | null;
|
||||
if (!sub) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const triggerId = sub.dataset.parentTriggerId;
|
||||
setPlaylistSubmenuOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
const trigger = triggerId
|
||||
? Array.from(menuRef.current?.querySelectorAll<HTMLElement>('.context-menu-item--submenu') ?? [])
|
||||
.find(el => el.dataset.playlistTriggerId === triggerId) ?? null
|
||||
: null;
|
||||
if (trigger) {
|
||||
menuRef.current
|
||||
?.querySelectorAll<HTMLElement>('.context-menu-keyboard-active')
|
||||
.forEach(el => el.classList.remove('context-menu-keyboard-active'));
|
||||
trigger.classList.add('context-menu-keyboard-active');
|
||||
trigger.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const scope: 'main' | 'submenu' = active?.closest('.context-submenu') ? 'submenu' : 'main';
|
||||
const items = getMenuNavItems(scope);
|
||||
if (items.length === 0) return;
|
||||
const activeIdx = items.findIndex(el => el === document.activeElement);
|
||||
const nextIdx =
|
||||
activeIdx >= 0
|
||||
? (e.key === 'ArrowDown' ? activeIdx + 1 : activeIdx - 1)
|
||||
: (e.key === 'ArrowDown' ? 0 : items.length - 1);
|
||||
focusMenuItemAt(scope, nextIdx);
|
||||
}, [
|
||||
closeContextMenu, keyboardRating, getRatingValueByKind, commitRatingByKind,
|
||||
getMenuNavItems, focusMenuItemAt, menuRef,
|
||||
setKeyboardRating, setPlaylistSubmenuOpen, setPlaylistSongIds, setPendingSubmenuKeyboardFocus,
|
||||
]);
|
||||
|
||||
return { onMenuKeyDown, getMenuNavItems, focusMenuItemAt };
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback } from 'react';
|
||||
import { setRating } from '../api/subsonicStarRating';
|
||||
import type { SubsonicAlbum, SubsonicArtist } from '../api/subsonicTypes';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
type RatingKind = 'song' | 'album' | 'artist';
|
||||
|
||||
interface Args {
|
||||
type: string | null;
|
||||
item: unknown;
|
||||
userRatingOverrides: Record<string, number>;
|
||||
setUserRatingOverride: (id: string, rating: number) => void;
|
||||
entityRatingSupport: 'full' | 'track_only' | 'unknown';
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
applySongRating: (songId: string, rating: number) => void;
|
||||
applyAlbumRating: (album: SubsonicAlbum, rating: number) => void;
|
||||
applyArtistRating: (artist: SubsonicArtist, rating: number) => void;
|
||||
getRatingValueByKind: (kind: RatingKind, id: string) => number;
|
||||
commitRatingByKind: (kind: RatingKind, id: string, rating: number) => void;
|
||||
}
|
||||
|
||||
export function useContextMenuRating({
|
||||
type, item, userRatingOverrides, setUserRatingOverride, entityRatingSupport, t,
|
||||
}: Args): Result {
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
|
||||
const applySongRating = useCallback((songId: string, rating: number) => {
|
||||
setUserRatingOverride(songId, rating);
|
||||
setRating(songId, rating).catch(() => {});
|
||||
}, [setUserRatingOverride]);
|
||||
|
||||
const applyAlbumRating = useCallback((album: SubsonicAlbum, rating: number) => {
|
||||
setUserRatingOverride(album.id, rating);
|
||||
if (entityRatingSupport !== 'full') return;
|
||||
setRating(album.id, rating).catch(err => {
|
||||
if (activeServerId) setEntityRatingSupport(activeServerId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
});
|
||||
}, [setUserRatingOverride, entityRatingSupport, activeServerId, setEntityRatingSupport, t]);
|
||||
|
||||
const applyArtistRating = useCallback((artist: SubsonicArtist, rating: number) => {
|
||||
setUserRatingOverride(artist.id, rating);
|
||||
if (entityRatingSupport !== 'full') return;
|
||||
setRating(artist.id, rating).catch(err => {
|
||||
if (activeServerId) setEntityRatingSupport(activeServerId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
});
|
||||
}, [setUserRatingOverride, entityRatingSupport, activeServerId, setEntityRatingSupport, t]);
|
||||
|
||||
const getRatingValueByKind = useCallback((kind: RatingKind, id: string): number => {
|
||||
if (kind === 'song' && (type === 'song' || type === 'album-song' || type === 'queue-item')) {
|
||||
const song = item as Track;
|
||||
if (song.id === id) return userRatingOverrides[id] ?? song.userRating ?? 0;
|
||||
}
|
||||
if (kind === 'album' && type === 'album') {
|
||||
const album = item as SubsonicAlbum;
|
||||
if (album.id === id) return userRatingOverrides[id] ?? album.userRating ?? 0;
|
||||
}
|
||||
if (kind === 'album' && type === 'multi-album') {
|
||||
const albums = item as SubsonicAlbum[];
|
||||
const compositeId = [...albums.map(a => a.id)].sort().join('\x1e');
|
||||
if (id !== compositeId) return userRatingOverrides[id] ?? 0;
|
||||
if (albums.length === 0) return 0;
|
||||
const vals = albums.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
|
||||
const first = vals[0];
|
||||
return vals.every(v => v === first) ? first : 0;
|
||||
}
|
||||
if (kind === 'artist' && type === 'artist') {
|
||||
const artist = item as SubsonicArtist;
|
||||
if (artist.id === id) return userRatingOverrides[id] ?? artist.userRating ?? 0;
|
||||
}
|
||||
if (kind === 'artist' && type === 'multi-artist') {
|
||||
const artists = item as SubsonicArtist[];
|
||||
const compositeId = [...artists.map(a => a.id)].sort().join('\x1e');
|
||||
if (id !== compositeId) return userRatingOverrides[id] ?? 0;
|
||||
if (artists.length === 0) return 0;
|
||||
const vals = artists.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
|
||||
const first = vals[0];
|
||||
return vals.every(v => v === first) ? first : 0;
|
||||
}
|
||||
return userRatingOverrides[id] ?? 0;
|
||||
}, [type, item, userRatingOverrides]);
|
||||
|
||||
const commitRatingByKind = useCallback((kind: RatingKind, id: string, rating: number) => {
|
||||
if (kind === 'song') {
|
||||
applySongRating(id, rating);
|
||||
return;
|
||||
}
|
||||
if (kind === 'album' && type === 'album') {
|
||||
applyAlbumRating(item as SubsonicAlbum, rating);
|
||||
return;
|
||||
}
|
||||
if (kind === 'album' && type === 'multi-album') {
|
||||
const albums = item as SubsonicAlbum[];
|
||||
const compositeId = [...albums.map(a => a.id)].sort().join('\x1e');
|
||||
if (id !== compositeId) return;
|
||||
for (const a of albums) applyAlbumRating(a, rating);
|
||||
return;
|
||||
}
|
||||
if (kind === 'artist' && type === 'artist') {
|
||||
applyArtistRating(item as SubsonicArtist, rating);
|
||||
return;
|
||||
}
|
||||
if (kind === 'artist' && type === 'multi-artist') {
|
||||
const artists = item as SubsonicArtist[];
|
||||
const compositeId = [...artists.map(a => a.id)].sort().join('\x1e');
|
||||
if (id !== compositeId) return;
|
||||
for (const a of artists) applyArtistRating(a, rating);
|
||||
}
|
||||
}, [applySongRating, applyAlbumRating, applyArtistRating, type, item]);
|
||||
|
||||
return { applySongRating, applyAlbumRating, applyArtistRating, getRatingValueByKind, commitRatingByKind };
|
||||
}
|
||||
Reference in New Issue
Block a user