mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
* track watchtime and view time * add view count sorting, added continue position filter * display metrics in file info * add toggle for tracking activity * save activity every 10 seconds * reset resume when video is nearly complete * start from beginning when playing scene in queue Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
731 lines
20 KiB
TypeScript
731 lines
20 KiB
TypeScript
import { Form, Col, Row, Button, FormControl } from "react-bootstrap";
|
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import * as GQL from "src/core/generated-graphql";
|
|
import {
|
|
GallerySelect,
|
|
Icon,
|
|
LoadingIndicator,
|
|
Modal,
|
|
SceneSelect,
|
|
StringListSelect,
|
|
} from "src/components/Shared";
|
|
import { FormUtils, ImageUtils, TextUtils } from "src/utils";
|
|
import { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService";
|
|
import { FormattedMessage, useIntl } from "react-intl";
|
|
import { useToast } from "src/hooks";
|
|
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
|
|
import {
|
|
hasScrapedValues,
|
|
ScrapeDialog,
|
|
ScrapeDialogRow,
|
|
ScrapedImageRow,
|
|
ScrapedInputGroupRow,
|
|
ScrapedTextAreaRow,
|
|
ScrapeResult,
|
|
} from "../Shared/ScrapeDialog";
|
|
import { clone, uniq } from "lodash-es";
|
|
import {
|
|
ScrapedMoviesRow,
|
|
ScrapedPerformersRow,
|
|
ScrapedStudioRow,
|
|
ScrapedTagsRow,
|
|
} from "./SceneDetails/SceneScrapeDialog";
|
|
import { galleryTitle } from "src/core/galleries";
|
|
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
|
|
|
interface IStashIDsField {
|
|
values: GQL.StashId[];
|
|
}
|
|
|
|
const StashIDsField: React.FC<IStashIDsField> = ({ values }) => {
|
|
return <StringListSelect value={values.map((v) => v.stash_id)} />;
|
|
};
|
|
|
|
interface ISceneMergeDetailsProps {
|
|
sources: GQL.SlimSceneDataFragment[];
|
|
dest: GQL.SlimSceneDataFragment;
|
|
onClose: (values?: GQL.SceneUpdateInput) => void;
|
|
}
|
|
|
|
const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
|
|
sources,
|
|
dest,
|
|
onClose,
|
|
}) => {
|
|
const intl = useIntl();
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [title, setTitle] = useState<ScrapeResult<string>>(
|
|
new ScrapeResult<string>(dest.title)
|
|
);
|
|
const [url, setURL] = useState<ScrapeResult<string>>(
|
|
new ScrapeResult<string>(dest.url)
|
|
);
|
|
const [date, setDate] = useState<ScrapeResult<string>>(
|
|
new ScrapeResult<string>(dest.date)
|
|
);
|
|
|
|
const [rating, setRating] = useState(
|
|
new ScrapeResult<number>(dest.rating100)
|
|
);
|
|
const [oCounter, setOCounter] = useState(
|
|
new ScrapeResult<number>(dest.o_counter)
|
|
);
|
|
const [playCount, setPlayCount] = useState(
|
|
new ScrapeResult<number>(dest.play_count)
|
|
);
|
|
const [playDuration, setPlayDuration] = useState(
|
|
new ScrapeResult<number>(dest.play_duration)
|
|
);
|
|
|
|
const [studio, setStudio] = useState<ScrapeResult<string>>(
|
|
new ScrapeResult<string>(dest.studio?.id)
|
|
);
|
|
|
|
function sortIdList(idList?: string[] | null) {
|
|
if (!idList) {
|
|
return;
|
|
}
|
|
|
|
const ret = clone(idList);
|
|
// sort by id numerically
|
|
ret.sort((a, b) => {
|
|
return parseInt(a, 10) - parseInt(b, 10);
|
|
});
|
|
|
|
return ret;
|
|
}
|
|
|
|
const [performers, setPerformers] = useState<ScrapeResult<string[]>>(
|
|
new ScrapeResult<string[]>(sortIdList(dest.performers.map((p) => p.id)))
|
|
);
|
|
|
|
const [movies, setMovies] = useState<ScrapeResult<string[]>>(
|
|
new ScrapeResult<string[]>(sortIdList(dest.movies.map((p) => p.movie.id)))
|
|
);
|
|
|
|
const [tags, setTags] = useState<ScrapeResult<string[]>>(
|
|
new ScrapeResult<string[]>(sortIdList(dest.tags.map((t) => t.id)))
|
|
);
|
|
|
|
const [details, setDetails] = useState<ScrapeResult<string>>(
|
|
new ScrapeResult<string>(dest.details)
|
|
);
|
|
|
|
const [galleries, setGalleries] = useState<ScrapeResult<string[]>>(
|
|
new ScrapeResult<string[]>(sortIdList(dest.galleries.map((p) => p.id)))
|
|
);
|
|
|
|
const [stashIDs, setStashIDs] = useState(new ScrapeResult<GQL.StashId[]>([]));
|
|
|
|
const [image, setImage] = useState<ScrapeResult<string>>(
|
|
new ScrapeResult<string>(dest.paths.screenshot)
|
|
);
|
|
|
|
// calculate the values for everything
|
|
// uses the first set value for single value fields, and combines all
|
|
useEffect(() => {
|
|
async function loadImages() {
|
|
const src = sources.find((s) => s.paths.screenshot);
|
|
if (!dest.paths.screenshot || !src) return;
|
|
|
|
setLoading(true);
|
|
|
|
const destData = await ImageUtils.imageToDataURL(dest.paths.screenshot);
|
|
const srcData = await ImageUtils.imageToDataURL(src.paths!.screenshot!);
|
|
|
|
// keep destination image by default
|
|
const useNewValue = false;
|
|
setImage(new ScrapeResult(destData, srcData, useNewValue));
|
|
|
|
setLoading(false);
|
|
}
|
|
|
|
// append dest to all so that if dest has stash_ids with the same
|
|
// endpoint, then it will be excluded first
|
|
const all = sources.concat(dest);
|
|
|
|
setTitle(
|
|
new ScrapeResult(
|
|
dest.title,
|
|
sources.find((s) => s.title)?.title,
|
|
!dest.title
|
|
)
|
|
);
|
|
setURL(
|
|
new ScrapeResult(dest.url, sources.find((s) => s.url)?.url, !dest.url)
|
|
);
|
|
setDate(
|
|
new ScrapeResult(dest.date, sources.find((s) => s.date)?.date, !dest.date)
|
|
);
|
|
setStudio(
|
|
new ScrapeResult(
|
|
dest.studio?.id,
|
|
sources.find((s) => s.studio)?.studio?.id,
|
|
!dest.studio
|
|
)
|
|
);
|
|
|
|
setPerformers(
|
|
new ScrapeResult(
|
|
dest.performers.map((p) => p.id),
|
|
uniq(all.map((s) => s.performers.map((p) => p.id)).flat())
|
|
)
|
|
);
|
|
setTags(
|
|
new ScrapeResult(
|
|
dest.tags.map((p) => p.id),
|
|
uniq(all.map((s) => s.tags.map((p) => p.id)).flat())
|
|
)
|
|
);
|
|
setDetails(
|
|
new ScrapeResult(
|
|
dest.details,
|
|
sources.find((s) => s.details)?.details,
|
|
!dest.details
|
|
)
|
|
);
|
|
|
|
setMovies(
|
|
new ScrapeResult(
|
|
dest.movies.map((m) => m.movie.id),
|
|
uniq(all.map((s) => s.movies.map((m) => m.movie.id)).flat())
|
|
)
|
|
);
|
|
|
|
setGalleries(
|
|
new ScrapeResult(
|
|
dest.galleries.map((p) => p.id),
|
|
uniq(all.map((s) => s.galleries.map((p) => p.id)).flat())
|
|
)
|
|
);
|
|
|
|
setRating(
|
|
new ScrapeResult(
|
|
dest.rating100,
|
|
sources.find((s) => s.rating100)?.rating100,
|
|
!dest.rating100
|
|
)
|
|
);
|
|
|
|
setOCounter(
|
|
new ScrapeResult(
|
|
dest.o_counter ?? 0,
|
|
all.map((s) => s.o_counter ?? 0).reduce((pv, cv) => pv + cv, 0)
|
|
)
|
|
);
|
|
|
|
setPlayCount(
|
|
new ScrapeResult(
|
|
dest.play_count ?? 0,
|
|
all.map((s) => s.play_count ?? 0).reduce((pv, cv) => pv + cv, 0)
|
|
)
|
|
);
|
|
|
|
setPlayDuration(
|
|
new ScrapeResult(
|
|
dest.play_duration ?? 0,
|
|
all.map((s) => s.play_duration ?? 0).reduce((pv, cv) => pv + cv, 0)
|
|
)
|
|
);
|
|
|
|
setStashIDs(
|
|
new ScrapeResult(
|
|
dest.stash_ids,
|
|
all
|
|
.map((s) => s.stash_ids)
|
|
.flat()
|
|
.filter((s, index, a) => {
|
|
// remove entries with duplicate endpoints
|
|
return index === a.findIndex((ss) => ss.endpoint === s.endpoint);
|
|
}),
|
|
!dest.stash_ids.length
|
|
)
|
|
);
|
|
|
|
loadImages();
|
|
}, [sources, dest]);
|
|
|
|
const convertGalleries = useCallback(
|
|
(ids?: string[]) => {
|
|
const all = [dest, ...sources];
|
|
return ids
|
|
?.map((g) =>
|
|
all
|
|
.map((s) => s.galleries)
|
|
.flat()
|
|
.find((gg) => g === gg.id)
|
|
)
|
|
.map((g) => {
|
|
return {
|
|
id: g!.id,
|
|
title: galleryTitle(g!),
|
|
};
|
|
});
|
|
},
|
|
[dest, sources]
|
|
);
|
|
|
|
const originalGalleries = useMemo(() => {
|
|
return convertGalleries(galleries.originalValue);
|
|
}, [galleries, convertGalleries]);
|
|
|
|
const newGalleries = useMemo(() => {
|
|
return convertGalleries(galleries.newValue);
|
|
}, [galleries, convertGalleries]);
|
|
|
|
// ensure this is updated if fields are changed
|
|
const hasValues = useMemo(() => {
|
|
return hasScrapedValues([
|
|
title,
|
|
url,
|
|
date,
|
|
rating,
|
|
oCounter,
|
|
galleries,
|
|
studio,
|
|
performers,
|
|
movies,
|
|
tags,
|
|
details,
|
|
stashIDs,
|
|
image,
|
|
]);
|
|
}, [
|
|
title,
|
|
url,
|
|
date,
|
|
rating,
|
|
oCounter,
|
|
galleries,
|
|
studio,
|
|
performers,
|
|
movies,
|
|
tags,
|
|
details,
|
|
stashIDs,
|
|
image,
|
|
]);
|
|
|
|
function renderScrapeRows() {
|
|
if (loading) {
|
|
return (
|
|
<div>
|
|
<LoadingIndicator />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!hasValues) {
|
|
return (
|
|
<div>
|
|
<FormattedMessage id="dialogs.merge.empty_results" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<ScrapedInputGroupRow
|
|
title={intl.formatMessage({ id: "title" })}
|
|
result={title}
|
|
onChange={(value) => setTitle(value)}
|
|
/>
|
|
<ScrapedInputGroupRow
|
|
title={intl.formatMessage({ id: "url" })}
|
|
result={url}
|
|
onChange={(value) => setURL(value)}
|
|
/>
|
|
<ScrapedInputGroupRow
|
|
title={intl.formatMessage({ id: "date" })}
|
|
placeholder="YYYY-MM-DD"
|
|
result={date}
|
|
onChange={(value) => setDate(value)}
|
|
/>
|
|
<ScrapeDialogRow
|
|
title={intl.formatMessage({ id: "rating" })}
|
|
result={rating}
|
|
renderOriginalField={() => (
|
|
<RatingSystem value={rating.originalValue} disabled />
|
|
)}
|
|
renderNewField={() => (
|
|
<RatingSystem value={rating.newValue} disabled />
|
|
)}
|
|
onChange={(value) => setRating(value)}
|
|
/>
|
|
<ScrapeDialogRow
|
|
title={intl.formatMessage({ id: "o_counter" })}
|
|
result={oCounter}
|
|
renderOriginalField={() => (
|
|
<FormControl
|
|
value={oCounter.originalValue ?? 0}
|
|
readOnly
|
|
onChange={() => {}}
|
|
className="bg-secondary text-white border-secondary"
|
|
/>
|
|
)}
|
|
renderNewField={() => (
|
|
<FormControl
|
|
value={oCounter.newValue ?? 0}
|
|
readOnly
|
|
onChange={() => {}}
|
|
className="bg-secondary text-white border-secondary"
|
|
/>
|
|
)}
|
|
onChange={(value) => setOCounter(value)}
|
|
/>
|
|
<ScrapeDialogRow
|
|
title={intl.formatMessage({ id: "play_count" })}
|
|
result={playCount}
|
|
renderOriginalField={() => (
|
|
<FormControl
|
|
value={playCount.originalValue ?? 0}
|
|
readOnly
|
|
onChange={() => {}}
|
|
className="bg-secondary text-white border-secondary"
|
|
/>
|
|
)}
|
|
renderNewField={() => (
|
|
<FormControl
|
|
value={playCount.newValue ?? 0}
|
|
readOnly
|
|
onChange={() => {}}
|
|
className="bg-secondary text-white border-secondary"
|
|
/>
|
|
)}
|
|
onChange={(value) => setPlayCount(value)}
|
|
/>
|
|
<ScrapeDialogRow
|
|
title={intl.formatMessage({ id: "play_duration" })}
|
|
result={playDuration}
|
|
renderOriginalField={() => (
|
|
<FormControl
|
|
value={TextUtils.secondsToTimestamp(
|
|
playDuration.originalValue ?? 0
|
|
)}
|
|
readOnly
|
|
onChange={() => {}}
|
|
className="bg-secondary text-white border-secondary"
|
|
/>
|
|
)}
|
|
renderNewField={() => (
|
|
<FormControl
|
|
value={TextUtils.secondsToTimestamp(playDuration.newValue ?? 0)}
|
|
readOnly
|
|
onChange={() => {}}
|
|
className="bg-secondary text-white border-secondary"
|
|
/>
|
|
)}
|
|
onChange={(value) => setPlayDuration(value)}
|
|
/>
|
|
<ScrapeDialogRow
|
|
title={intl.formatMessage({ id: "galleries" })}
|
|
result={galleries}
|
|
renderOriginalField={() => (
|
|
<GallerySelect
|
|
className="form-control react-select"
|
|
selected={originalGalleries ?? []}
|
|
onSelect={() => {}}
|
|
disabled
|
|
/>
|
|
)}
|
|
renderNewField={() => (
|
|
<GallerySelect
|
|
className="form-control react-select"
|
|
selected={newGalleries ?? []}
|
|
onSelect={() => {}}
|
|
disabled
|
|
/>
|
|
)}
|
|
onChange={(value) => setGalleries(value)}
|
|
/>
|
|
<ScrapedStudioRow
|
|
title={intl.formatMessage({ id: "studios" })}
|
|
result={studio}
|
|
onChange={(value) => setStudio(value)}
|
|
/>
|
|
<ScrapedPerformersRow
|
|
title={intl.formatMessage({ id: "performers" })}
|
|
result={performers}
|
|
onChange={(value) => setPerformers(value)}
|
|
/>
|
|
<ScrapedMoviesRow
|
|
title={intl.formatMessage({ id: "movies" })}
|
|
result={movies}
|
|
onChange={(value) => setMovies(value)}
|
|
/>
|
|
<ScrapedTagsRow
|
|
title={intl.formatMessage({ id: "tags" })}
|
|
result={tags}
|
|
onChange={(value) => setTags(value)}
|
|
/>
|
|
<ScrapedTextAreaRow
|
|
title={intl.formatMessage({ id: "details" })}
|
|
result={details}
|
|
onChange={(value) => setDetails(value)}
|
|
/>
|
|
<ScrapeDialogRow
|
|
title={intl.formatMessage({ id: "stash_id" })}
|
|
result={stashIDs}
|
|
renderOriginalField={() => (
|
|
<StashIDsField values={stashIDs?.originalValue ?? []} />
|
|
)}
|
|
renderNewField={() => (
|
|
<StashIDsField values={stashIDs?.newValue ?? []} />
|
|
)}
|
|
onChange={(value) => setStashIDs(value)}
|
|
/>
|
|
<ScrapedImageRow
|
|
title={intl.formatMessage({ id: "cover_image" })}
|
|
className="scene-cover"
|
|
result={image}
|
|
onChange={(value) => setImage(value)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function createValues(): GQL.SceneUpdateInput {
|
|
const all = [dest, ...sources];
|
|
|
|
// only set the cover image if it's different from the existing cover image
|
|
const coverImage = image.useNewValue ? image.getNewValue() : undefined;
|
|
|
|
return {
|
|
id: dest.id,
|
|
title: title.getNewValue(),
|
|
url: url.getNewValue(),
|
|
date: date.getNewValue(),
|
|
rating100: rating.getNewValue(),
|
|
o_counter: oCounter.getNewValue(),
|
|
play_count: playCount.getNewValue(),
|
|
play_duration: playDuration.getNewValue(),
|
|
gallery_ids: galleries.getNewValue(),
|
|
studio_id: studio.getNewValue(),
|
|
performer_ids: performers.getNewValue(),
|
|
movies: movies.getNewValue()?.map((m) => {
|
|
// find the equivalent movie in the original scenes
|
|
const found = all
|
|
.map((s) => s.movies)
|
|
.flat()
|
|
.find((mm) => mm.movie.id === m);
|
|
return {
|
|
movie_id: m,
|
|
scene_index: found!.scene_index,
|
|
};
|
|
}),
|
|
tag_ids: tags.getNewValue(),
|
|
details: details.getNewValue(),
|
|
stash_ids: stashIDs.getNewValue(),
|
|
cover_image: coverImage,
|
|
};
|
|
}
|
|
|
|
const dialogTitle = intl.formatMessage({
|
|
id: "actions.merge",
|
|
});
|
|
|
|
const destinationLabel = !hasValues
|
|
? ""
|
|
: intl.formatMessage({ id: "dialogs.merge.destination" });
|
|
const sourceLabel = !hasValues
|
|
? ""
|
|
: intl.formatMessage({ id: "dialogs.merge.source" });
|
|
|
|
return (
|
|
<ScrapeDialog
|
|
title={dialogTitle}
|
|
existingLabel={destinationLabel}
|
|
scrapedLabel={sourceLabel}
|
|
renderScrapeRows={renderScrapeRows}
|
|
onClose={(apply) => {
|
|
if (!apply) {
|
|
onClose();
|
|
} else {
|
|
onClose(createValues());
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
interface ISceneMergeModalProps {
|
|
show: boolean;
|
|
onClose: (mergedID?: string) => void;
|
|
scenes: { id: string; title: string }[];
|
|
}
|
|
|
|
export const SceneMergeModal: React.FC<ISceneMergeModalProps> = ({
|
|
show,
|
|
onClose,
|
|
scenes,
|
|
}) => {
|
|
const [sourceScenes, setSourceScenes] = useState<
|
|
{ id: string; title: string }[]
|
|
>([]);
|
|
const [destScene, setDestScene] = useState<{ id: string; title: string }[]>(
|
|
[]
|
|
);
|
|
|
|
const [loadedSources, setLoadedSources] = useState<
|
|
GQL.SlimSceneDataFragment[]
|
|
>([]);
|
|
const [loadedDest, setLoadedDest] = useState<GQL.SlimSceneDataFragment>();
|
|
|
|
const [running, setRunning] = useState(false);
|
|
const [secondStep, setSecondStep] = useState(false);
|
|
|
|
const intl = useIntl();
|
|
const Toast = useToast();
|
|
|
|
const title = intl.formatMessage({
|
|
id: "actions.merge",
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (scenes.length > 0) {
|
|
// set the first scene as the destination, others as source
|
|
setDestScene([scenes[0]]);
|
|
|
|
if (scenes.length > 1) {
|
|
setSourceScenes(scenes.slice(1));
|
|
}
|
|
}
|
|
}, [scenes]);
|
|
|
|
async function loadScenes() {
|
|
const sceneIDs = sourceScenes.map((s) => parseInt(s.id));
|
|
sceneIDs.push(parseInt(destScene[0].id));
|
|
const query = await queryFindScenesByID(sceneIDs);
|
|
const { scenes: loadedScenes } = query.data.findScenes;
|
|
|
|
setLoadedDest(loadedScenes.find((s) => s.id === destScene[0].id));
|
|
setLoadedSources(loadedScenes.filter((s) => s.id !== destScene[0].id));
|
|
setSecondStep(true);
|
|
}
|
|
|
|
async function onMerge(values: GQL.SceneUpdateInput) {
|
|
try {
|
|
setRunning(true);
|
|
const result = await mutateSceneMerge(
|
|
destScene[0].id,
|
|
sourceScenes.map((s) => s.id),
|
|
values
|
|
);
|
|
if (result.data?.sceneMerge) {
|
|
Toast.success({
|
|
content: intl.formatMessage({ id: "toast.merged_scenes" }),
|
|
});
|
|
// refetch the scene
|
|
await queryFindScenesByID([parseInt(destScene[0].id)]);
|
|
onClose(destScene[0].id);
|
|
}
|
|
onClose();
|
|
} catch (e) {
|
|
Toast.error(e);
|
|
} finally {
|
|
setRunning(false);
|
|
}
|
|
}
|
|
|
|
function canMerge() {
|
|
return sourceScenes.length > 0 && destScene.length !== 0;
|
|
}
|
|
|
|
function switchScenes() {
|
|
if (sourceScenes.length && destScene.length) {
|
|
const newDest = sourceScenes[0];
|
|
setSourceScenes([...sourceScenes.slice(1), destScene[0]]);
|
|
setDestScene([newDest]);
|
|
}
|
|
}
|
|
|
|
if (secondStep && destScene.length > 0) {
|
|
return (
|
|
<SceneMergeDetails
|
|
sources={loadedSources}
|
|
dest={loadedDest!}
|
|
onClose={(values) => {
|
|
if (values) {
|
|
onMerge(values);
|
|
} else {
|
|
onClose();
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
show={show}
|
|
header={title}
|
|
icon={faSignInAlt}
|
|
accept={{
|
|
text: intl.formatMessage({ id: "actions.next_action" }),
|
|
onClick: () => loadScenes(),
|
|
}}
|
|
disabled={!canMerge()}
|
|
cancel={{
|
|
variant: "secondary",
|
|
onClick: () => onClose(),
|
|
}}
|
|
isRunning={running}
|
|
>
|
|
<div className="form-container row px-3">
|
|
<div className="col-12 col-lg-6 col-xl-12">
|
|
<Form.Group controlId="source" as={Row}>
|
|
{FormUtils.renderLabel({
|
|
title: intl.formatMessage({ id: "dialogs.merge.source" }),
|
|
labelProps: {
|
|
column: true,
|
|
sm: 3,
|
|
xl: 12,
|
|
},
|
|
})}
|
|
<Col sm={9} xl={12}>
|
|
<SceneSelect
|
|
isMulti
|
|
onSelect={(items) => setSourceScenes(items)}
|
|
selected={sourceScenes}
|
|
/>
|
|
</Col>
|
|
</Form.Group>
|
|
<Form.Group
|
|
controlId="switch"
|
|
as={Row}
|
|
className="justify-content-center"
|
|
>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => switchScenes()}
|
|
disabled={!sourceScenes.length || !destScene.length}
|
|
title={intl.formatMessage({ id: "actions.swap" })}
|
|
>
|
|
<Icon className="fa-fw" icon={faExchangeAlt} />
|
|
</Button>
|
|
</Form.Group>
|
|
<Form.Group controlId="destination" as={Row}>
|
|
{FormUtils.renderLabel({
|
|
title: intl.formatMessage({
|
|
id: "dialogs.merge.destination",
|
|
}),
|
|
labelProps: {
|
|
column: true,
|
|
sm: 3,
|
|
xl: 12,
|
|
},
|
|
})}
|
|
<Col sm={9} xl={12}>
|
|
<SceneSelect
|
|
onSelect={(items) => setDestScene(items)}
|
|
selected={destScene}
|
|
/>
|
|
</Col>
|
|
</Form.Group>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|