mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b44e16708d | |||
| 946d9d92f9 | |||
| 5e28dc597c | |||
| 6462d46c79 | |||
| 1a51d52047 | |||
| d82ded479e | |||
| 2e2233cb7e | |||
| 7344758114 | |||
| ac40949572 | |||
| c7c72d27db | |||
| b41a91c178 | |||
| 86f158ee5f | |||
| 86f6cc9cef | |||
| f15e399ddc | |||
| 8efb32407d | |||
| c9223c402a | |||
| 78d6e5b1d1 | |||
| d0067c5dbf | |||
| 9f73cfdda1 | |||
| 95dee5f4ee | |||
| c7509472c1 | |||
| feca53a53d | |||
| ab52693092 | |||
| 9a2540f954 | |||
| b4c45f0956 | |||
| 0ab2d89c58 | |||
| 817e1dc7ba | |||
| e34282338d | |||
| ba4b07614c | |||
| 72b2dca759 | |||
| 42b51f104c | |||
| d99ecd485f | |||
| bec1e35faf | |||
| cb6c2092e5 | |||
| 2d01b8e3f7 | |||
| 775cb6be07 | |||
| de6cd7d0dc | |||
| 9e448f7266 | |||
| 7bb54f9fa0 | |||
| 332fc5f9f9 | |||
| d4c0754bd2 | |||
| 177bb156cb | |||
| 31c3f1b062 | |||
| 5421182cc1 | |||
| 3d67b02724 | |||
| b8aa006b1c | |||
| a16f43c427 |
@@ -1145,6 +1145,7 @@
|
|||||||
"year": "$t(common.year)"
|
"year": "$t(common.year)"
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
|
"detail": "detail",
|
||||||
"grid": "grid",
|
"grid": "grid",
|
||||||
"list": "list",
|
"list": "list",
|
||||||
"table": "table"
|
"table": "table"
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const JF_FIELDS = {
|
|||||||
ALBUM_ARTIST_DETAIL: 'Genres, Overview, SortName, ProviderIds',
|
ALBUM_ARTIST_DETAIL: 'Genres, Overview, SortName, ProviderIds',
|
||||||
ALBUM_ARTIST_LIST: 'Genres, DateCreated, ExternalUrls, Overview, SortName, ProviderIds',
|
ALBUM_ARTIST_LIST: 'Genres, DateCreated, ExternalUrls, Overview, SortName, ProviderIds',
|
||||||
ALBUM_DETAIL: 'Genres, DateCreated, ChildCount, People, Tags, ProviderIds',
|
ALBUM_DETAIL: 'Genres, DateCreated, ChildCount, People, Tags, ProviderIds',
|
||||||
ALBUM_LIST: 'People, Tags, Studios, SortName, UserData, ProviderIds',
|
ALBUM_LIST: 'People, Tags, Studios, SortName, UserData, ProviderIds, ChildCount',
|
||||||
FOLDER: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
FOLDER: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||||
GENRE: 'ItemCounts',
|
GENRE: 'ItemCounts',
|
||||||
PLAYLIST_DETAIL: 'Genres, DateCreated, MediaSources, ChildCount, ParentId, SortName',
|
PLAYLIST_DETAIL: 'Genres, DateCreated, MediaSources, ChildCount, ParentId, SortName',
|
||||||
@@ -1112,7 +1112,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
GenreIds: query.genreIds?.join(','),
|
GenreIds: query.genreIds?.join(','),
|
||||||
IncludeItemTypes: 'Audio',
|
IncludeItemTypes: 'Audio',
|
||||||
IsFavorite: query.favorite,
|
IsFavorite: query.favorite,
|
||||||
Limit: query.limit,
|
Limit: query.limit === -1 ? undefined : query.limit,
|
||||||
ParentId: getLibraryId(query.musicFolderId),
|
ParentId: getLibraryId(query.musicFolderId),
|
||||||
Recursive: true,
|
Recursive: true,
|
||||||
SearchTerm: query.searchTerm,
|
SearchTerm: query.searchTerm,
|
||||||
@@ -1147,7 +1147,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
GenreIds: query.genreIds?.join(','),
|
GenreIds: query.genreIds?.join(','),
|
||||||
IncludeItemTypes: 'Audio',
|
IncludeItemTypes: 'Audio',
|
||||||
IsFavorite: query.favorite,
|
IsFavorite: query.favorite,
|
||||||
Limit: query.limit,
|
Limit: query.limit === -1 ? undefined : query.limit,
|
||||||
ParentId: getLibraryId(query.musicFolderId),
|
ParentId: getLibraryId(query.musicFolderId),
|
||||||
Recursive: true,
|
Recursive: true,
|
||||||
SearchTerm: query.searchTerm,
|
SearchTerm: query.searchTerm,
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
.container {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 1fr;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
gap: var(--theme-spacing-sm);
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: var(--theme-spacing-sm);
|
|
||||||
container-type: inline-size;
|
|
||||||
background: var(--theme-colors-surface);
|
|
||||||
border-radius: var(--theme-radius-md);
|
|
||||||
|
|
||||||
@container (min-width: 500px) {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-container {
|
|
||||||
position: relative;
|
|
||||||
display: none;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
aspect-ratio: 1/1;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
content: '';
|
|
||||||
background-color: rgb(0 0 0);
|
|
||||||
opacity: 0;
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
&::before {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@container (min-width: 500px) {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image {
|
|
||||||
aspect-ratio: 1/1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--theme-spacing-sm);
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: var(--theme-spacing-xs) 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-container .header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-container .header .title {
|
|
||||||
max-width: 70%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-container .content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--theme-spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-container .content .tags {
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
// import { AnimatePresence } from 'motion/react';
|
|
||||||
// import { MouseEvent, useMemo, useState } from 'react';
|
|
||||||
// import { Link } from 'react-router';
|
|
||||||
|
|
||||||
// import styles from './item-detail.module.css';
|
|
||||||
|
|
||||||
// import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
|
|
||||||
// import { useFastAverageColor } from '/@/renderer/hooks';
|
|
||||||
// import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
|
||||||
// import { Badge } from '/@/shared/components/badge/badge';
|
|
||||||
// import { Divider } from '/@/shared/components/divider/divider';
|
|
||||||
// import { Group } from '/@/shared/components/group/group';
|
|
||||||
// import { Image } from '/@/shared/components/image/image';
|
|
||||||
// import { Rating } from '/@/shared/components/rating/rating';
|
|
||||||
// import { Text } from '/@/shared/components/text/text';
|
|
||||||
// import {
|
|
||||||
// Album,
|
|
||||||
// AlbumArtist,
|
|
||||||
// Artist,
|
|
||||||
// LibraryItem,
|
|
||||||
// Playlist,
|
|
||||||
// Song,
|
|
||||||
// } from '/@/shared/types/domain-types';
|
|
||||||
// import { stringToColor } from '/@/shared/utils/string-to-color';
|
|
||||||
|
|
||||||
// interface ItemDetailProps {
|
|
||||||
// data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
|
|
||||||
// itemHeight: number;
|
|
||||||
// itemType: LibraryItem;
|
|
||||||
// onClick?: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
|
|
||||||
// withControls?: boolean;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => {
|
|
||||||
// const imageUrl = getImageUrl(data);
|
|
||||||
|
|
||||||
// const [showControls, setShowControls] = useState(false);
|
|
||||||
|
|
||||||
// const { background } = useFastAverageColor({
|
|
||||||
// algorithm: 'simple',
|
|
||||||
// src: imageUrl,
|
|
||||||
// srcLoaded: false,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// // const tags = [...(data?.genres ?? [])];
|
|
||||||
|
|
||||||
// const tags = useMemo(() => {
|
|
||||||
// if (!data) {
|
|
||||||
// return [];
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const items: {
|
|
||||||
// color?: string;
|
|
||||||
// id: string;
|
|
||||||
// isLight?: boolean;
|
|
||||||
// itemType: LibraryItem;
|
|
||||||
// name: string;
|
|
||||||
// }[] = [];
|
|
||||||
|
|
||||||
// if ('albumArtists' in data && Array.isArray(data.albumArtists)) {
|
|
||||||
// data.albumArtists?.forEach((tag: { id: string; name: string }) => {
|
|
||||||
// items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name });
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if ('genres' in data && Array.isArray(data.genres)) {
|
|
||||||
// data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => {
|
|
||||||
// const { color, isLight } = stringToColor(tag.name);
|
|
||||||
// items.push({ ...tag, color, isLight });
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // if ('tags' in data && typeof data.tags === 'object') {
|
|
||||||
// // console.log('data.tags :>> ', data.tags);
|
|
||||||
// // Object.entries(data.tags).forEach(([key, value]) => {
|
|
||||||
// // items.push({ id: key, itemType: LibraryItem.TAG, name: value });
|
|
||||||
// // });
|
|
||||||
// // }
|
|
||||||
|
|
||||||
// return items;
|
|
||||||
// }, [data]);
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <div
|
|
||||||
// className={styles.container}
|
|
||||||
// onClick={(e) => onClick?.(e, data, itemType)}
|
|
||||||
// style={{ backgroundColor: background }}
|
|
||||||
// >
|
|
||||||
// <div
|
|
||||||
// className={styles.imageContainer}
|
|
||||||
// onMouseEnter={() => withControls && setShowControls(true)}
|
|
||||||
// onMouseLeave={() => withControls && setShowControls(false)}
|
|
||||||
// >
|
|
||||||
// <Image alt={data?.name} src={imageUrl} />
|
|
||||||
// <AnimatePresence>
|
|
||||||
// {withControls && showControls && <ItemCardControls type="compact" />}
|
|
||||||
// </AnimatePresence>
|
|
||||||
// </div>
|
|
||||||
// <div className={styles.metadataContainer}>
|
|
||||||
// <div className={styles.header}>
|
|
||||||
// <Text className={styles.title} component={Link} isLink size="lg" weight={500}>
|
|
||||||
// {data?.name}
|
|
||||||
// </Text>
|
|
||||||
// <Group>
|
|
||||||
// {data && 'userRating' in data && (
|
|
||||||
// <Rating size="xs" value={data?.userRating ?? 0} />
|
|
||||||
// )}
|
|
||||||
// {data && 'userFavorite' in data && (
|
|
||||||
// <ActionIcon
|
|
||||||
// icon="favorite"
|
|
||||||
// iconProps={{
|
|
||||||
// fill: data?.userFavorite ? 'primary' : 'default',
|
|
||||||
// }}
|
|
||||||
// size="xs"
|
|
||||||
// />
|
|
||||||
// )}
|
|
||||||
// </Group>
|
|
||||||
// </div>
|
|
||||||
// <Divider />
|
|
||||||
// <div className={styles.content}>
|
|
||||||
// <Group className={styles.tags} gap="xs">
|
|
||||||
// {tags.map((tag) => (
|
|
||||||
// <Badge
|
|
||||||
// key={tag.id}
|
|
||||||
// style={{
|
|
||||||
// backgroundColor: tag.color,
|
|
||||||
// color: tag.isLight ? 'black' : 'white',
|
|
||||||
// }}
|
|
||||||
// >
|
|
||||||
// {tag.name}
|
|
||||||
// </Badge>
|
|
||||||
// ))}
|
|
||||||
// </Group>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
|
|
||||||
// if (data && 'imageUrl' in data) {
|
|
||||||
// return data.imageUrl || undefined;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return undefined;
|
|
||||||
// };
|
|
||||||
@@ -192,9 +192,10 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
|||||||
onColumnReordered?.(columnIdFrom, columnIdTo, edge);
|
onColumnReordered?.(columnIdFrom, columnIdTo, edge);
|
||||||
},
|
},
|
||||||
|
|
||||||
onColumnResized: ({ columnId, width }: { columnId: TableColumn; width: number }) => {
|
onColumnResized: onColumnResized
|
||||||
onColumnResized?.(columnId, width);
|
? ({ columnId, width }: { columnId: TableColumn; width: number }) =>
|
||||||
},
|
onColumnResized(columnId, width)
|
||||||
|
: undefined,
|
||||||
|
|
||||||
onDoubleClick: ({ internalState, item, itemType, meta }: DefaultItemControlProps) => {
|
onDoubleClick: ({ internalState, item, itemType, meta }: DefaultItemControlProps) => {
|
||||||
if (!item || !internalState) {
|
if (!item || !internalState) {
|
||||||
@@ -241,11 +242,13 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
|||||||
}
|
}
|
||||||
|
|
||||||
const playType = (meta?.playType as Play) || Play.NOW;
|
const playType = (meta?.playType as Play) || Play.NOW;
|
||||||
|
const singleSongOnly = meta?.singleSongOnly === true;
|
||||||
|
|
||||||
// For NEXT, LAST, NEXT_SHUFFLE, and LAST_SHUFFLE, only add the clicked song
|
// For single-song actions (e.g. image play button), or NEXT/LAST/..., only add the clicked song
|
||||||
// For NOW and SHUFFLE, add a range of songs around the clicked song
|
// For row double-click with NOW/SHUFFLE, add a range of songs around the clicked song
|
||||||
let songsToAdd: Song[];
|
let songsToAdd: Song[];
|
||||||
if (
|
if (
|
||||||
|
singleSongOnly ||
|
||||||
playType === Play.NEXT ||
|
playType === Play.NEXT ||
|
||||||
playType === Play.LAST ||
|
playType === Play.LAST ||
|
||||||
playType === Play.NEXT_SHUFFLE ||
|
playType === Play.NEXT_SHUFFLE ||
|
||||||
|
|||||||
@@ -7,14 +7,19 @@ import { ItemListKey, TableColumn } from '/@/shared/types/types';
|
|||||||
|
|
||||||
interface UseItemListColumnReorderProps {
|
interface UseItemListColumnReorderProps {
|
||||||
itemListKey: ItemListKey;
|
itemListKey: ItemListKey;
|
||||||
|
tableKey?: 'detail' | 'main';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useItemListColumnReorder = ({ itemListKey }: UseItemListColumnReorderProps) => {
|
export const useItemListColumnReorder = ({
|
||||||
|
itemListKey,
|
||||||
|
tableKey = 'main',
|
||||||
|
}: UseItemListColumnReorderProps) => {
|
||||||
const { setList } = useSettingsStoreActions();
|
const { setList } = useSettingsStoreActions();
|
||||||
|
|
||||||
const handleColumnReordered = useCallback(
|
const handleColumnReordered = useCallback(
|
||||||
(columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: Edge | null) => {
|
(columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: Edge | null) => {
|
||||||
const columns = useSettingsStore.getState().lists[itemListKey]?.table.columns;
|
const list = useSettingsStore.getState().lists[itemListKey];
|
||||||
|
const columns = tableKey === 'detail' ? list?.detail?.columns : list?.table?.columns;
|
||||||
|
|
||||||
if (!columns) {
|
if (!columns) {
|
||||||
return;
|
return;
|
||||||
@@ -83,13 +88,20 @@ export const useItemListColumnReorder = ({ itemListKey }: UseItemListColumnReord
|
|||||||
// Insert the column at the new position
|
// Insert the column at the new position
|
||||||
newColumns.splice(newIndex, 0, updatedMovedColumn);
|
newColumns.splice(newIndex, 0, updatedMovedColumn);
|
||||||
|
|
||||||
setList(itemListKey, {
|
if (tableKey === 'detail') {
|
||||||
table: {
|
type SetListData = Parameters<
|
||||||
columns: newColumns,
|
ReturnType<typeof useSettingsStoreActions>['setList']
|
||||||
},
|
>[1];
|
||||||
});
|
setList(itemListKey, { detail: { columns: newColumns } } as SetListData);
|
||||||
|
} else {
|
||||||
|
setList(itemListKey, {
|
||||||
|
table: {
|
||||||
|
columns: newColumns,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[itemListKey, setList],
|
[itemListKey, setList, tableKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handleColumnReordered };
|
return { handleColumnReordered };
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import { ItemListKey, TableColumn } from '/@/shared/types/types';
|
|||||||
|
|
||||||
interface UseItemListColumnResizeProps {
|
interface UseItemListColumnResizeProps {
|
||||||
itemListKey: ItemListKey;
|
itemListKey: ItemListKey;
|
||||||
|
tableKey?: 'detail' | 'main';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useItemListColumnResize = ({ itemListKey }: UseItemListColumnResizeProps) => {
|
export const useItemListColumnResize = ({
|
||||||
|
itemListKey,
|
||||||
|
tableKey = 'main',
|
||||||
|
}: UseItemListColumnResizeProps) => {
|
||||||
const { setList } = useSettingsStoreActions();
|
const { setList } = useSettingsStoreActions();
|
||||||
const columns = useSettingsStore((state) => state.lists[itemListKey]?.table.columns);
|
const columns = useSettingsStore((state) => {
|
||||||
|
const list = state.lists[itemListKey];
|
||||||
|
return tableKey === 'detail' ? list?.detail?.columns : list?.table?.columns;
|
||||||
|
});
|
||||||
|
|
||||||
const handleColumnResized = useCallback(
|
const handleColumnResized = useCallback(
|
||||||
(columnId: TableColumn, width: number) => {
|
(columnId: TableColumn, width: number) => {
|
||||||
@@ -19,13 +26,20 @@ export const useItemListColumnResize = ({ itemListKey }: UseItemListColumnResize
|
|||||||
column.id === columnId ? { ...column, width } : column,
|
column.id === columnId ? { ...column, width } : column,
|
||||||
);
|
);
|
||||||
|
|
||||||
setList(itemListKey, {
|
if (tableKey === 'detail') {
|
||||||
table: {
|
type SetListData = Parameters<
|
||||||
columns: updatedColumns,
|
ReturnType<typeof useSettingsStoreActions>['setList']
|
||||||
},
|
>[1];
|
||||||
});
|
setList(itemListKey, { detail: { columns: updatedColumns } } as SetListData);
|
||||||
|
} else {
|
||||||
|
setList(itemListKey, {
|
||||||
|
table: {
|
||||||
|
columns: updatedColumns,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[columns, itemListKey, setList],
|
[columns, itemListKey, setList, tableKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handleColumnResized };
|
return { handleColumnResized };
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useLocation, useNavigationType } from 'react-router';
|
||||||
|
|
||||||
import { parseIntParam, setSearchParam } from '/@/renderer/utils/query-params';
|
import { useScrollStore } from '/@/renderer/store/scroll.store';
|
||||||
|
|
||||||
interface UseItemListScrollPersistProps {
|
interface UseItemListScrollPersistProps {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useItemListScrollPersist = ({ enabled }: UseItemListScrollPersistProps) => {
|
export const useItemListScrollPersist = ({ enabled }: UseItemListScrollPersistProps) => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const location = useLocation();
|
||||||
|
const navigationType = useNavigationType();
|
||||||
|
const setOffset = useScrollStore((s) => s.setOffset);
|
||||||
|
const getOffset = useScrollStore((s) => s.getOffset);
|
||||||
|
|
||||||
const scrollOffset = useMemo(() => parseIntParam(searchParams, 'scrollOffset'), [searchParams]);
|
const scrollOffset = useMemo(() => {
|
||||||
|
if (navigationType !== 'POP') return undefined;
|
||||||
|
return getOffset(location.key);
|
||||||
|
}, [getOffset, location.key, navigationType]);
|
||||||
|
|
||||||
const handleOnScrollEnd = useCallback(
|
const handleOnScrollEnd = useCallback(
|
||||||
(offset: number) => {
|
(offset: number) => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
setOffset(location.key, offset);
|
||||||
setSearchParams((prev) => setSearchParam(prev, 'scrollOffset', offset), {
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[enabled, setSearchParams],
|
[enabled, location.key, setOffset],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handleOnScrollEnd, scrollOffset };
|
return { handleOnScrollEnd, scrollOffset };
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
export const ActionsColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => {
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
const index = internalState?.findItemIndex(song.id) ?? -1;
|
||||||
|
controls?.onMore?.({
|
||||||
|
event,
|
||||||
|
index,
|
||||||
|
internalState: internalState ?? undefined,
|
||||||
|
item: song,
|
||||||
|
itemType: LibraryItem.SONG,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoubleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionIcon
|
||||||
|
icon="ellipsisHorizontal"
|
||||||
|
iconProps={{
|
||||||
|
color: 'muted',
|
||||||
|
size: 'xs',
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
JOINED_ARTISTS_MUTED_PROPS,
|
||||||
|
JoinedArtists,
|
||||||
|
} from '/@/renderer/features/albums/components/joined-artists';
|
||||||
|
|
||||||
|
export const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
||||||
|
const name = song.albumArtistName?.trim() ?? '';
|
||||||
|
const hasArtists = name.length > 0 || (song.albumArtists?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
if (!hasArtists) return <> </>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JoinedArtists
|
||||||
|
artistName={song.albumArtistName ?? ''}
|
||||||
|
artists={song.albumArtists ?? []}
|
||||||
|
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
|
||||||
|
readOnly={!isRowHovered}
|
||||||
|
rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const AlbumColumn = ({ song }: ItemDetailListCellProps) => song.album ?? <> </>;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
JOINED_ARTISTS_MUTED_PROPS,
|
||||||
|
JoinedArtists,
|
||||||
|
} from '/@/renderer/features/albums/components/joined-artists';
|
||||||
|
|
||||||
|
export const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
||||||
|
const name = song.artistName?.trim() ?? '';
|
||||||
|
const hasArtists = name.length > 0 || (song.artists?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
if (!hasArtists) return <> </>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JoinedArtists
|
||||||
|
artistName={song.artistName ?? ''}
|
||||||
|
artists={song.artists ?? []}
|
||||||
|
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
|
||||||
|
readOnly={!isRowHovered}
|
||||||
|
rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const BitDepthColumn = ({ song }: ItemDetailListCellProps) => song.bitDepth;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const BitRateColumn = ({ song }: ItemDetailListCellProps) =>
|
||||||
|
song.bitRate != null ? `${song.bitRate} kbps` : <> </>;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const BpmColumn = ({ song }: ItemDetailListCellProps) => song.bpm ?? <> </>;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const ChannelsColumn = ({ song }: ItemDetailListCellProps) =>
|
||||||
|
song.channels != null ? String(song.channels) : <> </>;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const CodecColumn = ({ song }: ItemDetailListCellProps) => song.container ?? <> </>;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const CommentColumn = ({ song }: ItemDetailListCellProps) => song.comment ?? <> </>;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const ComposerColumn = ({ song }: ItemDetailListCellProps) => {
|
||||||
|
const composers = song.participants?.composer;
|
||||||
|
if (!composers?.length) return <> </>;
|
||||||
|
return composers.map((a) => a.name).join(', ');
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import { formatDateAbsolute } from '/@/renderer/utils/format';
|
||||||
|
|
||||||
|
export const DateAddedColumn = ({ song }: ItemDetailListCellProps) =>
|
||||||
|
song.createdAt ? formatDateAbsolute(song.createdAt) : <> </>;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
interface DefaultColumnProps extends ItemDetailListCellProps {
|
||||||
|
columnId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DefaultColumn = ({ columnId, song }: DefaultColumnProps) => {
|
||||||
|
const raw = (song as Record<string, unknown>)[columnId];
|
||||||
|
if (raw === undefined || raw === null || typeof raw === 'object') return <> </>;
|
||||||
|
return String(raw);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const DiscNumberColumn = ({ song }: ItemDetailListCellProps) => String(song.discNumber ?? 1);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import formatDuration from 'format-duration';
|
||||||
|
|
||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const DurationColumn = ({ song }: ItemDetailListCellProps) => formatDuration(song.duration);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||||
|
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||||||
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
export const FavoriteColumn = ({
|
||||||
|
controls,
|
||||||
|
internalState,
|
||||||
|
isMutatingFavorite,
|
||||||
|
onFavoriteClick,
|
||||||
|
song,
|
||||||
|
}: ItemDetailListCellProps) => {
|
||||||
|
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
|
||||||
|
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
|
||||||
|
const isMutating = isMutatingFavorite ?? (isMutatingCreateFavorite || isMutatingDeleteFavorite);
|
||||||
|
const isFavorite = song.userFavorite ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionIcon
|
||||||
|
disabled={isMutating}
|
||||||
|
icon="favorite"
|
||||||
|
iconProps={{
|
||||||
|
color: isFavorite ? 'primary' : 'muted',
|
||||||
|
fill: isFavorite ? 'primary' : undefined,
|
||||||
|
size: 'xs',
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
const index = internalState?.findItemIndex(song.id) ?? -1;
|
||||||
|
if (controls?.onFavorite) {
|
||||||
|
controls.onFavorite({
|
||||||
|
event,
|
||||||
|
favorite: !isFavorite,
|
||||||
|
index,
|
||||||
|
internalState: internalState ?? undefined,
|
||||||
|
item: song,
|
||||||
|
itemType: LibraryItem.SONG,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onFavoriteClick?.(song);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
.group {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: var(--theme-spacing-sm) var(--theme-spacing-xs);
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--theme-spacing-xs) 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group a {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { generatePath, Link } from 'react-router';
|
||||||
|
|
||||||
|
import styles from './genre-badge-column.module.css';
|
||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { Badge } from '/@/shared/components/badge/badge';
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { stringToColor } from '/@/shared/utils/string-to-color';
|
||||||
|
|
||||||
|
const MAX_GENRES = 4;
|
||||||
|
|
||||||
|
export const GenreBadgeColumn = ({ song }: ItemDetailListCellProps) => {
|
||||||
|
const genres = song.genres;
|
||||||
|
|
||||||
|
const genresWithStyle = useMemo(() => {
|
||||||
|
if (!genres) return [];
|
||||||
|
return genres.slice(0, MAX_GENRES).map((genre) => {
|
||||||
|
const { color, isLight } = stringToColor(genre.name);
|
||||||
|
const path = generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: genre.id });
|
||||||
|
return { ...genre, color, isLight, path };
|
||||||
|
});
|
||||||
|
}, [genres]);
|
||||||
|
|
||||||
|
if (!genresWithStyle.length) return <> </>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group className={styles.group} wrap="nowrap">
|
||||||
|
{genresWithStyle.map((genre) => (
|
||||||
|
<Badge
|
||||||
|
component={Link}
|
||||||
|
key={genre.id}
|
||||||
|
state={{ item: genre }}
|
||||||
|
style={{
|
||||||
|
backgroundColor: genre.color,
|
||||||
|
color: genre.isLight ? 'black' : 'white',
|
||||||
|
}}
|
||||||
|
to={genre.path}
|
||||||
|
>
|
||||||
|
{genre.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { generatePath, Link } from 'react-router';
|
||||||
|
|
||||||
|
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
|
||||||
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
|
||||||
|
const TEXT_PROPS = { isMuted: true, isNoSelect: true, size: 'sm' as const } as const;
|
||||||
|
|
||||||
|
export const GenreColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
||||||
|
const genres = song.genres ?? [];
|
||||||
|
if (!genres.length) return <> </>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{genres.map((genre, index) => (
|
||||||
|
<Fragment key={genre.id}>
|
||||||
|
{isRowHovered ? (
|
||||||
|
<Text
|
||||||
|
component={Link}
|
||||||
|
isLink
|
||||||
|
state={{ item: genre }}
|
||||||
|
to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {
|
||||||
|
genreId: genre.id,
|
||||||
|
})}
|
||||||
|
{...TEXT_PROPS}
|
||||||
|
>
|
||||||
|
{genre.name}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text component="span" {...TEXT_PROPS}>
|
||||||
|
{genre.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{index < genres.length - 1 && ', '}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
.image-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-container {
|
||||||
|
flex: 1 1 0;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
aspect-ratio: unset;
|
||||||
|
padding-top: var(--theme-spacing-xs);
|
||||||
|
padding-bottom: var(--theme-spacing-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 10;
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button-overlay:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button-overlay button {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import styles from './image-column.module.css';
|
||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||||
|
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
|
||||||
|
import {
|
||||||
|
LONG_PRESS_PLAY_BEHAVIOR,
|
||||||
|
PlayTooltip,
|
||||||
|
} from '/@/renderer/features/shared/components/play-button-group';
|
||||||
|
import { usePlayButtonBehavior } from '/@/renderer/store';
|
||||||
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
import { Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
export const ImageColumn = ({
|
||||||
|
controls,
|
||||||
|
internalState,
|
||||||
|
rowIndex = 0,
|
||||||
|
song,
|
||||||
|
}: ItemDetailListCellProps) => {
|
||||||
|
const playButtonBehavior = usePlayButtonBehavior();
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
const handlePlay = (playType: Play) => {
|
||||||
|
if (!song || !controls?.onDoubleClick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.onDoubleClick({
|
||||||
|
event: null,
|
||||||
|
index: rowIndex,
|
||||||
|
internalState,
|
||||||
|
item: song,
|
||||||
|
itemType: LibraryItem.SONG,
|
||||||
|
meta: { playType, singleSongOnly: true },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.imageContainer}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<ItemImage
|
||||||
|
className={styles.compactImage}
|
||||||
|
containerClassName={styles.compactContainer}
|
||||||
|
explicitStatus={song.explicitStatus}
|
||||||
|
id={song.imageId}
|
||||||
|
itemType={LibraryItem.SONG}
|
||||||
|
serverId={song._serverId}
|
||||||
|
type="table"
|
||||||
|
/>
|
||||||
|
{isHovered && (
|
||||||
|
<div className={clsx(styles.playButtonOverlay)}>
|
||||||
|
<PlayTooltip disabled={false} type={playButtonBehavior}>
|
||||||
|
<PlayButton
|
||||||
|
fill
|
||||||
|
onClick={() => handlePlay(playButtonBehavior)}
|
||||||
|
onLongPress={() =>
|
||||||
|
handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PlayTooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import { ActionsColumn } from './actions-column';
|
||||||
|
import { AlbumArtistColumn } from './album-artist-column';
|
||||||
|
import { AlbumColumn } from './album-column';
|
||||||
|
import { ArtistColumn } from './artist-column';
|
||||||
|
import { BitDepthColumn } from './bit-depth-column';
|
||||||
|
import { BitRateColumn } from './bit-rate-column';
|
||||||
|
import { BpmColumn } from './bpm-column';
|
||||||
|
import { ChannelsColumn } from './channels-column';
|
||||||
|
import { CodecColumn } from './codec-column';
|
||||||
|
import { CommentColumn } from './comment-column';
|
||||||
|
import { ComposerColumn } from './composer-column';
|
||||||
|
import { DateAddedColumn } from './date-added-column';
|
||||||
|
import { DefaultColumn } from './default-column';
|
||||||
|
import { DiscNumberColumn } from './disc-number-column';
|
||||||
|
import { DurationColumn } from './duration-column';
|
||||||
|
import { FavoriteColumn } from './favorite-column';
|
||||||
|
import { GenreBadgeColumn } from './genre-badge-column';
|
||||||
|
import { GenreColumn } from './genre-column';
|
||||||
|
import { ImageColumn } from './image-column';
|
||||||
|
import { LastPlayedColumn } from './last-played-column';
|
||||||
|
import { PathColumn } from './path-column';
|
||||||
|
import { PlayCountColumn } from './play-count-column';
|
||||||
|
import { RatingColumn } from './rating-column';
|
||||||
|
import { ReleaseDateColumn } from './release-date-column';
|
||||||
|
import { RowIndexColumn } from './row-index-column';
|
||||||
|
import { SampleRateColumn } from './sample-rate-column';
|
||||||
|
import { SizeColumn } from './size-column';
|
||||||
|
import { TitleArtistColumn } from './title-artist-column';
|
||||||
|
import { TitleColumn } from './title-column';
|
||||||
|
import { TitleCombinedColumn } from './title-combined-column';
|
||||||
|
import { TrackNumberColumn } from './track-number-column';
|
||||||
|
import { YearColumn } from './year-column';
|
||||||
|
|
||||||
|
import { TableColumn } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
type CellComponent = (props: ItemDetailListCellProps) => ReactNode;
|
||||||
|
|
||||||
|
const COLUMN_MAP: Partial<Record<TableColumn, CellComponent>> = {
|
||||||
|
[TableColumn.ACTIONS]: ActionsColumn,
|
||||||
|
[TableColumn.ALBUM]: AlbumColumn,
|
||||||
|
[TableColumn.ALBUM_ARTIST]: AlbumArtistColumn,
|
||||||
|
[TableColumn.ARTIST]: ArtistColumn,
|
||||||
|
[TableColumn.BIT_DEPTH]: BitDepthColumn,
|
||||||
|
[TableColumn.BIT_RATE]: BitRateColumn,
|
||||||
|
[TableColumn.BPM]: BpmColumn,
|
||||||
|
[TableColumn.CHANNELS]: ChannelsColumn,
|
||||||
|
[TableColumn.CODEC]: CodecColumn,
|
||||||
|
[TableColumn.COMMENT]: CommentColumn,
|
||||||
|
[TableColumn.COMPOSER]: ComposerColumn,
|
||||||
|
[TableColumn.DATE_ADDED]: DateAddedColumn,
|
||||||
|
[TableColumn.DISC_NUMBER]: DiscNumberColumn,
|
||||||
|
[TableColumn.DURATION]: DurationColumn,
|
||||||
|
[TableColumn.GENRE]: GenreColumn,
|
||||||
|
[TableColumn.GENRE_BADGE]: GenreBadgeColumn,
|
||||||
|
[TableColumn.IMAGE]: ImageColumn,
|
||||||
|
[TableColumn.LAST_PLAYED]: LastPlayedColumn,
|
||||||
|
[TableColumn.PATH]: PathColumn,
|
||||||
|
[TableColumn.PLAY_COUNT]: PlayCountColumn,
|
||||||
|
[TableColumn.RELEASE_DATE]: ReleaseDateColumn,
|
||||||
|
[TableColumn.ROW_INDEX]: RowIndexColumn,
|
||||||
|
[TableColumn.SAMPLE_RATE]: SampleRateColumn,
|
||||||
|
[TableColumn.SIZE]: SizeColumn,
|
||||||
|
[TableColumn.TITLE]: TitleColumn,
|
||||||
|
[TableColumn.TITLE_ARTIST]: TitleArtistColumn,
|
||||||
|
[TableColumn.TITLE_COMBINED]: TitleCombinedColumn,
|
||||||
|
[TableColumn.TRACK_NUMBER]: TrackNumberColumn,
|
||||||
|
[TableColumn.USER_FAVORITE]: FavoriteColumn,
|
||||||
|
[TableColumn.USER_RATING]: RatingColumn,
|
||||||
|
[TableColumn.YEAR]: YearColumn,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DetailListCellComponentProps = ItemDetailListCellProps & { columnId?: string };
|
||||||
|
|
||||||
|
export function getDetailListCellComponent(
|
||||||
|
columnId: string | TableColumn,
|
||||||
|
): (props: DetailListCellComponentProps) => ReactNode {
|
||||||
|
const Component = COLUMN_MAP[columnId as TableColumn];
|
||||||
|
if (Component) {
|
||||||
|
return Component as (props: DetailListCellComponentProps) => ReactNode;
|
||||||
|
}
|
||||||
|
return (props: DetailListCellComponentProps) =>
|
||||||
|
React.createElement(DefaultColumn, {
|
||||||
|
columnId: props.columnId ?? (columnId as string),
|
||||||
|
song: props.song,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export {
|
||||||
|
ActionsColumn,
|
||||||
|
AlbumArtistColumn,
|
||||||
|
AlbumColumn,
|
||||||
|
ArtistColumn,
|
||||||
|
BitDepthColumn,
|
||||||
|
BitRateColumn,
|
||||||
|
BpmColumn,
|
||||||
|
ChannelsColumn,
|
||||||
|
CodecColumn,
|
||||||
|
CommentColumn,
|
||||||
|
ComposerColumn,
|
||||||
|
DateAddedColumn,
|
||||||
|
DefaultColumn,
|
||||||
|
DiscNumberColumn,
|
||||||
|
DurationColumn,
|
||||||
|
FavoriteColumn,
|
||||||
|
GenreBadgeColumn,
|
||||||
|
GenreColumn,
|
||||||
|
ImageColumn,
|
||||||
|
LastPlayedColumn,
|
||||||
|
PathColumn,
|
||||||
|
PlayCountColumn,
|
||||||
|
RatingColumn,
|
||||||
|
ReleaseDateColumn,
|
||||||
|
RowIndexColumn,
|
||||||
|
SampleRateColumn,
|
||||||
|
SizeColumn,
|
||||||
|
TitleArtistColumn,
|
||||||
|
TitleColumn,
|
||||||
|
TitleCombinedColumn,
|
||||||
|
TrackNumberColumn,
|
||||||
|
YearColumn,
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import { formatDateRelative } from '/@/renderer/utils/format';
|
||||||
|
|
||||||
|
export const LastPlayedColumn = ({ song }: ItemDetailListCellProps) =>
|
||||||
|
song.lastPlayedAt ? formatDateRelative(song.lastPlayedAt) : <> </>;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const PathColumn = ({ song }: ItemDetailListCellProps) => song.path ?? <> </>;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const PlayCountColumn = ({ song }: ItemDetailListCellProps) =>
|
||||||
|
song.playCount ? String(song.playCount) : <> </>;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
|
||||||
|
import { Rating } from '/@/shared/components/rating/rating';
|
||||||
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
export const RatingColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => {
|
||||||
|
const isMutatingRating = useIsMutatingRating();
|
||||||
|
const value = song.userRating ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rating
|
||||||
|
onChange={(rating) => {
|
||||||
|
const index = internalState?.findItemIndex(song.id) ?? -1;
|
||||||
|
controls?.onRating?.({
|
||||||
|
event: null,
|
||||||
|
index,
|
||||||
|
internalState: internalState ?? undefined,
|
||||||
|
item: song,
|
||||||
|
itemType: LibraryItem.SONG,
|
||||||
|
rating,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
readOnly={isMutatingRating}
|
||||||
|
size="xs"
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import { formatDateAbsoluteUTC } from '/@/renderer/utils/format';
|
||||||
|
|
||||||
|
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) =>
|
||||||
|
song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <> </>;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.icon-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import styles from './row-index-column.module.css';
|
||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||||
|
import { usePlayerStatus } from '/@/renderer/store';
|
||||||
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
|
import { PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
export const RowIndexColumn = ({ rowIndex, song }: ItemDetailListCellProps) => {
|
||||||
|
const status = usePlayerStatus();
|
||||||
|
const { isActive } = useIsCurrentSong(song);
|
||||||
|
const isPlaying = isActive && status === PlayerStatus.PLAYING;
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
return (
|
||||||
|
<div className={styles.iconWrapper}>
|
||||||
|
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{String((rowIndex ?? 0) + 1)}</>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const SampleRateColumn = ({ song }: ItemDetailListCellProps) =>
|
||||||
|
song.sampleRate ? `${song.sampleRate} Hz` : <> </>;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
import { formatSizeString } from '/@/renderer/utils/format';
|
||||||
|
|
||||||
|
export const SizeColumn = ({ song }: ItemDetailListCellProps) =>
|
||||||
|
song.size ? formatSizeString(song.size) : <> </>;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import styles from './title-column.module.css';
|
||||||
|
|
||||||
|
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
|
||||||
|
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||||
|
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||||
|
|
||||||
|
export const TitleArtistColumn = ({ song }: ItemDetailListCellProps) => {
|
||||||
|
const { isActive } = useIsCurrentSong(song);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={clsx({ [styles.active]: isActive })}>
|
||||||
|
<ExplicitIndicator explicitStatus={song.explicitStatus} />
|
||||||
|
{[song.name, song.artistName].filter(Boolean).join(' — ') ?? <> </>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.active {
|
||||||
|
color: var(--theme-colors-primary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import styles from './title-column.module.css';
|
||||||
|
|
||||||
|
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
|
||||||
|
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||||
|
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||||
|
|
||||||
|
export const TitleColumn = ({ song }: ItemDetailListCellProps) => {
|
||||||
|
const { isActive } = useIsCurrentSong(song);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={clsx({ [styles.active]: isActive })}>
|
||||||
|
<ExplicitIndicator explicitStatus={song.explicitStatus} />
|
||||||
|
{song.name ?? <> </>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import styles from './title-column.module.css';
|
||||||
|
|
||||||
|
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
|
||||||
|
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
||||||
|
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
||||||
|
|
||||||
|
export const TitleCombinedColumn = ({ song }: ItemDetailListCellProps) => {
|
||||||
|
const { isActive } = useIsCurrentSong(song);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={clsx({ [styles.active]: isActive })}>
|
||||||
|
<ExplicitIndicator explicitStatus={song.explicitStatus} />
|
||||||
|
{[song.name, song.artistName].filter(Boolean).join(' — ') ?? <> </>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
|
||||||
|
const disc = song.discNumber ?? 1;
|
||||||
|
const track = song.trackNumber.toString().padStart(2, '0');
|
||||||
|
return `${disc}-${track}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||||
|
import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||||
|
import { Song } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
export interface ItemDetailListCellProps {
|
||||||
|
controls?: ItemControls;
|
||||||
|
internalState?: ItemListStateActions;
|
||||||
|
isMutatingFavorite?: boolean;
|
||||||
|
isRowHovered?: boolean;
|
||||||
|
onFavoriteClick?: (song: Song) => void;
|
||||||
|
rowIndex?: number;
|
||||||
|
size?: 'compact' | 'default' | 'large';
|
||||||
|
song: Song;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { ItemDetailListCellProps } from './types';
|
||||||
|
|
||||||
|
export const YearColumn = ({ song }: ItemDetailListCellProps) =>
|
||||||
|
song.releaseYear ? String(song.releaseYear) : <> </>;
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list-header {
|
||||||
|
display: grid;
|
||||||
|
flex-shrink: 0;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
gap: var(--theme-spacing-md);
|
||||||
|
padding: 0 var(--theme-spacing-md);
|
||||||
|
font-size: var(--theme-font-size-sm);
|
||||||
|
user-select: none;
|
||||||
|
background-color: var(--theme-colors-background);
|
||||||
|
border-bottom: 1px solid var(--theme-colors-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left-album-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: var(--theme-font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--theme-colors-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks-table-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks-table-header-size-compact {
|
||||||
|
height: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks-table-header-size-default {
|
||||||
|
height: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks-table-header-size-large {
|
||||||
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-header-cell {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 60%;
|
||||||
|
padding-right: var(--theme-spacing-sm);
|
||||||
|
padding-left: var(--theme-spacing-sm);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-header-cell-no-h-padding {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-header-cell-with-vertical-border {
|
||||||
|
border-right: 1px solid var(--theme-colors-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-header-cell-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-header-cell-dragged-over-left::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
width: 3px;
|
||||||
|
content: '';
|
||||||
|
background-color: var(--theme-colors-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-header-cell-dragged-over-right::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
width: 3px;
|
||||||
|
content: '';
|
||||||
|
background-color: var(--theme-colors-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-header-cell:hover .resize-handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-header-cell:hover .resize-handle::before {
|
||||||
|
background-color: var(--theme-colors-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
width: 2px;
|
||||||
|
margin-right: -4px;
|
||||||
|
cursor: col-resize;
|
||||||
|
background: var(--theme-colors-border);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .resize-handle::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
content: '';
|
||||||
|
background-color: transparent;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
} */
|
||||||
|
|
||||||
|
.resize-handle-left {
|
||||||
|
left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-left::before {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-right {
|
||||||
|
right: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle-dragging {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
gap: var(--theme-spacing-md);
|
||||||
|
padding: var(--theme-spacing-md);
|
||||||
|
border-bottom: 1px solid var(--theme-colors-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-column-wrapper {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 5;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
content: '';
|
||||||
|
background-color: rgb(0 0 0);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@mixin dark {
|
||||||
|
&::before {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
&::before {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .favorite-badge,
|
||||||
|
&:hover .rating-badge {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -50px;
|
||||||
|
left: -50px;
|
||||||
|
z-index: 1;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: var(--theme-colors-primary);
|
||||||
|
box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%);
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--theme-spacing-sm);
|
||||||
|
right: var(--theme-spacing-sm);
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
||||||
|
font-size: var(--theme-font-size-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--theme-colors-foreground);
|
||||||
|
text-shadow: 0 1px 2px rgb(0 0 0 / 80%);
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: var(--theme-colors-primary);
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 50%);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .image {
|
||||||
|
object-fit: var(--theme-image-fit);
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .metadata {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--theme-spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: var(--theme-font-size-md);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .title:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .artist {
|
||||||
|
font-size: var(--theme-font-size-sm);
|
||||||
|
color: var(--theme-colors-foreground-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .artist-plain-text:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .metadata-link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .metadata-link:hover {
|
||||||
|
color: var(--theme-colors-foreground);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .metadata-extra {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--theme-spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
font-size: var(--theme-font-size-sm);
|
||||||
|
color: var(--theme-colors-foreground-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .metadata-line {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-wrap-style: balance;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .metadata-line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .right {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .tracks-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
font-size: var(--theme-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .track-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .track-header-cell {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .track-cell {
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: var(--theme-spacing-sm);
|
||||||
|
padding-left: var(--theme-spacing-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .track-row-size-compact {
|
||||||
|
height: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
max-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .track-row-size-default {
|
||||||
|
height: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
max-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .track-row-size-large {
|
||||||
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
max-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .track-cell-muted {
|
||||||
|
color: var(--theme-colors-foreground-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .track-cell-with-vertical-border {
|
||||||
|
border-right: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .track-cell-vertical-border-visible {
|
||||||
|
border-right-color: var(--theme-colors-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .track-cell-image {
|
||||||
|
display: flex;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
padding-right: var(--theme-spacing-sm);
|
||||||
|
padding-left: var(--theme-spacing-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .track-cell-no-h-padding {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row-dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row.track-row-alternate-even {
|
||||||
|
background-color: var(--theme-colors-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row.track-row-alternate-odd {
|
||||||
|
@mixin dark {
|
||||||
|
background-color: darken(var(--theme-colors-background), 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: darken(var(--theme-colors-background), 2%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row.track-row-selected {
|
||||||
|
@mixin dark {
|
||||||
|
background-color: lighten(var(--theme-colors-surface), 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: darken(var(--theme-colors-surface), 5%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row.track-row-with-horizontal-border {
|
||||||
|
border-top: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row.track-row-horizontal-border-visible {
|
||||||
|
border-top-color: var(--theme-colors-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row.track-row-hover-highlight-enabled {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row.track-row-hover-highlight-enabled .track-cell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row.track-row-hover-highlight-enabled:hover::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
content: '';
|
||||||
|
background-color: var(--theme-colors-surface);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-image-container {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-title-container {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-title {
|
||||||
|
width: 75%;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-artist-container {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-artist {
|
||||||
|
width: 50%;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-tracks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-track-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 1fr 8rem;
|
||||||
|
gap: var(--theme-spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
padding-right: var(--theme-spacing-sm);
|
||||||
|
padding-left: var(--theme-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-tracks-size-compact .skeleton-track-row {
|
||||||
|
height: 32px;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-tracks-size-default .skeleton-track-row {
|
||||||
|
height: 40px;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-tracks-size-large .skeleton-track-row {
|
||||||
|
height: 48px;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-track-cell {
|
||||||
|
width: 100%;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-track-cell-title {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
|||||||
|
import { TableColumn } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
|
||||||
|
[TableColumn.ACTIONS]: 32,
|
||||||
|
[TableColumn.BIT_DEPTH]: 80,
|
||||||
|
[TableColumn.BIT_RATE]: 80,
|
||||||
|
[TableColumn.BPM]: 56,
|
||||||
|
[TableColumn.CHANNELS]: 80,
|
||||||
|
[TableColumn.CODEC]: 80,
|
||||||
|
[TableColumn.DATE_ADDED]: 128,
|
||||||
|
[TableColumn.DISC_NUMBER]: 36,
|
||||||
|
[TableColumn.DURATION]: 72,
|
||||||
|
[TableColumn.RELEASE_DATE]: 128,
|
||||||
|
[TableColumn.SAMPLE_RATE]: 90,
|
||||||
|
[TableColumn.TRACK_NUMBER]: 56,
|
||||||
|
[TableColumn.USER_FAVORITE]: 32,
|
||||||
|
[TableColumn.USER_RATING]: 64,
|
||||||
|
[TableColumn.YEAR]: 56,
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOVER_ONLY_COLUMNS: TableColumn[] = [
|
||||||
|
TableColumn.ACTIONS,
|
||||||
|
TableColumn.USER_FAVORITE,
|
||||||
|
TableColumn.USER_RATING,
|
||||||
|
];
|
||||||
|
|
||||||
|
const NO_HORIZONTAL_PADDING_COLUMNS: TableColumn[] = [
|
||||||
|
TableColumn.ACTIONS,
|
||||||
|
TableColumn.USER_FAVORITE,
|
||||||
|
TableColumn.USER_RATING,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getTrackColumnFixed(columnId: TableColumn): {
|
||||||
|
fixedWidth: number;
|
||||||
|
isFixedColumn: boolean;
|
||||||
|
} {
|
||||||
|
const width = FIXED_TRACK_COLUMN_WIDTHS[columnId];
|
||||||
|
return width !== undefined
|
||||||
|
? { fixedWidth: width, isFixedColumn: true }
|
||||||
|
: { fixedWidth: 0, isFixedColumn: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNoHorizontalPaddingColumn(columnId: TableColumn): boolean {
|
||||||
|
return NO_HORIZONTAL_PADDING_COLUMNS.includes(columnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTrackColumnHoverOnly(columnId: TableColumn): boolean {
|
||||||
|
return HOVER_ONLY_COLUMNS.includes(columnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowHoverOnlyColumnContent(
|
||||||
|
columnId: TableColumn,
|
||||||
|
isRowHovered: boolean,
|
||||||
|
song: { userFavorite?: boolean | null; userRating?: null | number },
|
||||||
|
): boolean {
|
||||||
|
if (!HOVER_ONLY_COLUMNS.includes(columnId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
isRowHovered ||
|
||||||
|
(columnId === TableColumn.USER_FAVORITE && song.userFavorite !== false) ||
|
||||||
|
(columnId === TableColumn.USER_RATING && song.userRating != null)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ const ImageColumnBase = (props: ItemTableListInnerColumn) => {
|
|||||||
itemType: props.itemType,
|
itemType: props.itemType,
|
||||||
meta: {
|
meta: {
|
||||||
playType,
|
playType,
|
||||||
|
singleSongOnly: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
itemType: props.itemType,
|
itemType: props.itemType,
|
||||||
meta: {
|
meta: {
|
||||||
playType,
|
playType,
|
||||||
|
singleSongOnly: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -200,6 +201,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
|
|||||||
itemType: props.itemType,
|
itemType: props.itemType,
|
||||||
meta: {
|
meta: {
|
||||||
playType,
|
playType,
|
||||||
|
singleSongOnly: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
+5
-5
@@ -7,8 +7,8 @@ import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
|||||||
import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
||||||
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
|
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
|
||||||
|
|
||||||
interface DragDropState {
|
interface DragDropState<TElement extends HTMLElement = HTMLDivElement> {
|
||||||
dragRef: null | React.Ref<HTMLDivElement>;
|
dragRef: null | React.Ref<TElement>;
|
||||||
isDraggedOver: 'bottom' | 'top' | null;
|
isDraggedOver: 'bottom' | 'top' | null;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ interface UseItemDragDropStateProps {
|
|||||||
playlistId?: string;
|
playlistId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useItemDragDropState = ({
|
export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivElement>({
|
||||||
enableDrag,
|
enableDrag,
|
||||||
internalState,
|
internalState,
|
||||||
isDataRow,
|
isDataRow,
|
||||||
@@ -31,14 +31,14 @@ export const useItemDragDropState = ({
|
|||||||
itemType,
|
itemType,
|
||||||
playerContext,
|
playerContext,
|
||||||
playlistId,
|
playlistId,
|
||||||
}: UseItemDragDropStateProps): DragDropState => {
|
}: UseItemDragDropStateProps): DragDropState<TElement> => {
|
||||||
const shouldEnableDrag = enableDrag && isDataRow && !!item;
|
const shouldEnableDrag = enableDrag && isDataRow && !!item;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDraggedOver,
|
isDraggedOver,
|
||||||
isDragging: isDraggingLocal,
|
isDragging: isDraggingLocal,
|
||||||
ref: dragRef,
|
ref: dragRef,
|
||||||
} = useDragDrop<HTMLDivElement>({
|
} = useDragDrop<TElement>({
|
||||||
drag: {
|
drag: {
|
||||||
getId: () => {
|
getId: () => {
|
||||||
if (!item || !isDataRow) {
|
if (!item || !isDataRow) {
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
padding: var(--theme-spacing-xs) var(--theme-spacing-xl);
|
padding: var(--theme-spacing-xs) var(--theme-spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container.no-horizontal-padding {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.container.center {
|
.container.center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -205,6 +210,11 @@
|
|||||||
padding: 0 var(--theme-spacing-xl);
|
padding: 0 var(--theme-spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-container.no-horizontal-padding {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.header-dragging {
|
.header-dragging {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import styles from './item-table-list-column.module.css';
|
|||||||
|
|
||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
import { useItemSelectionState } from '/@/renderer/components/item-list/helpers/item-list-state';
|
import { useItemSelectionState } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||||
|
import { isNoHorizontalPaddingColumn } from '/@/renderer/components/item-list/item-detail-list/utils';
|
||||||
import { ActionsColumn } from '/@/renderer/components/item-list/item-table-list/columns/actions-column';
|
import { ActionsColumn } from '/@/renderer/components/item-list/item-table-list/columns/actions-column';
|
||||||
import { AlbumArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-artists-column';
|
import { AlbumArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-artists-column';
|
||||||
import { AlbumColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-column';
|
import { AlbumColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-column';
|
||||||
@@ -479,6 +480,7 @@ export const TableColumnTextContainer = (
|
|||||||
[styles.dragging]: isDataRow && isDragging,
|
[styles.dragging]: isDataRow && isDragging,
|
||||||
[styles.large]: props.size === 'large',
|
[styles.large]: props.size === 'large',
|
||||||
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
||||||
|
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
|
||||||
[styles.paddingLg]: props.cellPadding === 'lg',
|
[styles.paddingLg]: props.cellPadding === 'lg',
|
||||||
[styles.paddingMd]: props.cellPadding === 'md',
|
[styles.paddingMd]: props.cellPadding === 'md',
|
||||||
[styles.paddingSm]: props.cellPadding === 'sm',
|
[styles.paddingSm]: props.cellPadding === 'sm',
|
||||||
@@ -632,6 +634,7 @@ export const TableColumnContainer = (
|
|||||||
[styles.dragging]: isDataRow && isDragging,
|
[styles.dragging]: isDataRow && isDragging,
|
||||||
[styles.large]: props.size === 'large',
|
[styles.large]: props.size === 'large',
|
||||||
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
||||||
|
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
|
||||||
[styles.paddingLg]: props.cellPadding === 'lg',
|
[styles.paddingLg]: props.cellPadding === 'lg',
|
||||||
[styles.paddingMd]: props.cellPadding === 'md',
|
[styles.paddingMd]: props.cellPadding === 'md',
|
||||||
[styles.paddingSm]: props.cellPadding === 'sm',
|
[styles.paddingSm]: props.cellPadding === 'sm',
|
||||||
@@ -850,6 +853,7 @@ export const TableColumnHeaderContainer = (
|
|||||||
[styles.headerDraggedOverLeft]: isDraggedOver === 'left',
|
[styles.headerDraggedOverLeft]: isDraggedOver === 'left',
|
||||||
[styles.headerDraggedOverRight]: isDraggedOver === 'right',
|
[styles.headerDraggedOverRight]: isDraggedOver === 'right',
|
||||||
[styles.headerDragging]: isDragging,
|
[styles.headerDragging]: isDragging,
|
||||||
|
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
|
||||||
[styles.paddingLg]: props.cellPadding === 'lg',
|
[styles.paddingLg]: props.cellPadding === 'lg',
|
||||||
[styles.paddingMd]: props.cellPadding === 'md',
|
[styles.paddingMd]: props.cellPadding === 'md',
|
||||||
[styles.paddingSm]: props.cellPadding === 'sm',
|
[styles.paddingSm]: props.cellPadding === 'sm',
|
||||||
@@ -881,7 +885,7 @@ export const TableColumnHeaderContainer = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columnLabelMap: Record<TableColumn, ReactNode | string> = {
|
export const columnLabelMap: Record<TableColumn, ReactNode | string> = {
|
||||||
[TableColumn.ACTIONS]: (
|
[TableColumn.ACTIONS]: (
|
||||||
<Flex className={styles.headerIconWrapper}>
|
<Flex className={styles.headerIconWrapper}>
|
||||||
<Icon fill="default" icon="ellipsisHorizontal" />
|
<Icon fill="default" icon="ellipsisHorizontal" />
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export interface ItemListTableComponentProps<TQuery> extends ItemListComponentPr
|
|||||||
enableRowHoverHighlight?: boolean;
|
enableRowHoverHighlight?: boolean;
|
||||||
enableSelection?: boolean;
|
enableSelection?: boolean;
|
||||||
enableVerticalBorders?: boolean;
|
enableVerticalBorders?: boolean;
|
||||||
size?: 'compact' | 'default';
|
size?: 'compact' | 'default' | 'large';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemTableListColumnConfig {
|
export interface ItemTableListColumnConfig {
|
||||||
|
|||||||
@@ -233,8 +233,8 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
|||||||
{metadataItems.map((item, index) => (
|
{metadataItems.map((item, index) => (
|
||||||
<Fragment key={item.id}>
|
<Fragment key={item.id}>
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<Text fw={400} isMuted isNoSelect>
|
<Text isMuted isNoSelect>
|
||||||
•
|
<Separator />
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text fw={400}>{item.value}</Text>
|
<Text fw={400}>{item.value}</Text>
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ const AlbumListPaginatedTable = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const AlbumListInfiniteDetail = lazy(() =>
|
||||||
|
import('/@/renderer/features/albums/components/album-list-infinite-detail').then((module) => ({
|
||||||
|
default: module.AlbumListInfiniteDetail,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlbumListPaginatedDetail = lazy(() =>
|
||||||
|
import('/@/renderer/features/albums/components/album-list-paginated-detail').then((module) => ({
|
||||||
|
default: module.AlbumListPaginatedDetail,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
const AlbumListFilters = () => {
|
const AlbumListFilters = () => {
|
||||||
return (
|
return (
|
||||||
<ListWithSidebarContainer.SidebarPortal>
|
<ListWithSidebarContainer.SidebarPortal>
|
||||||
@@ -62,13 +74,16 @@ export const AlbumListContent = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AlbumListSuspenseContainer = () => {
|
const AlbumListSuspenseContainer = () => {
|
||||||
const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ALBUM);
|
const { detail, display, grid, itemsPerPage, pagination, table } = useListSettings(
|
||||||
|
ItemListKey.ALBUM,
|
||||||
|
);
|
||||||
|
|
||||||
const { customFilters } = useListContext();
|
const { customFilters } = useListContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Spinner container />}>
|
<Suspense fallback={<Spinner container />}>
|
||||||
<AlbumListView
|
<AlbumListView
|
||||||
|
detail={detail}
|
||||||
display={display}
|
display={display}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
@@ -83,13 +98,17 @@ const AlbumListSuspenseContainer = () => {
|
|||||||
export type OverrideAlbumListQuery = Omit<Partial<AlbumListQuery>, 'limit' | 'startIndex'>;
|
export type OverrideAlbumListQuery = Omit<Partial<AlbumListQuery>, 'limit' | 'startIndex'>;
|
||||||
|
|
||||||
export const AlbumListView = ({
|
export const AlbumListView = ({
|
||||||
|
detail,
|
||||||
display,
|
display,
|
||||||
grid,
|
grid,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
overrideQuery,
|
overrideQuery,
|
||||||
pagination,
|
pagination,
|
||||||
table,
|
table,
|
||||||
}: ItemListSettings & { overrideQuery?: OverrideAlbumListQuery }) => {
|
}: ItemListSettings & {
|
||||||
|
detail?: ItemListSettings['detail'];
|
||||||
|
overrideQuery?: OverrideAlbumListQuery;
|
||||||
|
}) => {
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const { pageKey } = useListContext();
|
const { pageKey } = useListContext();
|
||||||
|
|
||||||
@@ -179,6 +198,32 @@ export const AlbumListView = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case ListDisplayType.DETAIL: {
|
||||||
|
switch (pagination) {
|
||||||
|
case ListPaginationType.INFINITE: {
|
||||||
|
return (
|
||||||
|
<AlbumListInfiniteDetail
|
||||||
|
enableHeader={detail?.enableHeader}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
query={mergedQuery}
|
||||||
|
serverId={server.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case ListPaginationType.PAGINATED: {
|
||||||
|
return (
|
||||||
|
<AlbumListPaginatedDetail
|
||||||
|
enableHeader={detail?.enableHeader}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
query={mergedQuery}
|
||||||
|
serverId={server.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
import {
|
||||||
|
ALBUM_TABLE_COLUMNS,
|
||||||
|
SONG_TABLE_COLUMNS,
|
||||||
|
} from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||||
import { useListContext } from '/@/renderer/context/list-context';
|
import { useListContext } from '/@/renderer/context/list-context';
|
||||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
@@ -92,8 +95,15 @@ export const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarge
|
|||||||
<ListRefreshButton listKey={pageKey as ItemListKey} />
|
<ListRefreshButton listKey={pageKey as ItemListKey} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<ListDisplayTypeToggleButton listKey={ItemListKey.ALBUM} />
|
<ListDisplayTypeToggleButton enableDetail listKey={ItemListKey.ALBUM} />
|
||||||
<ListConfigMenu
|
<ListConfigMenu
|
||||||
|
detailConfig={{
|
||||||
|
optionsConfig: {
|
||||||
|
autoFitColumns: { hidden: true },
|
||||||
|
},
|
||||||
|
tableColumnsData: SONG_TABLE_COLUMNS,
|
||||||
|
tableKey: 'detail',
|
||||||
|
}}
|
||||||
listKey={ItemListKey.ALBUM}
|
listKey={ItemListKey.ALBUM}
|
||||||
tableColumnsData={ALBUM_TABLE_COLUMNS}
|
tableColumnsData={ALBUM_TABLE_COLUMNS}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { UseSuspenseQueryOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';
|
||||||
|
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||||
|
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||||
|
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
|
||||||
|
import { ItemListComponentProps } from '/@/renderer/components/item-list/types';
|
||||||
|
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||||
|
import {
|
||||||
|
AlbumListQuery,
|
||||||
|
AlbumListSort,
|
||||||
|
LibraryItem,
|
||||||
|
SortOrder,
|
||||||
|
} from '/@/shared/types/domain-types';
|
||||||
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
interface AlbumListInfiniteDetailProps extends ItemListComponentProps<AlbumListQuery> {
|
||||||
|
enableHeader?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlbumListInfiniteDetail = ({
|
||||||
|
enableHeader = true,
|
||||||
|
itemsPerPage = 100,
|
||||||
|
query = {
|
||||||
|
sortBy: AlbumListSort.NAME,
|
||||||
|
sortOrder: SortOrder.ASC,
|
||||||
|
},
|
||||||
|
serverId,
|
||||||
|
}: AlbumListInfiniteDetailProps) => {
|
||||||
|
const listCountQuery = albumQueries.listCount({
|
||||||
|
query: { ...query },
|
||||||
|
serverId: serverId,
|
||||||
|
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||||
|
|
||||||
|
const listQueryFn = api.controller.getAlbumList;
|
||||||
|
|
||||||
|
const { handleColumnReordered } = useItemListColumnReorder({
|
||||||
|
itemListKey: ItemListKey.ALBUM,
|
||||||
|
tableKey: 'detail',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleColumnResized } = useItemListColumnResize({
|
||||||
|
itemListKey: ItemListKey.ALBUM,
|
||||||
|
tableKey: 'detail',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getItem, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({
|
||||||
|
eventKey: ItemListKey.ALBUM,
|
||||||
|
itemsPerPage,
|
||||||
|
itemType: LibraryItem.ALBUM,
|
||||||
|
listCountQuery,
|
||||||
|
listQueryFn,
|
||||||
|
query,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemDetailList
|
||||||
|
data={loadedItems}
|
||||||
|
enableHeader={enableHeader}
|
||||||
|
getItem={getItem}
|
||||||
|
itemCount={itemCount}
|
||||||
|
onColumnReordered={handleColumnReordered}
|
||||||
|
onColumnResized={handleColumnResized}
|
||||||
|
onRangeChanged={onRangeChanged}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { UseSuspenseQueryOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';
|
||||||
|
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
||||||
|
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
||||||
|
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
|
||||||
|
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
|
||||||
|
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
|
||||||
|
import { ItemListComponentProps } from '/@/renderer/components/item-list/types';
|
||||||
|
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||||
|
import {
|
||||||
|
AlbumListQuery,
|
||||||
|
AlbumListSort,
|
||||||
|
LibraryItem,
|
||||||
|
SortOrder,
|
||||||
|
} from '/@/shared/types/domain-types';
|
||||||
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
interface AlbumListPaginatedDetailProps extends ItemListComponentProps<AlbumListQuery> {
|
||||||
|
enableHeader?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlbumListPaginatedDetail = ({
|
||||||
|
enableHeader = true,
|
||||||
|
itemsPerPage = 100,
|
||||||
|
query = {
|
||||||
|
sortBy: AlbumListSort.NAME,
|
||||||
|
sortOrder: SortOrder.ASC,
|
||||||
|
},
|
||||||
|
serverId,
|
||||||
|
}: AlbumListPaginatedDetailProps) => {
|
||||||
|
const listCountQuery = albumQueries.listCount({
|
||||||
|
query: { ...query },
|
||||||
|
serverId: serverId,
|
||||||
|
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
||||||
|
|
||||||
|
const listQueryFn = api.controller.getAlbumList;
|
||||||
|
|
||||||
|
const { handleColumnReordered } = useItemListColumnReorder({
|
||||||
|
itemListKey: ItemListKey.ALBUM,
|
||||||
|
tableKey: 'detail',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleColumnResized } = useItemListColumnResize({
|
||||||
|
itemListKey: ItemListKey.ALBUM,
|
||||||
|
tableKey: 'detail',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { currentPage, onChange } = useItemListPagination();
|
||||||
|
|
||||||
|
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
|
||||||
|
currentPage,
|
||||||
|
eventKey: ItemListKey.ALBUM,
|
||||||
|
itemsPerPage,
|
||||||
|
itemType: LibraryItem.ALBUM,
|
||||||
|
listCountQuery,
|
||||||
|
listQueryFn,
|
||||||
|
query,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemListWithPagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
onChange={onChange}
|
||||||
|
pageCount={pageCount}
|
||||||
|
totalItemCount={totalItemCount}
|
||||||
|
>
|
||||||
|
<ItemDetailList
|
||||||
|
currentPage={currentPage}
|
||||||
|
enableHeader={enableHeader}
|
||||||
|
items={data || []}
|
||||||
|
onColumnReordered={handleColumnReordered}
|
||||||
|
onColumnResized={handleColumnResized}
|
||||||
|
/>
|
||||||
|
</ItemListWithPagination>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,21 +1,28 @@
|
|||||||
import { Fragment } from 'react';
|
import { Fragment, memo } from 'react';
|
||||||
import { generatePath, Link } from 'react-router';
|
import { generatePath, Link } from 'react-router';
|
||||||
|
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { Text, TextProps } from '/@/shared/components/text/text';
|
import { Text, TextProps } from '/@/shared/components/text/text';
|
||||||
import { AlbumArtist, RelatedAlbumArtist, RelatedArtist } from '/@/shared/types/domain-types';
|
import { AlbumArtist, RelatedAlbumArtist, RelatedArtist } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
export const JOINED_ARTISTS_MUTED_PROPS = {
|
||||||
|
linkProps: { fw: 400, isMuted: true },
|
||||||
|
rootTextProps: { fw: 400, isMuted: true, size: 'sm' as const },
|
||||||
|
} as const;
|
||||||
|
|
||||||
interface JoinedArtistsProps {
|
interface JoinedArtistsProps {
|
||||||
artistName: string;
|
artistName: string;
|
||||||
artists: AlbumArtist[] | RelatedAlbumArtist[] | RelatedArtist[];
|
artists: AlbumArtist[] | RelatedAlbumArtist[] | RelatedArtist[];
|
||||||
linkProps?: Partial<Omit<TextProps, 'children' | 'component' | 'to'>>;
|
linkProps?: Partial<Omit<TextProps, 'children' | 'component' | 'to'>>;
|
||||||
|
readOnly?: boolean;
|
||||||
rootTextProps?: Partial<Omit<TextProps, 'children' | 'component'>>;
|
rootTextProps?: Partial<Omit<TextProps, 'children' | 'component'>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JoinedArtists = ({
|
const JoinedArtistsComponent = ({
|
||||||
artistName,
|
artistName,
|
||||||
artists,
|
artists,
|
||||||
linkProps,
|
linkProps,
|
||||||
|
readOnly = false,
|
||||||
rootTextProps,
|
rootTextProps,
|
||||||
}: JoinedArtistsProps) => {
|
}: JoinedArtistsProps) => {
|
||||||
const parts: (
|
const parts: (
|
||||||
@@ -111,7 +118,7 @@ export const JoinedArtists = ({
|
|||||||
{artists.map((artist, index) => (
|
{artists.map((artist, index) => (
|
||||||
<Fragment key={artist.id || `artist-${index}`}>
|
<Fragment key={artist.id || `artist-${index}`}>
|
||||||
{index > 0 && ', '}
|
{index > 0 && ', '}
|
||||||
{artist.id ? (
|
{artist.id && !readOnly ? (
|
||||||
<Text
|
<Text
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
@@ -124,7 +131,7 @@ export const JoinedArtists = ({
|
|||||||
{artist.name}
|
{artist.name}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text fw={500} {...linkProps}>
|
<Text component="span" fw={500} {...linkProps}>
|
||||||
{artist.name}
|
{artist.name}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -152,7 +159,7 @@ export const JoinedArtists = ({
|
|||||||
|
|
||||||
const { artist, text } = part;
|
const { artist, text } = part;
|
||||||
|
|
||||||
if (artist.id) {
|
if (artist.id && !readOnly) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
component={Link}
|
component={Link}
|
||||||
@@ -169,7 +176,7 @@ export const JoinedArtists = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Text fw={500} key={`${artist.name}-${index}`} {...linkProps}>
|
<Text component="span" fw={500} key={`${artist.name}-${index}`} {...linkProps}>
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -180,7 +187,7 @@ export const JoinedArtists = ({
|
|||||||
{unmatchedArtists.map((artist, index) => (
|
{unmatchedArtists.map((artist, index) => (
|
||||||
<Fragment key={artist.id}>
|
<Fragment key={artist.id}>
|
||||||
{index > 0 && ', '}
|
{index > 0 && ', '}
|
||||||
{artist.id ? (
|
{artist.id && !readOnly ? (
|
||||||
<Text
|
<Text
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
@@ -192,6 +199,10 @@ export const JoinedArtists = ({
|
|||||||
>
|
>
|
||||||
{artist.name}
|
{artist.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : artist.id ? (
|
||||||
|
<Text component="span" fw={500} {...linkProps}>
|
||||||
|
{artist.name}
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text component="span" isMuted>
|
<Text component="span" isMuted>
|
||||||
{artist.name}
|
{artist.name}
|
||||||
@@ -205,6 +216,8 @@ export const JoinedArtists = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const JoinedArtists = memo(JoinedArtistsComponent);
|
||||||
|
|
||||||
function escapeRegex(str: string): string {
|
function escapeRegex(str: string): string {
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { AppRoute } from '/@/renderer/router/routes';
|
|||||||
import { useCurrentServer, useShowRatings } from '/@/renderer/store';
|
import { useCurrentServer, useShowRatings } from '/@/renderer/store';
|
||||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||||
import { formatDurationString } from '/@/renderer/utils';
|
import { formatDurationString } from '/@/renderer/utils';
|
||||||
|
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
@@ -160,7 +161,11 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
|
|||||||
.filter((i) => i.enabled)
|
.filter((i) => i.enabled)
|
||||||
.map((item, index) => (
|
.map((item, index) => (
|
||||||
<Fragment key={`item-${item.id}-${index}`}>
|
<Fragment key={`item-${item.id}-${index}`}>
|
||||||
{index > 0 && <Text isNoSelect>•</Text>}
|
{index > 0 && (
|
||||||
|
<Text isMuted isNoSelect>
|
||||||
|
{SEPARATOR_STRING}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Text isMuted={item.secondary}>{item.value}</Text>
|
<Text isMuted={item.secondary}>{item.value}</Text>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { usePlayerSong } from '/@/renderer/store';
|
import { usePlayerSong } from '/@/renderer/store';
|
||||||
import { QueueSong } from '/@/shared/types/domain-types';
|
import { QueueSong, Song } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export const useIsCurrentSong = (song: QueueSong) => {
|
export const useIsCurrentSong = (song: QueueSong | Song) => {
|
||||||
const currentSong = usePlayerSong();
|
const currentSong = usePlayerSong();
|
||||||
|
|
||||||
const isActive = useMemo(() => {
|
const isActive = useMemo(() => {
|
||||||
return song._uniqueId === currentSong?._uniqueId;
|
const queueSong = song as QueueSong;
|
||||||
}, [song._uniqueId, currentSong?._uniqueId]);
|
|
||||||
|
if (queueSong._uniqueId != null && queueSong._uniqueId !== '') {
|
||||||
|
return queueSong._uniqueId === currentSong?._uniqueId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return song.id === currentSong?.id;
|
||||||
|
}, [song, currentSong?.id, currentSong?._uniqueId]);
|
||||||
|
|
||||||
return { isActive };
|
return { isActive };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ export const DisplayTypeToggleButton = ({
|
|||||||
}: DisplayTypeToggleButtonProps) => {
|
}: DisplayTypeToggleButtonProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isGrid = displayType === ListDisplayType.GRID;
|
const isGrid = displayType === ListDisplayType.GRID;
|
||||||
|
const isDetail = displayType === ListDisplayType.DETAIL;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon={isGrid ? 'layoutGrid' : 'layoutTable'}
|
icon={isGrid ? 'layoutGrid' : isDetail ? 'layoutDetail' : 'layoutTable'}
|
||||||
iconProps={{
|
iconProps={{
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
}}
|
}}
|
||||||
@@ -27,7 +28,9 @@ export const DisplayTypeToggleButton = ({
|
|||||||
tooltip={{
|
tooltip={{
|
||||||
label: isGrid
|
label: isGrid
|
||||||
? t('table.config.view.grid', { postProcess: 'sentenceCase' })
|
? t('table.config.view.grid', { postProcess: 'sentenceCase' })
|
||||||
: t('table.config.view.table', { postProcess: 'sentenceCase' }),
|
: isDetail
|
||||||
|
? t('table.config.view.detail', { postProcess: 'sentenceCase' })
|
||||||
|
: t('table.config.view.table', { postProcess: 'sentenceCase' }),
|
||||||
}}
|
}}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ const DISPLAY_TYPES = [
|
|||||||
),
|
),
|
||||||
value: ListDisplayType.GRID,
|
value: ListDisplayType.GRID,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<Group align="center" justify="center" p="sm">
|
||||||
|
<Icon icon="layoutDetail" size="lg" />
|
||||||
|
{i18n.t('table.config.view.detail', { postProcess: 'sentenceCase' }) as string}
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
value: ListDisplayType.DETAIL,
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// disabled: true,
|
// disabled: true,
|
||||||
// label: (
|
// label: (
|
||||||
@@ -63,6 +72,12 @@ export const ListConfigBooleanControl = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ListConfigMenuDetailConfig {
|
||||||
|
optionsConfig?: ListConfigMenuOptionsConfig['detail'];
|
||||||
|
tableColumnsData: { label: string; value: string }[];
|
||||||
|
tableKey: 'detail';
|
||||||
|
}
|
||||||
|
|
||||||
export interface ListConfigMenuDisplayTypeConfig {
|
export interface ListConfigMenuDisplayTypeConfig {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
@@ -75,6 +90,9 @@ export interface ListConfigMenuOptionConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ListConfigMenuOptionsConfig {
|
export interface ListConfigMenuOptionsConfig {
|
||||||
|
detail?: {
|
||||||
|
[key: string]: ListConfigMenuOptionConfig;
|
||||||
|
};
|
||||||
grid?: {
|
grid?: {
|
||||||
[key: string]: ListConfigMenuOptionConfig;
|
[key: string]: ListConfigMenuOptionConfig;
|
||||||
};
|
};
|
||||||
@@ -85,6 +103,7 @@ export interface ListConfigMenuOptionsConfig {
|
|||||||
|
|
||||||
interface ListConfigMenuProps {
|
interface ListConfigMenuProps {
|
||||||
buttonProps?: ActionIconProps;
|
buttonProps?: ActionIconProps;
|
||||||
|
detailConfig?: ListConfigMenuDetailConfig;
|
||||||
displayTypes?: ListConfigMenuDisplayTypeConfig[];
|
displayTypes?: ListConfigMenuDisplayTypeConfig[];
|
||||||
listKey: ItemListKey;
|
listKey: ItemListKey;
|
||||||
optionsConfig?: ListConfigMenuOptionsConfig;
|
optionsConfig?: ListConfigMenuOptionsConfig;
|
||||||
@@ -172,6 +191,20 @@ const Config = ({
|
|||||||
...props
|
...props
|
||||||
}: ListConfigMenuProps & { displayType: ListDisplayType }) => {
|
}: ListConfigMenuProps & { displayType: ListDisplayType }) => {
|
||||||
switch (displayType) {
|
switch (displayType) {
|
||||||
|
case ListDisplayType.DETAIL:
|
||||||
|
if (props.detailConfig) {
|
||||||
|
return (
|
||||||
|
<TableConfig
|
||||||
|
enablePinColumnButtons={false}
|
||||||
|
listKey={props.listKey}
|
||||||
|
optionsConfig={props.detailConfig.optionsConfig}
|
||||||
|
tableColumnsData={props.detailConfig.tableColumnsData}
|
||||||
|
tableKey="detail"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
case ListDisplayType.GRID:
|
case ListDisplayType.GRID:
|
||||||
return (
|
return (
|
||||||
<GridConfig
|
<GridConfig
|
||||||
|
|||||||
@@ -3,21 +3,47 @@ import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';
|
|||||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface ListDisplayTypeToggleButtonProps {
|
interface ListDisplayTypeToggleButtonProps {
|
||||||
|
enableDetail?: boolean;
|
||||||
listKey: ItemListKey;
|
listKey: ItemListKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ListDisplayTypeToggleButton = ({ listKey }: ListDisplayTypeToggleButtonProps) => {
|
export const ListDisplayTypeToggleButton = ({
|
||||||
|
enableDetail = false,
|
||||||
|
listKey,
|
||||||
|
}: ListDisplayTypeToggleButtonProps) => {
|
||||||
const displayType = useSettingsStore(
|
const displayType = useSettingsStore(
|
||||||
(state) => state.lists[listKey]?.display,
|
(state) => state.lists[listKey]?.display,
|
||||||
) as ListDisplayType;
|
) as ListDisplayType;
|
||||||
const { setList } = useSettingsStoreActions();
|
const { setList } = useSettingsStoreActions();
|
||||||
|
|
||||||
const handleToggleDisplayType = () => {
|
const handleToggleDisplayType = () => {
|
||||||
const newDisplayType =
|
let newDisplayType: ListDisplayType;
|
||||||
displayType === ListDisplayType.GRID ? ListDisplayType.TABLE : ListDisplayType.GRID;
|
|
||||||
|
if (enableDetail) {
|
||||||
|
if (displayType === ListDisplayType.DETAIL) {
|
||||||
|
newDisplayType = ListDisplayType.TABLE;
|
||||||
|
} else if (displayType === ListDisplayType.TABLE) {
|
||||||
|
newDisplayType = ListDisplayType.GRID;
|
||||||
|
} else if (displayType === ListDisplayType.GRID) {
|
||||||
|
newDisplayType = ListDisplayType.DETAIL;
|
||||||
|
} else {
|
||||||
|
newDisplayType = ListDisplayType.GRID;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (displayType === ListDisplayType.GRID) {
|
||||||
|
newDisplayType = ListDisplayType.TABLE;
|
||||||
|
} else if (displayType === ListDisplayType.TABLE) {
|
||||||
|
newDisplayType = ListDisplayType.GRID;
|
||||||
|
} else {
|
||||||
|
newDisplayType = ListDisplayType.GRID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setList(listKey, {
|
setList(listKey, {
|
||||||
display: newDisplayType,
|
display: newDisplayType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
return <DisplayTypeToggleButton displayType={displayType} onToggle={handleToggleDisplayType} />;
|
return <DisplayTypeToggleButton displayType={displayType} onToggle={handleToggleDisplayType} />;
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ import {
|
|||||||
ListConfigBooleanControl,
|
ListConfigBooleanControl,
|
||||||
ListConfigTable,
|
ListConfigTable,
|
||||||
} from '/@/renderer/features/shared/components/list-config-menu';
|
} from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
import { ItemListSettings, useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';
|
import {
|
||||||
|
type DataTableProps,
|
||||||
|
ItemListSettings,
|
||||||
|
useSettingsStore,
|
||||||
|
useSettingsStoreActions,
|
||||||
|
} from '/@/renderer/store';
|
||||||
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Badge } from '/@/shared/components/badge/badge';
|
import { Badge } from '/@/shared/components/badge/badge';
|
||||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||||
@@ -39,6 +44,7 @@ import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/d
|
|||||||
import { ItemListKey, ListPaginationType } from '/@/shared/types/types';
|
import { ItemListKey, ListPaginationType } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface TableConfigProps {
|
interface TableConfigProps {
|
||||||
|
enablePinColumnButtons?: boolean;
|
||||||
extraOptions?: {
|
extraOptions?: {
|
||||||
component: React.ReactNode;
|
component: React.ReactNode;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,19 +58,37 @@ interface TableConfigProps {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
tableColumnsData: { label: string; value: string }[];
|
tableColumnsData: { label: string; value: string }[];
|
||||||
|
tableKey?: 'detail' | 'main';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableConfig = ({
|
export const TableConfig = ({
|
||||||
|
enablePinColumnButtons = true,
|
||||||
extraOptions,
|
extraOptions,
|
||||||
listKey,
|
listKey,
|
||||||
optionsConfig,
|
optionsConfig,
|
||||||
tableColumnsData,
|
tableColumnsData,
|
||||||
|
tableKey = 'main',
|
||||||
}: TableConfigProps) => {
|
}: TableConfigProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const list = useSettingsStore((state) => state.lists[listKey]) as ItemListSettings;
|
const list = useSettingsStore((state) => state.lists[listKey]) as ItemListSettings;
|
||||||
const { setList } = useSettingsStoreActions();
|
const { setList } = useSettingsStoreActions();
|
||||||
|
|
||||||
|
const table = tableKey === 'detail' ? (list?.detail ?? list?.table) : list?.table;
|
||||||
|
|
||||||
|
const setTableUpdate = useCallback(
|
||||||
|
(patch: Partial<DataTableProps>) => {
|
||||||
|
if (tableKey === 'detail') {
|
||||||
|
setList(listKey, { detail: patch } as Parameters<
|
||||||
|
ReturnType<typeof useSettingsStoreActions>['setList']
|
||||||
|
>[1]);
|
||||||
|
} else {
|
||||||
|
setList(listKey, { table: patch });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[listKey, setList, tableKey],
|
||||||
|
);
|
||||||
|
|
||||||
const advancedSettings = useMemo(() => {
|
const advancedSettings = useMemo(() => {
|
||||||
const allOptions = [
|
const allOptions = [
|
||||||
{
|
{
|
||||||
@@ -152,12 +176,12 @@ export const TableConfig = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setList(listKey, {
|
setTableUpdate({
|
||||||
table: { size: value as 'compact' | 'default' },
|
size: value as 'compact' | 'default' | 'large',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
value={list.table.size}
|
value={table?.size ?? 'default'}
|
||||||
w="100%"
|
w="100%"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -169,8 +193,8 @@ export const TableConfig = ({
|
|||||||
{
|
{
|
||||||
component: (
|
component: (
|
||||||
<ListConfigBooleanControl
|
<ListConfigBooleanControl
|
||||||
onChange={(e) => setList(listKey, { table: { enableHeader: e } })}
|
onChange={(e) => setTableUpdate({ enableHeader: e })}
|
||||||
value={list.table.enableHeader}
|
value={table.enableHeader}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
id: 'enableHeader',
|
id: 'enableHeader',
|
||||||
@@ -181,10 +205,8 @@ export const TableConfig = ({
|
|||||||
{
|
{
|
||||||
component: (
|
component: (
|
||||||
<ListConfigBooleanControl
|
<ListConfigBooleanControl
|
||||||
onChange={(e) =>
|
onChange={(e) => setTableUpdate({ enableRowHoverHighlight: e })}
|
||||||
setList(listKey, { table: { enableRowHoverHighlight: e } })
|
value={table.enableRowHoverHighlight}
|
||||||
}
|
|
||||||
value={list.table.enableRowHoverHighlight}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
id: 'enableRowHoverHighlight',
|
id: 'enableRowHoverHighlight',
|
||||||
@@ -195,10 +217,8 @@ export const TableConfig = ({
|
|||||||
{
|
{
|
||||||
component: (
|
component: (
|
||||||
<ListConfigBooleanControl
|
<ListConfigBooleanControl
|
||||||
onChange={(e) =>
|
onChange={(e) => setTableUpdate({ enableAlternateRowColors: e })}
|
||||||
setList(listKey, { table: { enableAlternateRowColors: e } })
|
value={table.enableAlternateRowColors}
|
||||||
}
|
|
||||||
value={list.table.enableAlternateRowColors}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
id: 'enableAlternateRowColors',
|
id: 'enableAlternateRowColors',
|
||||||
@@ -209,10 +229,8 @@ export const TableConfig = ({
|
|||||||
{
|
{
|
||||||
component: (
|
component: (
|
||||||
<ListConfigBooleanControl
|
<ListConfigBooleanControl
|
||||||
onChange={(e) =>
|
onChange={(e) => setTableUpdate({ enableHorizontalBorders: e })}
|
||||||
setList(listKey, { table: { enableHorizontalBorders: e } })
|
value={table.enableHorizontalBorders}
|
||||||
}
|
|
||||||
value={list.table.enableHorizontalBorders}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
id: 'enableHorizontalBorders',
|
id: 'enableHorizontalBorders',
|
||||||
@@ -223,8 +241,8 @@ export const TableConfig = ({
|
|||||||
{
|
{
|
||||||
component: (
|
component: (
|
||||||
<ListConfigBooleanControl
|
<ListConfigBooleanControl
|
||||||
onChange={(e) => setList(listKey, { table: { enableVerticalBorders: e } })}
|
onChange={(e) => setTableUpdate({ enableVerticalBorders: e })}
|
||||||
value={list.table.enableVerticalBorders}
|
value={table.enableVerticalBorders}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
id: 'enableVerticalBorders',
|
id: 'enableVerticalBorders',
|
||||||
@@ -235,8 +253,10 @@ export const TableConfig = ({
|
|||||||
{
|
{
|
||||||
component: (
|
component: (
|
||||||
<ListConfigBooleanControl
|
<ListConfigBooleanControl
|
||||||
onChange={(e) => setList(listKey, { table: { autoFitColumns: e } })}
|
onChange={(e) => setTableUpdate({ autoFitColumns: e })}
|
||||||
value={list.table.autoFitColumns}
|
value={
|
||||||
|
tableKey === 'main' ? (table as DataTableProps).autoFitColumns : false
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
id: 'autoFitColumns',
|
id: 'autoFitColumns',
|
||||||
@@ -256,7 +276,18 @@ export const TableConfig = ({
|
|||||||
return option;
|
return option;
|
||||||
})
|
})
|
||||||
.filter((option): option is NonNullable<typeof option> => option !== null);
|
.filter((option): option is NonNullable<typeof option> => option !== null);
|
||||||
}, [extraOptions, listKey, optionsConfig, setList, t, list]);
|
}, [
|
||||||
|
t,
|
||||||
|
list.pagination,
|
||||||
|
list.itemsPerPage,
|
||||||
|
table,
|
||||||
|
tableKey,
|
||||||
|
extraOptions,
|
||||||
|
setList,
|
||||||
|
listKey,
|
||||||
|
setTableUpdate,
|
||||||
|
optionsConfig,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -264,8 +295,9 @@ export const TableConfig = ({
|
|||||||
<Divider />
|
<Divider />
|
||||||
<TableColumnConfig
|
<TableColumnConfig
|
||||||
data={tableColumnsData}
|
data={tableColumnsData}
|
||||||
onChange={(columns) => setList(listKey, { table: { columns } })}
|
enablePinColumnButtons={enablePinColumnButtons}
|
||||||
value={list.table.columns}
|
onChange={(columns) => setTableUpdate({ columns })}
|
||||||
|
value={table.columns}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -273,10 +305,12 @@ export const TableConfig = ({
|
|||||||
|
|
||||||
const TableColumnConfig = ({
|
const TableColumnConfig = ({
|
||||||
data,
|
data,
|
||||||
|
enablePinColumnButtons,
|
||||||
onChange,
|
onChange,
|
||||||
value,
|
value,
|
||||||
}: {
|
}: {
|
||||||
data: { label: string; value: string }[];
|
data: { label: string; value: string }[];
|
||||||
|
enablePinColumnButtons: boolean;
|
||||||
onChange: (value: ItemTableListColumnConfig[]) => void;
|
onChange: (value: ItemTableListColumnConfig[]) => void;
|
||||||
value: ItemTableListColumnConfig[];
|
value: ItemTableListColumnConfig[];
|
||||||
}) => {
|
}) => {
|
||||||
@@ -473,6 +507,7 @@ const TableColumnConfig = ({
|
|||||||
<div style={{ userSelect: 'none' }}>
|
<div style={{ userSelect: 'none' }}>
|
||||||
{filteredColumns.map(({ item, matches }) => (
|
{filteredColumns.map(({ item, matches }) => (
|
||||||
<TableColumnItem
|
<TableColumnItem
|
||||||
|
enablePinColumnButtons={enablePinColumnButtons}
|
||||||
handleAlignCenter={handleAlignCenter}
|
handleAlignCenter={handleAlignCenter}
|
||||||
handleAlignLeft={handleAlignLeft}
|
handleAlignLeft={handleAlignLeft}
|
||||||
handleAlignRight={handleAlignRight}
|
handleAlignRight={handleAlignRight}
|
||||||
@@ -516,6 +551,7 @@ const DragHandle = ({
|
|||||||
|
|
||||||
const TableColumnItem = memo(
|
const TableColumnItem = memo(
|
||||||
({
|
({
|
||||||
|
enablePinColumnButtons,
|
||||||
handleAlignCenter,
|
handleAlignCenter,
|
||||||
handleAlignLeft,
|
handleAlignLeft,
|
||||||
handleAlignRight,
|
handleAlignRight,
|
||||||
@@ -531,6 +567,7 @@ const TableColumnItem = memo(
|
|||||||
label,
|
label,
|
||||||
matches,
|
matches,
|
||||||
}: {
|
}: {
|
||||||
|
enablePinColumnButtons: boolean;
|
||||||
handleAlignCenter: (item: ItemTableListColumnConfig) => void;
|
handleAlignCenter: (item: ItemTableListColumnConfig) => void;
|
||||||
handleAlignLeft: (item: ItemTableListColumnConfig) => void;
|
handleAlignLeft: (item: ItemTableListColumnConfig) => void;
|
||||||
handleAlignRight: (item: ItemTableListColumnConfig) => void;
|
handleAlignRight: (item: ItemTableListColumnConfig) => void;
|
||||||
@@ -667,32 +704,34 @@ const TableColumnItem = memo(
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
/>
|
/>
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
<ActionIconGroup className={styles.group}>
|
{enablePinColumnButtons && (
|
||||||
<ActionIcon
|
<ActionIconGroup className={styles.group}>
|
||||||
icon="arrowLeftToLine"
|
<ActionIcon
|
||||||
iconProps={{ size: 'md' }}
|
icon="arrowLeftToLine"
|
||||||
onClick={() => handlePinToLeft(item)}
|
iconProps={{ size: 'md' }}
|
||||||
size="xs"
|
onClick={() => handlePinToLeft(item)}
|
||||||
tooltip={{
|
size="xs"
|
||||||
label: t('table.config.general.pinToLeft', {
|
tooltip={{
|
||||||
postProcess: 'sentenceCase',
|
label: t('table.config.general.pinToLeft', {
|
||||||
}),
|
postProcess: 'sentenceCase',
|
||||||
}}
|
}),
|
||||||
variant={item.pinned === 'left' ? 'filled' : 'subtle'}
|
}}
|
||||||
/>
|
variant={item.pinned === 'left' ? 'filled' : 'subtle'}
|
||||||
<ActionIcon
|
/>
|
||||||
icon="arrowRightToLine"
|
<ActionIcon
|
||||||
iconProps={{ size: 'md' }}
|
icon="arrowRightToLine"
|
||||||
onClick={() => handlePinToRight(item)}
|
iconProps={{ size: 'md' }}
|
||||||
size="xs"
|
onClick={() => handlePinToRight(item)}
|
||||||
tooltip={{
|
size="xs"
|
||||||
label: t('table.config.general.pinToRight', {
|
tooltip={{
|
||||||
postProcess: 'sentenceCase',
|
label: t('table.config.general.pinToRight', {
|
||||||
}),
|
postProcess: 'sentenceCase',
|
||||||
}}
|
}),
|
||||||
variant={item.pinned === 'right' ? 'filled' : 'subtle'}
|
}}
|
||||||
/>
|
variant={item.pinned === 'right' ? 'filled' : 'subtle'}
|
||||||
</ActionIconGroup>
|
/>
|
||||||
|
</ActionIconGroup>
|
||||||
|
)}
|
||||||
<ActionIconGroup className={styles.group}>
|
<ActionIconGroup className={styles.group}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="alignLeft"
|
icon="alignLeft"
|
||||||
@@ -772,6 +811,7 @@ const TableColumnItem = memo(
|
|||||||
(prevProps, nextProps) => {
|
(prevProps, nextProps) => {
|
||||||
// Custom comparison function for better memoization
|
// Custom comparison function for better memoization
|
||||||
return (
|
return (
|
||||||
|
prevProps.enablePinColumnButtons === nextProps.enablePinColumnButtons &&
|
||||||
prevProps.item.id === nextProps.item.id &&
|
prevProps.item.id === nextProps.item.id &&
|
||||||
prevProps.item.isEnabled === nextProps.item.isEnabled &&
|
prevProps.item.isEnabled === nextProps.item.isEnabled &&
|
||||||
prevProps.item.autoSize === nextProps.item.autoSize &&
|
prevProps.item.autoSize === nextProps.item.autoSize &&
|
||||||
|
|||||||
@@ -520,6 +520,28 @@ export const applyFavoriteOptimisticUpdates = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const songListQueryKey = queryKeys.songs.list(variables.apiClientProps.serverId);
|
||||||
|
const songListQueries = queryClient.getQueriesData({
|
||||||
|
exact: false,
|
||||||
|
queryKey: songListQueryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
songListQueries.forEach(([queryKey, data]) => {
|
||||||
|
if (data) {
|
||||||
|
pendingUpdates.push({
|
||||||
|
previousData: data,
|
||||||
|
queryKey,
|
||||||
|
updater: (prev: undefined | { items: Song[] }) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) =>
|
||||||
|
createFavoriteUpdater<Song>(item),
|
||||||
|
);
|
||||||
|
return updatedItems ? { ...prev, items: updatedItems } : prev;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const topSongsQueryKey = queryKeys.albumArtists.topSongs(
|
const topSongsQueryKey = queryKeys.albumArtists.topSongs(
|
||||||
variables.apiClientProps.serverId,
|
variables.apiClientProps.serverId,
|
||||||
);
|
);
|
||||||
@@ -679,6 +701,7 @@ export const applyFavoriteOptimisticUpdatesDeferred = (
|
|||||||
queryKeys.playlists.songList(variables.apiClientProps.serverId),
|
queryKeys.playlists.songList(variables.apiClientProps.serverId),
|
||||||
'playlist-song-list',
|
'playlist-song-list',
|
||||||
);
|
);
|
||||||
|
collectQueries(queryKeys.songs.list(variables.apiClientProps.serverId), 'song-list');
|
||||||
collectQueries(
|
collectQueries(
|
||||||
queryKeys.albumArtists.topSongs(variables.apiClientProps.serverId),
|
queryKeys.albumArtists.topSongs(variables.apiClientProps.serverId),
|
||||||
'top-songs',
|
'top-songs',
|
||||||
@@ -742,6 +765,7 @@ export const applyFavoriteOptimisticUpdatesDeferred = (
|
|||||||
case 'album-list':
|
case 'album-list':
|
||||||
case 'artist-list':
|
case 'artist-list':
|
||||||
case 'playlist-song-list':
|
case 'playlist-song-list':
|
||||||
|
case 'song-list':
|
||||||
case 'top-songs': {
|
case 'top-songs': {
|
||||||
const updatedItems = updateItemInArray(
|
const updatedItems = updateItemInArray(
|
||||||
prev.items || [],
|
prev.items || [],
|
||||||
|
|||||||
@@ -519,6 +519,28 @@ export const applyRatingOptimisticUpdates = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const songListQueryKey = queryKeys.songs.list(variables.apiClientProps.serverId);
|
||||||
|
const songListQueries = queryClient.getQueriesData({
|
||||||
|
exact: false,
|
||||||
|
queryKey: songListQueryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
songListQueries.forEach(([queryKey, data]) => {
|
||||||
|
if (data) {
|
||||||
|
pendingUpdates.push({
|
||||||
|
previousData: data,
|
||||||
|
queryKey,
|
||||||
|
updater: (prev: undefined | { items: Song[] }) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const updatedItems = updateItemInArray(prev.items, itemIdSet, (item) =>
|
||||||
|
createRatingUpdater<Song>(item),
|
||||||
|
);
|
||||||
|
return updatedItems ? { ...prev, items: updatedItems } : prev;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const topSongsQueryKey = queryKeys.albumArtists.topSongs(
|
const topSongsQueryKey = queryKeys.albumArtists.topSongs(
|
||||||
variables.apiClientProps.serverId,
|
variables.apiClientProps.serverId,
|
||||||
);
|
);
|
||||||
@@ -652,6 +674,7 @@ export const applyRatingOptimisticUpdatesDeferred = (
|
|||||||
queryKeys.songs.detail(variables.apiClientProps.serverId),
|
queryKeys.songs.detail(variables.apiClientProps.serverId),
|
||||||
'song-detail',
|
'song-detail',
|
||||||
);
|
);
|
||||||
|
collectQueries(queryKeys.songs.list(variables.apiClientProps.serverId), 'song-list');
|
||||||
collectQueries(
|
collectQueries(
|
||||||
queryKeys.albumArtists.topSongs(variables.apiClientProps.serverId),
|
queryKeys.albumArtists.topSongs(variables.apiClientProps.serverId),
|
||||||
'top-songs',
|
'top-songs',
|
||||||
@@ -712,6 +735,7 @@ export const applyRatingOptimisticUpdatesDeferred = (
|
|||||||
case 'album-artist-list':
|
case 'album-artist-list':
|
||||||
case 'album-list':
|
case 'album-list':
|
||||||
case 'artist-list':
|
case 'artist-list':
|
||||||
|
case 'song-list':
|
||||||
case 'top-songs': {
|
case 'top-songs': {
|
||||||
const updatedItems = updateItemInArray(
|
const updatedItems = updateItemInArray(
|
||||||
prev.items || [],
|
prev.items || [],
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export * from './app.store';
|
|||||||
export * from './auth.store';
|
export * from './auth.store';
|
||||||
export * from './full-screen-player.store';
|
export * from './full-screen-player.store';
|
||||||
export * from './player.store';
|
export * from './player.store';
|
||||||
|
export * from './scroll.store';
|
||||||
export * from './settings.store';
|
export * from './settings.store';
|
||||||
export * from './timestamp.store';
|
export * from './timestamp.store';
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
type ScrollState = {
|
||||||
|
getOffset: (key: string) => number | undefined;
|
||||||
|
offsets: Record<string, number>;
|
||||||
|
setOffset: (key: string, offset: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useScrollStore = create<ScrollState>((set, get) => ({
|
||||||
|
getOffset: (key) => get().offsets[key],
|
||||||
|
offsets: {},
|
||||||
|
setOffset: (key, offset) =>
|
||||||
|
set((s) => ({
|
||||||
|
offsets: { ...s.offsets, [key]: offset },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
@@ -203,10 +203,21 @@ const ItemTableListPropsSchema = z.object({
|
|||||||
enableHorizontalBorders: z.boolean(),
|
enableHorizontalBorders: z.boolean(),
|
||||||
enableRowHoverHighlight: z.boolean(),
|
enableRowHoverHighlight: z.boolean(),
|
||||||
enableVerticalBorders: z.boolean(),
|
enableVerticalBorders: z.boolean(),
|
||||||
size: z.enum(['compact', 'default']),
|
size: z.enum(['compact', 'default', 'large']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ItemDetailListPropsSchema = z.object({
|
||||||
|
columns: z.array(ItemTableListColumnConfigSchema),
|
||||||
|
enableAlternateRowColors: z.boolean(),
|
||||||
|
enableHeader: z.boolean(),
|
||||||
|
enableHorizontalBorders: z.boolean(),
|
||||||
|
enableRowHoverHighlight: z.boolean(),
|
||||||
|
enableVerticalBorders: z.boolean(),
|
||||||
|
size: z.enum(['compact', 'default', 'large']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ItemListConfigSchema = z.object({
|
const ItemListConfigSchema = z.object({
|
||||||
|
detail: ItemDetailListPropsSchema.optional(),
|
||||||
display: z.nativeEnum(ListDisplayType),
|
display: z.nativeEnum(ListDisplayType),
|
||||||
grid: z.object({
|
grid: z.object({
|
||||||
itemGap: z.enum(['lg', 'md', 'sm', 'xl', 'xs']),
|
itemGap: z.enum(['lg', 'md', 'sm', 'xl', 'xs']),
|
||||||
@@ -790,7 +801,9 @@ export type DataGridProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type DataTableProps = z.infer<typeof ItemTableListPropsSchema>;
|
export type DataTableProps = z.infer<typeof ItemTableListPropsSchema>;
|
||||||
|
export type ItemDetailListProps = z.infer<typeof ItemDetailListPropsSchema>;
|
||||||
export type ItemListSettings = {
|
export type ItemListSettings = {
|
||||||
|
detail?: ItemDetailListProps;
|
||||||
display: ListDisplayType;
|
display: ListDisplayType;
|
||||||
grid: DataGridProps;
|
grid: DataGridProps;
|
||||||
itemsPerPage: number;
|
itemsPerPage: number;
|
||||||
@@ -1163,6 +1176,32 @@ const initialState: SettingsState = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
[LibraryItem.ALBUM]: {
|
[LibraryItem.ALBUM]: {
|
||||||
|
detail: {
|
||||||
|
columns: pickTableColumns({
|
||||||
|
autoSizeColumns: [],
|
||||||
|
columns: SONG_TABLE_COLUMNS,
|
||||||
|
columnWidths: {
|
||||||
|
[TableColumn.ACTIONS]: 60,
|
||||||
|
[TableColumn.DURATION]: 100,
|
||||||
|
[TableColumn.TITLE]: 400,
|
||||||
|
[TableColumn.TRACK_NUMBER]: 50,
|
||||||
|
[TableColumn.USER_FAVORITE]: 60,
|
||||||
|
},
|
||||||
|
enabledColumns: [
|
||||||
|
TableColumn.TRACK_NUMBER,
|
||||||
|
TableColumn.TITLE,
|
||||||
|
TableColumn.DURATION,
|
||||||
|
TableColumn.USER_FAVORITE,
|
||||||
|
TableColumn.ACTIONS,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
enableAlternateRowColors: false,
|
||||||
|
enableHeader: true,
|
||||||
|
enableHorizontalBorders: false,
|
||||||
|
enableRowHoverHighlight: true,
|
||||||
|
enableVerticalBorders: false,
|
||||||
|
size: 'compact',
|
||||||
|
},
|
||||||
display: ListDisplayType.GRID,
|
display: ListDisplayType.GRID,
|
||||||
grid: {
|
grid: {
|
||||||
itemGap: 'sm',
|
itemGap: 'sm',
|
||||||
@@ -1737,6 +1776,23 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
|||||||
delete data.table;
|
delete data.table;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (listState && data.detail) {
|
||||||
|
if (!listState.detail) {
|
||||||
|
const t = listState.table;
|
||||||
|
listState.detail = {
|
||||||
|
columns: t.columns,
|
||||||
|
enableAlternateRowColors: false,
|
||||||
|
enableHeader: t.enableHeader,
|
||||||
|
enableHorizontalBorders: t.enableHorizontalBorders,
|
||||||
|
enableRowHoverHighlight: t.enableRowHoverHighlight,
|
||||||
|
enableVerticalBorders: t.enableVerticalBorders,
|
||||||
|
size: t.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Object.assign(listState.detail, data.detail);
|
||||||
|
delete data.detail;
|
||||||
|
}
|
||||||
|
|
||||||
if (listState && data.grid) {
|
if (listState && data.grid) {
|
||||||
Object.assign(listState.grid, data.grid);
|
Object.assign(listState.grid, data.grid);
|
||||||
delete data.grid;
|
delete data.grid;
|
||||||
@@ -2092,7 +2148,7 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
|||||||
return persistedState;
|
return persistedState;
|
||||||
},
|
},
|
||||||
name: 'store_settings',
|
name: 'store_settings',
|
||||||
version: 24,
|
version: 25,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,24 +104,23 @@ export const formatDurationString = (duration: number) => {
|
|||||||
return part.replace(/^0/, '');
|
return part.replace(/^0/, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
let string: string = '';
|
const parts: string[] = [];
|
||||||
|
const len = rawDuration.length;
|
||||||
|
|
||||||
switch (rawDuration.length) {
|
if (len >= 1 && formattedDuration[len - 1] !== undefined) {
|
||||||
case 1:
|
parts.push(`${formattedDuration[len - 1]}${i18n.t('datetime.secondShort')}`);
|
||||||
string = `${formattedDuration[0]}${i18n.t('datetime.secondShort')}`;
|
}
|
||||||
break;
|
if (len >= 2 && formattedDuration[len - 2]) {
|
||||||
case 2:
|
parts.unshift(`${formattedDuration[len - 2]}${i18n.t('datetime.minuteShort')}`);
|
||||||
string = `${formattedDuration[0]}${i18n.t('datetime.minuteShort')} ${formattedDuration[1]}${i18n.t('datetime.secondShort')}`;
|
}
|
||||||
break;
|
if (len >= 3 && formattedDuration[len - 3]) {
|
||||||
case 3:
|
parts.unshift(`${formattedDuration[len - 3]}${i18n.t('datetime.hourShort')}`);
|
||||||
string = `${formattedDuration[0]}${i18n.t('datetime.hourShort')} ${formattedDuration[1]}${i18n.t('datetime.minuteShort')} ${formattedDuration[2]}${i18n.t('datetime.secondShort')}`;
|
}
|
||||||
break;
|
if (len >= 4 && formattedDuration[len - 4]) {
|
||||||
case 4:
|
parts.unshift(`${formattedDuration[len - 4]}${i18n.t('datetime.dayShort')}`);
|
||||||
string = `${formattedDuration[0]}${i18n.t('datetime.dayShort')} ${formattedDuration[1]}${i18n.t('datetime.hourShort')} ${formattedDuration[2]}${i18n.t('datetime.minuteShort')} ${formattedDuration[3]}${i18n.t('datetime.secondShort')}`;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return string;
|
return parts.join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDurationStringShort = (duration: number) => {
|
export const formatDurationStringShort = (duration: number) => {
|
||||||
|
|||||||
@@ -137,14 +137,19 @@ const normalizeSong = (
|
|||||||
discTitleMap?: Map<number, string>,
|
discTitleMap?: Map<number, string>,
|
||||||
): Song => {
|
): Song => {
|
||||||
const participants = getParticipants(item);
|
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 {
|
return {
|
||||||
_itemType: LibraryItem.SONG,
|
_itemType: LibraryItem.SONG,
|
||||||
_serverId: server?.id || 'unknown',
|
_serverId: server?.id || 'unknown',
|
||||||
_serverType: ServerType.SUBSONIC,
|
_serverType: ServerType.SUBSONIC,
|
||||||
album: item.album || '',
|
album: item.album || '',
|
||||||
albumArtistName: item.artist || '',
|
albumArtistName,
|
||||||
albumArtists: getArtistList(item.albumArtists, item.artistId, item.artist),
|
albumArtists: albumArtistsList,
|
||||||
albumId: item.albumId?.toString() || '',
|
albumId: item.albumId?.toString() || '',
|
||||||
artistName: item.artist || '',
|
artistName: item.artist || '',
|
||||||
artists: getArtistList(item.artists, item.artistId, item.artist, participants),
|
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) => {
|
export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: SortOrder) => {
|
||||||
let results: Song[] = songs;
|
let results: Song[] = songs;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
LuInfo,
|
LuInfo,
|
||||||
LuKeyboard,
|
LuKeyboard,
|
||||||
LuLayoutGrid,
|
LuLayoutGrid,
|
||||||
|
LuLayoutList,
|
||||||
LuLibrary,
|
LuLibrary,
|
||||||
LuList,
|
LuList,
|
||||||
LuListFilter,
|
LuListFilter,
|
||||||
@@ -186,6 +187,7 @@ export const AppIcon = {
|
|||||||
itemSong: LuMusic,
|
itemSong: LuMusic,
|
||||||
keyboard: LuKeyboard,
|
keyboard: LuKeyboard,
|
||||||
lastPlayed: LuHeadphones,
|
lastPlayed: LuHeadphones,
|
||||||
|
layoutDetail: LuLayoutList,
|
||||||
layoutGrid: LuLayoutGrid,
|
layoutGrid: LuLayoutGrid,
|
||||||
layoutList: LuList,
|
layoutList: LuList,
|
||||||
layoutTable: LuTable,
|
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 {
|
export enum ListDisplayType {
|
||||||
|
DETAIL = 'detail',
|
||||||
GRID = 'poster',
|
GRID = 'poster',
|
||||||
LIST = 'list',
|
LIST = 'list',
|
||||||
TABLE = 'table',
|
TABLE = 'table',
|
||||||
|
|||||||
Reference in New Issue
Block a user