mirror of
https://github.com/stashapp/stash.git
synced 2026-04-14 02:53:16 +02:00
Pinned Filters (#3675)
* Pinned Filters // Add the ability to pin filters in the new filter dialog * Pinned Filters // Prevent overlap with x * Pinned Filters // Pills in the button show up correctly now... * Pinned Filters // Maximum height for mobile view * Pinned Filters // Save in config.yml * Style changes and minor fixes * Pinned Filters // Increase divider space --------- Co-authored-by: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com>
This commit is contained in:
parent
3bc5caa6de
commit
8d3f632d4c
3 changed files with 160 additions and 45 deletions
|
|
@ -26,18 +26,24 @@ import {
|
|||
faChevronDown,
|
||||
faChevronRight,
|
||||
faTimes,
|
||||
faThumbtack,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useCompare, usePrevious } from "src/hooks/state";
|
||||
import { CriterionType } from "src/models/list-filter/types";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { useConfigureUI } from "src/core/StashService";
|
||||
import { IUIConfig } from "src/core/config";
|
||||
|
||||
interface ICriterionList {
|
||||
criteria: string[];
|
||||
currentCriterion?: Criterion<CriterionValue>;
|
||||
setCriterion: (c: Criterion<CriterionValue>) => void;
|
||||
criterionOptions: CriterionOption[];
|
||||
pinnedCriterionOptions: CriterionOption[];
|
||||
selected?: CriterionOption;
|
||||
optionSelected: (o?: CriterionOption) => void;
|
||||
onRemoveCriterion: (c: string) => void;
|
||||
onTogglePin: (c: CriterionOption) => void;
|
||||
}
|
||||
|
||||
const CriterionOptionList: React.FC<ICriterionList> = ({
|
||||
|
|
@ -45,9 +51,11 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
|
|||
currentCriterion,
|
||||
setCriterion,
|
||||
criterionOptions,
|
||||
pinnedCriterionOptions,
|
||||
selected,
|
||||
optionSelected,
|
||||
onRemoveCriterion,
|
||||
onTogglePin,
|
||||
}) => {
|
||||
const prevCriterion = usePrevious(currentCriterion);
|
||||
|
||||
|
|
@ -61,15 +69,22 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
|
|||
criterionOptions.forEach((c) => {
|
||||
refs[c.type] = React.createRef();
|
||||
});
|
||||
pinnedCriterionOptions.forEach((c) => {
|
||||
refs[c.type] = React.createRef();
|
||||
});
|
||||
return refs;
|
||||
}, [criterionOptions]);
|
||||
}, [criterionOptions, pinnedCriterionOptions]);
|
||||
|
||||
function onSelect(k: string | null) {
|
||||
if (!k) {
|
||||
optionSelected(undefined);
|
||||
return;
|
||||
}
|
||||
const option = criterionOptions.find((c) => c.type === k);
|
||||
|
||||
let option = criterionOptions.find((c) => c.type === k);
|
||||
if (!option) {
|
||||
option = pinnedCriterionOptions.find((c) => c.type === k);
|
||||
}
|
||||
|
||||
if (option) {
|
||||
optionSelected(option);
|
||||
|
|
@ -104,47 +119,71 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
|
|||
onRemoveCriterion(t);
|
||||
}
|
||||
|
||||
function togglePin(ev: React.MouseEvent, c: CriterionOption) {
|
||||
// needed to prevent the nav item from being selected
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
onTogglePin(c);
|
||||
}
|
||||
|
||||
function renderCard(c: CriterionOption, isPin: boolean) {
|
||||
return (
|
||||
<Card key={c.type} data-type={c.type} ref={criteriaRefs[c.type]!}>
|
||||
<Accordion.Toggle eventKey={c.type} as={Card.Header}>
|
||||
<span className="mr-auto">
|
||||
<Icon
|
||||
className="collapse-icon fa-fw"
|
||||
icon={type === c.type ? faChevronDown : faChevronRight}
|
||||
/>
|
||||
<FormattedMessage id={c.messageID} />
|
||||
</span>
|
||||
{criteria.some((cc) => c.type === cc) && (
|
||||
<Button
|
||||
className="remove-criterion-button"
|
||||
variant="minimal"
|
||||
onClick={(e) => removeClicked(e, c.type)}
|
||||
>
|
||||
<Icon icon={faTimes} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="pin-criterion-button"
|
||||
variant="minimal"
|
||||
onClick={(e) => togglePin(e, c)}
|
||||
>
|
||||
<Icon icon={faThumbtack} className={isPin ? "" : "tilted"} />
|
||||
</Button>
|
||||
</Accordion.Toggle>
|
||||
<Accordion.Collapse eventKey={c.type}>
|
||||
{(type === c.type && currentCriterion) ||
|
||||
(prevType === c.type && prevCriterion) ? (
|
||||
<Card.Body>
|
||||
<CriterionEditor
|
||||
criterion={getReleventCriterion(c.type)!}
|
||||
setCriterion={setCriterion}
|
||||
/>
|
||||
</Card.Body>
|
||||
) : (
|
||||
<Card.Body></Card.Body>
|
||||
)}
|
||||
</Accordion.Collapse>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
className="criterion-list"
|
||||
activeKey={selected?.type}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
{criterionOptions.map((c) => (
|
||||
<Card key={c.type} data-type={c.type} ref={criteriaRefs[c.type]!}>
|
||||
<Accordion.Toggle eventKey={c.type} as={Card.Header}>
|
||||
<span>
|
||||
<Icon
|
||||
className="collapse-icon fa-fw"
|
||||
icon={type === c.type ? faChevronDown : faChevronRight}
|
||||
/>
|
||||
<FormattedMessage id={c.messageID} />
|
||||
</span>
|
||||
{criteria.some((cc) => c.type === cc) && (
|
||||
<Button
|
||||
className="remove-criterion-button"
|
||||
variant="minimal"
|
||||
onClick={(e) => removeClicked(e, c.type)}
|
||||
>
|
||||
<Icon icon={faTimes} />
|
||||
</Button>
|
||||
)}
|
||||
</Accordion.Toggle>
|
||||
<Accordion.Collapse eventKey={c.type}>
|
||||
{(type === c.type && currentCriterion) ||
|
||||
(prevType === c.type && prevCriterion) ? (
|
||||
<Card.Body>
|
||||
<CriterionEditor
|
||||
criterion={getReleventCriterion(c.type)!}
|
||||
setCriterion={setCriterion}
|
||||
/>
|
||||
</Card.Body>
|
||||
) : (
|
||||
<Card.Body></Card.Body>
|
||||
)}
|
||||
</Accordion.Collapse>
|
||||
</Card>
|
||||
))}
|
||||
{pinnedCriterionOptions.length !== 0 && (
|
||||
<>
|
||||
{pinnedCriterionOptions.map((c) => renderCard(c, true))}
|
||||
<div className="pinned-criterion-divider" />
|
||||
</>
|
||||
)}
|
||||
{criterionOptions.map((c) => renderCard(c, false))}
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
@ -162,9 +201,10 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
|||
onApply,
|
||||
onCancel,
|
||||
}) => {
|
||||
const Toast = useToast();
|
||||
const intl = useIntl();
|
||||
|
||||
const { configuration: config } = useContext(ConfigurationContext);
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
|
||||
const [currentFilter, setCurrentFilter] = useState<ListFilterModel>(
|
||||
cloneDeep(filter)
|
||||
|
|
@ -209,11 +249,27 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
|||
if (existing) {
|
||||
setCriterion(existing);
|
||||
} else {
|
||||
const newCriterion = makeCriteria(config, option.type);
|
||||
const newCriterion = makeCriteria(configuration, option.type);
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
},
|
||||
[criteria, config]
|
||||
[criteria, configuration]
|
||||
);
|
||||
|
||||
const ui = (configuration?.ui ?? {}) as IUIConfig;
|
||||
const [saveUI] = useConfigureUI();
|
||||
|
||||
const pinnedFilters = useMemo(
|
||||
() => ui.pinnedFilters?.[currentFilter.mode.toLowerCase()] ?? [],
|
||||
[currentFilter.mode, ui.pinnedFilters]
|
||||
);
|
||||
const pinnedElements = useMemo(
|
||||
() => criterionOptions.filter((c) => pinnedFilters.includes(c.messageID)),
|
||||
[pinnedFilters, criterionOptions]
|
||||
);
|
||||
const unpinnedElements = useMemo(
|
||||
() => criterionOptions.filter((c) => !pinnedFilters.includes(c.messageID)),
|
||||
[pinnedFilters, criterionOptions]
|
||||
);
|
||||
|
||||
const editingCriterionChanged = useCompare(editingCriterion);
|
||||
|
|
@ -232,6 +288,40 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
|||
editingCriterionChanged,
|
||||
]);
|
||||
|
||||
async function updatePinnedFilters(filters: string[]) {
|
||||
const currentMode = currentFilter.mode.toLowerCase();
|
||||
try {
|
||||
await saveUI({
|
||||
variables: {
|
||||
input: {
|
||||
...configuration?.ui,
|
||||
pinnedFilters: {
|
||||
...ui.pinnedFilters,
|
||||
[currentMode]: filters,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onTogglePinFilter(f: CriterionOption) {
|
||||
try {
|
||||
const existing = pinnedFilters.find((name) => name === f.messageID);
|
||||
if (existing) {
|
||||
await updatePinnedFilters(
|
||||
pinnedFilters.filter((name) => name !== f.messageID)
|
||||
);
|
||||
} else {
|
||||
await updatePinnedFilters([...pinnedFilters, f.messageID]);
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceCriterion(c: Criterion<CriterionValue>) {
|
||||
const newFilter = cloneDeep(currentFilter);
|
||||
|
||||
|
|
@ -309,10 +399,12 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
|||
criteria={criteriaList}
|
||||
currentCriterion={criterion}
|
||||
setCriterion={replaceCriterion}
|
||||
criterionOptions={criterionOptions}
|
||||
criterionOptions={unpinnedElements}
|
||||
pinnedCriterionOptions={pinnedElements}
|
||||
optionSelected={optionSelected}
|
||||
selected={criterion?.criterionOption}
|
||||
onRemoveCriterion={(c) => removeCriterionString(c)}
|
||||
onTogglePin={(c) => onTogglePinFilter(c)}
|
||||
/>
|
||||
{criteria.length > 0 && (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -134,6 +134,10 @@ input[type="range"].zoom-slider {
|
|||
max-height: 550px;
|
||||
overflow-y: auto;
|
||||
|
||||
.pinned-criterion-divider {
|
||||
padding-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid rgb(16 22 26 / 40%);
|
||||
box-shadow: none;
|
||||
|
|
@ -147,15 +151,25 @@ input[type="range"].zoom-slider {
|
|||
.card-header {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 0;
|
||||
padding-bottom: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.pin-criterion-button {
|
||||
color: $text_color;
|
||||
|
||||
&:hover svg {
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
.remove-criterion-button {
|
||||
border: 0;
|
||||
color: $danger;
|
||||
padding-bottom: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,3 +210,7 @@ input[type="range"].zoom-slider {
|
|||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.tilted {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ export interface ICustomFilter extends ITypename {
|
|||
direction: SortDirectionEnum;
|
||||
}
|
||||
|
||||
// NOTE: This value cannot be more defined, because the generated enum it depends upon is UpperCase, which leads to errors on saving
|
||||
export type PinnedFilters = Record<string, Array<string>>;
|
||||
|
||||
export type FrontPageContent = ISavedFilterRow | ICustomFilter;
|
||||
|
||||
export const defaultMaxOptionsShown = 200;
|
||||
|
|
@ -55,6 +58,8 @@ export interface IUIConfig {
|
|||
imageWallOptions?: ImageWallOptions;
|
||||
|
||||
lastNoteSeen?: number;
|
||||
|
||||
pinnedFilters?: PinnedFilters;
|
||||
}
|
||||
|
||||
function recentlyReleased(
|
||||
|
|
|
|||
Loading…
Reference in a new issue