diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index e827f80a5..cd06049ed 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -1,12 +1,12 @@ import { Button, Tab, Nav, Dropdown } from "react-bootstrap"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useContext, useEffect, useMemo, useState } from "react"; import { useHistory, Link, RouteComponentProps, Redirect, } from "react-router-dom"; -import { FormattedMessage, useIntl } from "react-intl"; +import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; import { @@ -37,6 +37,11 @@ import { import { galleryPath, galleryTitle } from "src/core/galleries"; import { GalleryChapterPanel } from "./GalleryChaptersPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; +import cx from "classnames"; +import { useRatingKeybinds } from "src/hooks/keybinds"; +import { ConfigurationContext } from "src/hooks/Config"; +import { TruncatedText } from "src/components/Shared/TruncatedText"; interface IProps { gallery: GQL.GalleryDataFragment; @@ -52,6 +57,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { const history = useHistory(); const Toast = useToast(); const intl = useIntl(); + const { configuration } = useContext(ConfigurationContext); const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters); const [collapsed, setCollapsed] = useState(false); @@ -236,14 +242,6 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { - - - - {renderOperations()} @@ -320,6 +318,23 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { ); } + function setRating(v: number | null) { + updateGallery({ + variables: { + input: { + id: gallery.id, + rating100: v, + }, + }, + }); + } + + useRatingKeybinds( + true, + configuration?.ui.ratingSystemOptions?.type, + setRating + ); + // set up hotkeys useEffect(() => { Mousetrap.bind("a", () => setActiveTabKey("gallery-details-panel")); @@ -346,19 +361,58 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { {maybeRenderDeleteDialog()}
-
- {gallery.studio && ( -

- - {`${gallery.studio.name} +
+ {gallery.studio && ( +

+ + {`${gallery.studio.name} + +

+ )} +

+ +

+
+ +
+ {!!gallery.date && ( + + - -

- )} -

{title}

+ + )} +
+ +
+ + + + + + + + {renderOperations()} + +
{renderTabs()} diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx index 9b03bed1b..597a57b15 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx @@ -1,14 +1,10 @@ import React from "react"; -import { Link } from "react-router-dom"; -import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; import { TagLink } from "src/components/Shared/TagLink"; -import { TruncatedText } from "src/components/Shared/TruncatedText"; import { PerformerCard } from "src/components/Performers/PerformerCard"; -import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { sortPerformers } from "src/core/performers"; -import { galleryTitle } from "src/core/galleries"; import { PhotographerLink } from "src/components/Shared/Link"; interface IGalleryDetailProps { @@ -78,32 +74,11 @@ export const GalleryDetailPanel: React.FC = ({ // filename should use entire row if there is no studio const galleryDetailsWidth = gallery.studio ? "col-9" : "col-12"; - const title = galleryTitle(gallery); return ( <>
-
-

- -

- {gallery.date ? ( -
- -
- ) : undefined} - {gallery.rating100 ? ( -
- :{" "} - -
- ) : ( - "" - )} +
:{" "} {TextUtils.formatDateTime(intl, gallery.created_at)}{" "} @@ -127,17 +102,6 @@ export const GalleryDetailPanel: React.FC = ({
)}
- {gallery.studio && ( -
- - {`${gallery.studio.name} - -
- )}
diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 65a94c2bf..b400c6329 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -26,8 +26,6 @@ import { useFormik } from "formik"; import { GalleryScrapeDialog } from "./GalleryScrapeDialog"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { galleryTitle } from "src/core/galleries"; -import { useRatingKeybinds } from "src/hooks/keybinds"; -import { ConfigurationContext } from "src/hooks/Config"; import isEqual from "lodash-es/isEqual"; import { handleUnsavedChanges } from "src/utils/navigation"; import { @@ -70,7 +68,6 @@ export const GalleryEditPanel: React.FC = ({ const [studio, setStudio] = useState(null); const isNew = gallery.id === undefined; - const { configuration: stashConfig } = React.useContext(ConfigurationContext); const Scrapers = useListGalleryScrapers(); const [queryableScrapers, setQueryableScrapers] = useState([]); @@ -90,7 +87,6 @@ export const GalleryEditPanel: React.FC = ({ urls: yupUniqueStringList(intl), date: yupDateString(intl), photographer: yup.string().ensure(), - rating100: yup.number().integer().nullable().defined(), studio_id: yup.string().required().nullable(), performer_ids: yup.array(yup.string().required()).defined(), tag_ids: yup.array(yup.string().required()).defined(), @@ -104,7 +100,6 @@ export const GalleryEditPanel: React.FC = ({ urls: gallery?.urls ?? [], date: gallery?.date ?? "", photographer: gallery?.photographer ?? "", - rating100: gallery?.rating100 ?? null, studio_id: gallery?.studio?.id ?? null, performer_ids: (gallery?.performers ?? []).map((p) => p.id), tag_ids: (gallery?.tags ?? []).map((t) => t.id), @@ -121,10 +116,6 @@ export const GalleryEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); - function setRating(v: number) { - formik.setFieldValue("rating100", v); - } - interface ISceneSelectValue { id: string; title: string; @@ -159,12 +150,6 @@ export const GalleryEditPanel: React.FC = ({ formik.setFieldValue("studio_id", item ? item.id : null); } - useRatingKeybinds( - isVisible, - stashConfig?.ui.ratingSystemOptions?.type, - setRating - ); - useEffect(() => { setPerformers(gallery.performers ?? []); }, [gallery.performers]); @@ -420,13 +405,8 @@ export const GalleryEditPanel: React.FC = ({ xl: 12, }, }; - const { - renderField, - renderInputField, - renderDateField, - renderRatingField, - renderURLListField, - } = formikUtils(intl, formik, splitProps); + const { renderField, renderInputField, renderDateField, renderURLListField } = + formikUtils(intl, formik, splitProps); function renderScenesField() { const title = intl.formatMessage({ id: "scenes" }); @@ -532,7 +512,6 @@ export const GalleryEditPanel: React.FC = ({ {renderDateField("date")} {renderInputField("photographer")} - {renderRatingField("rating100", "rating")} {renderScenesField()} {renderStudioField()} diff --git a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx index f378cb257..7e6a56188 100644 --- a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx @@ -73,6 +73,7 @@ export const GalleryListTable: React.FC = ( setRating(value, gallery.id)} + clickToRate /> ); diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index acb26a066..82b2def92 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -50,7 +50,7 @@ const GalleryWallCard: React.FC = ({ gallery }) => { role="button" tabIndex={0} > - +
= ({ image }) => { const history = useHistory(); const Toast = useToast(); const intl = useIntl(); + const { configuration } = useContext(ConfigurationContext); const [incrementO] = useImageIncrementO(image.id); const [decrementO] = useImageDecrementO(image.id); @@ -128,6 +135,23 @@ const ImagePage: React.FC = ({ image }) => { } }; + function setRating(v: number | null) { + updateImage({ + variables: { + input: { + id: image.id, + rating100: v, + }, + }, + }); + } + + useRatingKeybinds( + true, + configuration?.ui.ratingSystemOptions?.type, + setRating + ); + function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { @@ -205,22 +229,6 @@ const ImagePage: React.FC = ({ image }) => { - - - - - - - {renderOperations()}
@@ -264,9 +272,20 @@ const ImagePage: React.FC = ({ image }) => { }; }); + const file = useMemo( + () => (image.files.length > 0 ? image.files[0] : undefined), + [image] + ); + const title = objectTitle(image); const ImageView = isVideo(image.visual_files[0]) ? "video" : "img"; + const resolution = useMemo(() => { + return file?.width && file?.height + ? TextUtils.resolution(file?.width, file?.height) + : undefined; + }, [file?.width, file?.height]); + return (
@@ -275,19 +294,69 @@ const ImagePage: React.FC = ({ image }) => { {maybeRenderDeleteDialog()}
-
- {image.studio && ( -

- - {`${image.studio.name} +
+ {image.studio && ( +

+ + {`${image.studio.name} + +

+ )} +

+ +

+
+ +
+ + {!!image.date && ( + - -

- )} -

{title}

+ )} + + {resolution ? ( + + {resolution} + + ) : undefined} +
+
+ +
+ + + + + + + + + + + {renderOperations()} +
{renderTabs()}
diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx index dbd8ac43b..65cce87de 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx @@ -1,14 +1,10 @@ -import React, { useMemo } from "react"; -import { Link } from "react-router-dom"; +import React from "react"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; import { GalleryLink, TagLink } from "src/components/Shared/TagLink"; -import { TruncatedText } from "src/components/Shared/TruncatedText"; import { PerformerCard } from "src/components/Performers/PerformerCard"; -import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { sortPerformers } from "src/core/performers"; -import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; -import { objectTitle } from "src/core/files"; +import { FormattedMessage, useIntl } from "react-intl"; import { PhotographerLink } from "src/components/Shared/Link"; interface IImageDetailProps { image: GQL.ImageDataFragment; @@ -17,11 +13,6 @@ interface IImageDetailProps { export const ImageDetailPanel: React.FC = (props) => { const intl = useIntl(); - const file = useMemo( - () => (props.image.files.length > 0 ? props.image.files[0] : undefined), - [props.image] - ); - function renderDetails() { if (!props.image.details) return; return ( @@ -102,39 +93,8 @@ export const ImageDetailPanel: React.FC = (props) => { return ( <>
-
-
-

- -

-
- {props.image.date ? ( -
- -
- ) : undefined} - {props.image.rating100 ? ( -
- :{" "} - -
- ) : ( - "" - )} - +
{renderGalleries()} - {file?.width && file?.height ? ( -
- :{" "} - {TextUtils.resolution(file.width, file.height)} -
- ) : ( - "" - )} {
{" "} @@ -163,17 +123,6 @@ export const ImageDetailPanel: React.FC = (props) => {
)}
- {props.image.studio && ( -
- - {`${props.image.studio.name} - -
- )}
diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index 0d55c7340..ac411ffe8 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -8,8 +8,6 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; -import { useRatingKeybinds } from "src/hooks/keybinds"; -import { ConfigurationContext } from "src/hooks/Config"; import isEqual from "lodash-es/isEqual"; import { yupDateString, @@ -49,8 +47,6 @@ export const ImageEditPanel: React.FC = ({ // Network state const [isLoading, setIsLoading] = useState(false); - const { configuration } = React.useContext(ConfigurationContext); - const [galleries, setGalleries] = useState([]); const [performers, setPerformers] = useState([]); const [tags, setTags] = useState([]); @@ -74,7 +70,6 @@ export const ImageEditPanel: React.FC = ({ date: yupDateString(intl), details: yup.string().ensure(), photographer: yup.string().ensure(), - rating100: yup.number().integer().nullable().defined(), gallery_ids: yup.array(yup.string().required()).defined(), studio_id: yup.string().required().nullable(), performer_ids: yup.array(yup.string().required()).defined(), @@ -88,7 +83,6 @@ export const ImageEditPanel: React.FC = ({ date: image?.date ?? "", details: image.details ?? "", photographer: image.photographer ?? "", - rating100: image.rating100 ?? null, gallery_ids: (image.galleries ?? []).map((g) => g.id), studio_id: image.studio?.id ?? null, performer_ids: (image.performers ?? []).map((p) => p.id), @@ -104,10 +98,6 @@ export const ImageEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); - function setRating(v: number) { - formik.setFieldValue("rating100", v); - } - function onSetGalleries(items: Gallery[]) { setGalleries(items); formik.setFieldValue( @@ -137,12 +127,6 @@ export const ImageEditPanel: React.FC = ({ formik.setFieldValue("studio_id", item ? item.id : null); } - useRatingKeybinds( - true, - configuration?.ui.ratingSystemOptions?.type, - setRating - ); - useEffect(() => { setPerformers(image.performers ?? []); }, [image.performers]); @@ -209,13 +193,8 @@ export const ImageEditPanel: React.FC = ({ xl: 12, }, }; - const { - renderField, - renderInputField, - renderDateField, - renderRatingField, - renderURLListField, - } = formikUtils(intl, formik, splitProps); + const { renderField, renderInputField, renderDateField, renderURLListField } = + formikUtils(intl, formik, splitProps); function renderGalleriesField() { const title = intl.formatMessage({ id: "galleries" }); @@ -318,7 +297,6 @@ export const ImageEditPanel: React.FC = ({ {renderDateField("date")} {renderInputField("photographer")} - {renderRatingField("rating100", "rating")} {renderGalleriesField()} {renderStudioField()} diff --git a/ui/v2.5/src/components/Images/styles.scss b/ui/v2.5/src/components/Images/styles.scss index ed80c0ffa..936947bc3 100644 --- a/ui/v2.5/src/components/Images/styles.scss +++ b/ui/v2.5/src/components/Images/styles.scss @@ -1,6 +1,64 @@ +@include media-breakpoint-only(lg) { + .image-header-container { + align-items: center; + display: flex; + justify-content: space-between; + + .image-header { + flex: 0 0 75%; + order: 1; + } + + .image-studio-image { + flex: 0 0 25%; + order: 2; + } + } +} + .image-header { flex-basis: auto; + font-size: 1.5rem; margin-top: 30px; + + @include media-breakpoint-down(xl) { + font-size: 1.75rem; + } +} + +.image-subheader { + display: flex; + justify-content: space-between; + margin-top: 0.5rem; + + .date { + color: $text-muted; + } + + .resolution { + font-weight: bold; + } +} + +.image-toolbar { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 0.25rem; + margin-top: 0.5rem; + padding-bottom: 0.25rem; + width: 100%; + + .image-toolbar-group { + align-items: center; + column-gap: 0.25rem; + display: flex; + width: 100%; + + &:last-child { + justify-content: flex-end; + } + } } #image-details-container { diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index 32690a307..d61d9a61d 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -427,6 +427,8 @@ const MoviePage: React.FC = ({ movie }) => { setRating(value)} + clickToRate + withoutContext /> {maybeRenderDetails()} {maybeRenderEditPanel()} diff --git a/ui/v2.5/src/components/Movies/styles.scss b/ui/v2.5/src/components/Movies/styles.scss index 343d2617a..ae400086f 100644 --- a/ui/v2.5/src/components/Movies/styles.scss +++ b/ui/v2.5/src/components/Movies/styles.scss @@ -39,3 +39,7 @@ object-fit: contain; } } + +#movie-page .rating-number .text-input { + width: auto; +} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 710829657..b0712f489 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -592,6 +592,8 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { setRating(value)} + clickToRate + withoutContext /> {maybeRenderDetails()} {maybeRenderEditPanel()} diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 756efa979..6a3818824 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -108,6 +108,7 @@ export const PerformerListTable: React.FC = ( setRating(value, performer.id)} + clickToRate /> ); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index f4cbbe0e3..1951a8664 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -1,4 +1,4 @@ -import { Tab, Nav, Dropdown, Button, ButtonGroup } from "react-bootstrap"; +import { Tab, Nav, Dropdown, Button } from "react-bootstrap"; import React, { useEffect, useState, @@ -7,7 +7,7 @@ import React, { useRef, useLayoutEffect, } from "react"; -import { FormattedMessage, useIntl } from "react-intl"; +import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; import { Link, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; @@ -15,12 +15,11 @@ import { mutateMetadataScan, useFindScene, useSceneIncrementO, - useSceneDecrementO, - useSceneResetO, useSceneGenerateScreenshot, useSceneUpdate, queryFindScenes, queryFindScenesByID, + useSceneIncrementPlayCount, } from "src/core/StashService"; import { SceneEditPanel } from "./SceneEditPanel"; @@ -32,7 +31,6 @@ import { useToast } from "src/hooks/Toast"; import SceneQueue, { QueuedScene } from "src/models/sceneQueue"; import { ListFilterModel } from "src/models/list-filter/filter"; import Mousetrap from "mousetrap"; -import { OCounterButton } from "./OCounterButton"; import { OrganizedButton } from "./OrganizedButton"; import { ConfigurationContext } from "src/hooks/Config"; import { getPlayerPosition } from "src/components/ScenePlayer/util"; @@ -41,7 +39,17 @@ import { faChevronRight, faChevronLeft, } from "@fortawesome/free-solid-svg-icons"; +import { objectPath, objectTitle } from "src/core/files"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; +import TextUtils from "src/utils/text"; +import { + OCounterButton, + ViewCountButton, +} from "src/components/Shared/CountButton"; +import { useRatingKeybinds } from "src/hooks/keybinds"; import { lazyComponent } from "src/utils/lazyComponent"; +import cx from "classnames"; +import { TruncatedText } from "src/components/Shared/TruncatedText"; const SubmitStashBoxDraft = lazyComponent( () => import("src/components/Dialogs/SubmitDraft") @@ -73,7 +81,54 @@ const GenerateDialog = lazyComponent( const SceneVideoFilterPanel = lazyComponent( () => import("./SceneVideoFilterPanel") ); -import { objectPath, objectTitle } from "src/core/files"; + +const VideoFrameRateResolution: React.FC<{ + width?: number; + height?: number; + frameRate?: number; +}> = ({ width, height, frameRate }) => { + const intl = useIntl(); + + const resolution = useMemo(() => { + if (width && height) { + const r = TextUtils.resolution(width, height); + return ( + + {r} + + ); + } + return undefined; + }, [width, height]); + + const frameRateDisplay = useMemo(() => { + if (frameRate) { + return ( + + + + ); + } + return undefined; + }, [intl, frameRate]); + + const divider = useMemo(() => { + return resolution && frameRateDisplay ? ( + | + ) : undefined; + }, [resolution, frameRateDisplay]); + + return ( + + {frameRateDisplay} + {divider} + {resolution} + + ); +}; interface IProps { scene: GQL.SceneDataFragment; @@ -126,8 +181,16 @@ const ScenePage: React.FC = ({ const boxes = configuration?.general?.stashBoxes ?? []; const [incrementO] = useSceneIncrementO(scene.id); - const [decrementO] = useSceneDecrementO(scene.id); - const [resetO] = useSceneResetO(scene.id); + + const [incrementPlay] = useSceneIncrementPlayCount(); + + function incrementPlayCount() { + incrementPlay({ + variables: { + id: scene.id, + }, + }); + } const [organizedLoading, setOrganizedLoading] = useState(false); @@ -136,7 +199,7 @@ const ScenePage: React.FC = ({ const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); - const onIncrementClick = async () => { + const onIncrementOClick = async () => { try { await incrementO(); } catch (e) { @@ -144,13 +207,22 @@ const ScenePage: React.FC = ({ } }; - const onDecrementClick = async () => { - try { - await decrementO(); - } catch (e) { - Toast.error(e); - } - }; + function setRating(v: number | null) { + updateScene({ + variables: { + input: { + id: scene.id, + rating100: v, + }, + }, + }); + } + + useRatingKeybinds( + true, + configuration?.ui.ratingSystemOptions?.type, + setRating + ); // set up hotkeys useEffect(() => { @@ -161,7 +233,7 @@ const ScenePage: React.FC = ({ Mousetrap.bind("i", () => setActiveTabKey("scene-file-info-panel")); Mousetrap.bind("h", () => setActiveTabKey("scene-history-panel")); Mousetrap.bind("o", () => { - onIncrementClick(); + onIncrementOClick(); }); Mousetrap.bind("p n", () => onQueueNext()); Mousetrap.bind("p p", () => onQueuePrevious()); @@ -218,14 +290,6 @@ const ScenePage: React.FC = ({ } }; - const onResetClick = async () => { - try { - await resetO(); - } catch (e) { - Toast.error(e); - } - }; - function onClickMarker(marker: GQL.SceneMarkerDataFragment) { setTimestamp(marker.seconds); } @@ -420,27 +484,6 @@ const ScenePage: React.FC = ({ - - - - - - - - - - - {renderOperations()} -
@@ -509,6 +552,11 @@ const ScenePage: React.FC = ({ const title = objectTitle(scene); + const file = useMemo( + () => (scene.files.length > 0 ? scene.files[0] : undefined), + [scene] + ); + return ( <> @@ -521,19 +569,76 @@ const ScenePage: React.FC = ({ collapsed ? "collapsed" : "" }`} > -
- {scene.studio && ( -

- - {`${scene.studio.name} +
+ {scene.studio && ( +

+ + {`${scene.studio.name} + +

+ )} +

+ +

+
+ +
+ + {!!scene.date && ( + - -

- )} -

{title}

+ )} + + +
+ +
+ + + + + + + + + incrementPlayCount()} + /> + + + onIncrementOClick()} + /> + + + + + {renderOperations()} + +
{renderTabs()}
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx index 729be465a..ad7663e9d 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx @@ -1,14 +1,10 @@ -import React, { useMemo } from "react"; -import { Link } from "react-router-dom"; -import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; +import React from "react"; +import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; import { TagLink } from "src/components/Shared/TagLink"; -import { TruncatedText } from "src/components/Shared/TruncatedText"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; -import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; -import { objectTitle } from "src/core/files"; import { DirectorLink } from "src/components/Shared/Link"; interface ISceneDetailProps { @@ -18,11 +14,6 @@ interface ISceneDetailProps { export const SceneDetailPanel: React.FC = (props) => { const intl = useIntl(); - const file = useMemo( - () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined), - [props.scene] - ); - function renderDetails() { if (!props.scene.details || props.scene.details === "") return; return ( @@ -85,35 +76,7 @@ export const SceneDetailPanel: React.FC = (props) => { return ( <>
-
-
-

- -

-
- {props.scene.date ? ( -
- -
- ) : undefined} - {props.scene.rating100 ? ( -
- :{" "} - -
- ) : ( - "" - )} - {file?.width && file?.height && ( -
- :{" "} - {TextUtils.resolution(file.width, file.height)} -
- )} +
:{" "} {TextUtils.formatDateTime(intl, props.scene.created_at)}{" "} @@ -134,17 +97,6 @@ export const SceneDetailPanel: React.FC = (props) => {
)}
- {props.scene.studio && ( -
- - {`${props.scene.studio.name} - -
- )}
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index b4c3048c3..bee912c59 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -33,7 +33,6 @@ import { IMovieEntry, SceneMovieTable } from "./SceneMovieTable"; import { faSearch, faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; -import { useRatingKeybinds } from "src/hooks/keybinds"; import { lazyComponent } from "src/utils/lazyComponent"; import isEqual from "lodash-es/isEqual"; import { @@ -128,7 +127,6 @@ export const SceneEditPanel: React.FC = ({ urls: yupUniqueStringList(intl), date: yupDateString(intl), director: yup.string().ensure(), - rating100: yup.number().integer().nullable().defined(), gallery_ids: yup.array(yup.string().required()).defined(), studio_id: yup.string().required().nullable(), performer_ids: yup.array(yup.string().required()).defined(), @@ -153,7 +151,6 @@ export const SceneEditPanel: React.FC = ({ urls: scene.urls ?? [], date: scene.date ?? "", director: scene.director ?? "", - rating100: scene.rating100 ?? null, gallery_ids: (scene.galleries ?? []).map((g) => g.id), studio_id: scene.studio?.id ?? null, performer_ids: (scene.performers ?? []).map((p) => p.id), @@ -201,10 +198,6 @@ export const SceneEditPanel: React.FC = ({ .filter((m) => m.movie !== undefined) as IMovieEntry[]; }, [formik.values.movies, movies]); - function setRating(v: number) { - formik.setFieldValue("rating100", v); - } - function onSetGalleries(items: Gallery[]) { setGalleries(items); formik.setFieldValue( @@ -234,12 +227,6 @@ export const SceneEditPanel: React.FC = ({ formik.setFieldValue("studio_id", item ? item.id : null); } - useRatingKeybinds( - isVisible, - stashConfig?.ui.ratingSystemOptions?.type, - setRating - ); - useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { @@ -726,7 +713,6 @@ export const SceneEditPanel: React.FC = ({ renderField, renderInputField, renderDateField, - renderRatingField, renderURLListField, renderStashIDsField, } = formikUtils(intl, formik, splitProps); @@ -865,7 +851,6 @@ export const SceneEditPanel: React.FC = ({ {renderDateField("date")} {renderInputField("director")} - {renderRatingField("rating100", "rating")} {renderGalleriesField()} {renderStudioField()} diff --git a/ui/v2.5/src/components/Scenes/SceneListTable.tsx b/ui/v2.5/src/components/Scenes/SceneListTable.tsx index 5500d096e..33581baa4 100644 --- a/ui/v2.5/src/components/Scenes/SceneListTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneListTable.tsx @@ -78,6 +78,7 @@ export const SceneListTable: React.FC = ( setRating(value, scene.id)} + clickToRate /> ); diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 2f66a54e9..e40f62ac5 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -63,9 +63,67 @@ max-width: 100%; } +@include media-breakpoint-only(lg) { + .scene-header-container { + align-items: center; + display: flex; + justify-content: space-between; + + .scene-header { + flex: 0 0 75%; + order: 1; + } + + .scene-studio-image { + flex: 0 0 25%; + order: 2; + } + } +} + .scene-header { flex-basis: auto; + font-size: 1.5rem; margin-top: 30px; + + @include media-breakpoint-down(xl) { + font-size: 1.75rem; + } +} + +.scene-subheader { + display: flex; + justify-content: space-between; + margin-top: 0.5rem; + + .date { + color: $text-muted; + } + + .resolution { + font-weight: bold; + } +} + +.scene-toolbar { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 0.25rem; + margin-top: 0.5rem; + padding-bottom: 0.25rem; + width: 100%; + + .scene-toolbar-group { + align-items: center; + column-gap: 0.25rem; + display: flex; + width: 100%; + + &:last-child { + justify-content: flex-end; + } + } } #scene-details-container { diff --git a/ui/v2.5/src/components/Shared/CountButton.tsx b/ui/v2.5/src/components/Shared/CountButton.tsx new file mode 100644 index 000000000..1519c104b --- /dev/null +++ b/ui/v2.5/src/components/Shared/CountButton.tsx @@ -0,0 +1,74 @@ +import { faEye } from "@fortawesome/free-solid-svg-icons"; +import React from "react"; +import { Button, ButtonGroup } from "react-bootstrap"; +import { Icon } from "src/components/Shared/Icon"; +import { SweatDrops } from "./SweatDrops"; +import cx from "classnames"; +import { useIntl } from "react-intl"; + +interface ICountButtonProps { + value: number; + icon: React.ReactNode; + onIncrement?: () => void; + onValueClicked?: () => void; + title?: string; + countTitle?: string; +} + +export const CountButton: React.FC = ({ + value, + icon, + onIncrement, + onValueClicked, + title, + countTitle, +}) => { + return ( + + + + + ); +}; + +type CountButtonPropsNoIcon = Omit; + +export const ViewCountButton: React.FC = (props) => { + const intl = useIntl(); + return ( + } + title={intl.formatMessage({ id: "media_info.play_count" })} + countTitle={intl.formatMessage({ id: "actions.view_history" })} + /> + ); +}; + +export const OCounterButton: React.FC = (props) => { + const intl = useIntl(); + return ( + } + title={intl.formatMessage({ id: "o_count" })} + countTitle={intl.formatMessage({ id: "actions.view_history" })} + /> + ); +}; diff --git a/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx b/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx index 465d19319..14cd701d1 100644 --- a/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx +++ b/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx @@ -1,15 +1,35 @@ -import React, { useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; +import { Button } from "react-bootstrap"; +import { Icon } from "../Icon"; +import { faPencil, faStar } from "@fortawesome/free-solid-svg-icons"; +import { useFocusOnce } from "src/utils/focus"; export interface IRatingNumberProps { value: number | null; onSetRating?: (value: number | null) => void; disabled?: boolean; + clickToRate?: boolean; + // true if we should indicate that this is a rating + withoutContext?: boolean; } export const RatingNumber: React.FC = ( props: IRatingNumberProps ) => { - const text = ((props.value ?? 0) / 10).toFixed(1); + const [editing, setEditing] = useState(false); + const [valueStage, setValueStage] = useState(props.value); + + useEffect(() => { + setValueStage(props.value); + }, [props.value]); + + const showTextField = !props.disabled && (editing || !props.clickToRate); + + const [ratingRef] = useFocusOnce(editing, true); + + const effectiveValue = editing ? valueStage : props.value; + + const text = ((effectiveValue ?? 0) / 10).toFixed(1); const useValidation = useRef(true); function stepChange() { @@ -38,11 +58,13 @@ export const RatingNumber: React.FC = ( return; } + const setRating = editing ? setValueStage : props.onSetRating; + let val = e.target.value; if (!useValidation.current) { e.target.value = Number(val).toFixed(1); const tempVal = Number(val) * 10; - props.onSetRating(tempVal || null); + setRating(tempVal || null); useValidation.current = true; return; } @@ -50,7 +72,7 @@ export const RatingNumber: React.FC = ( const match = /(\d?)(\d?)(.?)((\d)?)/g.exec(val); const matchOld = /(\d?)(\d?)(.?)((\d{0,2})?)/g.exec(text ?? ""); - if (match == null || props.onSetRating == null) { + if (match == null) { return; } @@ -70,7 +92,7 @@ export const RatingNumber: React.FC = ( } e.target.value = Number(value).toFixed(1); let tempVal = Number(value) * 10; - props.onSetRating(tempVal || null); + setRating(tempVal || null); let cursorPosition = 0; if (match[2] && !match[4]) { @@ -90,22 +112,44 @@ export const RatingNumber: React.FC = ( } } - if (props.disabled) { + function onBlur() { + if (editing) { + setEditing(false); + if (props.onSetRating && valueStage !== props.value) { + props.onSetRating(valueStage); + } + } + } + + if (!showTextField) { return (
- {Number((props.value ?? 0) / 10).toFixed(1)} + {props.withoutContext && } + {Number((effectiveValue ?? 0) / 10).toFixed(1)} + {!props.disabled && props.clickToRate && ( + + )}
); } else { return (
void; disabled?: boolean; valueRequired?: boolean; + // if true, requires a click first to edit the rating + clickToRate?: boolean; + // true if we should indicate that this is a rating + withoutContext?: boolean; } export const RatingSystem: React.FC = ( @@ -40,6 +44,8 @@ export const RatingSystem: React.FC = ( value={props.value ?? null} onSetRating={props.onSetRating} disabled={props.disabled} + clickToRate={props.clickToRate} + withoutContext={props.withoutContext} /> ); } diff --git a/ui/v2.5/src/components/Shared/Rating/styles.scss b/ui/v2.5/src/components/Shared/Rating/styles.scss index f7b463359..0285d8d6a 100644 --- a/ui/v2.5/src/components/Shared/Rating/styles.scss +++ b/ui/v2.5/src/components/Shared/Rating/styles.scss @@ -93,7 +93,18 @@ margin: auto 0.5rem; } -.rating-number.disabled { - align-items: center; - display: inline-flex; +.rating-number { + .fa-icon { + color: gold; + margin-left: 0; + } + + .edit-rating-button { + font-size: 0.75rem; + } + + &.disabled { + align-items: center; + display: inline-flex; + } } diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 2ac707f06..3736ad524 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -552,3 +552,43 @@ button.btn.favorite-button { box-shadow: none; } } + +.count-button { + border-radius: 5px; + + &:hover { + background: rgba(138, 155, 168, 0.15); + color: #f5f8fa; + } + + .count-icon { + padding-left: 0.5rem; + padding-right: 0.25rem; + } + + .count-value { + padding-left: 0.25rem; + padding-right: 0.5rem; + } + + button.count-icon, + &.increment-only button.count-value { + &:hover { + background: none; + color: #f5f8fa; + } + } + + button.btn-secondary.count-icon, + button.btn-secondary.count-value { + &:focus { + border: none; + box-shadow: none; + color: #f5f8fa; + + &:not(:hover) { + background: none; + } + } + } +} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index d72aa89e3..8b4e44d3f 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -565,6 +565,8 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { setRating(value)} + clickToRate + withoutContext /> {maybeRenderDetails()} {maybeRenderEditPanel()} diff --git a/ui/v2.5/src/components/Studios/styles.scss b/ui/v2.5/src/components/Studios/styles.scss index a851891b6..9d919f42d 100644 --- a/ui/v2.5/src/components/Studios/styles.scss +++ b/ui/v2.5/src/components/Studios/styles.scss @@ -36,4 +36,8 @@ } } } + + .rating-number .text-input { + width: auto; + } } diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index e2147858b..5a5bc3904 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -403,12 +403,14 @@ li.active .optional-field.excluded .scene-link { // margin-left: 1rem; // } -.scene-details, -.original-scene-details { - margin-top: 0.5rem; +.tagger-container { + .scene-details, + .original-scene-details { + margin-top: 0.5rem; - > .row { - width: 100%; + > .row { + width: 100%; + } } } diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index dcdbc4b33..47d45fa31 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -925,6 +925,8 @@ export const LightboxComponent: React.FC = ({ setRating(v)} + clickToRate + withoutContext /> )} diff --git a/ui/v2.5/src/hooks/Lightbox/lightbox.scss b/ui/v2.5/src/hooks/Lightbox/lightbox.scss index a4c82d639..b12de3cf9 100644 --- a/ui/v2.5/src/hooks/Lightbox/lightbox.scss +++ b/ui/v2.5/src/hooks/Lightbox/lightbox.scss @@ -94,6 +94,10 @@ padding-left: 0.38rem; } + .rating-number .text-input { + width: auto; + } + &-left { display: flex; flex-direction: column; diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 65d4c9b4b..73cc4da01 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -1365,3 +1365,8 @@ select { .primary-file { font-weight: bold; } + +// ensure rating number editing doesn't resize column +.table-list .rating-number { + width: 6rem; +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index de9905813..c752e5b73 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -134,6 +134,7 @@ "temp_enable": "Enable temporarily…", "unset": "Unset", "use_default": "Use default", + "view_history": "View history", "view_random": "View Random" }, "actions_name": "Actions", diff --git a/ui/v2.5/src/utils/focus.ts b/ui/v2.5/src/utils/focus.ts index 2048aede0..68db7d772 100644 --- a/ui/v2.5/src/utils/focus.ts +++ b/ui/v2.5/src/utils/focus.ts @@ -14,16 +14,16 @@ const useFocus = () => { }; // focuses on the element only once on mount -export const useFocusOnce = (active?: boolean) => { +export const useFocusOnce = (active?: boolean, override?: boolean) => { const [htmlElRef, setFocus] = useFocus(); const focused = useRef(false); useEffect(() => { - if (!focused.current && active) { + if ((!focused.current || override) && active) { setFocus(); focused.current = true; } - }, [setFocus, active]); + }, [setFocus, active, override]); return [htmlElRef, setFocus] as const; };