mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 12:30:12 +02:00
redesign feature carousel
This commit is contained in:
@@ -1,73 +1,287 @@
|
||||
.carousel-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-bottom: var(--theme-spacing-xl);
|
||||
container-type: inline-size;
|
||||
overflow: hidden;
|
||||
border-radius: var(--theme-radius-lg);
|
||||
}
|
||||
|
||||
.carousel {
|
||||
position: relative;
|
||||
height: 35vh;
|
||||
min-height: 250px;
|
||||
max-height: 300px;
|
||||
padding: var(--theme-spacing-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-areas: 'image info';
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
grid-auto-columns: 1fr;
|
||||
grid-template-columns: repeat(var(--items-per-row, 1), 1fr);
|
||||
gap: var(--theme-spacing-md);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
padding: var(--theme-spacing-xl);
|
||||
overflow: hidden;
|
||||
|
||||
--items-per-row: 1;
|
||||
}
|
||||
|
||||
.image-column {
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.info-column {
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
grid-area: info;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.background-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
user-select: none;
|
||||
object-fit: var(--theme-image-fit);
|
||||
object-position: 0 30%;
|
||||
filter: blur(24px);
|
||||
}
|
||||
|
||||
.background-image-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgb(25 26 28 / 30%), var(--theme-colors-background));
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
.carousel-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--theme-radius-md);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.title-wrapper {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
.carousel-item :global(.overlay) {
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
|
||||
.carousel-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--theme-spacing-md);
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
padding: var(--theme-spacing-xl);
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
min-height: 60px;
|
||||
max-height: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-section {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
min-height: 200px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
min-height: 100px;
|
||||
max-height: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-link {
|
||||
display: block;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.image-link:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.image-link:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.album-image {
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
height: auto;
|
||||
border-radius: var(--theme-radius-lg);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 60%);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.image-link:hover .album-image {
|
||||
box-shadow: 0 12px 32px rgb(0 0 0 / 40%);
|
||||
}
|
||||
|
||||
.artist-link {
|
||||
display: inline-block;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.artist-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--theme-spacing-xs);
|
||||
color: white;
|
||||
text-shadow: 0 0 10px rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
.artist {
|
||||
color: white;
|
||||
text-shadow: 0 0 10px rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
.nav-arrow-left,
|
||||
.nav-arrow-right {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 20;
|
||||
border: 1px solid rgb(255 255 255 / 25%);
|
||||
backdrop-filter: blur(10px);
|
||||
transform: translateY(-50%);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-arrow-left {
|
||||
left: var(--theme-spacing-xs);
|
||||
}
|
||||
|
||||
.nav-arrow-right {
|
||||
right: var(--theme-spacing-xs);
|
||||
}
|
||||
|
||||
.nav-arrow-left:hover,
|
||||
.nav-arrow-right:hover {
|
||||
background: transparent !important;
|
||||
border-color: rgb(255 255 255 / 35%);
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.nav-arrow-left:active,
|
||||
.nav-arrow-right:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
@container (min-width: 640px) {
|
||||
.carousel {
|
||||
--items-per-row: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: $mantine-breakpoint-sm) {
|
||||
.carousel {
|
||||
--items-per-row: 3;
|
||||
|
||||
gap: var(--theme-spacing-lg);
|
||||
}
|
||||
|
||||
.carousel-item {
|
||||
min-height: 450px;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 450px;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
height: 70px;
|
||||
min-height: 70px;
|
||||
max-height: 70px;
|
||||
}
|
||||
|
||||
.image-section {
|
||||
height: 220px;
|
||||
min-height: 220px;
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
height: 110px;
|
||||
min-height: 110px;
|
||||
max-height: 110px;
|
||||
}
|
||||
|
||||
.album-image {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: $mantine-breakpoint-md) {
|
||||
.carousel {
|
||||
--items-per-row: 4;
|
||||
}
|
||||
|
||||
.carousel-item {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
max-height: 80px;
|
||||
}
|
||||
|
||||
.image-section {
|
||||
height: 250px;
|
||||
min-height: 250px;
|
||||
max-height: 250px;
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
height: 120px;
|
||||
min-height: 120px;
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.album-image {
|
||||
max-width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: $mantine-breakpoint-xl) {
|
||||
.carousel {
|
||||
--items-per-row: 5;
|
||||
}
|
||||
|
||||
.carousel-item {
|
||||
min-height: 550px;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 550px;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
height: 90px;
|
||||
min-height: 90px;
|
||||
max-height: 90px;
|
||||
}
|
||||
|
||||
.image-section {
|
||||
height: 280px;
|
||||
min-height: 280px;
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
height: 130px;
|
||||
min-height: 130px;
|
||||
max-height: 130px;
|
||||
}
|
||||
|
||||
.album-image {
|
||||
max-width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,41 @@
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { generatePath, Link } from 'react-router';
|
||||
|
||||
import styles from './feature-carousel.module.css';
|
||||
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
|
||||
import { ItemCard } from '/@/renderer/components/item-card/item-card';
|
||||
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||
import { BackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
|
||||
import { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, usePlayButtonBehavior } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Badge } from '/@/shared/components/badge/badge';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Album, LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
const variants: Variants = {
|
||||
animate: {
|
||||
const fadeVariants = {
|
||||
center: {
|
||||
opacity: 1,
|
||||
transition: { opacity: { duration: 0.5 } },
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: 'easeInOut' as const,
|
||||
},
|
||||
},
|
||||
enter: {
|
||||
opacity: 0,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: { opacity: { duration: 0.5 } },
|
||||
},
|
||||
initial: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: 'easeInOut' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -41,146 +43,191 @@ interface FeatureCarouselProps {
|
||||
data: Album[] | undefined;
|
||||
}
|
||||
|
||||
export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { addToQueueByFetch } = usePlayer();
|
||||
const server = useCurrentServer();
|
||||
const [itemIndex, setItemIndex] = useState(0);
|
||||
const [direction, setDirection] = useState(0);
|
||||
const playType = usePlayButtonBehavior();
|
||||
const getItemsPerRow = (breakpoints: {
|
||||
is2xl: boolean;
|
||||
isLg: boolean;
|
||||
isMd: boolean;
|
||||
isSm: boolean;
|
||||
isXl: boolean;
|
||||
}) => {
|
||||
if (breakpoints.is2xl) return 5;
|
||||
if (breakpoints.isXl) return 5;
|
||||
if (breakpoints.isLg) return 4;
|
||||
if (breakpoints.isMd) return 3;
|
||||
return 1;
|
||||
};
|
||||
|
||||
const currentItem = data?.[itemIndex];
|
||||
interface CarouselItemProps {
|
||||
album: Album;
|
||||
}
|
||||
|
||||
const handleNext = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setDirection(1);
|
||||
if (itemIndex === (data?.length || 0) - 1 || 0) {
|
||||
setItemIndex(0);
|
||||
return;
|
||||
}
|
||||
const CarouselItem = ({ album }: CarouselItemProps) => {
|
||||
const { background: backgroundColor } = useFastAverageColor({
|
||||
algorithm: 'dominant',
|
||||
src: album.imageUrl || null,
|
||||
srcLoaded: true,
|
||||
});
|
||||
|
||||
setItemIndex((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handlePrevious = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setDirection(-1);
|
||||
if (itemIndex === 0) {
|
||||
setItemIndex((data?.length || 0) - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
setItemIndex((prev) => prev - 1);
|
||||
};
|
||||
const controls = useDefaultItemListControls();
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={styles.wrapper}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: currentItem?.id || '' })}
|
||||
>
|
||||
<AnimatePresence custom={direction} initial={false} mode="popLayout">
|
||||
{data && (
|
||||
<motion.div
|
||||
animate="animate"
|
||||
className={styles.carousel}
|
||||
custom={direction}
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
key={`image-${itemIndex}`}
|
||||
variants={variants}
|
||||
>
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.imageColumn}>
|
||||
<Image
|
||||
height={225}
|
||||
src={data[itemIndex]?.imageUrl || ''}
|
||||
width={225}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.infoColumn}>
|
||||
<Stack gap="md" style={{ width: '100%' }}>
|
||||
<div className={styles.titleWrapper}>
|
||||
<TextTitle
|
||||
fw={900}
|
||||
lineClamp={2}
|
||||
order={1}
|
||||
overflow="hidden"
|
||||
>
|
||||
{currentItem?.name}
|
||||
</TextTitle>
|
||||
</div>
|
||||
<div className={styles.titleWrapper}>
|
||||
{currentItem?.albumArtists.slice(0, 1).map((artist) => (
|
||||
<Text fw={600} key={`carousel-artist-${artist.id}`}>
|
||||
{artist.name}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
<Group>
|
||||
{currentItem?.genres?.slice(0, 1).map((genre) => (
|
||||
<Badge
|
||||
key={`carousel-genre-${genre.id}`}
|
||||
variant="default"
|
||||
>
|
||||
{genre.name}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge variant="default">{currentItem?.releaseYear}</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<PlayButton
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!currentItem || !server?.id) return;
|
||||
<div className={styles.carouselItem}>
|
||||
<BackgroundOverlay backgroundColor={backgroundColor} opacity={1} />
|
||||
<Link
|
||||
className={styles.carouselLink}
|
||||
state={{ item: album }}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: album.id,
|
||||
})}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.titleSection}>
|
||||
<TextTitle className={styles.title} fw={700} lineClamp={2} order={3}>
|
||||
{album.name}
|
||||
</TextTitle>
|
||||
</div>
|
||||
|
||||
addToQueueByFetch(
|
||||
server.id,
|
||||
[currentItem.id],
|
||||
LibraryItem.ALBUM,
|
||||
playType,
|
||||
);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t(
|
||||
playType === Play.NOW
|
||||
? 'player.play'
|
||||
: playType === Play.NEXT
|
||||
? 'player.addNext'
|
||||
: 'player.addLast',
|
||||
{ postProcess: 'titleCase' },
|
||||
)}
|
||||
</PlayButton>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
onClick={handlePrevious}
|
||||
radius="lg"
|
||||
variant="subtle"
|
||||
>
|
||||
<Icon icon="arrowLeftS" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
radius="lg"
|
||||
variant="subtle"
|
||||
>
|
||||
<Icon icon="arrowRightS" />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
<Image
|
||||
className={styles.backgroundImage}
|
||||
draggable="false"
|
||||
src={currentItem?.imageUrl || ''}
|
||||
<div
|
||||
className={styles.imageSection}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<ItemCard
|
||||
controls={controls}
|
||||
data={album}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
rows={[]}
|
||||
type="poster"
|
||||
withControls
|
||||
/>
|
||||
<div className={styles.backgroundImageOverlay} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.metadataSection}>
|
||||
<Stack gap="sm">
|
||||
{album.albumArtists.slice(0, 1).map((artist) => (
|
||||
<Link
|
||||
className={styles.artistLink}
|
||||
key={`artist-${artist.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
state={{ item: artist }}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
>
|
||||
<Text className={styles.artist} fw={600} size="md">
|
||||
{artist.name}
|
||||
</Text>
|
||||
</Link>
|
||||
))}
|
||||
<Group gap="sm" justify="center" wrap="wrap">
|
||||
{album.genres?.slice(0, 2).map((genre) => (
|
||||
<Badge key={`genre-${genre.id}`} size="sm">
|
||||
{genre.name}
|
||||
</Badge>
|
||||
))}
|
||||
{album.releaseYear && <Badge size="sm">{album.releaseYear}</Badge>}
|
||||
</Group>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
const [startIndex, setStartIndex] = useState(0);
|
||||
const {
|
||||
is2xl,
|
||||
isLg,
|
||||
isMd,
|
||||
isSm,
|
||||
isXl,
|
||||
ref: containerRef,
|
||||
} = useContainerQuery({
|
||||
'2xl': 1920,
|
||||
lg: 1024,
|
||||
md: 768,
|
||||
sm: 640,
|
||||
xl: 1440,
|
||||
});
|
||||
|
||||
const itemsPerRow = useMemo(
|
||||
() => getItemsPerRow({ is2xl, isLg, isMd, isSm, isXl }),
|
||||
[is2xl, isLg, isMd, isSm, isXl],
|
||||
);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const items: Album[] = [];
|
||||
for (let i = 0; i < itemsPerRow; i++) {
|
||||
const index = (startIndex + i) % data.length;
|
||||
items.push(data[index]);
|
||||
}
|
||||
return items;
|
||||
}, [data, startIndex, itemsPerRow]);
|
||||
|
||||
const handleNext = (e?: MouseEvent<HTMLButtonElement>) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
if (!data) return;
|
||||
setStartIndex((prev) => (prev + itemsPerRow) % data.length);
|
||||
};
|
||||
|
||||
const handlePrevious = (e?: MouseEvent<HTMLButtonElement>) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
if (!data) return;
|
||||
setStartIndex((prev) => (prev - itemsPerRow + data.length) % data.length);
|
||||
};
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.carouselContainer} ref={containerRef}>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<motion.div
|
||||
animate="center"
|
||||
className={styles.carousel}
|
||||
exit="exit"
|
||||
initial="enter"
|
||||
key={`carousel-${startIndex}`}
|
||||
variants={fadeVariants}
|
||||
>
|
||||
{visibleItems.map((album) => (
|
||||
<CarouselItem album={album} key={`item-${album.id}-${startIndex}`} />
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{data.length > itemsPerRow && (
|
||||
<>
|
||||
<ActionIcon
|
||||
className={styles.navArrowLeft}
|
||||
icon="arrowLeftS"
|
||||
iconProps={{ size: 'xl' }}
|
||||
onClick={handlePrevious}
|
||||
radius="50%"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
/>
|
||||
<ActionIcon
|
||||
className={styles.navArrowRight}
|
||||
icon="arrowRightS"
|
||||
iconProps={{ size: 'xl' }}
|
||||
onClick={handleNext}
|
||||
radius="50%"
|
||||
size="md"
|
||||
variant="subtle"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Suspense, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -28,12 +28,12 @@ const HomeRoute = () => {
|
||||
|
||||
const isJellyfin = server?.type === ServerType.JELLYFIN;
|
||||
|
||||
const feature = useQuery(
|
||||
albumQueries.list({
|
||||
const feature = useSuspenseQuery({
|
||||
...albumQueries.list({
|
||||
options: {
|
||||
enabled: homeFeature,
|
||||
gcTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
gcTime: 1000 * 30,
|
||||
staleTime: 1000 * 30,
|
||||
},
|
||||
query: {
|
||||
limit: 20,
|
||||
@@ -43,7 +43,8 @@ const HomeRoute = () => {
|
||||
},
|
||||
serverId: server?.id,
|
||||
}),
|
||||
);
|
||||
queryKey: ['home', 'feature'],
|
||||
});
|
||||
|
||||
const featureItemsWithImage = useMemo(() => {
|
||||
return feature.data?.items?.filter((item) => item.imageUrl) ?? [];
|
||||
@@ -124,17 +125,13 @@ const HomeRoute = () => {
|
||||
{sortedCarousel.map((carousel) => {
|
||||
if (carousel.itemType === LibraryItem.ALBUM) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<Spinner container />}
|
||||
<AlbumInfiniteCarousel
|
||||
key={`carousel-${carousel.uniqueId}`}
|
||||
>
|
||||
<AlbumInfiniteCarousel
|
||||
rowCount={1}
|
||||
sortBy={carousel.sortBy}
|
||||
sortOrder={carousel.sortOrder}
|
||||
title={carousel.title}
|
||||
/>
|
||||
</Suspense>
|
||||
rowCount={1}
|
||||
sortBy={carousel.sortBy}
|
||||
sortOrder={carousel.sortOrder}
|
||||
title={carousel.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,4 +148,12 @@ const HomeRoute = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeRoute;
|
||||
const SuspensedHomeRoute = () => {
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<HomeRoute />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuspensedHomeRoute;
|
||||
|
||||
@@ -6,7 +6,28 @@
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
background-image: var(--theme-overlay-subheader);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.background-overlay {
|
||||
--color-from: var(--background-base-min-contrast);
|
||||
--color-to: transparent;
|
||||
--dither: none;
|
||||
--direction-and-possibly-color-interpolation: to bottom;
|
||||
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
background-color: var(--color-from);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
var(--direction-and-possibly-color-interpolation),
|
||||
var(--color-from),
|
||||
var(--color-to)
|
||||
),
|
||||
var(--dither);
|
||||
}
|
||||
|
||||
.background-image {
|
||||
|
||||
@@ -1,28 +1,71 @@
|
||||
import { generateColors } from '@mantine/colors-generator';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import styles from './library-background-overlay.module.css';
|
||||
|
||||
import { useAppThemeColors } from '/@/renderer/themes/use-app-theme';
|
||||
|
||||
interface LibraryBackgroundOverlayProps {
|
||||
backgroundColor?: string;
|
||||
headerRef: React.RefObject<HTMLDivElement | null>;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export const LibraryBackgroundOverlay = ({
|
||||
backgroundColor,
|
||||
headerRef,
|
||||
opacity = 0.7,
|
||||
}: LibraryBackgroundOverlayProps) => {
|
||||
const height = useHeaderHeight(headerRef);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
style={{
|
||||
backgroundColor,
|
||||
height: height ? `${height + 64}px` : undefined,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface BackgroundOverlayProps {
|
||||
backgroundColor?: string;
|
||||
direction?: string;
|
||||
height?: number | string;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export const BackgroundOverlay = ({
|
||||
backgroundColor,
|
||||
direction = 'to bottom',
|
||||
height = '100%',
|
||||
opacity,
|
||||
}: BackgroundOverlayProps) => {
|
||||
const theme = useAppThemeColors();
|
||||
|
||||
const colors = generateColors(backgroundColor || theme.color['--theme-colors-background']);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.backgroundOverlay)}
|
||||
style={
|
||||
{
|
||||
'--color-from': colors[6],
|
||||
'--color-to': colors[9],
|
||||
'--direction-and-possibly-color-interpolation': direction,
|
||||
'--dither': 'none',
|
||||
backgroundColor: backgroundColor,
|
||||
height,
|
||||
opacity,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface LibraryBackgroundProps {
|
||||
blur?: number;
|
||||
headerRef: React.RefObject<HTMLDivElement | null>;
|
||||
|
||||
@@ -230,3 +230,55 @@ export const useColorScheme = () => {
|
||||
|
||||
return colorScheme === 'dark' ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
export const useAppThemeColors = () => {
|
||||
const accent = useSettingsStore((store) => store.general.accent);
|
||||
const getCurrentTheme = () => window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const [isDarkTheme] = useState(getCurrentTheme());
|
||||
const { followSystemTheme, theme, themeDark, themeLight } = useSettingsStore(
|
||||
(state) => state.general,
|
||||
);
|
||||
|
||||
const getSelectedTheme = () => {
|
||||
if (followSystemTheme) {
|
||||
return isDarkTheme ? themeDark : themeLight;
|
||||
}
|
||||
|
||||
return theme;
|
||||
};
|
||||
|
||||
const selectedTheme = getSelectedTheme();
|
||||
|
||||
const appTheme: AppThemeConfiguration = useMemo(() => {
|
||||
const themeProperties = getAppTheme(selectedTheme);
|
||||
|
||||
return {
|
||||
...themeProperties,
|
||||
colors: {
|
||||
...themeProperties.colors,
|
||||
primary: accent,
|
||||
},
|
||||
};
|
||||
}, [accent, selectedTheme]);
|
||||
|
||||
const themeVars = useMemo(() => {
|
||||
return Object.entries(appTheme?.app ?? {})
|
||||
.map(([key, value]) => {
|
||||
return [`--theme-${key}`, value];
|
||||
})
|
||||
.filter(Boolean) as [string, string][];
|
||||
}, [appTheme]);
|
||||
|
||||
const colorVars = useMemo(() => {
|
||||
return Object.entries(appTheme?.colors ?? {})
|
||||
.map(([key, value]) => {
|
||||
return [`--theme-colors-${key}`, value];
|
||||
})
|
||||
.filter(Boolean) as [string, string][];
|
||||
}, [appTheme]);
|
||||
|
||||
return {
|
||||
color: Object.fromEntries(colorVars),
|
||||
theme: Object.fromEntries(themeVars),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user