diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 18cf3ed001..3ad678eeca 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -317,7 +317,7 @@ var ( defaultVideoExtensions = []string{"m4v", "mp4", "mov", "wmv", "avi", "mpg", "mpeg", "rmvb", "rm", "flv", "asf", "mkv", "webm", "f4v"} defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp", "avif", "jxl"} defaultGalleryExtensions = []string{"zip", "cbz"} - defaultMenuItems = []string{"scenes", "images", "groups", "markers", "galleries", "performers", "studios", "tags"} + defaultMenuItems = []string{"scenes", "images", "groups", "markers", "galleries", "folders", "performers", "studios", "tags"} ) type MissingConfigError struct { diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 15c9b81544..b0982c745d 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -2157,7 +2157,8 @@ func TestSceneQuery(t *testing.T) { stashID2 = sceneStashID(sceneIdxWithPerformer).StashID stashIDs = []*string{&stashID, &stashID2} - depth = -1 + depth = -1 + directDepth = 0 ) tests := []struct { @@ -2329,6 +2330,24 @@ func TestSceneQuery(t *testing.T) { nil, false, }, + { + "with direct file parent folder", + nil, + &models.SceneFilterType{ + FilesFilter: &models.FileFilterType{ + ParentFolder: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(int(folderIDs[folderIdxWithSceneFiles])), + }, + Modifier: models.CriterionModifierIncludes, + Depth: &directDepth, + }, + }, + }, + []int{sceneIdxWithGroup}, + nil, + false, + }, { "single stash id", nil, diff --git a/ui/v2.5/graphql/data/file.graphql b/ui/v2.5/graphql/data/file.graphql index 7386adb81d..373f7d1918 100644 --- a/ui/v2.5/graphql/data/file.graphql +++ b/ui/v2.5/graphql/data/file.graphql @@ -94,6 +94,14 @@ fragment SelectFolderData on Folder { basename } +fragment FolderBrowserFolderData on Folder { + id + basename + zip_file { + id + } +} + fragment RecursiveFolderData on Folder { ...SelectFolderData diff --git a/ui/v2.5/graphql/queries/folder.graphql b/ui/v2.5/graphql/queries/folder.graphql index b1119cd61a..891f70fa5a 100644 --- a/ui/v2.5/graphql/queries/folder.graphql +++ b/ui/v2.5/graphql/queries/folder.graphql @@ -13,6 +13,35 @@ query FindRootFoldersForSelect($zip_file_filter: MultiCriterionInput) { } } +query FindRootFoldersForBrowser { + findFolders( + filter: { per_page: -1, sort: "path", direction: ASC } + folder_filter: { + parent_folder: { modifier: IS_NULL } + zip_file: { modifier: IS_NULL } + } + ) { + count + folders { + ...FolderBrowserFolderData + } + } +} + +query FindFolderForBrowser($id: ID!) { + findFolder(id: $id) { + ...FolderBrowserFolderData + + parent_folders { + ...FolderBrowserFolderData + } + + sub_folders { + ...FolderBrowserFolderData + } + } +} + query FindFoldersForQuery( $filter: FindFilterType $folder_filter: FolderFilterType diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index 21f5816891..5dc3b9c4ce 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -63,6 +63,7 @@ const FrontPage = lazyComponent( () => import("./components/FrontPage/FrontPage") ); const Scenes = lazyComponent(() => import("./components/Scenes/Scenes")); +const Folders = lazyComponent(() => import("./components/Folders/Folders")); const Settings = lazyComponent(() => import("./components/Settings/Settings")); const Stats = lazyComponent(() => import("./components/Stats")); const Studios = lazyComponent(() => import("./components/Studios/Studios")); @@ -253,6 +254,7 @@ export const App: React.FC = () => { + diff --git a/ui/v2.5/src/components/Folders/Breadcrumbs.tsx b/ui/v2.5/src/components/Folders/Breadcrumbs.tsx new file mode 100644 index 0000000000..c866029762 --- /dev/null +++ b/ui/v2.5/src/components/Folders/Breadcrumbs.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { FormattedMessage } from "react-intl"; +import { Link } from "react-router-dom"; +import type * as GQL from "src/core/generated-graphql"; + +export const Breadcrumbs: React.FC<{ + folder: GQL.FindFolderForBrowserQuery["findFolder"]; +}> = ({ folder }) => ( +
+ + + + {folder.parent_folders.toReversed().map((parent) => ( + + / + {parent.basename} + + ))} + / + {folder.basename} +
+); diff --git a/ui/v2.5/src/components/Folders/FolderDetails.tsx b/ui/v2.5/src/components/Folders/FolderDetails.tsx new file mode 100644 index 0000000000..952ccd10ea --- /dev/null +++ b/ui/v2.5/src/components/Folders/FolderDetails.tsx @@ -0,0 +1,80 @@ +import type React from "react"; +import { Helmet } from "react-helmet"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useParams } from "react-router-dom"; +import * as GQL from "src/core/generated-graphql"; +import { useTitleProps } from "src/hooks/title"; +import { ErrorMessage } from "../Shared/ErrorMessage"; +import { LoadingIndicator } from "../Shared/LoadingIndicator"; +import { Breadcrumbs } from "./Breadcrumbs"; +import { FolderNav } from "./FolderNav"; +import { ImageResults } from "./ImageResults"; +import { SceneResults } from "./SceneResults"; + +export const FolderDetails: React.FC = () => { + const intl = useIntl(); + const { folderId } = useParams<{ folderId: string }>(); + + const { loading, error, data } = GQL.useFindFolderForBrowserQuery({ + variables: { id: folderId }, + }); + + const titleProps = useTitleProps( + data?.findFolder.basename ?? { id: "folders" } + ); + + if (loading) { + return ; + } + + if (error) { + return ; + } + + if (data === undefined) { + // should never happen + return ( + + ); + } + + const folder = data.findFolder; + + if (folder.zip_file) { + return ( + + ); + } + + return ( + <> + + +

{folder.basename}

+ + !folder.zip_file)} + /> + +

+ +

+ + +

+ +

+ + + ); +}; diff --git a/ui/v2.5/src/components/Folders/FolderNav.tsx b/ui/v2.5/src/components/Folders/FolderNav.tsx new file mode 100644 index 0000000000..fdc54e183b --- /dev/null +++ b/ui/v2.5/src/components/Folders/FolderNav.tsx @@ -0,0 +1,21 @@ +import { faFolder } from "@fortawesome/free-solid-svg-icons"; +import type React from "react"; +import { Button } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import type * as GQL from "src/core/generated-graphql"; +import { Icon } from "../Shared/Icon"; + +export const FolderNav: React.FC<{ + folders: GQL.FolderBrowserFolderDataFragment[]; +}> = ({ folders }) => ( +
+ {folders.map((folder) => ( + + + + ))} +
+); diff --git a/ui/v2.5/src/components/Folders/Folders.tsx b/ui/v2.5/src/components/Folders/Folders.tsx new file mode 100644 index 0000000000..dfec903f5f --- /dev/null +++ b/ui/v2.5/src/components/Folders/Folders.tsx @@ -0,0 +1,13 @@ +import type React from "react"; +import { Route, Switch } from "react-router-dom"; +import { FolderDetails } from "./FolderDetails"; +import { RootFolders } from "./RootFolders"; + +const FoldersRoute: React.FC = () => ( + + + + +); + +export default FoldersRoute; diff --git a/ui/v2.5/src/components/Folders/ImageResults.tsx b/ui/v2.5/src/components/Folders/ImageResults.tsx new file mode 100644 index 0000000000..2e63bfc44c --- /dev/null +++ b/ui/v2.5/src/components/Folders/ImageResults.tsx @@ -0,0 +1,83 @@ +import type React from "react"; +import { useState } from "react"; +import { FormattedMessage } from "react-intl"; +import * as GQL from "src/core/generated-graphql"; +import { ImageCardGrid } from "../Images/ImageCardGrid"; +import { Pagination, PaginationIndex } from "../List/Pagination"; +import { ErrorMessage } from "../Shared/ErrorMessage"; +import { LoadingIndicator } from "../Shared/LoadingIndicator"; + +export const ImageResults: React.FC<{ + folderId: string; + imagesPerPage: number; +}> = ({ folderId, imagesPerPage }) => { + const [page, setPage] = useState(1); + + const { loading, error, data } = GQL.useFindImagesQuery({ + variables: { + filter: { + page, + per_page: imagesPerPage, + sort: "title", + direction: GQL.SortDirectionEnum.Asc, + }, + image_filter: { + files_filter: { + parent_folder: { + value: [folderId], + modifier: GQL.CriterionModifier.Includes, + depth: 0, + }, + }, + }, + }, + }); + + if (loading) { + return ; + } + + if (error) { + return ; + } + + if (data === undefined) { + // should never happen + return ; + } + + if (data.findImages.count === 0) { + return ( +

+ +

+ ); + } + + return ( + <> +
+ + +
+ + {}} + onPreview={() => {}} + /> + + ); +}; diff --git a/ui/v2.5/src/components/Folders/RootFolders.tsx b/ui/v2.5/src/components/Folders/RootFolders.tsx new file mode 100644 index 0000000000..14169dc2a3 --- /dev/null +++ b/ui/v2.5/src/components/Folders/RootFolders.tsx @@ -0,0 +1,30 @@ +import type React from "react"; +import { Helmet } from "react-helmet"; +import { useIntl } from "react-intl"; +import * as GQL from "src/core/generated-graphql"; +import { useTitleProps } from "src/hooks/title"; +import { ErrorMessage } from "../Shared/ErrorMessage"; +import { LoadingIndicator } from "../Shared/LoadingIndicator"; +import { FolderNav } from "./FolderNav"; + +export const RootFolders: React.FC = () => { + const intl = useIntl(); + const titleProps = useTitleProps({ id: "root_folders" }); + const { loading, error, data } = GQL.useFindRootFoldersForBrowserQuery(); + + if (loading) { + return ; + } + + if (error) { + return ; + } + + return ( + <> + +

{intl.formatMessage({ id: "root_folders" })}

+ + + ); +}; diff --git a/ui/v2.5/src/components/Folders/SceneResults.tsx b/ui/v2.5/src/components/Folders/SceneResults.tsx new file mode 100644 index 0000000000..cc7d8e9e92 --- /dev/null +++ b/ui/v2.5/src/components/Folders/SceneResults.tsx @@ -0,0 +1,86 @@ +import type React from "react"; +import { useState } from "react"; +import { FormattedMessage } from "react-intl"; +import * as GQL from "src/core/generated-graphql"; +import SceneQueue from "src/models/sceneQueue"; +import { Pagination, PaginationIndex } from "../List/Pagination"; +import { SceneCardGrid } from "../Scenes/SceneCardGrid"; +import { ErrorMessage } from "../Shared/ErrorMessage"; +import { LoadingIndicator } from "../Shared/LoadingIndicator"; + +export const SceneResults: React.FC<{ + folderId: string; + scenesPerPage: number; +}> = ({ folderId, scenesPerPage }) => { + const [page, setPage] = useState(1); + + const { loading, error, data } = GQL.useFindScenesQuery({ + variables: { + filter: { + page, + per_page: scenesPerPage, + sort: "title", + direction: GQL.SortDirectionEnum.Asc, + }, + scene_filter: { + files_filter: { + parent_folder: { + value: [folderId], + modifier: GQL.CriterionModifier.Includes, + depth: 0, + }, + }, + }, + }, + }); + + if (loading) { + return ; + } + + if (error) { + return ; + } + + if (data === undefined) { + // should never happen + return ; + } + + if (data.findScenes.count === 0) { + return ( +

+ +

+ ); + } + + return ( + <> +
+ + +
+ + scene.id) + )} + selectedIds={new Set()} + zoomIndex={0} + onSelectChange={() => {}} + /> + + ); +}; diff --git a/ui/v2.5/src/components/Folders/styles.scss b/ui/v2.5/src/components/Folders/styles.scss new file mode 100644 index 0000000000..8d5e990ece --- /dev/null +++ b/ui/v2.5/src/components/Folders/styles.scss @@ -0,0 +1,18 @@ +.folder-browser-folders { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.folder-browser-folder { + align-items: center; + display: inline-flex; + max-width: 18rem; + text-align: left; +} + +.folder-browser-folder span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index c96963680c..acdf50f9ab 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -26,6 +26,7 @@ import { faBars, faChartColumn, faFilm, + faFolderTree, faHeart, faImage, faImages, @@ -82,6 +83,10 @@ const messages = defineMessages({ id: "galleries", defaultMessage: "Galleries", }, + folders: { + id: "folders", + defaultMessage: "Folders", + }, sceneTagger: { id: "sceneTagger", defaultMessage: "Scene Tagger", @@ -135,6 +140,13 @@ const allMenuItems: IMenuItem[] = [ hotkey: "g l", userCreatable: true, }, + { + name: "folders", + message: messages.folders, + href: "/folders", + icon: faFolderTree, + hotkey: "g f", + }, { name: "performers", message: messages.performers, diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 75f1a16950..595c106bd4 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -51,6 +51,7 @@ const allMenuItems = [ { id: "groups", headingID: "groups" }, { id: "markers", headingID: "markers" }, { id: "galleries", headingID: "galleries" }, + { id: "folders", headingID: "folders" }, { id: "performers", headingID: "performers" }, { id: "studios", headingID: "studios" }, { id: "tags", headingID: "tags" }, diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index c8c6e90bd7..790c54d443 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -18,6 +18,7 @@ $sidebar-width: 250px; @import "components/List/styles.scss"; @import "components/Groups/styles.scss"; @import "components/Performers/styles.scss"; +@import "components/Folders/styles.scss"; @import "components/FrontPage/styles.scss"; @import "components/Scenes/styles.scss"; @import "components/SceneDuplicateChecker/styles.scss"; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e3653d7057..b39797820b 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1207,6 +1207,14 @@ "filter_name": "Filter name", "filters": "Filters", "folder": "Folder", + "folder_browser": { + "no_images": "No images in this folder.", + "no_scenes": "No scenes in this folder.", + "no_subfolders": "No subfolders.", + "zip_not_supported": "ZIP folders are not shown in this view." + }, + "folders": "Folders", + "root_folders": "Root Folders", "framerate": "Frame Rate", "frames_per_second": "{value} fps", "front_page": { @@ -1725,4 +1733,4 @@ "weight_kg": "Weight (kg)", "years_old": "years old", "zip_file_count": "Zip File Count" -} +} \ No newline at end of file