mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
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:
parent
911da87264
commit
ec6acab2f4
32 changed files with 752 additions and 336 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export const GalleryListTable: React.FC<IGalleryListTableProps> = (
|
|||
<RatingSystem
|
||||
value={gallery.rating100}
|
||||
onSetRating={(value) => setRating(value, gallery.id)}
|
||||
clickToRate
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -427,6 +427,8 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||
<RatingSystem
|
||||
value={movie.rating100}
|
||||
onSetRating={(value) => setRating(value)}
|
||||
clickToRate
|
||||
withoutContext
|
||||
/>
|
||||
{maybeRenderDetails()}
|
||||
{maybeRenderEditPanel()}
|
||||
|
|
|
|||
|
|
@ -39,3 +39,7 @@
|
|||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
#movie-page .rating-number .text-input {
|
||||
width: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -592,6 +592,8 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
|
|||
<RatingSystem
|
||||
value={performer.rating100}
|
||||
onSetRating={(value) => setRating(value)}
|
||||
clickToRate
|
||||
withoutContext
|
||||
/>
|
||||
{maybeRenderDetails()}
|
||||
{maybeRenderEditPanel()}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (
|
|||
<RatingSystem
|
||||
value={performer.rating100}
|
||||
onSetRating={(value) => setRating(value, performer.id)}
|
||||
clickToRate
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
|
|||
<RatingSystem
|
||||
value={scene.rating100}
|
||||
onSetRating={(value) => setRating(value, scene.id)}
|
||||
clickToRate
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
74
ui/v2.5/src/components/Shared/CountButton.tsx
Normal file
74
ui/v2.5/src/components/Shared/CountButton.tsx
Normal 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" })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -565,6 +565,8 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
|
|||
<RatingSystem
|
||||
value={studio.rating100}
|
||||
onSetRating={(value) => setRating(value)}
|
||||
clickToRate
|
||||
withoutContext
|
||||
/>
|
||||
{maybeRenderDetails()}
|
||||
{maybeRenderEditPanel()}
|
||||
|
|
|
|||
|
|
@ -36,4 +36,8 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rating-number .text-input {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -925,6 +925,8 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
<RatingSystem
|
||||
value={currentImage?.rating100}
|
||||
onSetRating={(v) => setRating(v)}
|
||||
clickToRate
|
||||
withoutContext
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@
|
|||
padding-left: 0.38rem;
|
||||
}
|
||||
|
||||
.rating-number .text-input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
&-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -1365,3 +1365,8 @@ select {
|
|||
.primary-file {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
// ensure rating number editing doesn't resize column
|
||||
.table-list .rating-number {
|
||||
width: 6rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue