diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index c709944766..8a12e221d2 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -5,14 +5,8 @@ import React, { useCallback, useMemo, } from "react"; -import { - defineMessages, - FormattedMessage, - MessageDescriptor, - useIntl, -} from "react-intl"; +import { defineMessages, FormattedMessage, useIntl } from "react-intl"; import { Nav, Navbar, Button } from "react-bootstrap"; -import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { LinkContainer } from "react-router-bootstrap"; import { Link, NavLink, useLocation, useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; @@ -20,8 +14,13 @@ import Mousetrap from "mousetrap"; import SessionUtils from "src/utils/session"; import { Icon } from "src/components/Shared/Icon"; import { useConfigurationContext } from "src/hooks/Config"; +import * as GQL from "src/core/generated-graphql"; import { ManualStateContext } from "./Help/context"; import { SettingsButton } from "./SettingsButton"; +import { + buildMainNavbarMenuItems, + type INavbarMenuItem, +} from "./MainNavbar/menuItems"; import { faBars, faChartColumn, @@ -41,14 +40,6 @@ import { import { baseURL } from "src/core/createClient"; import { PatchComponent } from "src/patch"; -interface IMenuItem { - name: string; - message: MessageDescriptor; - href: string; - icon: IconDefinition; - hotkey: string; - userCreatable?: boolean; -} const messages = defineMessages({ scenes: { id: "scenes", @@ -96,7 +87,7 @@ const messages = defineMessages({ }, }); -const allMenuItems: IMenuItem[] = [ +const allMenuItems: INavbarMenuItem[] = [ { name: "scenes", message: messages.scenes, @@ -184,28 +175,24 @@ export const MainNavbar: React.FC = () => { const location = useLocation(); const { configuration } = useConfigurationContext(); const { openManual } = React.useContext(ManualStateContext); + const { savedFilterMenuItems } = configuration.ui; + const { data: savedFilterData } = GQL.useFindSavedFiltersQuery({ + variables: { mode: GQL.FilterMode.Galleries }, + skip: !savedFilterMenuItems?.length, + }); const [expanded, setExpanded] = useState(false); // Show all menu items by default, unless config says otherwise const menuItems = useMemo(() => { - let cfgMenuItems = configuration?.interface.menuItems; - if (!cfgMenuItems) { - return allMenuItems; - } - - // translate old movies menu item to groups - cfgMenuItems = cfgMenuItems.map((item) => { - if (item === "movies") { - return "groups"; - } - return item; - }); - - return allMenuItems.filter((menuItem) => - cfgMenuItems!.includes(menuItem.name) + return buildMainNavbarMenuItems( + allMenuItems, + configuration?.interface.menuItems, + savedFilterMenuItems, + savedFilterData?.findSavedFilters ?? [], + configuration ); - }, [configuration]); + }, [configuration, savedFilterData?.findSavedFilters, savedFilterMenuItems]); // react-bootstrap typing bug const navbarRef = useRef(null); @@ -259,9 +246,11 @@ export const MainNavbar: React.FC = () => { Mousetrap.bind("?", () => openManual()); Mousetrap.bind("g z", () => goto("/settings")); - menuItems.forEach((item) => - Mousetrap.bind(item.hotkey, () => goto(item.href)) - ); + menuItems.forEach((item) => { + if (item.hotkey) { + Mousetrap.bind(item.hotkey, () => goto(item.href)); + } + }); if (newPath) { Mousetrap.bind("n", () => history.push(String(newPath))); @@ -270,7 +259,11 @@ export const MainNavbar: React.FC = () => { return () => { Mousetrap.unbind("?"); Mousetrap.unbind("g z"); - menuItems.forEach((item) => Mousetrap.unbind(item.hotkey)); + menuItems.forEach((item) => { + if (item.hotkey) { + Mousetrap.unbind(item.hotkey); + } + }); if (newPath) { Mousetrap.unbind("n"); @@ -361,11 +354,11 @@ export const MainNavbar: React.FC = () => { > - {menuItems.map(({ href, icon, message }) => ( + {menuItems.map(({ href, icon, label, message, name }) => ( @@ -374,7 +367,7 @@ export const MainNavbar: React.FC = () => { {...{ icon }} className="nav-menu-icon d-block d-xl-inline mb-2 mb-xl-0" /> - {intl.formatMessage(message)} + {message ? intl.formatMessage(message) : label} diff --git a/ui/v2.5/src/components/MainNavbar/menuItems.test.ts b/ui/v2.5/src/components/MainNavbar/menuItems.test.ts new file mode 100644 index 0000000000..2a6b1e148c --- /dev/null +++ b/ui/v2.5/src/components/MainNavbar/menuItems.test.ts @@ -0,0 +1,92 @@ +import { faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import type { + CriterionModifier, + FilterMode, + SortDirectionEnum, + SavedFilterDataFragment, +} from "../../core/generated-graphql"; +import { + buildMainNavbarMenuItems, + buildSavedFilterMenuItems, + type INavbarMenuItem, +} from "./menuItems"; +import type { ISavedFilterMenuItem } from "../../core/config"; + +const mangaFilter: SavedFilterDataFragment = { + __typename: "SavedFilter", + id: "7", + mode: "GALLERIES" as FilterMode, + name: "Manga", + find_filter: { + __typename: "SavedFindFilterType", + q: "", + page: 1, + per_page: 40, + sort: "date", + direction: "ASC" as SortDirectionEnum, + }, + object_filter: { + tags: { + value: ["12"], + modifier: "INCLUDES_ALL" as CriterionModifier, + }, + }, + ui_options: { + display_mode: 2, + zoom_index: 0, + }, +}; + +const configuredItems: ISavedFilterMenuItem[] = [ + { + id: "manga-section", + label: "Manga", + filterId: "7", + mode: "GALLERIES" as FilterMode, + }, +]; + +const [item] = buildSavedFilterMenuItems(configuredItems, [mangaFilter]); + +assertEqual(item.name, "saved-filter:manga-section"); +assertEqual(item.label, "Manga"); +assertEqual(item.message, undefined); +assertEqual(item.icon, faBookOpen); +assertEqual(item.hotkey, undefined); +assertMatch(item.href, /^\/galleries\?/); +assertMatch(item.href, /sortby=date/); +assertMatch(item.href, /sortdir=asc/); +assertMatch(item.href, /z=0/); +assertMatch(item.href, /c=/); +assertEqual(buildSavedFilterMenuItems(configuredItems, []).length, 0); + +const builtInItems: INavbarMenuItem[] = [ + { + name: "galleries", + label: "Galleries", + href: "/galleries", + icon: faBookOpen, + }, +]; + +const combinedItems = buildMainNavbarMenuItems( + builtInItems, + undefined, + configuredItems, + [mangaFilter] +); + +assertEqual(combinedItems.length, 2); +assertEqual(combinedItems[0].name, "galleries"); +assertEqual(combinedItems[1].name, "saved-filter:manga-section"); +function assertEqual(actual: T, expected: T) { + if (actual !== expected) { + throw new Error(`Expected ${String(expected)}, got ${String(actual)}`); + } +} + +function assertMatch(actual: string, expected: RegExp) { + if (!expected.test(actual)) { + throw new Error(`Expected ${actual} to match ${expected.toString()}`); + } +} diff --git a/ui/v2.5/src/components/MainNavbar/menuItems.ts b/ui/v2.5/src/components/MainNavbar/menuItems.ts new file mode 100644 index 0000000000..4c33c85172 --- /dev/null +++ b/ui/v2.5/src/components/MainNavbar/menuItems.ts @@ -0,0 +1,94 @@ +import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import type { MessageDescriptor } from "react-intl"; +import { FilterMode } from "../../core/generated-graphql"; +import type { + ConfigDataFragment, + SavedFilterDataFragment, +} from "../../core/generated-graphql"; +import type { ISavedFilterMenuItem } from "../../core/config"; +import { ListFilterModel } from "../../models/list-filter/filter"; + +export interface INavbarMenuItem { + name: string; + href: string; + icon: IconDefinition; + message?: MessageDescriptor; + label?: string; + hotkey?: string; + userCreatable?: boolean; +} + +function makeSavedFilterHref( + savedFilter: SavedFilterDataFragment, + config?: ConfigDataFragment +) { + const pathname = + savedFilter.mode === FilterMode.Galleries ? "/galleries" : undefined; + if (!pathname) { + return; + } + + const filter = new ListFilterModel(savedFilter.mode, config); + filter.configureFromSavedFilter(savedFilter); + const query = filter.makeQueryParameters(); + + return query ? `${pathname}?${query}` : pathname; +} + +export function buildSavedFilterMenuItems( + configuredItems: ISavedFilterMenuItem[] | undefined, + savedFilters: SavedFilterDataFragment[], + config?: ConfigDataFragment +): INavbarMenuItem[] { + if (!configuredItems?.length) { + return []; + } + + return configuredItems.flatMap((item) => { + const savedFilter = savedFilters.find( + (filter) => filter.id === item.filterId && filter.mode === item.mode + ); + if (!savedFilter) { + return []; + } + + const href = makeSavedFilterHref(savedFilter, config); + if (!href) { + return []; + } + + return [ + { + name: `saved-filter:${item.id}`, + label: item.label || savedFilter.name, + href, + icon: faBookOpen, + }, + ]; + }); +} + +export function buildMainNavbarMenuItems( + builtInItems: INavbarMenuItem[], + configuredBuiltInNames: string[] | null | undefined, + configuredSavedFilterItems: ISavedFilterMenuItem[] | undefined, + savedFilters: SavedFilterDataFragment[], + config?: ConfigDataFragment +) { + const builtInNames = configuredBuiltInNames?.map((item) => + item === "movies" ? "groups" : item + ); + const enabledBuiltInItems = builtInNames + ? builtInItems.filter((menuItem) => builtInNames.includes(menuItem.name)) + : builtInItems; + + return [ + ...enabledBuiltInItems, + ...buildSavedFilterMenuItems( + configuredSavedFilterItems, + savedFilters, + config + ), + ]; +} diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 5b4e8c5de4..0d75e3ed05 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { Button, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { DurationInput } from "src/components/Shared/DurationInput"; @@ -43,7 +43,10 @@ import { defaultImageWallMargin, } from "src/utils/imageWall"; import { defaultMaxOptionsShown, defaultPreviewVolume } from "src/core/config"; +import { useFindSavedFilters } from "src/core/StashService"; import { PatchComponent } from "src/patch"; +import { Icon } from "src/components/Shared/Icon"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; const allMenuItems = [ { id: "scenes", headingID: "scenes" }, @@ -96,6 +99,19 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( } = React.useContext(InteractiveContext); const [, setInterfaceLocalForage] = useInterfaceLocalForage(); + const { data: savedFilterData, loading: loadingSavedFilters } = + useFindSavedFilters(GQL.FilterMode.Galleries); + const savedGalleryFilters = savedFilterData?.findSavedFilters ?? []; + const savedFilterMenuItems = ui.savedFilterMenuItems ?? []; + const [selectedSavedFilterID, setSelectedSavedFilterID] = useState(""); + const [savedFilterMenuLabel, setSavedFilterMenuLabel] = useState(""); + + const selectedSavedFilter = savedGalleryFilters.find( + (filter) => filter.id === selectedSavedFilterID + ); + const selectedSavedFilterAlreadyAdded = savedFilterMenuItems.some( + (item) => item.filterId === selectedSavedFilterID + ); function saveLightboxSettings(v: Partial) { // save in local forage as well for consistency @@ -151,6 +167,44 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( }); } + function onSelectSavedFilterMenuItem(filterID: string) { + const savedFilter = savedGalleryFilters.find( + (filter) => filter.id === filterID + ); + + setSelectedSavedFilterID(filterID); + setSavedFilterMenuLabel(savedFilter?.name ?? ""); + } + + function addSavedFilterMenuItem() { + if (!selectedSavedFilter || selectedSavedFilterAlreadyAdded) { + return; + } + + saveUI({ + savedFilterMenuItems: [ + ...savedFilterMenuItems, + { + id: `saved-filter-${selectedSavedFilter.id}-${Date.now()}`, + label: savedFilterMenuLabel.trim() || selectedSavedFilter.name, + filterId: selectedSavedFilter.id, + mode: selectedSavedFilter.mode, + }, + ], + }); + + setSelectedSavedFilterID(""); + setSavedFilterMenuLabel(""); + } + + function removeSavedFilterMenuItem(id: string) { + saveUI({ + savedFilterMenuItems: savedFilterMenuItems.filter( + (item) => item.id !== id + ), + }); + } + function validateLocaleString(v: string) { if (!v) return; try { @@ -283,6 +337,109 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( /> +
+
+
+

+ +

+
+ +
+
+
+
+ + {savedFilterMenuItems.map((item) => ( +
+
+ {item.label} +
+ { + savedGalleryFilters.find( + (filter) => filter.id === item.filterId + )?.name + } +
+
+ +
+ ))} + + + + + + + onSelectSavedFilterMenuItem(e.currentTarget.value) + } + > + + {savedGalleryFilters.map((filter) => ( + + ))} + + + + + + + + setSavedFilterMenuLabel(e.currentTarget.value)} + /> + + + +
+ ; + savedFilterMenuItems?: ISavedFilterMenuItem[]; tableColumns?: Record; advancedMode?: boolean;