From 1747395b3e87d624bed4cfb1eace679ef3bf020f Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 22 Nov 2025 21:14:03 -0800 Subject: [PATCH] add feature genres component to home route --- src/i18n/locales/en.json | 1 + .../components/featured-genres.module.css | 50 ++++++ .../home/components/featured-genres.tsx | 148 ++++++++++++++++++ .../features/home/routes/home-route.tsx | 2 + 4 files changed, 201 insertions(+) create mode 100644 src/renderer/features/home/components/featured-genres.module.css create mode 100644 src/renderer/features/home/components/featured-genres.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 19df25db7..29f08482e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -23,6 +23,7 @@ "setRating": "set rating", "toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor", "viewPlaylists": "view $t(entity.playlist_other)", + "viewMore": "view more", "openIn": { "lastfm": "Open in Last.fm", "musicbrainz": "Open in MusicBrainz" diff --git a/src/renderer/features/home/components/featured-genres.module.css b/src/renderer/features/home/components/featured-genres.module.css new file mode 100644 index 000000000..4ed827eca --- /dev/null +++ b/src/renderer/features/home/components/featured-genres.module.css @@ -0,0 +1,50 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-md); + width: 100%; +} + +.group { + gap: var(--theme-spacing-sm); + padding: var(--theme-spacing-xs) 0; +} + +.genre-container { + position: relative; + min-height: 3rem; + overflow: hidden; + border-radius: var(--theme-radius-md); +} + +.genre-link { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-width: 90px; + min-height: 3rem; + padding: 0 var(--theme-spacing-md); + font-size: var(--theme-font-size-md); + font-weight: 600; + color: white; + text-align: center; + text-decoration: none; + text-shadow: 0 0 10px rgb(0 0 0 / 50%); + cursor: pointer; + user-select: none; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.genre-link:hover { + box-shadow: 0 4px 8px rgb(0 0 0 / 20%); + transform: translateY(-2px); +} + +.genre-link:active { + transform: translateY(0); +} diff --git a/src/renderer/features/home/components/featured-genres.tsx b/src/renderer/features/home/components/featured-genres.tsx new file mode 100644 index 000000000..808134787 --- /dev/null +++ b/src/renderer/features/home/components/featured-genres.tsx @@ -0,0 +1,148 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { shuffle } from 'lodash'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { generatePath, Link } from 'react-router'; + +import styles from './featured-genres.module.css'; + +import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; +import { BackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay'; +import { useContainerQuery } from '/@/renderer/hooks'; +import { AppRoute } from '/@/renderer/router/routes'; +import { useCurrentServer } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { TextTitle } from '/@/shared/components/text-title/text-title'; +import { Genre, GenreListSort, SortOrder } from '/@/shared/types/domain-types'; +import { stringToColor } from '/@/shared/utils/string-to-color'; + +function getGenresToShow(breakpoints: { + isLargerThanLg: boolean; + isLargerThanMd: boolean; + isLargerThanSm: boolean; + isLargerThanXl: boolean; + isLargerThanXxl: boolean; + isLargerThanXxxl: boolean; +}) { + if (breakpoints.isLargerThanXxxl) { + return 42; + } + + if (breakpoints.isLargerThanXxl) { + return 30; + } + + if (breakpoints.isLargerThanXl) { + return 24; + } + + if (breakpoints.isLargerThanLg) { + return 18; + } + + if (breakpoints.isLargerThanMd) { + return 12; + } + + if (breakpoints.isLargerThanSm) { + return 9; + } + + return 6; +} + +export const FeaturedGenres = () => { + const { t } = useTranslation(); + const server = useCurrentServer(); + const { ref, ...cq } = useContainerQuery({ + lg: 900, + md: 600, + sm: 360, + }); + + const genresQuery = useSuspenseQuery({ + ...genresQueries.list({ + query: { + limit: -1, + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: server?.id, + }), + queryKey: ['home', 'featured-genres'], + }); + + const randomGenres = useMemo(() => { + if (!genresQuery.data?.items) return []; + return shuffle(genresQuery.data.items); + }, [genresQuery.data]); + + const genresToShow = useMemo(() => { + return getGenresToShow({ + isLargerThanLg: cq.isLg, + isLargerThanMd: cq.isMd, + isLargerThanSm: cq.isSm, + isLargerThanXl: cq.isXl, + isLargerThanXxl: cq.is2xl, + isLargerThanXxxl: cq.is3xl, + }); + }, [cq.isLg, cq.isMd, cq.isSm, cq.isXl, cq.is2xl, cq.is3xl]); + + const visibleGenres = useMemo(() => { + return randomGenres.slice(0, genresToShow); + }, [randomGenres, genresToShow]); + + const genresWithColors = useMemo(() => { + if (!visibleGenres) return []; + + return visibleGenres.map((genre: Genre) => { + const { color, isLight } = stringToColor(genre.name); + const path = generatePath(AppRoute.LIBRARY_GENRES_ALBUMS, { genreId: genre.id }); + + return { + ...genre, + color, + isLight, + path, + }; + }); + }, [visibleGenres]); + + return ( +
+ {cq.isCalculated && ( + <> + + + {t('entity.genre_other', { postProcess: 'titleCase' })} + + + + + {genresWithColors.map((genre) => ( +
+ + + {genre.name} + +
+ ))} +
+ + )} +
+ ); +}; diff --git a/src/renderer/features/home/routes/home-route.tsx b/src/renderer/features/home/routes/home-route.tsx index 1bc181e49..0540c1342 100644 --- a/src/renderer/features/home/routes/home-route.tsx +++ b/src/renderer/features/home/routes/home-route.tsx @@ -6,6 +6,7 @@ import { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel'; +import { FeaturedGenres } from '/@/renderer/features/home/components/featured-genres'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { LibraryContainer } from '/@/renderer/features/shared/components/library-container'; import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar'; @@ -125,6 +126,7 @@ const HomeRoute = () => { px="2rem" > {homeFeature && } + {sortedCarousel.map((carousel) => { if (carousel.itemType === LibraryItem.ALBUM) { return (