Details operation toolbar (#4714)

* Add scene detail header
* Make common count button and add view count
* Add titles to play count and o count buttons
* Move rating from edit panel
* Include frame rate in header
* Remove redundant title/studio
* Improve numeric rating presentation
* Add star where there is no rating header
* Set rating on blur when click to edit
* Add star to numeric rating on gallery wall card
* Apply click to rate on movie page
* Apply click to rate to performer page
* Apply click to rate to studio page
* Fix rating number presentation on list tables
* Add data-value attributes
This commit is contained in:
WithoutPants 2024-04-17 10:29:36 +10:00 committed by GitHub
parent 911da87264
commit ec6acab2f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 752 additions and 336 deletions

View file

@ -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<IProps> = ({ 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<IProps> = ({ gallery, add }) => {
<FormattedMessage id="actions.edit" />
</Nav.Link>
</Nav.Item>
<Nav.Item className="ml-auto">
<OrganizedButton
loading={organizedLoading}
organized={gallery.organized}
onClick={onOrganizedClick}
/>
</Nav.Item>
<Nav.Item>{renderOperations()}</Nav.Item>
</Nav>
</div>
@ -320,6 +318,23 @@ export const GalleryPage: React.FC<IProps> = ({ 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<IProps> = ({ gallery, add }) => {
</Helmet>
{maybeRenderDeleteDialog()}
<div className={`gallery-tabs ${collapsed ? "collapsed" : ""}`}>
<div className="d-none d-xl-block">
{gallery.studio && (
<h1 className="text-center">
<Link to={`/studios/${gallery.studio.id}`}>
<img
src={gallery.studio.image_path ?? ""}
alt={`${gallery.studio.name} logo`}
className="studio-logo"
<div>
<div className="gallery-header-container">
{gallery.studio && (
<h1 className="text-center gallery-studio-image">
<Link to={`/studios/${gallery.studio.id}`}>
<img
src={gallery.studio.image_path ?? ""}
alt={`${gallery.studio.name} logo`}
className="studio-logo"
/>
</Link>
</h1>
)}
<h3
className={cx("gallery-header", { "no-studio": !gallery.studio })}
>
<TruncatedText lineCount={2} text={title} />
</h3>
</div>
<div className="gallery-subheader">
{!!gallery.date && (
<span className="date" data-value={gallery.date}>
<FormattedDate
value={gallery.date}
format="long"
timeZone="utc"
/>
</Link>
</h1>
)}
<h3 className="gallery-header">{title}</h3>
</span>
)}
</div>
<div className="gallery-toolbar">
<span className="gallery-toolbar-group">
<RatingSystem
value={gallery.rating100}
onSetRating={setRating}
clickToRate
withoutContext
/>
</span>
<span className="gallery-toolbar-group">
<span>
<OrganizedButton
loading={organizedLoading}
organized={gallery.organized}
onClick={onOrganizedClick}
/>
</span>
<span>{renderOperations()}</span>
</span>
</div>
</div>
{renderTabs()}
</div>

View file

@ -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<IGalleryDetailProps> = ({
// filename should use entire row if there is no studio
const galleryDetailsWidth = gallery.studio ? "col-9" : "col-12";
const title = galleryTitle(gallery);
return (
<>
<div className="row">
<div className={`${galleryDetailsWidth} col-xl-12 gallery-details`}>
<h3 className="gallery-header d-xl-none">
<TruncatedText text={title} />
</h3>
{gallery.date ? (
<h5>
<FormattedDate
value={gallery.date}
format="long"
timeZone="utc"
/>
</h5>
) : undefined}
{gallery.rating100 ? (
<h6>
<FormattedMessage id="rating" />:{" "}
<RatingSystem value={gallery.rating100} disabled />
</h6>
) : (
""
)}
<div className={`${galleryDetailsWidth} col-12 gallery-details`}>
<h6>
<FormattedMessage id="created_at" />:{" "}
{TextUtils.formatDateTime(intl, gallery.created_at)}{" "}
@ -127,17 +102,6 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
</h6>
)}
</div>
{gallery.studio && (
<div className="col-3 d-xl-none">
<Link to={`/studios/${gallery.studio.id}`}>
<img
src={gallery.studio.image_path ?? ""}
alt={`${gallery.studio.name} logo`}
className="studio-logo float-right"
/>
</Link>
</div>
)}
</div>
<div className="row">
<div className="col-12">

View file

@ -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<IProps> = ({
const [studio, setStudio] = useState<Studio | null>(null);
const isNew = gallery.id === undefined;
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
const Scrapers = useListGalleryScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
@ -90,7 +87,6 @@ export const GalleryEditPanel: React.FC<IProps> = ({
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<IProps> = ({
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<IProps> = ({
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<IProps> = ({
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<IProps> = ({
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<IProps> = ({
{renderDateField("date")}
{renderInputField("photographer")}
{renderRatingField("rating100", "rating")}
{renderScenesField()}
{renderStudioField()}

View file

@ -73,6 +73,7 @@ export const GalleryListTable: React.FC<IGalleryListTableProps> = (
<RatingSystem
value={gallery.rating100}
onSetRating={(value) => setRating(value, gallery.id)}
clickToRate
/>
);

View file

@ -50,7 +50,7 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
role="button"
tabIndex={0}
>
<RatingSystem value={gallery.rating100} disabled />
<RatingSystem value={gallery.rating100} disabled withoutContext />
<img loading="lazy" src={cover} alt="" className={CLASSNAME_IMG} />
<footer className={CLASSNAME_FOOTER}>
<Link

View file

@ -6,9 +6,67 @@
}
}
@include media-breakpoint-only(lg) {
.gallery-header-container {
align-items: center;
display: flex;
justify-content: space-between;
.gallery-header {
flex: 0 0 75%;
order: 1;
}
.gallery-studio-image {
flex: 0 0 25%;
order: 2;
}
}
}
.gallery-header {
flex-basis: auto;
font-size: 1.5rem;
margin-top: 30px;
@include media-breakpoint-down(xl) {
font-size: 1.75rem;
}
}
.gallery-subheader {
display: flex;
justify-content: space-between;
margin-top: 0.5rem;
.date {
color: $text-muted;
}
.resolution {
font-weight: bold;
}
}
.gallery-toolbar {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 0.25rem;
margin-top: 0.5rem;
padding-bottom: 0.25rem;
width: 100%;
.gallery-toolbar-group {
align-items: center;
column-gap: 0.25rem;
display: flex;
width: 100%;
&:last-child {
justify-content: flex-end;
}
}
}
#gallery-details-container {
@ -245,6 +303,7 @@ $galleryTabWidth: 450px;
.rating-number {
position: absolute;
right: 1rem;
text-shadow: 1px 1px 3px black;
top: 1rem;
}

View file

@ -1,15 +1,15 @@
import { Tab, Nav, Dropdown } from "react-bootstrap";
import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import React, { useContext, useEffect, useMemo, useState } from "react";
import { FormattedDate, FormattedMessage, useIntl } from "react-intl";
import { useHistory, Link, RouteComponentProps } from "react-router-dom";
import { Helmet } from "react-helmet";
import {
useFindImage,
useImageIncrementO,
useImageDecrementO,
useImageResetO,
useImageUpdate,
mutateMetadataScan,
useImageDecrementO,
useImageResetO,
} from "src/core/StashService";
import { ErrorMessage } from "src/components/Shared/ErrorMessage";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
@ -28,6 +28,12 @@ import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
import { objectPath, objectTitle } from "src/core/files";
import { isVideo } from "src/utils/visualFile";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config";
import TextUtils from "src/utils/text";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import cx from "classnames";
import { TruncatedText } from "src/components/Shared/TruncatedText";
interface IProps {
image: GQL.ImageDataFragment;
@ -41,6 +47,7 @@ const ImagePage: React.FC<IProps> = ({ 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<IProps> = ({ 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<IProps> = ({ image }) => {
<FormattedMessage id="actions.edit" />
</Nav.Link>
</Nav.Item>
<Nav.Item className="ml-auto">
<OCounterButton
value={image.o_counter || 0}
onIncrement={onIncrementClick}
onDecrement={onDecrementClick}
onReset={onResetClick}
/>
</Nav.Item>
<Nav.Item>
<OrganizedButton
loading={organizedLoading}
organized={image.organized}
onClick={onOrganizedClick}
/>
</Nav.Item>
<Nav.Item>{renderOperations()}</Nav.Item>
</Nav>
</div>
@ -264,9 +272,20 @@ const ImagePage: React.FC<IProps> = ({ 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 (
<div className="row">
<Helmet>
@ -275,19 +294,69 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
{maybeRenderDeleteDialog()}
<div className="image-tabs order-xl-first order-last">
<div className="d-none d-xl-block">
{image.studio && (
<h1 className="text-center">
<Link to={`/studios/${image.studio.id}`}>
<img
src={image.studio.image_path ?? ""}
alt={`${image.studio.name} logo`}
className="studio-logo"
<div>
<div className="image-header-container">
{image.studio && (
<h1 className="text-center image-studio-image">
<Link to={`/studios/${image.studio.id}`}>
<img
src={image.studio.image_path ?? ""}
alt={`${image.studio.name} logo`}
className="studio-logo"
/>
</Link>
</h1>
)}
<h3 className={cx("image-header", { "no-studio": !image.studio })}>
<TruncatedText lineCount={2} text={title} />
</h3>
</div>
<div className="image-subheader">
<span className="date" data-value={image.date}>
{!!image.date && (
<FormattedDate
value={image.date}
format="long"
timeZone="utc"
/>
</Link>
</h1>
)}
<h3 className="image-header">{title}</h3>
)}
</span>
{resolution ? (
<span className="resolution" data-value={resolution}>
{resolution}
</span>
) : undefined}
</div>
</div>
<div className="image-toolbar">
<span className="image-toolbar-group">
<RatingSystem
value={image.rating100}
onSetRating={setRating}
clickToRate
withoutContext
/>
</span>
<span className="image-toolbar-group">
<span>
<OCounterButton
value={image.o_counter || 0}
onIncrement={onIncrementClick}
onDecrement={onDecrementClick}
onReset={onResetClick}
/>
</span>
<span>
<OrganizedButton
loading={organizedLoading}
organized={image.organized}
onClick={onOrganizedClick}
/>
</span>
<span>{renderOperations()}</span>
</span>
</div>
{renderTabs()}
</div>

View file

@ -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<IImageDetailProps> = (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<IImageDetailProps> = (props) => {
return (
<>
<div className="row">
<div className={`${imageDetailsWidth} col-xl-12 image-details`}>
<div className="image-header d-xl-none">
<h3>
<TruncatedText text={objectTitle(props.image)} />
</h3>
</div>
{props.image.date ? (
<h5>
<FormattedDate
value={props.image.date}
format="long"
timeZone="utc"
/>
</h5>
) : undefined}
{props.image.rating100 ? (
<h6>
<FormattedMessage id="rating" />:{" "}
<RatingSystem value={props.image.rating100} disabled />
</h6>
) : (
""
)}
<div className={`${imageDetailsWidth} col-12 image-details`}>
{renderGalleries()}
{file?.width && file?.height ? (
<h6>
<FormattedMessage id="resolution" />:{" "}
{TextUtils.resolution(file.width, file.height)}
</h6>
) : (
""
)}
{
<h6>
{" "}
@ -163,17 +123,6 @@ export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
</h6>
)}
</div>
{props.image.studio && (
<div className="col-3 d-xl-none">
<Link to={`/studios/${props.image.studio.id}`}>
<img
src={props.image.studio.image_path ?? ""}
alt={`${props.image.studio.name} logo`}
className="studio-logo float-right"
/>
</Link>
</div>
)}
</div>
<div className="row">
<div className="col-12">

View file

@ -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<IProps> = ({
// Network state
const [isLoading, setIsLoading] = useState(false);
const { configuration } = React.useContext(ConfigurationContext);
const [galleries, setGalleries] = useState<Gallery[]>([]);
const [performers, setPerformers] = useState<Performer[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
@ -74,7 +70,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
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<IProps> = ({
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<IProps> = ({
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<IProps> = ({
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<IProps> = ({
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<IProps> = ({
{renderDateField("date")}
{renderInputField("photographer")}
{renderRatingField("rating100", "rating")}
{renderGalleriesField()}
{renderStudioField()}

View file

@ -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 {

View file

@ -427,6 +427,8 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
<RatingSystem
value={movie.rating100}
onSetRating={(value) => setRating(value)}
clickToRate
withoutContext
/>
{maybeRenderDetails()}
{maybeRenderEditPanel()}

View file

@ -39,3 +39,7 @@
object-fit: contain;
}
}
#movie-page .rating-number .text-input {
width: auto;
}

View file

@ -592,6 +592,8 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
<RatingSystem
value={performer.rating100}
onSetRating={(value) => setRating(value)}
clickToRate
withoutContext
/>
{maybeRenderDetails()}
{maybeRenderEditPanel()}

View file

@ -108,6 +108,7 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (
<RatingSystem
value={performer.rating100}
onSetRating={(value) => setRating(value, performer.id)}
clickToRate
/>
);

View file

@ -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 (
<span className="resolution" data-value={r}>
{r}
</span>
);
}
return undefined;
}, [width, height]);
const frameRateDisplay = useMemo(() => {
if (frameRate) {
return (
<span className="frame-rate" data-value={frameRate}>
<FormattedMessage
id="frames_per_second"
values={{ value: intl.formatNumber(frameRate ?? 0) }}
/>
</span>
);
}
return undefined;
}, [intl, frameRate]);
const divider = useMemo(() => {
return resolution && frameRateDisplay ? (
<span className="divider"> | </span>
) : undefined;
}, [resolution, frameRateDisplay]);
return (
<span>
{frameRateDisplay}
{divider}
{resolution}
</span>
);
};
interface IProps {
scene: GQL.SceneDataFragment;
@ -126,8 +181,16 @@ const ScenePage: React.FC<IProps> = ({
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<IProps> = ({
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(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<IProps> = ({
}
};
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<IProps> = ({
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<IProps> = ({
}
};
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<IProps> = ({
<FormattedMessage id="actions.edit" />
</Nav.Link>
</Nav.Item>
<ButtonGroup className="ml-auto">
<Nav.Item className="ml-auto">
<ExternalPlayerButton scene={scene} />
</Nav.Item>
<Nav.Item className="ml-auto">
<OCounterButton
value={scene.o_counter || 0}
onIncrement={onIncrementClick}
onDecrement={onDecrementClick}
onReset={onResetClick}
/>
</Nav.Item>
<Nav.Item>
<OrganizedButton
loading={organizedLoading}
organized={scene.organized}
onClick={onOrganizedClick}
/>
</Nav.Item>
<Nav.Item>{renderOperations()}</Nav.Item>
</ButtonGroup>
</Nav>
</div>
@ -509,6 +552,11 @@ const ScenePage: React.FC<IProps> = ({
const title = objectTitle(scene);
const file = useMemo(
() => (scene.files.length > 0 ? scene.files[0] : undefined),
[scene]
);
return (
<>
<Helmet>
@ -521,19 +569,76 @@ const ScenePage: React.FC<IProps> = ({
collapsed ? "collapsed" : ""
}`}
>
<div className="d-none d-xl-block">
{scene.studio && (
<h1 className="mt-3 text-center">
<Link to={`/studios/${scene.studio.id}`}>
<img
src={scene.studio.image_path ?? ""}
alt={`${scene.studio.name} logo`}
className="studio-logo"
<div>
<div className="scene-header-container">
{scene.studio && (
<h1 className="text-center scene-studio-image">
<Link to={`/studios/${scene.studio.id}`}>
<img
src={scene.studio.image_path ?? ""}
alt={`${scene.studio.name} logo`}
className="studio-logo"
/>
</Link>
</h1>
)}
<h3 className={cx("scene-header", { "no-studio": !scene.studio })}>
<TruncatedText lineCount={2} text={title} />
</h3>
</div>
<div className="scene-subheader">
<span className="date" data-value={scene.date}>
{!!scene.date && (
<FormattedDate
value={scene.date}
format="long"
timeZone="utc"
/>
</Link>
</h1>
)}
<h3 className="scene-header">{title}</h3>
)}
</span>
<VideoFrameRateResolution
width={file?.width}
height={file?.height}
frameRate={file?.frame_rate}
/>
</div>
<div className="scene-toolbar">
<span className="scene-toolbar-group">
<RatingSystem
value={scene.rating100}
onSetRating={setRating}
clickToRate
withoutContext
/>
</span>
<span className="scene-toolbar-group">
<span>
<ExternalPlayerButton scene={scene} />
</span>
<span>
<ViewCountButton
value={scene.play_count ?? 0}
onIncrement={() => incrementPlayCount()}
/>
</span>
<span>
<OCounterButton
value={scene.o_counter ?? 0}
onIncrement={() => onIncrementOClick()}
/>
</span>
<span>
<OrganizedButton
loading={organizedLoading}
organized={scene.organized}
onClick={onOrganizedClick}
/>
</span>
<span>{renderOperations()}</span>
</span>
</div>
</div>
{renderTabs()}
</div>

View file

@ -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<ISceneDetailProps> = (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<ISceneDetailProps> = (props) => {
return (
<>
<div className="row">
<div className={`${sceneDetailsWidth} col-xl-12 scene-details`}>
<div className="scene-header d-xl-none">
<h3>
<TruncatedText text={objectTitle(props.scene)} />
</h3>
</div>
{props.scene.date ? (
<h5>
<FormattedDate
value={props.scene.date}
format="long"
timeZone="utc"
/>
</h5>
) : undefined}
{props.scene.rating100 ? (
<h6>
<FormattedMessage id="rating" />:{" "}
<RatingSystem value={props.scene.rating100} disabled />
</h6>
) : (
""
)}
{file?.width && file?.height && (
<h6>
<FormattedMessage id="resolution" />:{" "}
{TextUtils.resolution(file.width, file.height)}
</h6>
)}
<div className={`${sceneDetailsWidth} col-12 scene-details`}>
<h6>
<FormattedMessage id="created_at" />:{" "}
{TextUtils.formatDateTime(intl, props.scene.created_at)}{" "}
@ -134,17 +97,6 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
</h6>
)}
</div>
{props.scene.studio && (
<div className="col-3 d-xl-none">
<Link to={`/studios/${props.scene.studio.id}`}>
<img
src={props.scene.studio.image_path ?? ""}
alt={`${props.scene.studio.name} logo`}
className="studio-logo float-right"
/>
</Link>
</div>
)}
</div>
<div className="row">
<div className="col-12">

View file

@ -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<IProps> = ({
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<IProps> = ({
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<IProps> = ({
.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<IProps> = ({
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<IProps> = ({
renderField,
renderInputField,
renderDateField,
renderRatingField,
renderURLListField,
renderStashIDsField,
} = formikUtils(intl, formik, splitProps);
@ -865,7 +851,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
{renderDateField("date")}
{renderInputField("director")}
{renderRatingField("rating100", "rating")}
{renderGalleriesField()}
{renderStudioField()}

View file

@ -78,6 +78,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
<RatingSystem
value={scene.rating100}
onSetRating={(value) => setRating(value, scene.id)}
clickToRate
/>
);

View file

@ -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 {

View file

@ -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<ICountButtonProps> = ({
value,
icon,
onIncrement,
onValueClicked,
title,
countTitle,
}) => {
return (
<ButtonGroup
className={cx("count-button", { "increment-only": !onValueClicked })}
>
<Button
className="minimal count-icon"
variant="secondary"
onClick={() => onIncrement?.()}
title={title}
>
{icon}
</Button>
<Button
className="minimal count-value"
variant="secondary"
onClick={() => (onValueClicked ?? onIncrement)?.()}
title={!!onValueClicked ? countTitle : undefined}
>
<span>{value}</span>
</Button>
</ButtonGroup>
);
};
type CountButtonPropsNoIcon = Omit<ICountButtonProps, "icon">;
export const ViewCountButton: React.FC<CountButtonPropsNoIcon> = (props) => {
const intl = useIntl();
return (
<CountButton
{...props}
icon={<Icon icon={faEye} />}
title={intl.formatMessage({ id: "media_info.play_count" })}
countTitle={intl.formatMessage({ id: "actions.view_history" })}
/>
);
};
export const OCounterButton: React.FC<CountButtonPropsNoIcon> = (props) => {
const intl = useIntl();
return (
<CountButton
{...props}
icon={<SweatDrops />}
title={intl.formatMessage({ id: "o_count" })}
countTitle={intl.formatMessage({ id: "actions.view_history" })}
/>
);
};

View file

@ -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<IRatingNumberProps> = (
props: IRatingNumberProps
) => {
const text = ((props.value ?? 0) / 10).toFixed(1);
const [editing, setEditing] = useState(false);
const [valueStage, setValueStage] = useState<number | null>(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<IRatingNumberProps> = (
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<IRatingNumberProps> = (
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<IRatingNumberProps> = (
}
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<IRatingNumberProps> = (
}
}
if (props.disabled) {
function onBlur() {
if (editing) {
setEditing(false);
if (props.onSetRating && valueStage !== props.value) {
props.onSetRating(valueStage);
}
}
}
if (!showTextField) {
return (
<div className="rating-number disabled">
<span>{Number((props.value ?? 0) / 10).toFixed(1)}</span>
{props.withoutContext && <Icon icon={faStar} />}
<span>{Number((effectiveValue ?? 0) / 10).toFixed(1)}</span>
{!props.disabled && props.clickToRate && (
<Button
variant="minimal"
size="sm"
className="edit-rating-button"
onClick={() => setEditing(true)}
>
<Icon className="text-primary" icon={faPencil} />
</Button>
)}
</div>
);
} else {
return (
<div className="rating-number">
<input
ref={ratingRef}
className="text-input form-control"
name="ratingnumber"
type="number"
onMouseDown={stepChange}
onKeyDown={nonStepChange}
onChange={handleChange}
onBlur={onBlur}
value={text}
min="0.0"
step="0.1"

View file

@ -13,6 +13,10 @@ export interface IRatingSystemProps {
onSetRating?: (value: number | null) => 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<IRatingSystemProps> = (
@ -40,6 +44,8 @@ export const RatingSystem: React.FC<IRatingSystemProps> = (
value={props.value ?? null}
onSetRating={props.onSetRating}
disabled={props.disabled}
clickToRate={props.clickToRate}
withoutContext={props.withoutContext}
/>
);
}

View file

@ -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;
}
}

View file

@ -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;
}
}
}
}

View file

@ -565,6 +565,8 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
<RatingSystem
value={studio.rating100}
onSetRating={(value) => setRating(value)}
clickToRate
withoutContext
/>
{maybeRenderDetails()}
{maybeRenderEditPanel()}

View file

@ -36,4 +36,8 @@
}
}
}
.rating-number .text-input {
width: auto;
}
}

View file

@ -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%;
}
}
}

View file

@ -925,6 +925,8 @@ export const LightboxComponent: React.FC<IProps> = ({
<RatingSystem
value={currentImage?.rating100}
onSetRating={(v) => setRating(v)}
clickToRate
withoutContext
/>
</>
)}

View file

@ -94,6 +94,10 @@
padding-left: 0.38rem;
}
.rating-number .text-input {
width: auto;
}
&-left {
display: flex;
flex-direction: column;

View file

@ -1365,3 +1365,8 @@ select {
.primary-file {
font-weight: bold;
}
// ensure rating number editing doesn't resize column
.table-list .rating-number {
width: 6rem;
}

View file

@ -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",

View file

@ -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;
};