add new card component

This commit is contained in:
jeffvli
2025-09-25 00:20:59 -07:00
parent c0317aca58
commit 1108cb7e9a
4 changed files with 649 additions and 0 deletions
@@ -0,0 +1,154 @@
.container {
position: absolute;
top: 0;
left: 0;
z-index: 100;
display: grid;
grid-template-rows: repeat(3, minmax(0, 1fr));
grid-template-columns: minmax(0, 1fr);
gap: var(--theme-spacing-sm);
width: 100%;
height: 100%;
}
.container.compact {
opacity: 0;
transform: scale(0.8);
transition:
opacity 0.2s ease-in-out,
transform 0.2s ease-in-out;
}
.container.visible.compact {
opacity: 1;
transform: scale(1);
}
.top-controls {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.secondary-controls {
position: absolute;
right: 0;
bottom: 0;
display: flex;
gap: var(--theme-spacing-xs);
justify-content: flex-end;
width: 100%;
height: 15%;
margin-right: var(--theme-spacing-sm);
margin-bottom: var(--theme-spacing-lg);
}
.play-button {
all: unset;
position: absolute;
top: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
border: none;
border-radius: 100%;
opacity: 0.8;
transform: translate(-50%, -50%) scale(1);
transition: opacity 0.1s ease-in-out;
transition: transform 0.1s ease-in-out;
&:hover {
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
}
&:active {
opacity: 1;
transform: translate(-50%, -50%) scale(0.9);
}
svg {
stroke: rgb(0 0 0);
}
}
.play-button.primary {
left: 50%;
width: 25%;
height: 25%;
svg {
fill: rgb(0 0 0);
}
}
.play-button.secondary {
width: 15%;
height: 15%;
}
.play-button.left {
left: 25%;
}
.play-button.right {
left: 75%;
}
.play-button.left-top {
top: 40%;
left: 25%;
}
.play-button.left-bottom {
top: 60%;
left: 25%;
}
.play-button.right-bottom {
top: 60%;
left: 75%;
}
.play-button.right-top {
top: 40%;
left: 75%;
}
.secondary-button {
all: unset;
position: absolute;
padding: var(--theme-spacing-md);
border-radius: var(--theme-radius-md);
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear;
&:hover {
opacity: 1;
transform: scale(1.1);
}
&:active {
opacity: 1;
transform: scale(0.9);
}
svg {
fill: rgb(255 255 255);
stroke: rgb(255 255 255);
}
}
.secondary-button.favorite {
top: 0;
right: 0;
}
.secondary-button.options {
right: 0;
bottom: 0;
}
@@ -0,0 +1,82 @@
import clsx from 'clsx';
import { motion } from 'motion/react';
import styles from './item-card-controls.module.css';
import { animationVariants } from '/@/shared/components/animations/animation-variants';
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
interface ItemCardControlsProps {
type?: 'compact' | 'default' | 'poster';
}
const containerProps = {
compact: {
animate: 'show',
exit: 'hidden',
initial: 'hidden',
variants: animationVariants.combine(animationVariants.zoomIn, animationVariants.fadeIn),
},
default: {
animate: 'show',
exit: 'hidden',
initial: 'hidden',
variants: animationVariants.combine(animationVariants.zoomIn, animationVariants.fadeIn),
},
poster: {
animate: 'show',
exit: 'hidden',
initial: 'hidden',
variants: animationVariants.combine(animationVariants.slideInUp, animationVariants.fadeIn),
},
};
export const ItemCardControls = ({ type = 'default' }: ItemCardControlsProps) => {
return (
<motion.div className={clsx(styles.container)} {...containerProps[type]}>
<PlayButton />
<SecondaryPlayButton className={styles.left} icon="mediaPlayNext" />
<SecondaryPlayButton className={styles.right} icon="mediaPlayLast" />
<SecondaryButton className={styles.favorite} icon="favorite" />
<SecondaryButton className={styles.options} icon="ellipsisHorizontal" />
</motion.div>
);
};
const PlayButton = () => {
return (
<button
className={clsx(styles.playButton, styles.primary)}
onClick={(e) => e.stopPropagation()}
>
<Icon icon="mediaPlay" size="lg" />
</button>
);
};
const SecondaryPlayButton = ({
className,
icon,
}: {
className?: string;
icon: keyof typeof AppIcon;
}) => {
return (
<button className={clsx(styles.playButton, styles.secondary, className)}>
<Icon icon={icon} size="lg" />
</button>
);
};
interface SecondaryButtonProps {
className?: string;
icon: keyof typeof AppIcon;
}
const SecondaryButton = ({ className, icon }: SecondaryButtonProps) => {
return (
<button className={clsx(styles.secondaryButton, className)}>
<Icon icon={icon} size="lg" />
</button>
);
};
@@ -0,0 +1,121 @@
.container {
display: flex;
flex-direction: column;
width: 100%;
padding: var(--theme-spacing-md);
overflow: hidden;
background-color: var(--theme-colors-surface);
border-radius: var(--theme-radius-md);
}
.image-container {
position: relative;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
&::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;
}
}
}
.image-container.is-round {
&::before {
border-radius: 50%;
}
}
.image {
width: 100%;
height: 100%;
object-fit: var(--theme-image-fit);
}
.image.is-round {
border-radius: 50%;
}
.detail-container {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-xs);
width: 100%;
min-width: 0;
max-width: 100%;
padding-top: var(--theme-spacing-sm);
overflow: hidden;
}
.row {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.6;
white-space: nowrap;
a {
display: inline;
max-width: 100%;
color: inherit;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
.row.muted {
color: var(--theme-colors-foreground-muted);
}
.container.poster {
padding: 0;
background-color: inherit;
}
.container.compact {
position: relative;
padding: 0;
}
.detail-container.compact {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: var(--theme-spacing-xs);
text-shadow: 0 1px 2px rgb(0 0 0 / 80%);
background-color: rgb(0 0 0 / 50%);
backdrop-filter: blur(2px);
transform: translateY(0);
transition:
transform 0.2s ease-in-out,
opacity 0.2s ease-in-out;
}
.image-container:hover .detail-container.compact {
opacity: 0;
transform: translateY(100%);
}
.row.muted.compact {
color: var(--theme-colors-foreground);
}
@@ -0,0 +1,292 @@
import clsx from 'clsx';
import { AnimatePresence } from 'motion/react';
import { Dispatch, Fragment, lazy, ReactNode, SetStateAction, useState } from 'react';
import { generatePath, Link } from 'react-router-dom';
import styles from './item-card.module.css';
import { AppRoute } from '/@/renderer/router/routes';
import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator';
import { Text } from '/@/shared/components/text/text';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
Playlist,
Song,
} from '/@/shared/types/domain-types';
const ItemCardControls = lazy(() =>
import('/@/renderer/components/item-card/item-card-controls').then((module) => ({
default: module.ItemCardControls,
})),
);
type DataRow = {
format: (data: Album | AlbumArtist | Artist | Playlist | Song) => ReactNode | string;
id: string;
isMuted?: boolean;
};
interface ItemCardProps {
data: Album | AlbumArtist | Artist | Playlist | Song;
isRound?: boolean;
onClick?: () => void;
type?: 'compact' | 'default' | 'poster';
withControls?: boolean;
}
export const ItemCard = ({
data,
isRound,
onClick,
type = 'poster',
withControls,
}: ItemCardProps) => {
const imageUrl = getImageUrl(data);
const rows = getDataRows(data);
const [showControls, setShowControls] = useState(false);
switch (type) {
case 'compact':
return (
<CompactItemCard
data={data}
imageUrl={imageUrl}
isRound={isRound}
onClick={onClick}
rows={rows}
setShowControls={setShowControls}
showControls={showControls}
withControls={withControls}
/>
);
case 'poster':
return (
<PosterItemCard
data={data}
imageUrl={imageUrl}
isRound={isRound}
onClick={onClick}
rows={rows}
setShowControls={setShowControls}
showControls={showControls}
withControls={withControls}
/>
);
case 'default':
default:
return (
<DefaultItemCard
data={data}
imageUrl={imageUrl}
isRound={isRound}
onClick={onClick}
rows={rows}
setShowControls={setShowControls}
showControls={showControls}
withControls={withControls}
/>
);
}
};
export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
imageUrl: string | undefined;
rows: DataRow[];
setShowControls: Dispatch<SetStateAction<boolean>>;
showControls: boolean;
}
const CompactItemCard = ({
data,
imageUrl,
isRound,
onClick,
rows,
setShowControls,
showControls,
withControls,
}: ItemCardDerivativeProps) => {
return (
<div className={clsx(styles.container, styles.compact)}>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={onClick}
onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)}
>
<Image
className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl}
/>
<AnimatePresence>
{withControls && showControls && <ItemCardControls type="compact" />}
</AnimatePresence>
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows.map((row) => (
<ItemCardRow data={data} key={row.id} row={row} type="compact" />
))}
</div>
</div>
</div>
);
};
const DefaultItemCard = ({
data,
imageUrl,
isRound,
onClick,
rows,
setShowControls,
showControls,
withControls,
}: ItemCardDerivativeProps) => {
return (
<div className={clsx(styles.container)}>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={onClick}
onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)}
>
<Image
className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl}
/>
<AnimatePresence>
{withControls && showControls && <ItemCardControls type="default" />}
</AnimatePresence>
</div>
<div className={styles.detailContainer}>
{rows.map((row) => (
<Fragment key={row.id}>
<ItemCardRow data={data} row={row} type="default" />
</Fragment>
))}
</div>
</div>
);
};
const PosterItemCard = ({
data,
imageUrl,
isRound,
onClick,
rows,
setShowControls,
showControls,
withControls,
}: ItemCardDerivativeProps) => {
return (
<div className={clsx(styles.container, styles.poster)}>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={onClick}
onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)}
>
<Image
className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl}
/>
<AnimatePresence>
{withControls && showControls && <ItemCardControls type="poster" />}
</AnimatePresence>
</div>
<div className={styles.detailContainer}>
{rows.map((row) => (
<Fragment key={row.id}>
<ItemCardRow data={data} row={row} type="poster" />
</Fragment>
))}
</div>
</div>
);
};
const getDataRows = (data: Album | AlbumArtist | Artist | Playlist | Song): DataRow[] => {
switch (data.itemType) {
case LibraryItem.ALBUM:
return [
{
format: (data) => {
const album = data as Album;
return (
<Link
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: album.id,
})}
>
{album.name}
</Link>
);
},
id: 'name',
},
{
format: (data) => {
const album = data as Album;
return album.albumArtists.map((artist, index) => (
<Fragment key={artist.id}>
<Link
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</Link>
{index < album.albumArtists.length - 1 && <Separator />}
</Fragment>
));
},
id: 'albumArtists',
isMuted: true,
},
];
case LibraryItem.ALBUM_ARTIST:
return [{ format: (data) => (data as AlbumArtist).name, id: 'name' }];
case LibraryItem.ARTIST:
return [{ format: (data) => (data as Artist).name, id: 'name' }];
case LibraryItem.PLAYLIST:
return [{ format: (data) => (data as Playlist).name, id: 'name' }];
case LibraryItem.SONG:
return [{ format: (data) => (data as Song).name, id: 'name' }];
}
};
const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song) => {
if ('imageUrl' in data) {
return data.imageUrl || undefined;
}
return undefined;
};
const ItemCardRow = ({
data,
row,
type,
}: {
data: Album | AlbumArtist | Artist | Playlist | Song;
row: DataRow;
type?: 'compact' | 'default' | 'poster';
}) => {
return (
<Text
className={clsx(styles.row, {
[styles.compact]: type === 'compact',
[styles.default]: type === 'default',
[styles.muted]: row.isMuted,
[styles.poster]: type === 'poster',
})}
>
{row.format(data)}
</Text>
);
};