-
-
-
{header}
+export const RecommendationRow: React.FC
> =
+ PatchComponent(
+ "RecommendationRow",
+ ({ className, header, link, children }) => (
+
+
+
+
{header}
+
+ {link}
+
+ {children}
- {link}
-
- {children}
-
-);
+ )
+ );
diff --git a/ui/v2.5/src/components/FrontPage/styles.scss b/ui/v2.5/src/components/FrontPage/styles.scss
index 88d7f0c0a..2de0c6a44 100644
--- a/ui/v2.5/src/components/FrontPage/styles.scss
+++ b/ui/v2.5/src/components/FrontPage/styles.scss
@@ -492,3 +492,10 @@
color: white;
opacity: 0.75;
}
+
+// HACK: compatibility with existing behaviour after removed width from zoom-1 class
+// this should really be changed to use the specific card types instead of a generic zoom-1 class,
+// but this is a quick fix to prevent breaking existing styles
+.recommendation-row .card.zoom-1 {
+ width: 320px;
+}
diff --git a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx
index 9ff7e00f2..cec44abf1 100644
--- a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx
+++ b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx
@@ -1,100 +1,129 @@
-import React, { useEffect, useState } from "react";
-import { Form, Col, Row } from "react-bootstrap";
-import { FormattedMessage, useIntl } from "react-intl";
-import isEqual from "lodash-es/isEqual";
+import React, { useEffect, useMemo, useState } from "react";
+import { Form } from "react-bootstrap";
+import { useIntl } from "react-intl";
import { useBulkGalleryUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { StudioSelect } from "../Shared/Select";
import { ModalComponent } from "../Shared/Modal";
-import { useToast } from "src/hooks/Toast";
-import * as FormUtils from "src/utils/form";
import { MultiSet } from "../Shared/MultiSet";
+import { useToast } from "src/hooks/Toast";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
- getAggregateInputIDs,
getAggregateInputValue,
getAggregatePerformerIds,
- getAggregateRating,
- getAggregateStudioId,
+ getAggregateStateObject,
getAggregateTagIds,
+ getAggregateStudioId,
+ getAggregateSceneIds,
} from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
+import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
+import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate";
+import { BulkUpdateDateInput } from "../Shared/DateInput";
+import { getDateError } from "src/utils/yup";
interface IListOperationProps {
selected: GQL.SlimGalleryDataFragment[];
onClose: (applied: boolean) => void;
}
+const galleryFields = [
+ "code",
+ "rating100",
+ "details",
+ "organized",
+ "photographer",
+ "date",
+];
+
export const EditGalleriesDialog: React.FC
= (
props: IListOperationProps
) => {
const intl = useIntl();
const Toast = useToast();
- const [rating100, setRating] = useState();
- const [studioId, setStudioId] = useState();
- const [performerMode, setPerformerMode] =
- React.useState(GQL.BulkUpdateIdMode.Add);
- const [performerIds, setPerformerIds] = useState();
- const [existingPerformerIds, setExistingPerformerIds] = useState();
- const [tagMode, setTagMode] = React.useState(
- GQL.BulkUpdateIdMode.Add
- );
- const [tagIds, setTagIds] = useState();
- const [existingTagIds, setExistingTagIds] = useState();
- const [organized, setOrganized] = useState();
+
+ const [updateInput, setUpdateInput] = useState({
+ ids: props.selected.map((gallery) => {
+ return gallery.id;
+ }),
+ });
+
+ const [performerIds, setPerformerIds] = useState({
+ mode: GQL.BulkUpdateIdMode.Add,
+ });
+ const [tagIds, setTagIds] = useState({
+ mode: GQL.BulkUpdateIdMode.Add,
+ });
+ const [sceneIds, setSceneIds] = useState({
+ mode: GQL.BulkUpdateIdMode.Add,
+ });
+
+ const unsetDisabled = props.selected.length < 2;
+
+ const [dateError, setDateError] = useState();
const [updateGalleries] = useBulkGalleryUpdate();
// Network state
const [isUpdating, setIsUpdating] = useState(false);
- const checkboxRef = React.createRef();
+ const aggregateState = useMemo(() => {
+ const updateState: Partial = {};
+ const state = props.selected;
+ updateState.studio_id = getAggregateStudioId(props.selected);
+ const updateTagIds = getAggregateTagIds(props.selected);
+ const updatePerformerIds = getAggregatePerformerIds(props.selected);
+ const updateSceneIds = getAggregateSceneIds(props.selected);
+ let first = true;
+
+ state.forEach((gallery: GQL.SlimGalleryDataFragment) => {
+ getAggregateStateObject(updateState, gallery, galleryFields, first);
+ first = false;
+ });
+
+ return {
+ state: updateState,
+ tagIds: updateTagIds,
+ performerIds: updatePerformerIds,
+ sceneIds: updateSceneIds,
+ };
+ }, [props.selected]);
+
+ // update initial state from aggregate
+ useEffect(() => {
+ setUpdateInput((current) => ({ ...current, ...aggregateState.state }));
+ }, [aggregateState]);
+
+ useEffect(() => {
+ setDateError(getDateError(updateInput.date ?? "", intl));
+ }, [updateInput.date, intl]);
+
+ function setUpdateField(input: Partial) {
+ setUpdateInput((current) => ({ ...current, ...input }));
+ }
function getGalleryInput(): GQL.BulkGalleryUpdateInput {
- // need to determine what we are actually setting on each gallery
- const aggregateRating = getAggregateRating(props.selected);
- const aggregateStudioId = getAggregateStudioId(props.selected);
- const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
- const aggregateTagIds = getAggregateTagIds(props.selected);
-
const galleryInput: GQL.BulkGalleryUpdateInput = {
- ids: props.selected.map((gallery) => {
- return gallery.id;
- }),
+ ...updateInput,
+ tag_ids: tagIds,
+ performer_ids: performerIds,
+ scene_ids: sceneIds,
};
- galleryInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
- galleryInput.studio_id = getAggregateInputValue(
- studioId,
- aggregateStudioId
+ // we don't have unset functionality for the rating star control
+ // so need to determine if we are setting a rating or not
+ galleryInput.rating100 = getAggregateInputValue(
+ updateInput.rating100,
+ aggregateState.state.rating100
);
- galleryInput.performer_ids = getAggregateInputIDs(
- performerMode,
- performerIds,
- aggregatePerformerIds
- );
- galleryInput.tag_ids = getAggregateInputIDs(
- tagMode,
- tagIds,
- aggregateTagIds
- );
-
- if (organized !== undefined) {
- galleryInput.organized = organized;
- }
-
return galleryInput;
}
async function onSave() {
setIsUpdating(true);
try {
- await updateGalleries({
- variables: {
- input: getGalleryInput(),
- },
- });
+ await updateGalleries({ variables: { input: getGalleryInput() } });
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
@@ -110,129 +139,13 @@ export const EditGalleriesDialog: React.FC = (
setIsUpdating(false);
}
- useEffect(() => {
- const state = props.selected;
- let updateRating: number | undefined;
- let updateStudioID: string | undefined;
- let updatePerformerIds: string[] = [];
- let updateTagIds: string[] = [];
- let updateOrganized: boolean | undefined;
- let first = true;
-
- state.forEach((gallery: GQL.SlimGalleryDataFragment) => {
- const galleryRating = gallery.rating100;
- const GalleriestudioID = gallery?.studio?.id;
- const galleryPerformerIDs = (gallery.performers ?? [])
- .map((p) => p.id)
- .sort();
- const galleryTagIDs = (gallery.tags ?? []).map((p) => p.id).sort();
-
- if (first) {
- updateRating = galleryRating ?? undefined;
- updateStudioID = GalleriestudioID;
- updatePerformerIds = galleryPerformerIDs;
- updateTagIds = galleryTagIDs;
- updateOrganized = gallery.organized;
- first = false;
- } else {
- if (galleryRating !== updateRating) {
- updateRating = undefined;
- }
- if (GalleriestudioID !== updateStudioID) {
- updateStudioID = undefined;
- }
- if (!isEqual(galleryPerformerIDs, updatePerformerIds)) {
- updatePerformerIds = [];
- }
- if (!isEqual(galleryTagIDs, updateTagIds)) {
- updateTagIds = [];
- }
- if (gallery.organized !== updateOrganized) {
- updateOrganized = undefined;
- }
- }
- });
-
- setRating(updateRating);
- setStudioId(updateStudioID);
- setExistingPerformerIds(updatePerformerIds);
- setExistingTagIds(updateTagIds);
-
- setOrganized(updateOrganized);
- }, [props.selected]);
-
- useEffect(() => {
- if (checkboxRef.current) {
- checkboxRef.current.indeterminate = organized === undefined;
- }
- }, [organized, checkboxRef]);
-
- function renderMultiSelect(
- type: "performers" | "tags",
- ids: string[] | undefined
- ) {
- let mode = GQL.BulkUpdateIdMode.Add;
- let existingIds: string[] | undefined = [];
- switch (type) {
- case "performers":
- mode = performerMode;
- existingIds = existingPerformerIds;
- break;
- case "tags":
- mode = tagMode;
- existingIds = existingTagIds;
- break;
- }
-
- return (
- {
- switch (type) {
- case "performers":
- setPerformerIds(itemIDs);
- break;
- case "tags":
- setTagIds(itemIDs);
- break;
- }
- }}
- onSetMode={(newMode) => {
- switch (type) {
- case "performers":
- setPerformerMode(newMode);
- break;
- case "tags":
- setTagMode(newMode);
- break;
- }
- }}
- existingIds={existingIds ?? []}
- ids={ids ?? []}
- mode={mode}
- menuPortalTarget={document.body}
- />
- );
- }
-
- function cycleOrganized() {
- if (organized) {
- setOrganized(undefined);
- } else if (organized === undefined) {
- setOrganized(false);
- } else {
- setOrganized(true);
- }
- }
-
function render() {
return (
= (
onClick: onSave,
text: intl.formatMessage({ id: "actions.apply" }),
}}
+ disabled={isUpdating || !!dateError}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
@@ -251,55 +165,119 @@ export const EditGalleriesDialog: React.FC = (
isRunning={isUpdating}
>
- {FormUtils.renderLabel({
- title: intl.formatMessage({ id: "rating" }),
- })}
-
- setRating(value ?? undefined)}
- disabled={isUpdating}
- />
-
-
-
- {FormUtils.renderLabel({
- title: intl.formatMessage({ id: "studio" }),
- })}
-
-
- setStudioId(items.length > 0 ? items[0]?.id : undefined)
- }
- ids={studioId ? [studioId] : []}
- isDisabled={isUpdating}
- menuPortalTarget={document.body}
- />
-
-
+
+
+ setUpdateField({ rating100: value ?? undefined })
+ }
+ disabled={isUpdating}
+ />
+
-
-
-
-
- {renderMultiSelect("performers", performerIds)}
-
+
+ setUpdateField({ code: newValue })}
+ unsetDisabled={unsetDisabled}
+ />
+
+
+ setUpdateField({ date: newValue })}
+ unsetDisabled={unsetDisabled}
+ error={dateError}
+ />
+
-
-
-
-
- {renderMultiSelect("tags", tagIds)}
-
+
+
+ setUpdateField({ photographer: newValue })
+ }
+ unsetDisabled={unsetDisabled}
+ />
+
+
+
+ setUpdateField({
+ studio_id: items.length > 0 ? items[0]?.id : undefined,
+ })
+ }
+ ids={updateInput.studio_id ? [updateInput.studio_id] : []}
+ isDisabled={isUpdating}
+ menuPortalTarget={document.body}
+ />
+
+
+
+ {
+ setPerformerIds((c) => ({ ...c, ids: itemIDs }));
+ }}
+ onSetMode={(newMode) => {
+ setPerformerIds((c) => ({ ...c, mode: newMode }));
+ }}
+ ids={performerIds.ids ?? []}
+ existingIds={aggregateState.performerIds}
+ mode={performerIds.mode}
+ menuPortalTarget={document.body}
+ />
+
+
+
+ {
+ setSceneIds((c) => ({ ...c, ids: itemIDs }));
+ }}
+ onSetMode={(newMode) => {
+ setSceneIds((c) => ({ ...c, mode: newMode }));
+ }}
+ ids={sceneIds.ids ?? []}
+ existingIds={aggregateState.sceneIds}
+ mode={sceneIds.mode}
+ menuPortalTarget={document.body}
+ />
+
+
+
+ {
+ setTagIds((c) => ({ ...c, ids: itemIDs }));
+ }}
+ onSetMode={(newMode) => {
+ setTagIds((c) => ({ ...c, mode: newMode }));
+ }}
+ ids={tagIds.ids ?? []}
+ existingIds={aggregateState.tagIds}
+ mode={tagIds.mode}
+ menuPortalTarget={document.body}
+ />
+
+
+
+ setUpdateField({ details: newValue })}
+ unsetDisabled={unsetDisabled}
+ as="textarea"
+ />
+
- cycleOrganized()}
+ setChecked={(checked) => setUpdateField({ organized: checked })}
+ checked={updateInput.organized ?? undefined}
/>
diff --git a/ui/v2.5/src/components/Galleries/Galleries.tsx b/ui/v2.5/src/components/Galleries/Galleries.tsx
index c845a153c..388ce6720 100644
--- a/ui/v2.5/src/components/Galleries/Galleries.tsx
+++ b/ui/v2.5/src/components/Galleries/Galleries.tsx
@@ -4,7 +4,7 @@ import { Helmet } from "react-helmet";
import { useTitleProps } from "src/hooks/title";
import Gallery from "./GalleryDetails/Gallery";
import GalleryCreate from "./GalleryDetails/GalleryCreate";
-import { GalleryList } from "./GalleryList";
+import { FilteredGalleryList } from "./GalleryList";
import { View } from "../List/views";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { ErrorMessage } from "../Shared/ErrorMessage";
@@ -40,7 +40,7 @@ const GalleryImage: React.FC> = ({
};
const Galleries: React.FC = () => {
- return ;
+ return ;
};
const GalleryRoutes: React.FC = () => {
diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx
index e4e227f3e..01e0b6045 100644
--- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx
+++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx
@@ -1,5 +1,5 @@
import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap";
-import React, { useState } from "react";
+import React, { useMemo, useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { GridCard } from "../Shared/GridCard/GridCard";
import { HoverPopover } from "../Shared/HoverPopover";
@@ -21,11 +21,13 @@ import { PatchComponent } from "src/patch";
interface IGalleryPreviewProps {
gallery: GQL.SlimGalleryDataFragment;
onScrubberClick?: (index: number) => void;
+ disabled?: boolean;
}
export const GalleryPreview: React.FC = ({
gallery,
onScrubberClick,
+ disabled,
}) => {
const [imgSrc, setImgSrc] = useState(
gallery.paths.cover ?? undefined
@@ -48,6 +50,7 @@ export const GalleryPreview: React.FC = ({
imageCount={gallery.image_count}
onClick={onScrubberClick}
onPathChanged={setImgSrc}
+ disabled={disabled}
/>
)}
@@ -195,7 +198,16 @@ const GalleryCardDetails = PatchComponent(
const GalleryCardOverlays = PatchComponent(
"GalleryCard.Overlays",
(props: IGalleryCardProps) => {
- return