mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Implement user customizable menu items (#974)
This commit is contained in:
@@ -40,6 +40,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
||||
}
|
||||
|
||||
fragment ConfigInterfaceData on ConfigInterfaceResult {
|
||||
menuItems
|
||||
soundOnPreview
|
||||
wallShowTitle
|
||||
wallPlayback
|
||||
|
||||
@@ -149,6 +149,8 @@ type ConfigGeneralResult {
|
||||
}
|
||||
|
||||
input ConfigInterfaceInput {
|
||||
"""Ordered list of items that should be shown in the menu"""
|
||||
menuItems: [String!]
|
||||
"""Enable sound on mouseover previews"""
|
||||
soundOnPreview: Boolean
|
||||
"""Show title and tags in wall view"""
|
||||
@@ -164,10 +166,13 @@ input ConfigInterfaceInput {
|
||||
"""Custom CSS"""
|
||||
css: String
|
||||
cssEnabled: Boolean
|
||||
"""Interface language"""
|
||||
language: String
|
||||
}
|
||||
|
||||
type ConfigInterfaceResult {
|
||||
"""Ordered list of items that should be shown in the menu"""
|
||||
menuItems: [String!]
|
||||
"""Enable sound on mouseover previews"""
|
||||
soundOnPreview: Boolean
|
||||
"""Show title and tags in wall view"""
|
||||
|
||||
@@ -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) {
|
||||
if input.MenuItems != nil {
|
||||
config.Set(config.MenuItems, input.MenuItems)
|
||||
}
|
||||
|
||||
if input.SoundOnPreview != nil {
|
||||
config.Set(config.SoundOnPreview, *input.SoundOnPreview)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
|
||||
}
|
||||
|
||||
func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
||||
menuItems := config.GetMenuItems()
|
||||
soundOnPreview := config.GetSoundOnPreview()
|
||||
wallShowTitle := config.GetWallShowTitle()
|
||||
wallPlayback := config.GetWallPlayback()
|
||||
@@ -88,6 +89,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
||||
language := config.GetLanguage()
|
||||
|
||||
return &models.ConfigInterfaceResult{
|
||||
MenuItems: menuItems,
|
||||
SoundOnPreview: &soundOnPreview,
|
||||
WallShowTitle: &wallShowTitle,
|
||||
WallPlayback: &wallPlayback,
|
||||
|
||||
@@ -101,6 +101,10 @@ const Language = "language"
|
||||
const CustomServedFolders = "custom_served_folders"
|
||||
|
||||
// Interface options
|
||||
const MenuItems = "menu_items"
|
||||
|
||||
var defaultMenuItems = []string{"scenes", "images", "movies", "markers", "galleries", "performers", "studios", "tags"}
|
||||
|
||||
const SoundOnPreview = "sound_on_preview"
|
||||
const WallShowTitle = "wall_show_title"
|
||||
const MaximumLoopDuration = "maximum_loop_duration"
|
||||
@@ -449,6 +453,13 @@ func GetCustomServedFolders() URLMap {
|
||||
}
|
||||
|
||||
// Interface options
|
||||
func GetMenuItems() []string {
|
||||
if viper.IsSet(MenuItems) {
|
||||
return viper.GetStringSlice(MenuItems)
|
||||
}
|
||||
return defaultMenuItems
|
||||
}
|
||||
|
||||
func GetSoundOnPreview() bool {
|
||||
viper.SetDefault(SoundOnPreview, true)
|
||||
return viper.GetBool(SoundOnPreview)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
### ✨ New Features
|
||||
* Allow configuration of visible navbar items.
|
||||
|
||||
### 🎨 Improvements
|
||||
* Add gallery tabs to performer and studio pages.
|
||||
* Add gallery scrapers to scraper page.
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
MessageDescriptor,
|
||||
useIntl,
|
||||
} 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 { LinkContainer } from "react-router-bootstrap";
|
||||
import { Link, NavLink, useLocation, useHistory } from "react-router-dom";
|
||||
@@ -14,8 +14,10 @@ import Mousetrap from "mousetrap";
|
||||
import { SessionUtils } from "src/utils";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import { Manual } from "./Help/Manual";
|
||||
import { useConfiguration } from "../core/StashService";
|
||||
|
||||
interface IMenuItem {
|
||||
name: string;
|
||||
message: MessageDescriptor;
|
||||
href: string;
|
||||
icon: IconName;
|
||||
@@ -60,55 +62,79 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
const menuItems: IMenuItem[] = [
|
||||
const allMenuItems: IMenuItem[] = [
|
||||
{
|
||||
icon: "play-circle",
|
||||
name: "scenes",
|
||||
message: messages.scenes,
|
||||
href: "/scenes",
|
||||
icon: "play-circle",
|
||||
},
|
||||
{
|
||||
icon: "image",
|
||||
name: "images",
|
||||
message: messages.images,
|
||||
href: "/images",
|
||||
icon: "image",
|
||||
},
|
||||
{
|
||||
name: "movies",
|
||||
message: messages.movies,
|
||||
href: "/movies",
|
||||
icon: "film",
|
||||
message: messages.movies,
|
||||
},
|
||||
{
|
||||
name: "markers",
|
||||
message: messages.markers,
|
||||
href: "/scenes/markers",
|
||||
icon: "map-marker-alt",
|
||||
message: messages.markers,
|
||||
},
|
||||
{
|
||||
name: "galleries",
|
||||
message: messages.galleries,
|
||||
href: "/galleries",
|
||||
icon: "image",
|
||||
message: messages.galleries,
|
||||
},
|
||||
{
|
||||
name: "performers",
|
||||
message: messages.performers,
|
||||
href: "/performers",
|
||||
icon: "user",
|
||||
message: messages.performers,
|
||||
},
|
||||
{
|
||||
name: "studios",
|
||||
message: messages.studios,
|
||||
href: "/studios",
|
||||
icon: "video",
|
||||
message: messages.studios,
|
||||
},
|
||||
{
|
||||
name: "tags",
|
||||
message: messages.tags,
|
||||
href: "/tags",
|
||||
icon: "tag",
|
||||
message: messages.tags,
|
||||
},
|
||||
];
|
||||
|
||||
export const MainNavbar: React.FC = () => {
|
||||
const history = useHistory();
|
||||
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 [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
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const navbarRef = useRef<any>();
|
||||
@@ -239,6 +265,7 @@ export const MainNavbar: React.FC = () => {
|
||||
</Navbar.Brand>
|
||||
<Navbar.Toggle className="order-0" />
|
||||
<Navbar.Collapse className="order-3 order-md-1">
|
||||
<Fade in={!loading}>
|
||||
<Nav className="mr-md-auto">
|
||||
{menuItems.map((i) => (
|
||||
<Nav.Link eventKey={i.href} as="div" key={i.href}>
|
||||
@@ -251,6 +278,7 @@ export const MainNavbar: React.FC = () => {
|
||||
</Nav.Link>
|
||||
))}
|
||||
</Nav>
|
||||
</Fade>
|
||||
</Navbar.Collapse>
|
||||
<Nav className="order-2 flex-row">
|
||||
<div className="d-none d-sm-block">{newButton}</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Card, Tab, Nav, Row, Col } from "react-bootstrap";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { SettingsAboutPanel } from "./SettingsAboutPanel";
|
||||
import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel";
|
||||
import { SettingsInterfacePanel } from "./SettingsInterfacePanel";
|
||||
import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel";
|
||||
import { SettingsLogsPanel } from "./SettingsLogsPanel";
|
||||
import { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel";
|
||||
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -3,10 +3,25 @@ import { Button, Form } from "react-bootstrap";
|
||||
import { DurationInput, LoadingIndicator } from "src/components/Shared";
|
||||
import { useConfiguration, useConfigureInterface } from "src/core/StashService";
|
||||
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 = () => {
|
||||
const Toast = useToast();
|
||||
const { data: config, error, loading } = useConfiguration();
|
||||
const [menuItemIds, setMenuItemIds] = useState<string[]>(
|
||||
allMenuItems.map((item) => item.id)
|
||||
);
|
||||
const [soundOnPreview, setSoundOnPreview] = useState<boolean>(true);
|
||||
const [wallShowTitle, setWallShowTitle] = useState<boolean>(true);
|
||||
const [wallPlayback, setWallPlayback] = useState<string>("video");
|
||||
@@ -18,6 +33,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
||||
const [language, setLanguage] = useState<string>("en");
|
||||
|
||||
const [updateInterfaceConfig] = useConfigureInterface({
|
||||
menuItems: menuItemIds,
|
||||
soundOnPreview,
|
||||
wallShowTitle,
|
||||
wallPlayback,
|
||||
@@ -31,6 +47,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const iCfg = config?.configuration?.interface;
|
||||
setMenuItemIds(iCfg?.menuItems ?? allMenuItems.map((item) => item.id));
|
||||
setSoundOnPreview(iCfg?.soundOnPreview ?? true);
|
||||
setWallShowTitle(iCfg?.wallShowTitle ?? true);
|
||||
setWallPlayback(iCfg?.wallPlayback ?? "video");
|
||||
@@ -60,7 +77,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
||||
<>
|
||||
<h4>User Interface</h4>
|
||||
<Form.Group controlId="language">
|
||||
<h6>Language</h6>
|
||||
<h5>Language</h5>
|
||||
<Form.Control
|
||||
as="select"
|
||||
className="col-4 input-control"
|
||||
@@ -73,6 +90,18 @@ export const SettingsInterfacePanel: React.FC = () => {
|
||||
<option value="en-GB">English (United Kingdom)</option>
|
||||
</Form.Control>
|
||||
</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>
|
||||
<h5>Scene / Marker Wall</h5>
|
||||
<Form.Check
|
||||
Reference in New Issue
Block a user