mirror of
https://github.com/stashapp/stash.git
synced 2025-12-16 20:07:05 +03:00
* Add types to graphql scalars * Upgrade dependencies * Override UI config type * Remove all IUIConfig casts * Add tableColumns to IUIConfig * Add BoolMap type, set strictScalars * Add PluginConfigMap * Replace any with unknown * Add SavedObjectFilter and SavedUIOptions * Remove unused items from CriterionType
420 lines
12 KiB
TypeScript
420 lines
12 KiB
TypeScript
import React, { useEffect, useMemo, useState } from "react";
|
|
import { FormattedMessage, IntlShape, useIntl } from "react-intl";
|
|
import { useFindSavedFilters } from "src/core/StashService";
|
|
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
|
import { Button, Form, Modal } from "react-bootstrap";
|
|
import * as GQL from "src/core/generated-graphql";
|
|
import { ConfigurationContext } from "src/hooks/Config";
|
|
import {
|
|
ISavedFilterRow,
|
|
ICustomFilter,
|
|
FrontPageContent,
|
|
generatePremadeFrontPageContent,
|
|
getFrontPageContent,
|
|
} from "src/core/config";
|
|
|
|
interface IAddSavedFilterModalProps {
|
|
onClose: (content?: FrontPageContent) => void;
|
|
existingSavedFilterIDs: string[];
|
|
candidates: GQL.FindSavedFiltersQuery;
|
|
}
|
|
|
|
const FilterModeToMessageID = {
|
|
[GQL.FilterMode.Galleries]: "galleries",
|
|
[GQL.FilterMode.Images]: "images",
|
|
[GQL.FilterMode.Movies]: "movies",
|
|
[GQL.FilterMode.Performers]: "performers",
|
|
[GQL.FilterMode.SceneMarkers]: "markers",
|
|
[GQL.FilterMode.Scenes]: "scenes",
|
|
[GQL.FilterMode.Studios]: "studios",
|
|
[GQL.FilterMode.Tags]: "tags",
|
|
};
|
|
|
|
type SavedFilter = Pick<GQL.SavedFilter, "id" | "mode" | "name">;
|
|
|
|
function filterTitle(intl: IntlShape, f: SavedFilter) {
|
|
const typeMessage = intl.formatMessage({ id: FilterModeToMessageID[f.mode] });
|
|
return `${typeMessage}: ${f.name}`;
|
|
}
|
|
|
|
const AddContentModal: React.FC<IAddSavedFilterModalProps> = ({
|
|
onClose,
|
|
existingSavedFilterIDs,
|
|
candidates,
|
|
}) => {
|
|
const intl = useIntl();
|
|
|
|
const premadeFilterOptions = useMemo(
|
|
() => generatePremadeFrontPageContent(intl),
|
|
[intl]
|
|
);
|
|
|
|
const [contentType, setContentType] = useState(
|
|
"front_page.types.premade_filter"
|
|
);
|
|
const [premadeFilterIndex, setPremadeFilterIndex] = useState<
|
|
number | undefined
|
|
>(0);
|
|
const [savedFilter, setSavedFilter] = useState<string | undefined>();
|
|
|
|
function onTypeSelected(t: string) {
|
|
setContentType(t);
|
|
|
|
switch (t) {
|
|
case "front_page.types.premade_filter":
|
|
setPremadeFilterIndex(0);
|
|
setSavedFilter(undefined);
|
|
break;
|
|
case "front_page.types.saved_filter":
|
|
setPremadeFilterIndex(undefined);
|
|
setSavedFilter(undefined);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function isValid() {
|
|
switch (contentType) {
|
|
case "front_page.types.premade_filter":
|
|
return premadeFilterIndex !== undefined;
|
|
case "front_page.types.saved_filter":
|
|
return savedFilter !== undefined;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
const savedFilterOptions = useMemo(() => {
|
|
const ret = [
|
|
{
|
|
value: "",
|
|
text: "",
|
|
},
|
|
].concat(
|
|
candidates.findSavedFilters
|
|
.filter((f) => {
|
|
// markers not currently supported
|
|
return (
|
|
f.mode !== GQL.FilterMode.SceneMarkers &&
|
|
!existingSavedFilterIDs.includes(f.id)
|
|
);
|
|
})
|
|
.map((f) => {
|
|
return {
|
|
value: f.id,
|
|
text: filterTitle(intl, f),
|
|
};
|
|
})
|
|
);
|
|
|
|
ret.sort((a, b) => {
|
|
return a.text.localeCompare(b.text);
|
|
});
|
|
|
|
return ret;
|
|
}, [candidates, existingSavedFilterIDs, intl]);
|
|
|
|
function renderTypeSelect() {
|
|
const options = [
|
|
"front_page.types.premade_filter",
|
|
"front_page.types.saved_filter",
|
|
];
|
|
return (
|
|
<Form.Group controlId="filter">
|
|
<Form.Label>
|
|
<FormattedMessage id="type" />
|
|
</Form.Label>
|
|
<Form.Control
|
|
as="select"
|
|
value={contentType}
|
|
onChange={(e) => onTypeSelected(e.target.value)}
|
|
className="btn-secondary"
|
|
>
|
|
{options.map((c) => (
|
|
<option key={c} value={c}>
|
|
{intl.formatMessage({ id: c })}
|
|
</option>
|
|
))}
|
|
</Form.Control>
|
|
</Form.Group>
|
|
);
|
|
}
|
|
|
|
function maybeRenderPremadeFiltersSelect() {
|
|
if (contentType !== "front_page.types.premade_filter") return;
|
|
|
|
return (
|
|
<Form.Group controlId="premade-filter">
|
|
<Form.Label>
|
|
<FormattedMessage id="front_page.types.premade_filter" />
|
|
</Form.Label>
|
|
<Form.Control
|
|
as="select"
|
|
value={premadeFilterIndex}
|
|
onChange={(e) => setPremadeFilterIndex(parseInt(e.target.value))}
|
|
className="btn-secondary"
|
|
>
|
|
{premadeFilterOptions.map((c, i) => (
|
|
<option key={i} value={i}>
|
|
{intl.formatMessage({ id: c.message!.id }, c.message!.values)}
|
|
</option>
|
|
))}
|
|
</Form.Control>
|
|
</Form.Group>
|
|
);
|
|
}
|
|
|
|
function maybeRenderSavedFiltersSelect() {
|
|
if (contentType !== "front_page.types.saved_filter") return;
|
|
return (
|
|
<Form.Group controlId="filter">
|
|
<Form.Label>
|
|
<FormattedMessage id="search_filter.name" />
|
|
</Form.Label>
|
|
<Form.Control
|
|
as="select"
|
|
value={savedFilter}
|
|
onChange={(e) => setSavedFilter(e.target.value)}
|
|
className="btn-secondary"
|
|
>
|
|
{savedFilterOptions.map((c) => (
|
|
<option key={c.value} value={c.value}>
|
|
{c.text}
|
|
</option>
|
|
))}
|
|
</Form.Control>
|
|
</Form.Group>
|
|
);
|
|
}
|
|
|
|
function doAdd() {
|
|
switch (contentType) {
|
|
case "front_page.types.premade_filter":
|
|
onClose(premadeFilterOptions[premadeFilterIndex!]);
|
|
return;
|
|
case "front_page.types.saved_filter":
|
|
onClose({
|
|
__typename: "SavedFilter",
|
|
savedFilterId: parseInt(savedFilter!),
|
|
});
|
|
return;
|
|
}
|
|
|
|
onClose();
|
|
}
|
|
|
|
return (
|
|
<Modal show onHide={() => onClose()}>
|
|
<Modal.Header>
|
|
<FormattedMessage id="actions.add" />
|
|
</Modal.Header>
|
|
<Modal.Body>
|
|
<div className="dialog-content">
|
|
{renderTypeSelect()}
|
|
{maybeRenderSavedFiltersSelect()}
|
|
{maybeRenderPremadeFiltersSelect()}
|
|
</div>
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
<Button variant="secondary" onClick={() => onClose()}>
|
|
<FormattedMessage id="actions.cancel" />
|
|
</Button>
|
|
<Button onClick={() => doAdd()} disabled={!isValid()}>
|
|
<FormattedMessage id="actions.add" />
|
|
</Button>
|
|
</Modal.Footer>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
interface IFilterRowProps {
|
|
content: FrontPageContent;
|
|
allSavedFilters: SavedFilter[];
|
|
onDelete: () => void;
|
|
}
|
|
|
|
const ContentRow: React.FC<IFilterRowProps> = (props: IFilterRowProps) => {
|
|
const intl = useIntl();
|
|
|
|
function title() {
|
|
switch (props.content.__typename) {
|
|
case "SavedFilter":
|
|
const savedFilterId = String(props.content.savedFilterId);
|
|
const savedFilter = props.allSavedFilters.find(
|
|
(f) => f.id === savedFilterId
|
|
);
|
|
if (!savedFilter) return "";
|
|
return filterTitle(intl, savedFilter);
|
|
case "CustomFilter":
|
|
const asCustomFilter = props.content as ICustomFilter;
|
|
if (asCustomFilter.message)
|
|
return intl.formatMessage(
|
|
{ id: asCustomFilter.message.id },
|
|
asCustomFilter.message.values
|
|
);
|
|
return asCustomFilter.title ?? "";
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="recommendation-row">
|
|
<div className="recommendation-row-head">
|
|
<div>
|
|
<h2>{title()}</h2>
|
|
</div>
|
|
<Button
|
|
variant="danger"
|
|
title={intl.formatMessage({ id: "actions.delete" })}
|
|
onClick={() => props.onDelete()}
|
|
>
|
|
<FormattedMessage id="actions.delete" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface IFrontPageConfigProps {
|
|
onClose: (content?: FrontPageContent[]) => void;
|
|
}
|
|
|
|
export const FrontPageConfig: React.FC<IFrontPageConfigProps> = ({
|
|
onClose,
|
|
}) => {
|
|
const { configuration, loading } = React.useContext(ConfigurationContext);
|
|
|
|
const ui = configuration?.ui;
|
|
|
|
const { data: allFilters, loading: loading2 } = useFindSavedFilters();
|
|
|
|
const [isAdd, setIsAdd] = useState(false);
|
|
const [currentContent, setCurrentContent] = useState<FrontPageContent[]>([]);
|
|
const [dragIndex, setDragIndex] = useState<number | undefined>();
|
|
|
|
useEffect(() => {
|
|
if (!allFilters?.findSavedFilters) {
|
|
return;
|
|
}
|
|
|
|
const frontPageContent = getFrontPageContent(ui);
|
|
if (frontPageContent) {
|
|
setCurrentContent(
|
|
// filter out rows where the saved filter no longer exists
|
|
frontPageContent.filter((r) => {
|
|
if (r.__typename === "SavedFilter") {
|
|
const savedFilterId = String(r.savedFilterId);
|
|
return allFilters.findSavedFilters.some(
|
|
(f) => f.id === savedFilterId
|
|
);
|
|
}
|
|
return true;
|
|
})
|
|
);
|
|
}
|
|
}, [allFilters, ui]);
|
|
|
|
function onDragStart(event: React.DragEvent<HTMLElement>, index: number) {
|
|
event.dataTransfer.effectAllowed = "move";
|
|
setDragIndex(index);
|
|
}
|
|
|
|
function onDragOver(event: React.DragEvent<HTMLElement>, index?: number) {
|
|
if (dragIndex !== undefined && index !== undefined && index !== dragIndex) {
|
|
const newFilters = [...currentContent];
|
|
const moved = newFilters.splice(dragIndex, 1);
|
|
newFilters.splice(index, 0, moved[0]);
|
|
setCurrentContent(newFilters);
|
|
setDragIndex(index);
|
|
}
|
|
|
|
event.dataTransfer.dropEffect = "move";
|
|
event.preventDefault();
|
|
}
|
|
|
|
function onDragOverDefault(event: React.DragEvent<HTMLDivElement>) {
|
|
event.dataTransfer.dropEffect = "move";
|
|
event.preventDefault();
|
|
}
|
|
|
|
function onDrop() {
|
|
// assume we've already set the temp filter list
|
|
// feed it up
|
|
setDragIndex(undefined);
|
|
}
|
|
|
|
if (loading || loading2) {
|
|
return <LoadingIndicator />;
|
|
}
|
|
|
|
const existingSavedFilterIDs = currentContent
|
|
.filter(
|
|
(f) =>
|
|
f.__typename === "SavedFilter" && (f as ISavedFilterRow).savedFilterId
|
|
)
|
|
.map((f) => (f as ISavedFilterRow).savedFilterId.toString());
|
|
|
|
function addSavedFilter(content?: FrontPageContent) {
|
|
setIsAdd(false);
|
|
|
|
if (!content) {
|
|
return;
|
|
}
|
|
|
|
setCurrentContent([...currentContent, content]);
|
|
}
|
|
|
|
function deleteSavedFilter(index: number) {
|
|
setCurrentContent(currentContent.filter((f, i) => i !== index));
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{isAdd && allFilters && (
|
|
<AddContentModal
|
|
candidates={allFilters}
|
|
existingSavedFilterIDs={existingSavedFilterIDs}
|
|
onClose={addSavedFilter}
|
|
/>
|
|
)}
|
|
<div className="recommendations-container recommendations-container-edit">
|
|
<div onDragOver={onDragOverDefault}>
|
|
{currentContent.map((content, index) => (
|
|
<div
|
|
key={index}
|
|
draggable
|
|
onDragStart={(e) => onDragStart(e, index)}
|
|
onDragEnter={(e) => onDragOver(e, index)}
|
|
onDrop={() => onDrop()}
|
|
>
|
|
<ContentRow
|
|
key={index}
|
|
allSavedFilters={allFilters!.findSavedFilters}
|
|
content={content}
|
|
onDelete={() => deleteSavedFilter(index)}
|
|
/>
|
|
</div>
|
|
))}
|
|
<div className="recommendation-row recommendation-row-add">
|
|
<div className="recommendation-row-head">
|
|
<Button
|
|
className="recommendations-add"
|
|
variant="primary"
|
|
onClick={() => setIsAdd(true)}
|
|
>
|
|
<FormattedMessage id="actions.add" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="recommendations-footer">
|
|
<Button onClick={() => onClose()} variant="secondary">
|
|
<FormattedMessage id={"actions.cancel"} />
|
|
</Button>
|
|
<Button onClick={() => onClose(currentContent)}>
|
|
<FormattedMessage id={"actions.save"} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|