mirror of
https://github.com/stashapp/stash.git
synced 2025-12-07 17:02:38 +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 { Button, Tab, Nav, Dropdown } from "react-bootstrap";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useContext, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
useHistory,
|
useHistory,
|
||||||
Link,
|
Link,
|
||||||
RouteComponentProps,
|
RouteComponentProps,
|
||||||
Redirect,
|
Redirect,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedDate, FormattedMessage, useIntl } from "react-intl";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import {
|
import {
|
||||||
|
|
@ -37,6 +37,11 @@ import {
|
||||||
import { galleryPath, galleryTitle } from "src/core/galleries";
|
import { galleryPath, galleryTitle } from "src/core/galleries";
|
||||||
import { GalleryChapterPanel } from "./GalleryChaptersPanel";
|
import { GalleryChapterPanel } from "./GalleryChaptersPanel";
|
||||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
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 {
|
interface IProps {
|
||||||
gallery: GQL.GalleryDataFragment;
|
gallery: GQL.GalleryDataFragment;
|
||||||
|
|
@ -52,6 +57,7 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const { configuration } = useContext(ConfigurationContext);
|
||||||
const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters);
|
const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters);
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
@ -236,14 +242,6 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
|
||||||
<FormattedMessage id="actions.edit" />
|
<FormattedMessage id="actions.edit" />
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Nav.Item className="ml-auto">
|
|
||||||
<OrganizedButton
|
|
||||||
loading={organizedLoading}
|
|
||||||
organized={gallery.organized}
|
|
||||||
onClick={onOrganizedClick}
|
|
||||||
/>
|
|
||||||
</Nav.Item>
|
|
||||||
<Nav.Item>{renderOperations()}</Nav.Item>
|
|
||||||
</Nav>
|
</Nav>
|
||||||
</div>
|
</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
|
// set up hotkeys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Mousetrap.bind("a", () => setActiveTabKey("gallery-details-panel"));
|
Mousetrap.bind("a", () => setActiveTabKey("gallery-details-panel"));
|
||||||
|
|
@ -346,9 +361,10 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
|
||||||
</Helmet>
|
</Helmet>
|
||||||
{maybeRenderDeleteDialog()}
|
{maybeRenderDeleteDialog()}
|
||||||
<div className={`gallery-tabs ${collapsed ? "collapsed" : ""}`}>
|
<div className={`gallery-tabs ${collapsed ? "collapsed" : ""}`}>
|
||||||
<div className="d-none d-xl-block">
|
<div>
|
||||||
|
<div className="gallery-header-container">
|
||||||
{gallery.studio && (
|
{gallery.studio && (
|
||||||
<h1 className="text-center">
|
<h1 className="text-center gallery-studio-image">
|
||||||
<Link to={`/studios/${gallery.studio.id}`}>
|
<Link to={`/studios/${gallery.studio.id}`}>
|
||||||
<img
|
<img
|
||||||
src={gallery.studio.image_path ?? ""}
|
src={gallery.studio.image_path ?? ""}
|
||||||
|
|
@ -358,7 +374,45 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
|
||||||
</Link>
|
</Link>
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
)}
|
||||||
<h3 className="gallery-header">{title}</h3>
|
<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"
|
||||||
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
{renderTabs()}
|
{renderTabs()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { FormattedDate, FormattedMessage, useIntl } from "react-intl";
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import TextUtils from "src/utils/text";
|
import TextUtils from "src/utils/text";
|
||||||
import { TagLink } from "src/components/Shared/TagLink";
|
import { TagLink } from "src/components/Shared/TagLink";
|
||||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
|
||||||
import { PerformerCard } from "src/components/Performers/PerformerCard";
|
import { PerformerCard } from "src/components/Performers/PerformerCard";
|
||||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
|
||||||
import { sortPerformers } from "src/core/performers";
|
import { sortPerformers } from "src/core/performers";
|
||||||
import { galleryTitle } from "src/core/galleries";
|
|
||||||
import { PhotographerLink } from "src/components/Shared/Link";
|
import { PhotographerLink } from "src/components/Shared/Link";
|
||||||
|
|
||||||
interface IGalleryDetailProps {
|
interface IGalleryDetailProps {
|
||||||
|
|
@ -78,32 +74,11 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
|
||||||
|
|
||||||
// filename should use entire row if there is no studio
|
// filename should use entire row if there is no studio
|
||||||
const galleryDetailsWidth = gallery.studio ? "col-9" : "col-12";
|
const galleryDetailsWidth = gallery.studio ? "col-9" : "col-12";
|
||||||
const title = galleryTitle(gallery);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className={`${galleryDetailsWidth} col-xl-12 gallery-details`}>
|
<div className={`${galleryDetailsWidth} col-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>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
<h6>
|
<h6>
|
||||||
<FormattedMessage id="created_at" />:{" "}
|
<FormattedMessage id="created_at" />:{" "}
|
||||||
{TextUtils.formatDateTime(intl, gallery.created_at)}{" "}
|
{TextUtils.formatDateTime(intl, gallery.created_at)}{" "}
|
||||||
|
|
@ -127,17 +102,6 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
|
||||||
</h6>
|
</h6>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,6 @@ import { useFormik } from "formik";
|
||||||
import { GalleryScrapeDialog } from "./GalleryScrapeDialog";
|
import { GalleryScrapeDialog } from "./GalleryScrapeDialog";
|
||||||
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { galleryTitle } from "src/core/galleries";
|
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 isEqual from "lodash-es/isEqual";
|
||||||
import { handleUnsavedChanges } from "src/utils/navigation";
|
import { handleUnsavedChanges } from "src/utils/navigation";
|
||||||
import {
|
import {
|
||||||
|
|
@ -70,7 +68,6 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
||||||
const [studio, setStudio] = useState<Studio | null>(null);
|
const [studio, setStudio] = useState<Studio | null>(null);
|
||||||
|
|
||||||
const isNew = gallery.id === undefined;
|
const isNew = gallery.id === undefined;
|
||||||
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
|
|
||||||
|
|
||||||
const Scrapers = useListGalleryScrapers();
|
const Scrapers = useListGalleryScrapers();
|
||||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
||||||
|
|
@ -90,7 +87,6 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
||||||
urls: yupUniqueStringList(intl),
|
urls: yupUniqueStringList(intl),
|
||||||
date: yupDateString(intl),
|
date: yupDateString(intl),
|
||||||
photographer: yup.string().ensure(),
|
photographer: yup.string().ensure(),
|
||||||
rating100: yup.number().integer().nullable().defined(),
|
|
||||||
studio_id: yup.string().required().nullable(),
|
studio_id: yup.string().required().nullable(),
|
||||||
performer_ids: yup.array(yup.string().required()).defined(),
|
performer_ids: yup.array(yup.string().required()).defined(),
|
||||||
tag_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 ?? [],
|
urls: gallery?.urls ?? [],
|
||||||
date: gallery?.date ?? "",
|
date: gallery?.date ?? "",
|
||||||
photographer: gallery?.photographer ?? "",
|
photographer: gallery?.photographer ?? "",
|
||||||
rating100: gallery?.rating100 ?? null,
|
|
||||||
studio_id: gallery?.studio?.id ?? null,
|
studio_id: gallery?.studio?.id ?? null,
|
||||||
performer_ids: (gallery?.performers ?? []).map((p) => p.id),
|
performer_ids: (gallery?.performers ?? []).map((p) => p.id),
|
||||||
tag_ids: (gallery?.tags ?? []).map((t) => t.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)),
|
onSubmit: (values) => onSave(schema.cast(values)),
|
||||||
});
|
});
|
||||||
|
|
||||||
function setRating(v: number) {
|
|
||||||
formik.setFieldValue("rating100", v);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ISceneSelectValue {
|
interface ISceneSelectValue {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -159,12 +150,6 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
||||||
formik.setFieldValue("studio_id", item ? item.id : null);
|
formik.setFieldValue("studio_id", item ? item.id : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
useRatingKeybinds(
|
|
||||||
isVisible,
|
|
||||||
stashConfig?.ui.ratingSystemOptions?.type,
|
|
||||||
setRating
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPerformers(gallery.performers ?? []);
|
setPerformers(gallery.performers ?? []);
|
||||||
}, [gallery.performers]);
|
}, [gallery.performers]);
|
||||||
|
|
@ -420,13 +405,8 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
||||||
xl: 12,
|
xl: 12,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const {
|
const { renderField, renderInputField, renderDateField, renderURLListField } =
|
||||||
renderField,
|
formikUtils(intl, formik, splitProps);
|
||||||
renderInputField,
|
|
||||||
renderDateField,
|
|
||||||
renderRatingField,
|
|
||||||
renderURLListField,
|
|
||||||
} = formikUtils(intl, formik, splitProps);
|
|
||||||
|
|
||||||
function renderScenesField() {
|
function renderScenesField() {
|
||||||
const title = intl.formatMessage({ id: "scenes" });
|
const title = intl.formatMessage({ id: "scenes" });
|
||||||
|
|
@ -532,7 +512,6 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
||||||
|
|
||||||
{renderDateField("date")}
|
{renderDateField("date")}
|
||||||
{renderInputField("photographer")}
|
{renderInputField("photographer")}
|
||||||
{renderRatingField("rating100", "rating")}
|
|
||||||
|
|
||||||
{renderScenesField()}
|
{renderScenesField()}
|
||||||
{renderStudioField()}
|
{renderStudioField()}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export const GalleryListTable: React.FC<IGalleryListTableProps> = (
|
||||||
<RatingSystem
|
<RatingSystem
|
||||||
value={gallery.rating100}
|
value={gallery.rating100}
|
||||||
onSetRating={(value) => setRating(value, gallery.id)}
|
onSetRating={(value) => setRating(value, gallery.id)}
|
||||||
|
clickToRate
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<RatingSystem value={gallery.rating100} disabled />
|
<RatingSystem value={gallery.rating100} disabled withoutContext />
|
||||||
<img loading="lazy" src={cover} alt="" className={CLASSNAME_IMG} />
|
<img loading="lazy" src={cover} alt="" className={CLASSNAME_IMG} />
|
||||||
<footer className={CLASSNAME_FOOTER}>
|
<footer className={CLASSNAME_FOOTER}>
|
||||||
<Link
|
<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 {
|
.gallery-header {
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
|
font-size: 1.5rem;
|
||||||
margin-top: 30px;
|
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 {
|
#gallery-details-container {
|
||||||
|
|
@ -245,6 +303,7 @@ $galleryTabWidth: 450px;
|
||||||
.rating-number {
|
.rating-number {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
|
text-shadow: 1px 1px 3px black;
|
||||||
top: 1rem;
|
top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { Tab, Nav, Dropdown } from "react-bootstrap";
|
import { Tab, Nav, Dropdown } from "react-bootstrap";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedDate, FormattedMessage, useIntl } from "react-intl";
|
||||||
import { useHistory, Link, RouteComponentProps } from "react-router-dom";
|
import { useHistory, Link, RouteComponentProps } from "react-router-dom";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import {
|
import {
|
||||||
useFindImage,
|
useFindImage,
|
||||||
useImageIncrementO,
|
useImageIncrementO,
|
||||||
useImageDecrementO,
|
|
||||||
useImageResetO,
|
|
||||||
useImageUpdate,
|
useImageUpdate,
|
||||||
mutateMetadataScan,
|
mutateMetadataScan,
|
||||||
|
useImageDecrementO,
|
||||||
|
useImageResetO,
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import { ErrorMessage } from "src/components/Shared/ErrorMessage";
|
import { ErrorMessage } from "src/components/Shared/ErrorMessage";
|
||||||
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
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 { objectPath, objectTitle } from "src/core/files";
|
||||||
import { isVideo } from "src/utils/visualFile";
|
import { isVideo } from "src/utils/visualFile";
|
||||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
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 {
|
interface IProps {
|
||||||
image: GQL.ImageDataFragment;
|
image: GQL.ImageDataFragment;
|
||||||
|
|
@ -41,6 +47,7 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const { configuration } = useContext(ConfigurationContext);
|
||||||
|
|
||||||
const [incrementO] = useImageIncrementO(image.id);
|
const [incrementO] = useImageIncrementO(image.id);
|
||||||
const [decrementO] = useImageDecrementO(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) {
|
function onDeleteDialogClosed(deleted: boolean) {
|
||||||
setIsDeleteAlertOpen(false);
|
setIsDeleteAlertOpen(false);
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
|
|
@ -205,22 +229,6 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
|
||||||
<FormattedMessage id="actions.edit" />
|
<FormattedMessage id="actions.edit" />
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
</Nav.Item>
|
</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>
|
</Nav>
|
||||||
</div>
|
</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 title = objectTitle(image);
|
||||||
const ImageView = isVideo(image.visual_files[0]) ? "video" : "img";
|
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 (
|
return (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
|
|
@ -275,9 +294,10 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
|
||||||
|
|
||||||
{maybeRenderDeleteDialog()}
|
{maybeRenderDeleteDialog()}
|
||||||
<div className="image-tabs order-xl-first order-last">
|
<div className="image-tabs order-xl-first order-last">
|
||||||
<div className="d-none d-xl-block">
|
<div>
|
||||||
|
<div className="image-header-container">
|
||||||
{image.studio && (
|
{image.studio && (
|
||||||
<h1 className="text-center">
|
<h1 className="text-center image-studio-image">
|
||||||
<Link to={`/studios/${image.studio.id}`}>
|
<Link to={`/studios/${image.studio.id}`}>
|
||||||
<img
|
<img
|
||||||
src={image.studio.image_path ?? ""}
|
src={image.studio.image_path ?? ""}
|
||||||
|
|
@ -287,7 +307,56 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
|
||||||
</Link>
|
</Link>
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
)}
|
||||||
<h3 className="image-header">{title}</h3>
|
<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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
</div>
|
||||||
{renderTabs()}
|
{renderTabs()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import React, { useMemo } from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import TextUtils from "src/utils/text";
|
import TextUtils from "src/utils/text";
|
||||||
import { GalleryLink, TagLink } from "src/components/Shared/TagLink";
|
import { GalleryLink, TagLink } from "src/components/Shared/TagLink";
|
||||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
|
||||||
import { PerformerCard } from "src/components/Performers/PerformerCard";
|
import { PerformerCard } from "src/components/Performers/PerformerCard";
|
||||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
|
||||||
import { sortPerformers } from "src/core/performers";
|
import { sortPerformers } from "src/core/performers";
|
||||||
import { FormattedDate, FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { objectTitle } from "src/core/files";
|
|
||||||
import { PhotographerLink } from "src/components/Shared/Link";
|
import { PhotographerLink } from "src/components/Shared/Link";
|
||||||
interface IImageDetailProps {
|
interface IImageDetailProps {
|
||||||
image: GQL.ImageDataFragment;
|
image: GQL.ImageDataFragment;
|
||||||
|
|
@ -17,11 +13,6 @@ interface IImageDetailProps {
|
||||||
export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
|
export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const file = useMemo(
|
|
||||||
() => (props.image.files.length > 0 ? props.image.files[0] : undefined),
|
|
||||||
[props.image]
|
|
||||||
);
|
|
||||||
|
|
||||||
function renderDetails() {
|
function renderDetails() {
|
||||||
if (!props.image.details) return;
|
if (!props.image.details) return;
|
||||||
return (
|
return (
|
||||||
|
|
@ -102,39 +93,8 @@ export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className={`${imageDetailsWidth} col-xl-12 image-details`}>
|
<div className={`${imageDetailsWidth} col-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>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderGalleries()}
|
{renderGalleries()}
|
||||||
{file?.width && file?.height ? (
|
|
||||||
<h6>
|
|
||||||
<FormattedMessage id="resolution" />:{" "}
|
|
||||||
{TextUtils.resolution(file.width, file.height)}
|
|
||||||
</h6>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
{
|
{
|
||||||
<h6>
|
<h6>
|
||||||
{" "}
|
{" "}
|
||||||
|
|
@ -163,17 +123,6 @@ export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
|
||||||
</h6>
|
</h6>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
||||||
import { useToast } from "src/hooks/Toast";
|
import { useToast } from "src/hooks/Toast";
|
||||||
import { useFormik } from "formik";
|
import { useFormik } from "formik";
|
||||||
import { Prompt } from "react-router-dom";
|
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 isEqual from "lodash-es/isEqual";
|
||||||
import {
|
import {
|
||||||
yupDateString,
|
yupDateString,
|
||||||
|
|
@ -49,8 +47,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
||||||
// Network state
|
// Network state
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const { configuration } = React.useContext(ConfigurationContext);
|
|
||||||
|
|
||||||
const [galleries, setGalleries] = useState<Gallery[]>([]);
|
const [galleries, setGalleries] = useState<Gallery[]>([]);
|
||||||
const [performers, setPerformers] = useState<Performer[]>([]);
|
const [performers, setPerformers] = useState<Performer[]>([]);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
|
@ -74,7 +70,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
||||||
date: yupDateString(intl),
|
date: yupDateString(intl),
|
||||||
details: yup.string().ensure(),
|
details: yup.string().ensure(),
|
||||||
photographer: yup.string().ensure(),
|
photographer: yup.string().ensure(),
|
||||||
rating100: yup.number().integer().nullable().defined(),
|
|
||||||
gallery_ids: yup.array(yup.string().required()).defined(),
|
gallery_ids: yup.array(yup.string().required()).defined(),
|
||||||
studio_id: yup.string().required().nullable(),
|
studio_id: yup.string().required().nullable(),
|
||||||
performer_ids: yup.array(yup.string().required()).defined(),
|
performer_ids: yup.array(yup.string().required()).defined(),
|
||||||
|
|
@ -88,7 +83,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
||||||
date: image?.date ?? "",
|
date: image?.date ?? "",
|
||||||
details: image.details ?? "",
|
details: image.details ?? "",
|
||||||
photographer: image.photographer ?? "",
|
photographer: image.photographer ?? "",
|
||||||
rating100: image.rating100 ?? null,
|
|
||||||
gallery_ids: (image.galleries ?? []).map((g) => g.id),
|
gallery_ids: (image.galleries ?? []).map((g) => g.id),
|
||||||
studio_id: image.studio?.id ?? null,
|
studio_id: image.studio?.id ?? null,
|
||||||
performer_ids: (image.performers ?? []).map((p) => p.id),
|
performer_ids: (image.performers ?? []).map((p) => p.id),
|
||||||
|
|
@ -104,10 +98,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
||||||
onSubmit: (values) => onSave(schema.cast(values)),
|
onSubmit: (values) => onSave(schema.cast(values)),
|
||||||
});
|
});
|
||||||
|
|
||||||
function setRating(v: number) {
|
|
||||||
formik.setFieldValue("rating100", v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSetGalleries(items: Gallery[]) {
|
function onSetGalleries(items: Gallery[]) {
|
||||||
setGalleries(items);
|
setGalleries(items);
|
||||||
formik.setFieldValue(
|
formik.setFieldValue(
|
||||||
|
|
@ -137,12 +127,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
||||||
formik.setFieldValue("studio_id", item ? item.id : null);
|
formik.setFieldValue("studio_id", item ? item.id : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
useRatingKeybinds(
|
|
||||||
true,
|
|
||||||
configuration?.ui.ratingSystemOptions?.type,
|
|
||||||
setRating
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPerformers(image.performers ?? []);
|
setPerformers(image.performers ?? []);
|
||||||
}, [image.performers]);
|
}, [image.performers]);
|
||||||
|
|
@ -209,13 +193,8 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
||||||
xl: 12,
|
xl: 12,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const {
|
const { renderField, renderInputField, renderDateField, renderURLListField } =
|
||||||
renderField,
|
formikUtils(intl, formik, splitProps);
|
||||||
renderInputField,
|
|
||||||
renderDateField,
|
|
||||||
renderRatingField,
|
|
||||||
renderURLListField,
|
|
||||||
} = formikUtils(intl, formik, splitProps);
|
|
||||||
|
|
||||||
function renderGalleriesField() {
|
function renderGalleriesField() {
|
||||||
const title = intl.formatMessage({ id: "galleries" });
|
const title = intl.formatMessage({ id: "galleries" });
|
||||||
|
|
@ -318,7 +297,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
||||||
|
|
||||||
{renderDateField("date")}
|
{renderDateField("date")}
|
||||||
{renderInputField("photographer")}
|
{renderInputField("photographer")}
|
||||||
{renderRatingField("rating100", "rating")}
|
|
||||||
|
|
||||||
{renderGalleriesField()}
|
{renderGalleriesField()}
|
||||||
{renderStudioField()}
|
{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 {
|
.image-header {
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
|
font-size: 1.5rem;
|
||||||
margin-top: 30px;
|
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 {
|
#image-details-container {
|
||||||
|
|
|
||||||
|
|
@ -427,6 +427,8 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
||||||
<RatingSystem
|
<RatingSystem
|
||||||
value={movie.rating100}
|
value={movie.rating100}
|
||||||
onSetRating={(value) => setRating(value)}
|
onSetRating={(value) => setRating(value)}
|
||||||
|
clickToRate
|
||||||
|
withoutContext
|
||||||
/>
|
/>
|
||||||
{maybeRenderDetails()}
|
{maybeRenderDetails()}
|
||||||
{maybeRenderEditPanel()}
|
{maybeRenderEditPanel()}
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,7 @@
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#movie-page .rating-number .text-input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,8 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
|
||||||
<RatingSystem
|
<RatingSystem
|
||||||
value={performer.rating100}
|
value={performer.rating100}
|
||||||
onSetRating={(value) => setRating(value)}
|
onSetRating={(value) => setRating(value)}
|
||||||
|
clickToRate
|
||||||
|
withoutContext
|
||||||
/>
|
/>
|
||||||
{maybeRenderDetails()}
|
{maybeRenderDetails()}
|
||||||
{maybeRenderEditPanel()}
|
{maybeRenderEditPanel()}
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (
|
||||||
<RatingSystem
|
<RatingSystem
|
||||||
value={performer.rating100}
|
value={performer.rating100}
|
||||||
onSetRating={(value) => setRating(value, performer.id)}
|
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, {
|
import React, {
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
|
|
@ -7,7 +7,7 @@ import React, {
|
||||||
useRef,
|
useRef,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedDate, FormattedMessage, useIntl } from "react-intl";
|
||||||
import { Link, RouteComponentProps } from "react-router-dom";
|
import { Link, RouteComponentProps } from "react-router-dom";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
|
@ -15,12 +15,11 @@ import {
|
||||||
mutateMetadataScan,
|
mutateMetadataScan,
|
||||||
useFindScene,
|
useFindScene,
|
||||||
useSceneIncrementO,
|
useSceneIncrementO,
|
||||||
useSceneDecrementO,
|
|
||||||
useSceneResetO,
|
|
||||||
useSceneGenerateScreenshot,
|
useSceneGenerateScreenshot,
|
||||||
useSceneUpdate,
|
useSceneUpdate,
|
||||||
queryFindScenes,
|
queryFindScenes,
|
||||||
queryFindScenesByID,
|
queryFindScenesByID,
|
||||||
|
useSceneIncrementPlayCount,
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
|
|
||||||
import { SceneEditPanel } from "./SceneEditPanel";
|
import { SceneEditPanel } from "./SceneEditPanel";
|
||||||
|
|
@ -32,7 +31,6 @@ import { useToast } from "src/hooks/Toast";
|
||||||
import SceneQueue, { QueuedScene } from "src/models/sceneQueue";
|
import SceneQueue, { QueuedScene } from "src/models/sceneQueue";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import { OCounterButton } from "./OCounterButton";
|
|
||||||
import { OrganizedButton } from "./OrganizedButton";
|
import { OrganizedButton } from "./OrganizedButton";
|
||||||
import { ConfigurationContext } from "src/hooks/Config";
|
import { ConfigurationContext } from "src/hooks/Config";
|
||||||
import { getPlayerPosition } from "src/components/ScenePlayer/util";
|
import { getPlayerPosition } from "src/components/ScenePlayer/util";
|
||||||
|
|
@ -41,7 +39,17 @@ import {
|
||||||
faChevronRight,
|
faChevronRight,
|
||||||
faChevronLeft,
|
faChevronLeft,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} 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 { lazyComponent } from "src/utils/lazyComponent";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||||
|
|
||||||
const SubmitStashBoxDraft = lazyComponent(
|
const SubmitStashBoxDraft = lazyComponent(
|
||||||
() => import("src/components/Dialogs/SubmitDraft")
|
() => import("src/components/Dialogs/SubmitDraft")
|
||||||
|
|
@ -73,7 +81,54 @@ const GenerateDialog = lazyComponent(
|
||||||
const SceneVideoFilterPanel = lazyComponent(
|
const SceneVideoFilterPanel = lazyComponent(
|
||||||
() => import("./SceneVideoFilterPanel")
|
() => 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 {
|
interface IProps {
|
||||||
scene: GQL.SceneDataFragment;
|
scene: GQL.SceneDataFragment;
|
||||||
|
|
@ -126,8 +181,16 @@ const ScenePage: React.FC<IProps> = ({
|
||||||
const boxes = configuration?.general?.stashBoxes ?? [];
|
const boxes = configuration?.general?.stashBoxes ?? [];
|
||||||
|
|
||||||
const [incrementO] = useSceneIncrementO(scene.id);
|
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);
|
const [organizedLoading, setOrganizedLoading] = useState(false);
|
||||||
|
|
||||||
|
|
@ -136,7 +199,7 @@ const ScenePage: React.FC<IProps> = ({
|
||||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||||
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
|
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
|
||||||
|
|
||||||
const onIncrementClick = async () => {
|
const onIncrementOClick = async () => {
|
||||||
try {
|
try {
|
||||||
await incrementO();
|
await incrementO();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -144,13 +207,22 @@ const ScenePage: React.FC<IProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDecrementClick = async () => {
|
function setRating(v: number | null) {
|
||||||
try {
|
updateScene({
|
||||||
await decrementO();
|
variables: {
|
||||||
} catch (e) {
|
input: {
|
||||||
Toast.error(e);
|
id: scene.id,
|
||||||
|
rating100: v,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
useRatingKeybinds(
|
||||||
|
true,
|
||||||
|
configuration?.ui.ratingSystemOptions?.type,
|
||||||
|
setRating
|
||||||
|
);
|
||||||
|
|
||||||
// set up hotkeys
|
// set up hotkeys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -161,7 +233,7 @@ const ScenePage: React.FC<IProps> = ({
|
||||||
Mousetrap.bind("i", () => setActiveTabKey("scene-file-info-panel"));
|
Mousetrap.bind("i", () => setActiveTabKey("scene-file-info-panel"));
|
||||||
Mousetrap.bind("h", () => setActiveTabKey("scene-history-panel"));
|
Mousetrap.bind("h", () => setActiveTabKey("scene-history-panel"));
|
||||||
Mousetrap.bind("o", () => {
|
Mousetrap.bind("o", () => {
|
||||||
onIncrementClick();
|
onIncrementOClick();
|
||||||
});
|
});
|
||||||
Mousetrap.bind("p n", () => onQueueNext());
|
Mousetrap.bind("p n", () => onQueueNext());
|
||||||
Mousetrap.bind("p p", () => onQueuePrevious());
|
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) {
|
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
|
||||||
setTimestamp(marker.seconds);
|
setTimestamp(marker.seconds);
|
||||||
}
|
}
|
||||||
|
|
@ -420,27 +484,6 @@ const ScenePage: React.FC<IProps> = ({
|
||||||
<FormattedMessage id="actions.edit" />
|
<FormattedMessage id="actions.edit" />
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
</Nav.Item>
|
</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>
|
</Nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -509,6 +552,11 @@ const ScenePage: React.FC<IProps> = ({
|
||||||
|
|
||||||
const title = objectTitle(scene);
|
const title = objectTitle(scene);
|
||||||
|
|
||||||
|
const file = useMemo(
|
||||||
|
() => (scene.files.length > 0 ? scene.files[0] : undefined),
|
||||||
|
[scene]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
|
|
@ -521,9 +569,10 @@ const ScenePage: React.FC<IProps> = ({
|
||||||
collapsed ? "collapsed" : ""
|
collapsed ? "collapsed" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="d-none d-xl-block">
|
<div>
|
||||||
|
<div className="scene-header-container">
|
||||||
{scene.studio && (
|
{scene.studio && (
|
||||||
<h1 className="mt-3 text-center">
|
<h1 className="text-center scene-studio-image">
|
||||||
<Link to={`/studios/${scene.studio.id}`}>
|
<Link to={`/studios/${scene.studio.id}`}>
|
||||||
<img
|
<img
|
||||||
src={scene.studio.image_path ?? ""}
|
src={scene.studio.image_path ?? ""}
|
||||||
|
|
@ -533,7 +582,63 @@ const ScenePage: React.FC<IProps> = ({
|
||||||
</Link>
|
</Link>
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
)}
|
||||||
<h3 className="scene-header">{title}</h3>
|
<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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
</div>
|
||||||
{renderTabs()}
|
{renderTabs()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import React, { useMemo } from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { FormattedDate, FormattedMessage, useIntl } from "react-intl";
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import TextUtils from "src/utils/text";
|
import TextUtils from "src/utils/text";
|
||||||
import { TagLink } from "src/components/Shared/TagLink";
|
import { TagLink } from "src/components/Shared/TagLink";
|
||||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
|
||||||
import { PerformerCard } from "src/components/Performers/PerformerCard";
|
import { PerformerCard } from "src/components/Performers/PerformerCard";
|
||||||
import { sortPerformers } from "src/core/performers";
|
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";
|
import { DirectorLink } from "src/components/Shared/Link";
|
||||||
|
|
||||||
interface ISceneDetailProps {
|
interface ISceneDetailProps {
|
||||||
|
|
@ -18,11 +14,6 @@ interface ISceneDetailProps {
|
||||||
export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
|
export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const file = useMemo(
|
|
||||||
() => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),
|
|
||||||
[props.scene]
|
|
||||||
);
|
|
||||||
|
|
||||||
function renderDetails() {
|
function renderDetails() {
|
||||||
if (!props.scene.details || props.scene.details === "") return;
|
if (!props.scene.details || props.scene.details === "") return;
|
||||||
return (
|
return (
|
||||||
|
|
@ -85,35 +76,7 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className={`${sceneDetailsWidth} col-xl-12 scene-details`}>
|
<div className={`${sceneDetailsWidth} col-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>
|
|
||||||
)}
|
|
||||||
<h6>
|
<h6>
|
||||||
<FormattedMessage id="created_at" />:{" "}
|
<FormattedMessage id="created_at" />:{" "}
|
||||||
{TextUtils.formatDateTime(intl, props.scene.created_at)}{" "}
|
{TextUtils.formatDateTime(intl, props.scene.created_at)}{" "}
|
||||||
|
|
@ -134,17 +97,6 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
|
||||||
</h6>
|
</h6>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import { IMovieEntry, SceneMovieTable } from "./SceneMovieTable";
|
||||||
import { faSearch, faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
import { faSearch, faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { objectTitle } from "src/core/files";
|
import { objectTitle } from "src/core/files";
|
||||||
import { galleryTitle } from "src/core/galleries";
|
import { galleryTitle } from "src/core/galleries";
|
||||||
import { useRatingKeybinds } from "src/hooks/keybinds";
|
|
||||||
import { lazyComponent } from "src/utils/lazyComponent";
|
import { lazyComponent } from "src/utils/lazyComponent";
|
||||||
import isEqual from "lodash-es/isEqual";
|
import isEqual from "lodash-es/isEqual";
|
||||||
import {
|
import {
|
||||||
|
|
@ -128,7 +127,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||||
urls: yupUniqueStringList(intl),
|
urls: yupUniqueStringList(intl),
|
||||||
date: yupDateString(intl),
|
date: yupDateString(intl),
|
||||||
director: yup.string().ensure(),
|
director: yup.string().ensure(),
|
||||||
rating100: yup.number().integer().nullable().defined(),
|
|
||||||
gallery_ids: yup.array(yup.string().required()).defined(),
|
gallery_ids: yup.array(yup.string().required()).defined(),
|
||||||
studio_id: yup.string().required().nullable(),
|
studio_id: yup.string().required().nullable(),
|
||||||
performer_ids: yup.array(yup.string().required()).defined(),
|
performer_ids: yup.array(yup.string().required()).defined(),
|
||||||
|
|
@ -153,7 +151,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||||
urls: scene.urls ?? [],
|
urls: scene.urls ?? [],
|
||||||
date: scene.date ?? "",
|
date: scene.date ?? "",
|
||||||
director: scene.director ?? "",
|
director: scene.director ?? "",
|
||||||
rating100: scene.rating100 ?? null,
|
|
||||||
gallery_ids: (scene.galleries ?? []).map((g) => g.id),
|
gallery_ids: (scene.galleries ?? []).map((g) => g.id),
|
||||||
studio_id: scene.studio?.id ?? null,
|
studio_id: scene.studio?.id ?? null,
|
||||||
performer_ids: (scene.performers ?? []).map((p) => p.id),
|
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[];
|
.filter((m) => m.movie !== undefined) as IMovieEntry[];
|
||||||
}, [formik.values.movies, movies]);
|
}, [formik.values.movies, movies]);
|
||||||
|
|
||||||
function setRating(v: number) {
|
|
||||||
formik.setFieldValue("rating100", v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSetGalleries(items: Gallery[]) {
|
function onSetGalleries(items: Gallery[]) {
|
||||||
setGalleries(items);
|
setGalleries(items);
|
||||||
formik.setFieldValue(
|
formik.setFieldValue(
|
||||||
|
|
@ -234,12 +227,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||||
formik.setFieldValue("studio_id", item ? item.id : null);
|
formik.setFieldValue("studio_id", item ? item.id : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
useRatingKeybinds(
|
|
||||||
isVisible,
|
|
||||||
stashConfig?.ui.ratingSystemOptions?.type,
|
|
||||||
setRating
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
Mousetrap.bind("s s", () => {
|
Mousetrap.bind("s s", () => {
|
||||||
|
|
@ -726,7 +713,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||||
renderField,
|
renderField,
|
||||||
renderInputField,
|
renderInputField,
|
||||||
renderDateField,
|
renderDateField,
|
||||||
renderRatingField,
|
|
||||||
renderURLListField,
|
renderURLListField,
|
||||||
renderStashIDsField,
|
renderStashIDsField,
|
||||||
} = formikUtils(intl, formik, splitProps);
|
} = formikUtils(intl, formik, splitProps);
|
||||||
|
|
@ -865,7 +851,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||||
|
|
||||||
{renderDateField("date")}
|
{renderDateField("date")}
|
||||||
{renderInputField("director")}
|
{renderInputField("director")}
|
||||||
{renderRatingField("rating100", "rating")}
|
|
||||||
|
|
||||||
{renderGalleriesField()}
|
{renderGalleriesField()}
|
||||||
{renderStudioField()}
|
{renderStudioField()}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
|
||||||
<RatingSystem
|
<RatingSystem
|
||||||
value={scene.rating100}
|
value={scene.rating100}
|
||||||
onSetRating={(value) => setRating(value, scene.id)}
|
onSetRating={(value) => setRating(value, scene.id)}
|
||||||
|
clickToRate
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,67 @@
|
||||||
max-width: 100%;
|
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 {
|
.scene-header {
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
|
font-size: 1.5rem;
|
||||||
margin-top: 30px;
|
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 {
|
#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 {
|
export interface IRatingNumberProps {
|
||||||
value: number | null;
|
value: number | null;
|
||||||
onSetRating?: (value: number | null) => void;
|
onSetRating?: (value: number | null) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
clickToRate?: boolean;
|
||||||
|
// true if we should indicate that this is a rating
|
||||||
|
withoutContext?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RatingNumber: React.FC<IRatingNumberProps> = (
|
export const RatingNumber: React.FC<IRatingNumberProps> = (
|
||||||
props: 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);
|
const useValidation = useRef(true);
|
||||||
|
|
||||||
function stepChange() {
|
function stepChange() {
|
||||||
|
|
@ -38,11 +58,13 @@ export const RatingNumber: React.FC<IRatingNumberProps> = (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setRating = editing ? setValueStage : props.onSetRating;
|
||||||
|
|
||||||
let val = e.target.value;
|
let val = e.target.value;
|
||||||
if (!useValidation.current) {
|
if (!useValidation.current) {
|
||||||
e.target.value = Number(val).toFixed(1);
|
e.target.value = Number(val).toFixed(1);
|
||||||
const tempVal = Number(val) * 10;
|
const tempVal = Number(val) * 10;
|
||||||
props.onSetRating(tempVal || null);
|
setRating(tempVal || null);
|
||||||
useValidation.current = true;
|
useValidation.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +72,7 @@ export const RatingNumber: React.FC<IRatingNumberProps> = (
|
||||||
const match = /(\d?)(\d?)(.?)((\d)?)/g.exec(val);
|
const match = /(\d?)(\d?)(.?)((\d)?)/g.exec(val);
|
||||||
const matchOld = /(\d?)(\d?)(.?)((\d{0,2})?)/g.exec(text ?? "");
|
const matchOld = /(\d?)(\d?)(.?)((\d{0,2})?)/g.exec(text ?? "");
|
||||||
|
|
||||||
if (match == null || props.onSetRating == null) {
|
if (match == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +92,7 @@ export const RatingNumber: React.FC<IRatingNumberProps> = (
|
||||||
}
|
}
|
||||||
e.target.value = Number(value).toFixed(1);
|
e.target.value = Number(value).toFixed(1);
|
||||||
let tempVal = Number(value) * 10;
|
let tempVal = Number(value) * 10;
|
||||||
props.onSetRating(tempVal || null);
|
setRating(tempVal || null);
|
||||||
|
|
||||||
let cursorPosition = 0;
|
let cursorPosition = 0;
|
||||||
if (match[2] && !match[4]) {
|
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 (
|
return (
|
||||||
<div className="rating-number disabled">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className="rating-number">
|
<div className="rating-number">
|
||||||
<input
|
<input
|
||||||
|
ref={ratingRef}
|
||||||
className="text-input form-control"
|
className="text-input form-control"
|
||||||
name="ratingnumber"
|
name="ratingnumber"
|
||||||
type="number"
|
type="number"
|
||||||
onMouseDown={stepChange}
|
onMouseDown={stepChange}
|
||||||
onKeyDown={nonStepChange}
|
onKeyDown={nonStepChange}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onBlur={onBlur}
|
||||||
value={text}
|
value={text}
|
||||||
min="0.0"
|
min="0.0"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ export interface IRatingSystemProps {
|
||||||
onSetRating?: (value: number | null) => void;
|
onSetRating?: (value: number | null) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
valueRequired?: 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> = (
|
export const RatingSystem: React.FC<IRatingSystemProps> = (
|
||||||
|
|
@ -40,6 +44,8 @@ export const RatingSystem: React.FC<IRatingSystemProps> = (
|
||||||
value={props.value ?? null}
|
value={props.value ?? null}
|
||||||
onSetRating={props.onSetRating}
|
onSetRating={props.onSetRating}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
|
clickToRate={props.clickToRate}
|
||||||
|
withoutContext={props.withoutContext}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,18 @@
|
||||||
margin: auto 0.5rem;
|
margin: auto 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rating-number.disabled {
|
.rating-number {
|
||||||
|
.fa-icon {
|
||||||
|
color: gold;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-rating-button {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -552,3 +552,43 @@ button.btn.favorite-button {
|
||||||
box-shadow: none;
|
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
|
<RatingSystem
|
||||||
value={studio.rating100}
|
value={studio.rating100}
|
||||||
onSetRating={(value) => setRating(value)}
|
onSetRating={(value) => setRating(value)}
|
||||||
|
clickToRate
|
||||||
|
withoutContext
|
||||||
/>
|
/>
|
||||||
{maybeRenderDetails()}
|
{maybeRenderDetails()}
|
||||||
{maybeRenderEditPanel()}
|
{maybeRenderEditPanel()}
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rating-number .text-input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -403,13 +403,15 @@ li.active .optional-field.excluded .scene-link {
|
||||||
// margin-left: 1rem;
|
// margin-left: 1rem;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
.scene-details,
|
.tagger-container {
|
||||||
.original-scene-details {
|
.scene-details,
|
||||||
|
.original-scene-details {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
> .row {
|
> .row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.PHashPopover {
|
.PHashPopover {
|
||||||
|
|
|
||||||
|
|
@ -925,6 +925,8 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||||
<RatingSystem
|
<RatingSystem
|
||||||
value={currentImage?.rating100}
|
value={currentImage?.rating100}
|
||||||
onSetRating={(v) => setRating(v)}
|
onSetRating={(v) => setRating(v)}
|
||||||
|
clickToRate
|
||||||
|
withoutContext
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,10 @@
|
||||||
padding-left: 0.38rem;
|
padding-left: 0.38rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rating-number .text-input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
&-left {
|
&-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -1365,3 +1365,8 @@ select {
|
||||||
.primary-file {
|
.primary-file {
|
||||||
font-weight: bold;
|
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…",
|
"temp_enable": "Enable temporarily…",
|
||||||
"unset": "Unset",
|
"unset": "Unset",
|
||||||
"use_default": "Use default",
|
"use_default": "Use default",
|
||||||
|
"view_history": "View history",
|
||||||
"view_random": "View Random"
|
"view_random": "View Random"
|
||||||
},
|
},
|
||||||
"actions_name": "Actions",
|
"actions_name": "Actions",
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,16 @@ const useFocus = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// focuses on the element only once on mount
|
// 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 [htmlElRef, setFocus] = useFocus();
|
||||||
const focused = useRef(false);
|
const focused = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focused.current && active) {
|
if ((!focused.current || override) && active) {
|
||||||
setFocus();
|
setFocus();
|
||||||
focused.current = true;
|
focused.current = true;
|
||||||
}
|
}
|
||||||
}, [setFocus, active]);
|
}, [setFocus, active, override]);
|
||||||
|
|
||||||
return [htmlElRef, setFocus] as const;
|
return [htmlElRef, setFocus] as const;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue