Implement user customizable menu items (#974)

This commit is contained in:
aGlkZGVu
2020-12-09 01:59:09 +01:00
committed by GitHub
parent 938559ca11
commit fad64ba126
10 changed files with 171 additions and 25 deletions

View File

@@ -40,6 +40,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
} }
fragment ConfigInterfaceData on ConfigInterfaceResult { fragment ConfigInterfaceData on ConfigInterfaceResult {
menuItems
soundOnPreview soundOnPreview
wallShowTitle wallShowTitle
wallPlayback wallPlayback

View File

@@ -149,6 +149,8 @@ type ConfigGeneralResult {
} }
input ConfigInterfaceInput { input ConfigInterfaceInput {
"""Ordered list of items that should be shown in the menu"""
menuItems: [String!]
"""Enable sound on mouseover previews""" """Enable sound on mouseover previews"""
soundOnPreview: Boolean soundOnPreview: Boolean
"""Show title and tags in wall view""" """Show title and tags in wall view"""
@@ -164,10 +166,13 @@ input ConfigInterfaceInput {
"""Custom CSS""" """Custom CSS"""
css: String css: String
cssEnabled: Boolean cssEnabled: Boolean
"""Interface language"""
language: String language: String
} }
type ConfigInterfaceResult { type ConfigInterfaceResult {
"""Ordered list of items that should be shown in the menu"""
menuItems: [String!]
"""Enable sound on mouseover previews""" """Enable sound on mouseover previews"""
soundOnPreview: Boolean soundOnPreview: Boolean
"""Show title and tags in wall view""" """Show title and tags in wall view"""

View File

@@ -171,6 +171,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
} }
func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.ConfigInterfaceInput) (*models.ConfigInterfaceResult, error) { func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.ConfigInterfaceInput) (*models.ConfigInterfaceResult, error) {
if input.MenuItems != nil {
config.Set(config.MenuItems, input.MenuItems)
}
if input.SoundOnPreview != nil { if input.SoundOnPreview != nil {
config.Set(config.SoundOnPreview, *input.SoundOnPreview) config.Set(config.SoundOnPreview, *input.SoundOnPreview)
} }

View File

@@ -77,6 +77,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
} }
func makeConfigInterfaceResult() *models.ConfigInterfaceResult { func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
menuItems := config.GetMenuItems()
soundOnPreview := config.GetSoundOnPreview() soundOnPreview := config.GetSoundOnPreview()
wallShowTitle := config.GetWallShowTitle() wallShowTitle := config.GetWallShowTitle()
wallPlayback := config.GetWallPlayback() wallPlayback := config.GetWallPlayback()
@@ -88,6 +89,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
language := config.GetLanguage() language := config.GetLanguage()
return &models.ConfigInterfaceResult{ return &models.ConfigInterfaceResult{
MenuItems: menuItems,
SoundOnPreview: &soundOnPreview, SoundOnPreview: &soundOnPreview,
WallShowTitle: &wallShowTitle, WallShowTitle: &wallShowTitle,
WallPlayback: &wallPlayback, WallPlayback: &wallPlayback,

View File

@@ -101,6 +101,10 @@ const Language = "language"
const CustomServedFolders = "custom_served_folders" const CustomServedFolders = "custom_served_folders"
// Interface options // Interface options
const MenuItems = "menu_items"
var defaultMenuItems = []string{"scenes", "images", "movies", "markers", "galleries", "performers", "studios", "tags"}
const SoundOnPreview = "sound_on_preview" const SoundOnPreview = "sound_on_preview"
const WallShowTitle = "wall_show_title" const WallShowTitle = "wall_show_title"
const MaximumLoopDuration = "maximum_loop_duration" const MaximumLoopDuration = "maximum_loop_duration"
@@ -449,6 +453,13 @@ func GetCustomServedFolders() URLMap {
} }
// Interface options // Interface options
func GetMenuItems() []string {
if viper.IsSet(MenuItems) {
return viper.GetStringSlice(MenuItems)
}
return defaultMenuItems
}
func GetSoundOnPreview() bool { func GetSoundOnPreview() bool {
viper.SetDefault(SoundOnPreview, true) viper.SetDefault(SoundOnPreview, true)
return viper.GetBool(SoundOnPreview) return viper.GetBool(SoundOnPreview)

View File

@@ -1,3 +1,6 @@
### ✨ New Features
* Allow configuration of visible navbar items.
### 🎨 Improvements ### 🎨 Improvements
* Add gallery tabs to performer and studio pages. * Add gallery tabs to performer and studio pages.
* Add gallery scrapers to scraper page. * Add gallery scrapers to scraper page.

View File

@@ -5,7 +5,7 @@ import {
MessageDescriptor, MessageDescriptor,
useIntl, useIntl,
} from "react-intl"; } from "react-intl";
import { Nav, Navbar, Button } from "react-bootstrap"; import { Nav, Navbar, Button, Fade } from "react-bootstrap";
import { IconName } from "@fortawesome/fontawesome-svg-core"; import { IconName } from "@fortawesome/fontawesome-svg-core";
import { LinkContainer } from "react-router-bootstrap"; import { LinkContainer } from "react-router-bootstrap";
import { Link, NavLink, useLocation, useHistory } from "react-router-dom"; import { Link, NavLink, useLocation, useHistory } from "react-router-dom";
@@ -14,8 +14,10 @@ import Mousetrap from "mousetrap";
import { SessionUtils } from "src/utils"; import { SessionUtils } from "src/utils";
import { Icon } from "src/components/Shared"; import { Icon } from "src/components/Shared";
import { Manual } from "./Help/Manual"; import { Manual } from "./Help/Manual";
import { useConfiguration } from "../core/StashService";
interface IMenuItem { interface IMenuItem {
name: string;
message: MessageDescriptor; message: MessageDescriptor;
href: string; href: string;
icon: IconName; icon: IconName;
@@ -60,55 +62,79 @@ const messages = defineMessages({
}, },
}); });
const menuItems: IMenuItem[] = [ const allMenuItems: IMenuItem[] = [
{ {
icon: "play-circle", name: "scenes",
message: messages.scenes, message: messages.scenes,
href: "/scenes", href: "/scenes",
icon: "play-circle",
}, },
{ {
icon: "image", name: "images",
message: messages.images, message: messages.images,
href: "/images", href: "/images",
icon: "image",
}, },
{ {
name: "movies",
message: messages.movies,
href: "/movies", href: "/movies",
icon: "film", icon: "film",
message: messages.movies,
}, },
{ {
name: "markers",
message: messages.markers,
href: "/scenes/markers", href: "/scenes/markers",
icon: "map-marker-alt", icon: "map-marker-alt",
message: messages.markers,
}, },
{ {
name: "galleries",
message: messages.galleries,
href: "/galleries", href: "/galleries",
icon: "image", icon: "image",
message: messages.galleries,
}, },
{ {
name: "performers",
message: messages.performers,
href: "/performers", href: "/performers",
icon: "user", icon: "user",
message: messages.performers,
}, },
{ {
name: "studios",
message: messages.studios,
href: "/studios", href: "/studios",
icon: "video", icon: "video",
message: messages.studios,
}, },
{ {
name: "tags",
message: messages.tags,
href: "/tags", href: "/tags",
icon: "tag", icon: "tag",
message: messages.tags,
}, },
]; ];
export const MainNavbar: React.FC = () => { export const MainNavbar: React.FC = () => {
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
const { data: config, loading } = useConfiguration();
// Show all menu items by default, unless config says otherwise
const [menuItems, setMenuItems] = useState<IMenuItem[]>(allMenuItems);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [showManual, setShowManual] = useState(false); const [showManual, setShowManual] = useState(false);
useEffect(() => {
const iCfg = config?.configuration?.interface;
if (iCfg?.menuItems) {
setMenuItems(
allMenuItems.filter((menuItem) =>
iCfg.menuItems!.includes(menuItem.name)
)
);
}
}, [config]);
// react-bootstrap typing bug // react-bootstrap typing bug
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const navbarRef = useRef<any>(); const navbarRef = useRef<any>();
@@ -239,6 +265,7 @@ export const MainNavbar: React.FC = () => {
</Navbar.Brand> </Navbar.Brand>
<Navbar.Toggle className="order-0" /> <Navbar.Toggle className="order-0" />
<Navbar.Collapse className="order-3 order-md-1"> <Navbar.Collapse className="order-3 order-md-1">
<Fade in={!loading}>
<Nav className="mr-md-auto"> <Nav className="mr-md-auto">
{menuItems.map((i) => ( {menuItems.map((i) => (
<Nav.Link eventKey={i.href} as="div" key={i.href}> <Nav.Link eventKey={i.href} as="div" key={i.href}>
@@ -251,6 +278,7 @@ export const MainNavbar: React.FC = () => {
</Nav.Link> </Nav.Link>
))} ))}
</Nav> </Nav>
</Fade>
</Navbar.Collapse> </Navbar.Collapse>
<Nav className="order-2 flex-row"> <Nav className="order-2 flex-row">
<div className="d-none d-sm-block">{newButton}</div> <div className="d-none d-sm-block">{newButton}</div>

View File

@@ -4,7 +4,7 @@ import { Card, Tab, Nav, Row, Col } from "react-bootstrap";
import { useHistory, useLocation } from "react-router-dom"; import { useHistory, useLocation } from "react-router-dom";
import { SettingsAboutPanel } from "./SettingsAboutPanel"; import { SettingsAboutPanel } from "./SettingsAboutPanel";
import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel"; import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel";
import { SettingsInterfacePanel } from "./SettingsInterfacePanel"; import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel";
import { SettingsLogsPanel } from "./SettingsLogsPanel"; import { SettingsLogsPanel } from "./SettingsLogsPanel";
import { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel"; import { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel";
import { SettingsPluginsPanel } from "./SettingsPluginsPanel"; import { SettingsPluginsPanel } from "./SettingsPluginsPanel";

View File

@@ -0,0 +1,63 @@
import React from "react";
import { Form } from "react-bootstrap";
interface IItem {
id: string;
label: string;
}
interface ICheckboxGroupProps {
groupId: string;
items: IItem[];
checkedIds?: string[];
onChange?: (ids: string[]) => void;
}
export const CheckboxGroup: React.FC<ICheckboxGroupProps> = ({
groupId,
items,
checkedIds = [],
onChange,
}) => {
function generateId(itemId: string) {
return `${groupId}-${itemId}`;
}
return (
<>
{items.map(({ id, label }) => (
<Form.Check
key={id}
type="checkbox"
id={generateId(id)}
label={label}
checked={checkedIds.includes(id)}
onChange={(event) => {
const target = event.currentTarget;
if (target.checked) {
onChange?.(
items
.map((item) => item.id)
.filter(
(itemId) =>
generateId(itemId) === target.id ||
checkedIds.includes(itemId)
)
);
} else {
onChange?.(
items
.map((item) => item.id)
.filter(
(itemId) =>
generateId(itemId) !== target.id &&
checkedIds.includes(itemId)
)
);
}
}}
/>
))}
</>
);
};

View File

@@ -3,10 +3,25 @@ import { Button, Form } from "react-bootstrap";
import { DurationInput, LoadingIndicator } from "src/components/Shared"; import { DurationInput, LoadingIndicator } from "src/components/Shared";
import { useConfiguration, useConfigureInterface } from "src/core/StashService"; import { useConfiguration, useConfigureInterface } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { CheckboxGroup } from "./CheckboxGroup";
const allMenuItems = [
{ id: "scenes", label: "Scenes" },
{ id: "images", label: "Images" },
{ id: "movies", label: "Movies" },
{ id: "markers", label: "Markers" },
{ id: "galleries", label: "Galleries" },
{ id: "performers", label: "Performers" },
{ id: "studios", label: "Studios" },
{ id: "tags", label: "Tags" },
];
export const SettingsInterfacePanel: React.FC = () => { export const SettingsInterfacePanel: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
const { data: config, error, loading } = useConfiguration(); const { data: config, error, loading } = useConfiguration();
const [menuItemIds, setMenuItemIds] = useState<string[]>(
allMenuItems.map((item) => item.id)
);
const [soundOnPreview, setSoundOnPreview] = useState<boolean>(true); const [soundOnPreview, setSoundOnPreview] = useState<boolean>(true);
const [wallShowTitle, setWallShowTitle] = useState<boolean>(true); const [wallShowTitle, setWallShowTitle] = useState<boolean>(true);
const [wallPlayback, setWallPlayback] = useState<string>("video"); const [wallPlayback, setWallPlayback] = useState<string>("video");
@@ -18,6 +33,7 @@ export const SettingsInterfacePanel: React.FC = () => {
const [language, setLanguage] = useState<string>("en"); const [language, setLanguage] = useState<string>("en");
const [updateInterfaceConfig] = useConfigureInterface({ const [updateInterfaceConfig] = useConfigureInterface({
menuItems: menuItemIds,
soundOnPreview, soundOnPreview,
wallShowTitle, wallShowTitle,
wallPlayback, wallPlayback,
@@ -31,6 +47,7 @@ export const SettingsInterfacePanel: React.FC = () => {
useEffect(() => { useEffect(() => {
const iCfg = config?.configuration?.interface; const iCfg = config?.configuration?.interface;
setMenuItemIds(iCfg?.menuItems ?? allMenuItems.map((item) => item.id));
setSoundOnPreview(iCfg?.soundOnPreview ?? true); setSoundOnPreview(iCfg?.soundOnPreview ?? true);
setWallShowTitle(iCfg?.wallShowTitle ?? true); setWallShowTitle(iCfg?.wallShowTitle ?? true);
setWallPlayback(iCfg?.wallPlayback ?? "video"); setWallPlayback(iCfg?.wallPlayback ?? "video");
@@ -60,7 +77,7 @@ export const SettingsInterfacePanel: React.FC = () => {
<> <>
<h4>User Interface</h4> <h4>User Interface</h4>
<Form.Group controlId="language"> <Form.Group controlId="language">
<h6>Language</h6> <h5>Language</h5>
<Form.Control <Form.Control
as="select" as="select"
className="col-4 input-control" className="col-4 input-control"
@@ -73,6 +90,18 @@ export const SettingsInterfacePanel: React.FC = () => {
<option value="en-GB">English (United Kingdom)</option> <option value="en-GB">English (United Kingdom)</option>
</Form.Control> </Form.Control>
</Form.Group> </Form.Group>
<Form.Group>
<h5>Menu items</h5>
<CheckboxGroup
groupId="menu-items"
items={allMenuItems}
checkedIds={menuItemIds}
onChange={setMenuItemIds}
/>
<Form.Text className="text-muted">
Show or hide different types of content on the navigation bar
</Form.Text>
</Form.Group>
<Form.Group> <Form.Group>
<h5>Scene / Marker Wall</h5> <h5>Scene / Marker Wall</h5>
<Form.Check <Form.Check