mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
add new card component
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user