diff --git a/src/renderer/components/item-card/item-card-controls.module.css b/src/renderer/components/item-card/item-card-controls.module.css new file mode 100644 index 000000000..5b788c398 --- /dev/null +++ b/src/renderer/components/item-card/item-card-controls.module.css @@ -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; +} diff --git a/src/renderer/components/item-card/item-card-controls.tsx b/src/renderer/components/item-card/item-card-controls.tsx new file mode 100644 index 000000000..d43ddbc30 --- /dev/null +++ b/src/renderer/components/item-card/item-card-controls.tsx @@ -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 ( + + + + + + + + ); +}; + +const PlayButton = () => { + return ( + + ); +}; + +const SecondaryPlayButton = ({ + className, + icon, +}: { + className?: string; + icon: keyof typeof AppIcon; +}) => { + return ( + + ); +}; + +interface SecondaryButtonProps { + className?: string; + icon: keyof typeof AppIcon; +} + +const SecondaryButton = ({ className, icon }: SecondaryButtonProps) => { + return ( + + ); +}; diff --git a/src/renderer/components/item-card/item-card.module.css b/src/renderer/components/item-card/item-card.module.css new file mode 100644 index 000000000..3f405df3a --- /dev/null +++ b/src/renderer/components/item-card/item-card.module.css @@ -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); +} diff --git a/src/renderer/components/item-card/item-card.tsx b/src/renderer/components/item-card/item-card.tsx new file mode 100644 index 000000000..4dd257855 --- /dev/null +++ b/src/renderer/components/item-card/item-card.tsx @@ -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 ( + + ); + case 'poster': + return ( + + ); + case 'default': + default: + return ( + + ); + } +}; + +export interface ItemCardDerivativeProps extends Omit { + imageUrl: string | undefined; + rows: DataRow[]; + setShowControls: Dispatch>; + showControls: boolean; +} + +const CompactItemCard = ({ + data, + imageUrl, + isRound, + onClick, + rows, + setShowControls, + showControls, + withControls, +}: ItemCardDerivativeProps) => { + return ( +
+
withControls && setShowControls(true)} + onMouseLeave={() => withControls && setShowControls(false)} + > + + + {withControls && showControls && } + +
+ {rows.map((row) => ( + + ))} +
+
+
+ ); +}; + +const DefaultItemCard = ({ + data, + imageUrl, + isRound, + onClick, + rows, + setShowControls, + showControls, + withControls, +}: ItemCardDerivativeProps) => { + return ( +
+
withControls && setShowControls(true)} + onMouseLeave={() => withControls && setShowControls(false)} + > + + + {withControls && showControls && } + +
+
+ {rows.map((row) => ( + + + + ))} +
+
+ ); +}; + +const PosterItemCard = ({ + data, + imageUrl, + isRound, + onClick, + rows, + setShowControls, + showControls, + withControls, +}: ItemCardDerivativeProps) => { + return ( +
+
withControls && setShowControls(true)} + onMouseLeave={() => withControls && setShowControls(false)} + > + + + {withControls && showControls && } + +
+
+ {rows.map((row) => ( + + + + ))} +
+
+ ); +}; + +const getDataRows = (data: Album | AlbumArtist | Artist | Playlist | Song): DataRow[] => { + switch (data.itemType) { + case LibraryItem.ALBUM: + return [ + { + format: (data) => { + const album = data as Album; + return ( + + {album.name} + + ); + }, + id: 'name', + }, + { + format: (data) => { + const album = data as Album; + return album.albumArtists.map((artist, index) => ( + + + {artist.name} + + {index < album.albumArtists.length - 1 && } + + )); + }, + 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 ( + + {row.format(data)} + + ); +};