mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +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 {
|
fragment ConfigInterfaceData on ConfigInterfaceResult {
|
||||||
|
menuItems
|
||||||
soundOnPreview
|
soundOnPreview
|
||||||
wallShowTitle
|
wallShowTitle
|
||||||
wallPlayback
|
wallPlayback
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 { 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
|
||||||
Reference in New Issue
Block a user