diff --git a/internal/api/server.go b/internal/api/server.go index 9290c65122..ed11a99a51 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "path" + "path/filepath" "runtime/debug" "strconv" "strings" @@ -255,6 +256,9 @@ func Initialize() (*Server, error) { staticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS)) } + // handle favicon override + r.HandleFunc("/favicon.ico", handleFavicon(staticUI)) + // Serve the web app r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { ext := path.Ext(r.URL.Path) @@ -295,6 +299,31 @@ func Initialize() (*Server, error) { return server, nil } +func handleFavicon(staticUI *statigz.Server) func(w http.ResponseWriter, r *http.Request) { + mgr := manager.GetInstance() + cfg := mgr.Config + + // check if favicon.ico exists in the config directory + // if so, use that + // otherwise, use the embedded one + iconPath := filepath.Join(cfg.GetConfigPath(), "favicon.ico") + exists, _ := fsutil.FileExists(iconPath) + + if exists { + logger.Debugf("Using custom favicon at %s", iconPath) + } + + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") + + if exists { + http.ServeFile(w, r, iconPath) + } else { + staticUI.ServeHTTP(w, r) + } + } +} + // Start starts the server. It listens on the configured address and port. // It calls ListenAndServeTLS if TLS is configured, otherwise it calls ListenAndServe. // Calls to Start are blocked until the server is shutdown. diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 4018913b00..8034576650 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime/debug" "strings" "sync" "time" @@ -178,7 +179,16 @@ func (s *scanJob) execute(ctx context.Context) { wg.Add(1) go func() { - defer wg.Done() + defer func() { + wg.Done() + + // handle panics in goroutine + if p := recover(); p != nil { + logger.Errorf("panic while queuing files for scan: %v", p) + logger.Errorf(string(debug.Stack())) + } + }() + if err := s.queueFiles(ctx, paths); err != nil { if errors.Is(err, context.Canceled) { return @@ -204,6 +214,15 @@ func (s *scanJob) execute(ctx context.Context) { } func (s *scanJob) queueFiles(ctx context.Context, paths []string) error { + defer func() { + close(s.fileQueue) + + if s.ProgressReports != nil { + s.ProgressReports.AddTotal(s.count) + s.ProgressReports.Definite() + } + }() + var err error s.ProgressReports.ExecuteTask("Walking directory tree", func() { for _, p := range paths { @@ -214,13 +233,6 @@ func (s *scanJob) queueFiles(ctx context.Context, paths []string) error { } }) - close(s.fileQueue) - - if s.ProgressReports != nil { - s.ProgressReports.AddTotal(s.count) - s.ProgressReports.Definite() - } - return err } diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index a8b92ecc32..761352373d 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -307,7 +307,8 @@ export const App: React.FC = () => { ); } - const titleProps = makeTitleProps(); + const title = config.data?.configuration.ui.title || "Stash"; + const titleProps = makeTitleProps(title); if (!messages) { return null; diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index d18aadbd3b..952f278082 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -153,7 +153,15 @@ export const GalleryList: React.FC = PatchComponent(
{result.data.findGalleries.galleries.map((gallery) => ( - + + onSelectChange(gallery.id, selected, shiftKey) + } + selecting={selectedIds.size > 0} + /> ))}
diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index c57bf45ad2..4c01db7514 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; @@ -18,6 +19,9 @@ const CLASSNAME_IMG_CONTAIN = `${CLASSNAME}-img-contain`; interface IProps { gallery: GQL.SlimGalleryDataFragment; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } type Orientation = "landscape" | "portrait"; @@ -26,7 +30,12 @@ function getOrientation(width: number, height: number): Orientation { return width > height ? "landscape" : "portrait"; } -const GalleryWallCard: React.FC = ({ gallery }) => { +const GalleryWallCard: React.FC = ({ + gallery, + selected, + onSelectedChanged, + selecting, +}) => { const intl = useIntl(); const [coverOrientation, setCoverOrientation] = React.useState("landscape"); @@ -58,7 +67,11 @@ const GalleryWallCard: React.FC = ({ gallery }) => { ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")] : performerNames; - async function showLightboxStart() { + async function showLightboxStart(event?: React.MouseEvent) { + if (selecting && onSelectedChanged && event) { + onSelectedChanged(!selected, event.shiftKey); + return; + } if (gallery.image_count === 0) { return; } @@ -69,15 +82,33 @@ const GalleryWallCard: React.FC = ({ gallery }) => { const imgClassname = imageOrientation !== coverOrientation ? CLASSNAME_IMG_CONTAIN : ""; + let shiftKey = false; + return ( <>
showLightboxStart(e)} + onKeyPress={() => showLightboxStart()} role="button" tabIndex={0} + draggable={selecting || undefined} + onDragStart={(e) => e.preventDefault()} > + {onSelectedChanged && ( + onSelectedChanged(!selected, shiftKey)} + onClick={( + event: React.MouseEvent + ) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} void; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } const zoomWidths = [280, 340, 480, 640]; @@ -49,6 +52,9 @@ const ImageWall: React.FC = ({ images, zoomIndex, handleImageOpen, + selectedIds, + onSelectChange, + selecting, }) => { const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; @@ -121,9 +127,26 @@ const ImageWall: React.FC = ({ ? props.photo.height : targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor; - return ; + const imageId = props.photo.key; + if (!imageId) { + return null; + } + return ( + + onSelectChange(imageId, selected, shiftKey) + : undefined + } + selecting={selecting} + /> + ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -258,6 +281,9 @@ const ImageListImages: React.FC = ({ pageCount={pageCount} handleImageOpen={handleImageOpen} zoomIndex={filter.zoomIndex} + selectedIds={selectedIds} + onSelectChange={onSelectChange} + selecting={!!selectedIds && selectedIds.size > 0} /> ); } diff --git a/ui/v2.5/src/components/Images/ImageWallItem.tsx b/ui/v2.5/src/components/Images/ImageWallItem.tsx index 9012951924..06903f34b4 100644 --- a/ui/v2.5/src/components/Images/ImageWallItem.tsx +++ b/ui/v2.5/src/components/Images/ImageWallItem.tsx @@ -1,8 +1,12 @@ import React from "react"; +import { Form } from "react-bootstrap"; import type { RenderImageProps } from "react-photo-gallery"; interface IExtraProps { maxHeight: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } export const ImageWallItem: React.FC = ( @@ -13,20 +17,27 @@ export const ImageWallItem: React.FC = ( const width = props.photo.width * zoomFactor; type style = Record; - var imgStyle: style = { + var divStyle: style = { margin: props.margin, display: "block", + position: "relative", }; if (props.direction === "column") { - imgStyle.position = "absolute"; - imgStyle.left = props.left; - imgStyle.top = props.top; + divStyle.position = "absolute"; + divStyle.left = props.left; + divStyle.top = props.top; } var handleClick = function handleClick( event: React.MouseEvent ) { + if (props.selecting && props.onSelectedChanged) { + props.onSelectedChanged(!props.selected, event.shiftKey); + event.preventDefault(); + event.stopPropagation(); + return; + } if (props.onClick) { props.onClick(event, { index: props.index }); } @@ -35,19 +46,40 @@ export const ImageWallItem: React.FC = ( const video = props.photo.src.includes("preview"); const ImagePreview = video ? "video" : "img"; + let shiftKey = false; + return ( - + draggable={props.selecting || undefined} + onDragStart={(e) => e.preventDefault()} + > + {props.onSelectedChanged && ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} + + ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 8258f9b575..abb2d4a335 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -235,6 +235,8 @@ const SceneList: React.FC<{ scenes={scenes} sceneQueue={queue} zoomIndex={filter.zoomIndex} + selectedIds={selectedIds} + onSelectChange={onSelectChange} /> ); } diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index 08d4a40461..074ee2b83d 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -101,6 +101,8 @@ export const SceneMarkerList: React.FC = PatchComponent( ); } diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx index 0349fae0ff..ad5d9b9f65 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import Gallery, { GalleryI, @@ -35,6 +36,9 @@ interface IMarkerPhoto { interface IExtraProps { maxHeight: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } export const MarkerWallItem: React.FC< @@ -63,6 +67,12 @@ export const MarkerWallItem: React.FC< } var handleClick = function handleClick(event: React.MouseEvent) { + if (props.selecting && props.onSelectedChanged) { + props.onSelectedChanged(!props.selected, event.shiftKey); + event.preventDefault(); + event.stopPropagation(); + return; + } if (props.onClick) { props.onClick(event, { index: props.index }); } @@ -75,16 +85,33 @@ export const MarkerWallItem: React.FC< const title = wallItemTitle(marker); const tagNames = marker.tags.map((p) => p.name); + let shiftKey = false; + return (
e.preventDefault()} style={{ ...divStyle, width, height, }} > + {props.onSelectedChanged && ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} ; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } // HACK: typescript doesn't allow Gallery to accept a parameter for some reason @@ -163,7 +193,13 @@ const breakpointZoomHeights = [ { minWidth: 1400, heights: [160, 240, 300, 480] }, ]; -const MarkerWall: React.FC = ({ markers, zoomIndex }) => { +const MarkerWall: React.FC = ({ + markers, + zoomIndex, + selectedIds, + onSelectChange, + selecting, +}) => { const history = useHistory(); const containerRef = React.useRef(null); @@ -233,6 +269,7 @@ const MarkerWall: React.FC = ({ markers, zoomIndex }) => { const renderImage = useCallback( (props: RenderImageProps) => { + const markerId = props.photo.marker.id; return ( = ({ markers, zoomIndex }) => { targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor } + selected={selectedIds?.has(markerId)} + onSelectedChanged={ + onSelectChange + ? (selected, shiftKey) => + onSelectChange(markerId, selected, shiftKey) + : undefined + } + selecting={selecting} /> ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -266,11 +311,24 @@ const MarkerWall: React.FC = ({ markers, zoomIndex }) => { interface IMarkerWallPanelProps { markers: GQL.SceneMarkerDataFragment[]; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; } export const MarkerWallPanel: React.FC = ({ markers, zoomIndex, + selectedIds, + onSelectChange, }) => { - return ; + const selecting = !!selectedIds && selectedIds.size > 0; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx index 92aa21f593..2659a66bf5 100644 --- a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; import Gallery, { @@ -22,6 +23,9 @@ interface IScenePhoto { interface IExtraProps { maxHeight: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } export const SceneWallItem: React.FC< @@ -52,6 +56,12 @@ export const SceneWallItem: React.FC< } var handleClick = function handleClick(event: React.MouseEvent) { + if (props.selecting && props.onSelectedChanged) { + props.onSelectedChanged(!props.selected, event.shiftKey); + event.preventDefault(); + event.stopPropagation(); + return; + } if (props.onClick) { props.onClick(event, { index: props.index }); } @@ -68,16 +78,33 @@ export const SceneWallItem: React.FC< ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")] : performerNames; + let shiftKey = false; + return (
e.preventDefault()} style={{ ...divStyle, width, height, }} > + {props.onSelectedChanged && ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} ; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } // HACK: typescript doesn't allow Gallery to accept a parameter for some reason @@ -148,6 +178,9 @@ const SceneWall: React.FC = ({ scenes, sceneQueue, zoomIndex, + selectedIds, + onSelectChange, + selecting, }) => { const history = useHistory(); @@ -223,6 +256,7 @@ const SceneWall: React.FC = ({ const renderImage = useCallback( (props: RenderImageProps) => { + const sceneId = props.photo.scene.id; return ( = ({ targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor } + selected={selectedIds?.has(sceneId)} + onSelectedChanged={ + onSelectChange + ? (selected, shiftKey) => + onSelectChange(sceneId, selected, shiftKey) + : undefined + } + selecting={selecting} /> ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -257,14 +299,26 @@ interface ISceneWallPanelProps { scenes: GQL.SlimSceneDataFragment[]; sceneQueue?: SceneQueue; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; } export const SceneWallPanel: React.FC = ({ scenes, sceneQueue, zoomIndex, + selectedIds, + onSelectChange, }) => { + const selecting = !!selectedIds && selectedIds.size > 0; return ( - + ); }; diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 7b3f936d32..0ebe3f736a 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -248,6 +248,14 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( onChange={(v) => saveInterface({ sfwContentMode: v })} /> + saveUI({ title: v })} + /> +
diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index aed03cef91..67f12b3bd8 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -314,6 +314,29 @@ button.collapse-button { } } +// Wall item checkbox styles +.wall-item-check { + height: 1.2rem; + left: 0.5rem; + opacity: 0; + position: absolute; + top: 0.5rem; + width: 1.2rem; + z-index: 10; + + &:checked { + opacity: 0.75; + } + + @media (hover: none) { + opacity: 0.25; + } +} + +.wall-item:hover .wall-item-check { + opacity: 0.75; +} + .TruncatedText { -webkit-box-orient: vertical; display: -webkit-box; diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index 36d915eeb2..b0dc15c9d3 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -103,6 +103,8 @@ export interface IUIConfig { defaultFilters?: DefaultFilters; taggerConfig?: ITaggerConfig; + + title?: string; } export function getFrontPageContent( diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index d7c1b48049..76464facf2 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -165,6 +165,12 @@ The following environment variables are also supported: |----------------------|---------| | `STASH_SQLITE_CACHE_SIZE` | Sets the SQLite cache size. See https://www.sqlite.org/pragma.html#pragma_cache_size. Default is `-2000` which is 2MB. | +### Custom favicon + +You can provide a custom favicon by placing a `favicon.ico` file in the configuration directory. The configuration directory is located alongside the `config.yml` file. + +When a custom favicon is provided, it will be served instead of the default embedded favicon. + ### Custom served folders Custom served folders are served when the server handles a request with the `/custom` URL prefix. The following is an example configuration: diff --git a/ui/v2.5/src/hooks/title.ts b/ui/v2.5/src/hooks/title.ts index 8dd311e47f..193a3f9208 100644 --- a/ui/v2.5/src/hooks/title.ts +++ b/ui/v2.5/src/hooks/title.ts @@ -1,10 +1,13 @@ import { MessageDescriptor, useIntl } from "react-intl"; +import { useConfigurationContext } from "./Config"; export const TITLE = "Stash"; export const TITLE_SEPARATOR = " | "; export function useTitleProps(...messages: (string | MessageDescriptor)[]) { const intl = useIntl(); + const config = useConfigurationContext(); + const title = config.configuration.ui.title || TITLE; const parts = messages.map((msg) => { if (typeof msg === "object") { @@ -14,13 +17,13 @@ export function useTitleProps(...messages: (string | MessageDescriptor)[]) { } }); - return makeTitleProps(...parts); + return makeTitleProps(title, ...parts); } -export function makeTitleProps(...parts: string[]) { - const title = [...parts, TITLE].join(TITLE_SEPARATOR); +export function makeTitleProps(title: string, ...parts: string[]) { + const fullTitle = [...parts, title].join(TITLE_SEPARATOR); return { - titleTemplate: `%s | ${title}`, - defaultTitle: title, + titleTemplate: `%s | ${fullTitle}`, + defaultTitle: fullTitle, }; } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 21d3f6a241..158164c8d5 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -625,6 +625,10 @@ "heading": "Custom localisation", "option_label": "Custom localisation enabled" }, + "custom_title": { + "description": "Custom text to append to the page title. If empty, defaults to 'Stash'.", + "heading": "Custom Title" + }, "delete_options": { "description": "Default settings when deleting images, galleries, and scenes.", "heading": "Delete Options",