Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 32 additions & 39 deletions ui/v2.5/src/components/MainNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,22 @@ 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";

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,
Expand All @@ -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",
Expand Down Expand Up @@ -96,7 +87,7 @@ const messages = defineMessages({
},
});

const allMenuItems: IMenuItem[] = [
const allMenuItems: INavbarMenuItem[] = [
{
name: "scenes",
message: messages.scenes,
Expand Down Expand Up @@ -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<HTMLElement | null>(null);
Expand Down Expand Up @@ -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)));
Expand All @@ -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");
Expand Down Expand Up @@ -361,11 +354,11 @@ export const MainNavbar: React.FC = () => {
>
<Navbar.Collapse className="bg-dark order-sm-1">
<MainNavbarMenuItems>
{menuItems.map(({ href, icon, message }) => (
{menuItems.map(({ href, icon, label, message, name }) => (
<Nav.Link
eventKey={href}
as="div"
key={href}
key={name}
className="col-4 col-sm-3 col-md-2 col-lg-auto"
>
<LinkContainer activeClassName="active" exact to={href}>
Expand All @@ -374,7 +367,7 @@ export const MainNavbar: React.FC = () => {
{...{ icon }}
className="nav-menu-icon d-block d-xl-inline mb-2 mb-xl-0"
/>
<span>{intl.formatMessage(message)}</span>
<span>{message ? intl.formatMessage(message) : label}</span>
</Button>
</LinkContainer>
</Nav.Link>
Expand Down
92 changes: 92 additions & 0 deletions ui/v2.5/src/components/MainNavbar/menuItems.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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()}`);
}
}
94 changes: 94 additions & 0 deletions ui/v2.5/src/components/MainNavbar/menuItems.ts
Original file line number Diff line number Diff line change
@@ -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
),
];
}
Loading