mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 04:50:12 +02:00
Add album detail list view (#1681)
This commit is contained in:
@@ -137,14 +137,19 @@ const normalizeSong = (
|
||||
discTitleMap?: Map<number, string>,
|
||||
): Song => {
|
||||
const participants = getParticipants(item);
|
||||
const albumArtistsList = getArtistList(item.albumArtists, item.artistId, item.artist);
|
||||
const albumArtistName =
|
||||
item.albumArtists?.length > 0
|
||||
? item.albumArtists.map((a) => a.name).join(', ')
|
||||
: item.artist || '';
|
||||
|
||||
return {
|
||||
_itemType: LibraryItem.SONG,
|
||||
_serverId: server?.id || 'unknown',
|
||||
_serverType: ServerType.SUBSONIC,
|
||||
album: item.album || '',
|
||||
albumArtistName: item.artist || '',
|
||||
albumArtists: getArtistList(item.albumArtists, item.artistId, item.artist),
|
||||
albumArtistName,
|
||||
albumArtists: albumArtistsList,
|
||||
albumId: item.albumId?.toString() || '',
|
||||
artistName: item.artist || '',
|
||||
artists: getArtistList(item.artists, item.artistId, item.artist, participants),
|
||||
|
||||
@@ -139,7 +139,7 @@ export const getClientType = (): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export const SEPARATOR_STRING = ' · ';
|
||||
export const SEPARATOR_STRING = ' • ';
|
||||
|
||||
export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: SortOrder) => {
|
||||
let results: Song[] = songs;
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuLayoutGrid,
|
||||
LuLayoutList,
|
||||
LuLibrary,
|
||||
LuList,
|
||||
LuListFilter,
|
||||
@@ -186,6 +187,7 @@ export const AppIcon = {
|
||||
itemSong: LuMusic,
|
||||
keyboard: LuKeyboard,
|
||||
lastPlayed: LuHeadphones,
|
||||
layoutDetail: LuLayoutList,
|
||||
layoutGrid: LuLayoutGrid,
|
||||
layoutList: LuList,
|
||||
layoutTable: LuTable,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
.root {
|
||||
display: inline-flex;
|
||||
gap: 0.125rem;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.root.interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root.xs {
|
||||
font-size: var(--theme-font-size-xs);
|
||||
}
|
||||
|
||||
.root.sm {
|
||||
font-size: var(--theme-font-size-sm);
|
||||
}
|
||||
|
||||
.root.md {
|
||||
font-size: var(--theme-font-size-md);
|
||||
}
|
||||
|
||||
.filled {
|
||||
color: var(--theme-colors-primary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import clsx from 'clsx';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
|
||||
import styles from './read-only-rating.module.css';
|
||||
|
||||
const MAX_STARS = 5;
|
||||
|
||||
interface ReadOnlyRatingProps {
|
||||
className?: string;
|
||||
onChange?: (value: number) => void;
|
||||
size?: 'md' | 'sm' | 'xs';
|
||||
value?: null | number;
|
||||
}
|
||||
|
||||
function ReadOnlyRatingComponent({ className, onChange, size = 'sm', value }: ReadOnlyRatingProps) {
|
||||
const [hoverIndex, setHoverIndex] = useState<null | number>(null);
|
||||
const rating = Math.min(MAX_STARS, Math.max(0, value ?? 0));
|
||||
const displayCount = hoverIndex !== null ? hoverIndex : Math.floor(rating);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!onChange) return;
|
||||
const el = e.currentTarget;
|
||||
const width = (el as HTMLElement).offsetWidth;
|
||||
if (width <= 0) return;
|
||||
const x = e.clientX - el.getBoundingClientRect().left;
|
||||
const segment = Math.floor((x / width) * MAX_STARS);
|
||||
const filled = segment < 0 ? 0 : Math.min(MAX_STARS, segment + 1);
|
||||
setHoverIndex(filled);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
setHoverIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!onChange) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const el = e.currentTarget;
|
||||
const width = (el as HTMLElement).offsetWidth;
|
||||
if (width <= 0) return;
|
||||
const x = e.clientX - el.getBoundingClientRect().left;
|
||||
const segment = Math.floor((x / width) * MAX_STARS);
|
||||
const clicked = segment < 0 ? 0 : Math.min(MAX_STARS, segment + 1);
|
||||
onChange(clicked === rating ? 0 : clicked);
|
||||
},
|
||||
[onChange, rating],
|
||||
);
|
||||
|
||||
const isInteractive = typeof onChange === 'function';
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={isInteractive ? undefined : `${rating} out of ${MAX_STARS} stars`}
|
||||
className={clsx(
|
||||
styles.root,
|
||||
size && styles[size],
|
||||
isInteractive && styles.interactive,
|
||||
className,
|
||||
)}
|
||||
onClick={isInteractive ? handleClick : undefined}
|
||||
onPointerLeave={isInteractive ? handlePointerLeave : undefined}
|
||||
onPointerMove={isInteractive ? handlePointerMove : undefined}
|
||||
role={isInteractive ? undefined : 'img'}
|
||||
>
|
||||
{Array.from({ length: MAX_STARS }, (_, i) => (
|
||||
<span className={i < displayCount ? styles.filled : styles.empty} key={i}>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const ReadOnlyRating = memo(ReadOnlyRatingComponent);
|
||||
|
||||
ReadOnlyRating.displayName = 'ReadOnlyRating';
|
||||
@@ -34,6 +34,7 @@ export enum ItemListKey {
|
||||
}
|
||||
|
||||
export enum ListDisplayType {
|
||||
DETAIL = 'detail',
|
||||
GRID = 'poster',
|
||||
LIST = 'list',
|
||||
TABLE = 'table',
|
||||
|
||||
Reference in New Issue
Block a user