diff --git a/frontend/src/Performer/Details/PerformerDetails.tsx b/frontend/src/Performer/Details/PerformerDetails.tsx index 39753b9a9..2a8b623c0 100644 --- a/frontend/src/Performer/Details/PerformerDetails.tsx +++ b/frontend/src/Performer/Details/PerformerDetails.tsx @@ -9,6 +9,9 @@ import FieldSet from 'Components/FieldSet'; import Icon from 'Components/Icon'; import Label from 'Components/Label'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenu from 'Components/Menu/ViewMenu'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; import MonitorToggleButton from 'Components/MonitorToggleButton'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; @@ -38,6 +41,8 @@ import translate from 'Utilities/String/translate'; import EditPerformerModal from '../Edit/EditPerformerModal'; import PerformerDetailsLinks from './PerformerDetailsLinks'; import PerformerDetailsYear from './PerformerDetailsYear'; +import PerformerPosterOptionsModal from './PerformerPosterOptionsModal'; +import PerformerScenePosters from './PerformerScenePosters'; import PerformerTags from './PerformerTags'; import { usePerformerDetails, @@ -81,6 +86,12 @@ function PerformerDetails() { const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false); const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false); + const [isPosterOptionsModalOpen, setIsPosterOptionsModalOpen] = + useState(false); + const [view, setView] = useState<'table' | 'posters'>('table'); + const [posterSize, setPosterSize] = useState<'small' | 'medium' | 'large'>( + 'medium' + ); const [allExpandedYears, setAllExpandedYears] = useState(false); // Initialize expandedYears so current year is expanded by default @@ -122,6 +133,12 @@ function PerformerDetails() { setExpandedYears(newExpanded); } + function handleViewChange(newView: string) { + if (newView === 'table' || newView === 'posters') { + setView(newView); + } + } + // Table columns for studios (from Redux, matches legacy connector) const { columns } = usePerformerScenesColumns(); // Sorting state for all studios @@ -230,6 +247,15 @@ function PerformerDetails() { function handleDeleteMoviePress() { setIsDeleteMovieModalOpen(true); } + function handlePosterOptionsPress() { + setIsPosterOptionsModalOpen(true); + } + function handlePosterOptionsModalClose() { + setIsPosterOptionsModalOpen(false); + } + function handlePosterSizeChange(size: string) { + setPosterSize(size as 'small' | 'medium' | 'large'); + } function handleRefreshPress() { onRefreshPress(); } @@ -278,11 +304,41 @@ function PerformerDetails() { onPress={handleDeleteMoviePress} /> - + {view === 'table' ? ( + + ) : null} + + {view === 'posters' ? ( + + ) : null} + + + + + {translate('Table')} + + + + {translate('Posters')} + + + @@ -527,7 +583,7 @@ function PerformerDetails() { ) : null} {/* Studios section (delayed render for each studio) */} - {movies.length > 0 && ( + {movies.length > 0 && view === 'table' && (
{moviesByYear.map(({ year, movies: yearMovies }) => ( )} + {movies.length > 0 && view === 'posters' && ( +
+ +
+ )} + ); diff --git a/frontend/src/Performer/Details/PerformerPosterOptionsModal.tsx b/frontend/src/Performer/Details/PerformerPosterOptionsModal.tsx new file mode 100644 index 000000000..c714846bb --- /dev/null +++ b/frontend/src/Performer/Details/PerformerPosterOptionsModal.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import PerformerPosterOptionsModalContent from './PerformerPosterOptionsModalContent'; + +interface PerformerPosterOptionsModalProps { + isOpen: boolean; + size: string; + onSizeChange(size: string): void; + onModalClose(...args: unknown[]): unknown; +} + +function PerformerPosterOptionsModal({ + isOpen, + size, + onSizeChange, + onModalClose, +}: PerformerPosterOptionsModalProps) { + return ( + + + + ); +} + +export default PerformerPosterOptionsModal; diff --git a/frontend/src/Performer/Details/PerformerPosterOptionsModalContent.tsx b/frontend/src/Performer/Details/PerformerPosterOptionsModalContent.tsx new file mode 100644 index 000000000..853699a87 --- /dev/null +++ b/frontend/src/Performer/Details/PerformerPosterOptionsModalContent.tsx @@ -0,0 +1,80 @@ +import React, { useCallback } from 'react'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; + +const posterSizeOptions = [ + { + key: 'small', + get value() { + return translate('Small'); + }, + }, + { + key: 'medium', + get value() { + return translate('Medium'); + }, + }, + { + key: 'large', + get value() { + return translate('Large'); + }, + }, +]; + +interface PerformerPosterOptionsModalContentProps { + size: string; + onSizeChange(size: string): void; + onModalClose(...args: unknown[]): unknown; +} + +function PerformerPosterOptionsModalContent( + props: PerformerPosterOptionsModalContentProps +) { + const { size, onSizeChange, onModalClose } = props; + + const onPosterOptionChange = useCallback( + ({ value }: { name: string; value: string }) => { + onSizeChange(value); + }, + [onSizeChange] + ); + + return ( + + {translate('PosterOptions')} + + +
+ + {translate('PosterSize')} + + + +
+
+ + + + +
+ ); +} + +export default PerformerPosterOptionsModalContent; diff --git a/frontend/src/Performer/Details/PerformerScenePoster.css b/frontend/src/Performer/Details/PerformerScenePoster.css new file mode 100644 index 000000000..6c41a880a --- /dev/null +++ b/frontend/src/Performer/Details/PerformerScenePoster.css @@ -0,0 +1,57 @@ +.content { + transition: all 200ms ease-in; + + &:hover { + z-index: 2; + box-shadow: 0 0 12px var(--black); + transition: all 200ms ease-in; + } +} + +.posterContainer { + position: relative; +} + +.link { + composes: link from '~Components/Link/Link.css'; + + position: relative; + display: block; + background-color: var(--defaultColor); +} + +.poster { + object-fit: cover; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: var(--offWhite); + text-align: center; + font-size: 20px; +} + +.title { + @add-mixin truncate; + + background-color: var(--sceneBackgroundColor); + text-align: center; + font-size: $smallFontSize; +} + +.info { + @add-mixin truncate; + + background-color: var(--sceneBackgroundColor); + color: var(--dimColor); + text-align: center; + font-size: $extraSmallFontSize; +} diff --git a/frontend/src/Performer/Details/PerformerScenePoster.css.d.ts b/frontend/src/Performer/Details/PerformerScenePoster.css.d.ts new file mode 100644 index 000000000..ce2047838 --- /dev/null +++ b/frontend/src/Performer/Details/PerformerScenePoster.css.d.ts @@ -0,0 +1,13 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'content': string; + 'info': string; + 'link': string; + 'overlayTitle': string; + 'poster': string; + 'posterContainer': string; + 'title': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Performer/Details/PerformerScenePoster.tsx b/frontend/src/Performer/Details/PerformerScenePoster.tsx new file mode 100644 index 000000000..20e37d5b1 --- /dev/null +++ b/frontend/src/Performer/Details/PerformerScenePoster.tsx @@ -0,0 +1,121 @@ +import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import { icons } from 'Helpers/Props'; +import Movie from 'Movie/Movie'; +import SceneIndexProgressBar from 'Scene/Index/ProgressBar/SceneIndexProgressBar'; +import ScenePoster from 'Scene/ScenePoster'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import styles from './PerformerScenePoster.css'; + +interface PerformerScenePosterProps { + movie: Movie; + posterWidth: number; + posterHeight: number; +} + +function PerformerScenePoster(props: PerformerScenePosterProps) { + const { movie, posterWidth, posterHeight } = props; + + const safeForWorkMode = useSelector( + (state: AppState) => state.settings.safeForWorkMode + ); + + const { showRelativeDates, shortDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + + const [hasPosterError, setHasPosterError] = useState(false); + + const onPosterLoadError = useCallback(() => { + setHasPosterError(true); + }, []); + + const onPosterLoad = useCallback(() => { + setHasPosterError(false); + }, []); + + const { + title, + foreignId, + images, + releaseDate, + studioTitle, + monitored, + status, + hasFile, + isAvailable, + movieFile, + } = movie; + + const link = `/movie/${foreignId}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px`, + }; + + return ( +
+
+ + + + {hasPosterError ? ( +
{title}
+ ) : null} + +
+ + + +
+ {title} +
+ + {releaseDate ? ( +
+ {' '} + {getRelativeDate({ + date: releaseDate, + shortDateFormat, + showRelativeDates, + timeFormat, + timeForToday: false, + })} +
+ ) : null} + + {studioTitle ? ( +
+ {studioTitle} +
+ ) : null} +
+ ); +} + +export default PerformerScenePoster; diff --git a/frontend/src/Performer/Details/PerformerScenePosters.css b/frontend/src/Performer/Details/PerformerScenePosters.css new file mode 100644 index 000000000..66f35faef --- /dev/null +++ b/frontend/src/Performer/Details/PerformerScenePosters.css @@ -0,0 +1,5 @@ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(167px, 1fr)); + gap: 20px; +} diff --git a/frontend/src/Performer/Details/PerformerScenePosters.css.d.ts b/frontend/src/Performer/Details/PerformerScenePosters.css.d.ts new file mode 100644 index 000000000..b97436b41 --- /dev/null +++ b/frontend/src/Performer/Details/PerformerScenePosters.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'grid': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Performer/Details/PerformerScenePosters.tsx b/frontend/src/Performer/Details/PerformerScenePosters.tsx new file mode 100644 index 000000000..65cec797f --- /dev/null +++ b/frontend/src/Performer/Details/PerformerScenePosters.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { SortDirection } from 'Helpers/Props/sortDirections'; +import Movie from 'Movie/Movie'; +import PerformerScenePoster from './PerformerScenePoster'; +import styles from './PerformerScenePosters.css'; + +type PosterSize = 'small' | 'medium' | 'large'; + +interface PerformerScenePostersProps { + movies: Movie[]; + sortKey: string; + sortDirection: SortDirection; + posterSize?: PosterSize; +} + +const POSTER_DIMENSIONS: Record< + PosterSize, + { width: number; height: number; minmax: number } +> = { + small: { width: 130, height: 73, minmax: 130 }, + medium: { width: 167, height: 94, minmax: 167 }, + large: { width: 240, height: 135, minmax: 240 }, +}; + +function getSortValue(movie: Movie, sortKey: string) { + switch (sortKey) { + case 'releaseDate': + return movie.releaseDate ?? ''; + case 'title': + case 'sortTitle': + return movie.sortTitle ?? movie.title ?? ''; + case 'studioTitle': + return movie.studioTitle ?? ''; + default: + return movie.releaseDate ?? ''; + } +} + +function PerformerScenePosters(props: PerformerScenePostersProps) { + const { movies, sortKey, sortDirection, posterSize = 'medium' } = props; + + const { width, height, minmax } = POSTER_DIMENSIONS[posterSize]; + + const sorted = [...movies].sort((a, b) => { + const aVal = getSortValue(a, sortKey); + const bVal = getSortValue(b, sortKey); + + let cmp = 0; + + if (aVal < bVal) { + cmp = -1; + } else if (aVal > bVal) { + cmp = 1; + } + + return sortDirection === 'ascending' ? cmp : -cmp; + }); + + const gridStyle = { + gridTemplateColumns: `repeat(auto-fill, minmax(${minmax}px, 1fr))`, + }; + + return ( +
+ {sorted.map((movie) => ( + + ))} +
+ ); +} + +export default PerformerScenePosters;