Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/App.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
@import "./components/performerCard/styles";
@import "./components/performerSelect/styles";
@import "./components/sceneCard/styles";
@import "./components/sceneSelect/styles";
@import "./components/searchField/styles";
@import "./components/studioSelect/styles";
@import "./components/tagFilter/styles";
Expand Down
139 changes: 139 additions & 0 deletions frontend/src/components/sceneSelect/SceneSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { useApolloClient } from "@apollo/client/react";
import debounce from "p-debounce";
import { type FC, useState } from "react";
import type { OnChangeValue } from "react-select";
import Async from "react-select/async";
import { SearchInput, TagLink } from "src/components/fragments";
import type {
SearchScenesQuery,
SearchScenesQueryVariables,
} from "src/graphql";
import SearchScenesGQL from "src/graphql/queries/SearchScenes.gql";
import { sceneHref } from "src/utils/route";

type Scene = NonNullable<SearchScenesQuery["searchScenes"]["scenes"][number]>;

export type SceneSlim = {
id: string;
title?: string | null;
release_date?: string | null;
deleted: boolean;
};

interface SceneSelectProps {
scenes?: SceneSlim[];
onChange: (scenes: SceneSlim[]) => void;
message?: string;
excludeScenes?: string[];
inputId?: string;
}

interface SearchResult {
value: Scene;
label: string;
sublabel: string;
}

const CLASSNAME = "SceneSelect";
const CLASSNAME_LIST = `${CLASSNAME}-list`;
const CLASSNAME_SELECT = `${CLASSNAME}-select`;
const CLASSNAME_CONTAINER = `${CLASSNAME}-container`;

const SceneSelect: FC<SceneSelectProps> = ({
scenes: initialScenes = [],
onChange,
message = "Add scene:",
excludeScenes = [],
inputId,
}) => {
const client = useApolloClient();
const [scenes, setScenes] = useState(initialScenes);
const excluded = [...excludeScenes, ...scenes.map((s) => s.id)];

const handleChange = (result: OnChangeValue<SearchResult, false>) => {
if (result?.value) {
const { id, title, release_date, deleted } = result.value;
const newScenes = [...scenes, { id, title, release_date, deleted }];
setScenes(newScenes);
onChange(newScenes);
}
};

const removeScene = (id: string) => {
const newScenes = scenes.filter((scene) => scene.id !== id);
setScenes(newScenes);
onChange(newScenes);
};

const sceneList = [...scenes]
.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""))
.map((scene) => (
<TagLink
key={scene.id}
title={scene.title ?? scene.id}
description={scene.release_date}
link={sceneHref(scene)}
onRemove={() => removeScene(scene.id)}
disabled
/>
));

const handleSearch = async (term: string) => {
const { data } = await client.query<
SearchScenesQuery,
SearchScenesQueryVariables
>({
query: SearchScenesGQL,
variables: { term, per_page: 25 },
});

const results = (data?.searchScenes.scenes ?? [])
.filter((scene) => !excluded.includes(scene.id) && !scene.deleted)
.map((scene) => ({
value: scene,
label: scene.title ?? "",
sublabel: [scene.release_date, scene.studio?.name]
.filter(Boolean)
.join(" \u2022 "),
}));

return results.length > 0 ? [{ label: "Scenes", options: results }] : [];
};

const debouncedLoadOptions = debounce(handleSearch, 400);

const formatOptionLabel = ({ label, sublabel, value }: SearchResult) => (
<div>
<div className={`${CLASSNAME_SELECT}-value`}>
{value.deleted ? <del>{label}</del> : label}
</div>
<div className={`${CLASSNAME_SELECT}-subvalue`}>{sublabel}</div>
</div>
);

return (
<div className={CLASSNAME}>
<div className={CLASSNAME_LIST}>{sceneList}</div>
<div className={CLASSNAME_CONTAINER}>
<span>{message}</span>
<Async
isMulti={false}
inputId={inputId}
classNamePrefix="react-select"
className={`react-select ${CLASSNAME_SELECT}`}
onChange={handleChange}
loadOptions={debouncedLoadOptions}
placeholder="Search for scene"
noOptionsMessage={({ inputValue }) =>
inputValue === "" ? null : `No scenes found for "${inputValue}"`
}
controlShouldRenderValue={false}
formatOptionLabel={formatOptionLabel}
components={{ Input: SearchInput }}
/>
</div>
</div>
);
};

export default SceneSelect;
4 changes: 4 additions & 0 deletions frontend/src/components/sceneSelect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import SceneSelect from "./SceneSelect";

export type { SceneSlim } from "./SceneSelect";
export default SceneSelect;
27 changes: 27 additions & 0 deletions frontend/src/components/sceneSelect/styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
.SceneSelect {
margin-top: 0.5rem;

&-list {
margin-bottom: 1rem;
}

&-container {
display: flex;
}

&-select {
display: inline-block;
margin-left: auto;
width: 25rem;

&-value {
font-size: 14px;
font-weight: 500;
}

&-subvalue {
font-size: 12px;
color: $text-muted;
}
}
}
1 change: 1 addition & 0 deletions frontend/src/constants/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const ROUTE_PERFORMERS = "/performers";
export const ROUTE_SCENE = "/scenes/:id";
export const ROUTE_SCENE_ADD = "/scenes/add";
export const ROUTE_SCENE_EDIT = "/scenes/:id/edit";
export const ROUTE_SCENE_MERGE = "/scenes/:id/merge";
export const ROUTE_SCENE_DELETE = "/scenes/:id/delete";
export const ROUTE_SCENE_FINGERPRINT_CLUSTERS = "/scenes/:id/fingerprints";
export const ROUTE_SCENES = "/scenes";
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/pages/scenes/Scene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
} from "src/components/fragments";
import Image from "src/components/image";
import { EditList, URLList } from "src/components/list";
import { ROUTE_SCENE_DELETE, ROUTE_SCENE_EDIT } from "src/constants/route";
import {
ROUTE_SCENE_DELETE,
ROUTE_SCENE_EDIT,
ROUTE_SCENE_MERGE,
} from "src/constants/route";
import {
type SceneFragment as Scene,
TargetTypeEnum,
Expand Down Expand Up @@ -87,6 +91,12 @@ const SceneComponent: FC<Props> = ({ scene }) => {
<Link to={createHref(ROUTE_SCENE_EDIT, { id: scene.id })}>
<Button>Edit</Button>
</Link>
<Link
to={createHref(ROUTE_SCENE_MERGE, { id: scene.id })}
className="ms-2"
>
<Button>Merge</Button>
</Link>
<Link
to={createHref(ROUTE_SCENE_DELETE, { id: scene.id })}
className="ms-2"
Expand Down
121 changes: 121 additions & 0 deletions frontend/src/pages/scenes/SceneMerge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { type FC, useMemo, useState } from "react";
import { Col, Row } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
import { LoadingIndicator } from "src/components/fragments";
import SceneSelect, { type SceneSlim } from "src/components/sceneSelect";
import {
OperationEnum,
type SceneFragment as Scene,
type SceneEditDetailsInput,
useSceneEdit,
} from "src/graphql";
import { SceneFragmentDoc } from "src/graphql/types";
import { useEntities } from "src/hooks";
import { editHref } from "src/utils";
import SceneForm from "./sceneForm";
import { buildSceneMerge } from "./sceneForm/merge";

interface Props {
scene: Scene;
}

const SceneMerge: FC<Props> = ({ scene }) => {
const navigate = useNavigate();
const [submissionError, setSubmissionError] = useState("");
const [mergeSources, setMergeSources] = useState<SceneSlim[]>([]);

const {
sources: loadedSources,
ready: sourcesReady,
error: sourcesError,
} = useEntities<Scene>(mergeSources, "findScene", SceneFragmentDoc);

const [insertSceneEdit, { loading: saving }] = useSceneEdit({
onCompleted: (data) => {
if (submissionError) setSubmissionError("");
if (data.sceneEdit.id) navigate(editHref(data.sceneEdit));
},
onError: (error) => setSubmissionError(error.message),
});

const doUpdate = (insertData: SceneEditDetailsInput, editNote: string) => {
insertSceneEdit({
variables: {
sceneData: {
edit: {
id: scene.id,
operation: OperationEnum.MERGE,
merge_source_ids: mergeSources.map((s) => s.id),
comment: editNote,
},
details: insertData,
},
},
});
};

const { initial, conflicts } = useMemo(
() => buildSceneMerge(scene, loadedSources),
[scene, loadedSources],
);

return (
<div>
<h3>
Merge scenes into <em>{scene.title}</em>
</h3>
<hr />
<Row className="g-0">
<Col xs={6}>
<label htmlFor="scene-merge-source-select" className="form-label">
Merge sources
</label>
<SceneSelect
scenes={[]}
onChange={(scenes) => setMergeSources(scenes)}
message="Select scenes to merge:"
excludeScenes={[scene.id, ...mergeSources.map((s) => s.id)]}
inputId="scene-merge-source-select"
/>
</Col>
<Col xs={6}>
<p>
Merging scenes deletes the source scenes and redirects them to the
target scene. Previously generated content referencing the source
scenes will resolve to the target scene.
</p>
<p>
This operation is not easily reversible and attention should be paid
that all scenes are truly the same.
</p>
</Col>
</Row>
<hr className="my-4" />
<h5>
Modify <em>{scene.title}</em>
</h5>
<Row className="g-0">
{submissionError && (
<div className="text-danger mb-2">Error: {submissionError}</div>
)}
{sourcesError ? (
<div className="text-danger">
Failed to load scene details: {sourcesError.message}
</div>
) : sourcesReady ? (
<SceneForm
scene={scene}
callback={doUpdate}
saving={saving}
initial={initial}
conflicts={conflicts}
/>
) : (
<LoadingIndicator message="Loading scene details..." />
)}
</Row>
</div>
);
};

export default SceneMerge;
10 changes: 10 additions & 0 deletions frontend/src/pages/scenes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Scene from "./Scene";
import SceneAdd from "./SceneAdd";
import SceneDelete from "./SceneDelete";
import SceneEdit from "./SceneEdit";
import SceneMerge from "./SceneMerge";
import Scenes from "./Scenes";

const SceneLoader: FC = () => {
Expand All @@ -24,6 +25,15 @@ const SceneLoader: FC = () => {

return (
<Routes>
<Route
path="/merge"
element={
<>
<Title page={`Merge Into "${scene.title}"`} />
<SceneMerge scene={scene} />
</>
}
/>
<Route
path="/delete"
element={
Expand Down
Loading
Loading