mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
Support file-less scenes. Add scene split, merge and reassign file (#3006)
* Reassign scene file functionality * Implement scene create * Add scene create UI * Add sceneMerge backend support * Add merge scene to UI * Populate split create with scene details * Add merge button to duplicate checker * Handle file-less scenes in marker preview generate * Make unique file name for file-less scene exports * Add o-counter to scene update input * Hide rescan for file-less scenes * Generate heatmap if no speed set on file * Fix count in scene/image queries
This commit is contained in:
662
ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx
Normal file
662
ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx
Normal file
@@ -0,0 +1,662 @@
|
||||
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 } 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 { RatingStars } from "./SceneDetails/RatingStars";
|
||||
|
||||
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.rating));
|
||||
const [oCounter, setOCounter] = useState(
|
||||
new ScrapeResult<number>(dest.o_counter)
|
||||
);
|
||||
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.rating,
|
||||
sources.find((s) => s.rating)?.rating,
|
||||
!dest.rating
|
||||
)
|
||||
);
|
||||
|
||||
setOCounter(
|
||||
new ScrapeResult(
|
||||
dest.o_counter ?? 0,
|
||||
all.map((s) => s.o_counter ?? 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={() => (
|
||||
<RatingStars value={rating.originalValue} disabled />
|
||||
)}
|
||||
renderNewField={() => (
|
||||
<RatingStars 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) => setRating(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(),
|
||||
rating: rating.getNewValue(),
|
||||
o_counter: oCounter.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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user