From 08b87431c33f643f22fd2f1be3678dce9d444032 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:28:00 +1100 Subject: [PATCH 1/8] Safely handle panic in scan queue goroutine (#6431) --- pkg/file/scan.go | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) 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 } From d9622470160dd9784458a7e371282cbf394316de Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:30:31 +1100 Subject: [PATCH 2/8] Custom favicon and title (#6366) * Load favicon if provided * Add custom title setting --- internal/api/server.go | 29 +++++++++++++++++++ ui/v2.5/src/App.tsx | 3 +- .../SettingsInterfacePanel.tsx | 8 +++++ ui/v2.5/src/core/config.ts | 2 ++ ui/v2.5/src/docs/en/Manual/Configuration.md | 6 ++++ ui/v2.5/src/hooks/title.ts | 13 +++++---- ui/v2.5/src/locales/en-GB.json | 4 +++ 7 files changed, 59 insertions(+), 6 deletions(-) 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/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/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/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", From 36c69a23e7ae7f9ea1d38120537f53da8fbec630 Mon Sep 17 00:00:00 2001 From: RyanAtNight <232988350+RyanAtNight@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:40:56 -0800 Subject: [PATCH 3/8] Add selecting mode to wall views --- .../src/components/Galleries/GalleryList.tsx | 10 ++- .../components/Galleries/GalleryWallCard.tsx | 37 +++++++++-- ui/v2.5/src/components/Images/ImageList.tsx | 29 ++++++++- .../src/components/Images/ImageWallItem.tsx | 60 +++++++++++++----- ui/v2.5/src/components/Scenes/SceneList.tsx | 2 + .../src/components/Scenes/SceneMarkerList.tsx | 2 + .../Scenes/SceneMarkerWallPanel.tsx | 62 ++++++++++++++++++- .../src/components/Scenes/SceneWallPanel.tsx | 56 ++++++++++++++++- ui/v2.5/src/components/Shared/styles.scss | 15 +++++ 9 files changed, 243 insertions(+), 30 deletions(-) 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..071d429746 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,29 @@ const GalleryWallCard: React.FC = ({ gallery }) => { const imgClassname = imageOrientation !== coverOrientation ? CLASSNAME_IMG_CONTAIN : ""; + let shiftKey = false; + return ( <>
showLightboxStart(e)} + onKeyPress={() => showLightboxStart()} role="button" tabIndex={0} > + {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; @@ -63,6 +69,7 @@ const ImageWall: React.FC = ({ height: number; alt?: string | undefined; key?: string | undefined; + imageId: string; }[] = []; images.forEach((image, index) => { @@ -78,6 +85,7 @@ const ImageWall: React.FC = ({ loading: "lazy", className: "gallery-image", alt: objectTitle(image), + imageId: image.id, }; photos.push(imageData); }); @@ -121,9 +129,23 @@ const ImageWall: React.FC = ({ ? props.photo.height : targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor; - return ; + const imageId = (props.photo as { imageId: string }).imageId; + return ( + + onSelectChange(imageId, selected, shiftKey) + : undefined + } + selecting={selecting} + /> + ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -258,6 +280,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..ebb58d25c0 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,34 @@ export const ImageWallItem: React.FC = ( const video = props.photo.src.includes("preview"); const ImagePreview = video ? "video" : "img"; + let shiftKey = false; + return ( - +
+ {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..f753347463 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,31 @@ export const MarkerWallItem: React.FC< const title = wallItemTitle(marker); const tagNames = marker.tags.map((p) => p.name); + let shiftKey = false; + return (
+ {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 +191,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 +267,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 +309,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..13ed8fdb86 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,31 @@ export const SceneWallItem: React.FC< ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")] : performerNames; + let shiftKey = false; + return (
+ {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 +176,9 @@ const SceneWall: React.FC = ({ scenes, sceneQueue, zoomIndex, + selectedIds, + onSelectChange, + selecting, }) => { const history = useHistory(); @@ -223,6 +254,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 +297,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/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index aed03cef91..4e9deeccdf 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -314,6 +314,21 @@ button.collapse-button { } } +// Wall item checkbox styles +.wall-item-check { + position: absolute; + top: 0.5rem; + left: 0.5rem; + width: 1.2rem; + height: 1.2rem; + opacity: 0; + 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; From f9d3df4dfa3fc5a544b00fe830c337d3bc824e7e Mon Sep 17 00:00:00 2001 From: RyanAtNight <232988350+RyanAtNight@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:52:45 -0800 Subject: [PATCH 4/8] Fix destructuring lint error in ImageList --- ui/v2.5/src/components/Images/ImageList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index d2eed63ecf..b907d4fb4c 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -129,7 +129,7 @@ const ImageWall: React.FC = ({ ? props.photo.height : targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor; - const imageId = (props.photo as { imageId: string }).imageId; + const { imageId } = props.photo as { imageId: string }; return ( Date: Sun, 4 Jan 2026 16:52:58 -0800 Subject: [PATCH 5/8] Fix CSS property ordering in wall-item-check --- ui/v2.5/src/components/Shared/styles.scss | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 4e9deeccdf..cce2a4df00 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -316,15 +316,16 @@ button.collapse-button { // Wall item checkbox styles .wall-item-check { + height: 1.2rem; + left: 0.5rem; + opacity: 0; position: absolute; top: 0.5rem; - left: 0.5rem; width: 1.2rem; - height: 1.2rem; - opacity: 0; z-index: 10; &:checked { opacity: 0.75; } + @media (hover: none) { opacity: 0.25; } } .wall-item:hover .wall-item-check { opacity: 0.75; } From c52e615fe8949c0c5640faf4b9d74983aba7249a Mon Sep 17 00:00:00 2001 From: RyanAtNight <232988350+RyanAtNight@users.noreply.github.com> Date: Sun, 4 Jan 2026 17:46:58 -0800 Subject: [PATCH 6/8] refactor: remove unnecessary imageId duplication and type casting - Remove duplicate imageId property from photos array (use existing key instead) - Remove module augmentation for react-photo-gallery Photo type - Replace 'as unknown as' type cast with direct access to props.photo.key - Add null check for imageId to handle optional key property - Eliminates code smell and improves type safety --- ui/v2.5/src/components/Images/ImageList.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index b907d4fb4c..79496823dc 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -69,7 +69,6 @@ const ImageWall: React.FC = ({ height: number; alt?: string | undefined; key?: string | undefined; - imageId: string; }[] = []; images.forEach((image, index) => { @@ -85,7 +84,6 @@ const ImageWall: React.FC = ({ loading: "lazy", className: "gallery-image", alt: objectTitle(image), - imageId: image.id, }; photos.push(imageData); }); @@ -129,7 +127,10 @@ const ImageWall: React.FC = ({ ? props.photo.height : targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor; - const { imageId } = props.photo as { imageId: string }; + const imageId = props.photo.key; + if (!imageId) { + return null; + } return ( Date: Sun, 4 Jan 2026 19:25:31 -0800 Subject: [PATCH 7/8] Fix text selection bug in wall view multi-select by making items draggable in selection mode --- ui/v2.5/src/components/Galleries/GalleryWallCard.tsx | 2 ++ ui/v2.5/src/components/Images/ImageWallItem.tsx | 8 +++++++- ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx | 2 ++ ui/v2.5/src/components/Scenes/SceneWallPanel.tsx | 2 ++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index 071d429746..3216f0ed65 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -92,6 +92,8 @@ const GalleryWallCard: React.FC = ({ onKeyPress={() => showLightboxStart()} role="button" tabIndex={0} + draggable={selecting || undefined} + onDragStart={(e) => e.preventDefault()} > {onSelectedChanged && ( = ( let shiftKey = false; return ( -
+
e.preventDefault()} + > {props.onSelectedChanged && ( e.preventDefault()} style={{ ...divStyle, width, diff --git a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx index 13ed8fdb86..2659a66bf5 100644 --- a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx @@ -85,6 +85,8 @@ export const SceneWallItem: React.FC< className={cx("wall-item", { "show-title": showTitle })} role="button" onClick={handleClick} + draggable={props.selecting || undefined} + onDragStart={(e) => e.preventDefault()} style={{ ...divStyle, width, From 50b4174496cb004ad4c9e4a028acbfa42429f8a5 Mon Sep 17 00:00:00 2001 From: RyanAtNight <232988350+RyanAtNight@users.noreply.github.com> Date: Sun, 4 Jan 2026 19:45:55 -0800 Subject: [PATCH 8/8] Fix prettier formatting issues --- .../src/components/Galleries/GalleryWallCard.tsx | 4 +++- ui/v2.5/src/components/Shared/styles.scss | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index 3216f0ed65..4c01db7514 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -101,7 +101,9 @@ const GalleryWallCard: React.FC = ({ className="wall-item-check mousetrap" checked={selected} onChange={() => onSelectedChanged(!selected, shiftKey)} - onClick={(event: React.MouseEvent) => { + onClick={( + event: React.MouseEvent + ) => { shiftKey = event.shiftKey; event.stopPropagation(); }} diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index cce2a4df00..67f12b3bd8 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -324,11 +324,18 @@ button.collapse-button { width: 1.2rem; z-index: 10; - &:checked { opacity: 0.75; } + &:checked { + opacity: 0.75; + } + + @media (hover: none) { + opacity: 0.25; + } +} - @media (hover: none) { opacity: 0.25; } +.wall-item:hover .wall-item-check { + opacity: 0.75; } -.wall-item:hover .wall-item-check { opacity: 0.75; } .TruncatedText { -webkit-box-orient: vertical;