Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/manager/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 20 additions & 1 deletion pkg/sqlite/scene_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions ui/v2.5/graphql/data/file.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ fragment SelectFolderData on Folder {
basename
}

fragment FolderBrowserFolderData on Folder {
id
basename
zip_file {
id
}
}

fragment RecursiveFolderData on Folder {
...SelectFolderData

Expand Down
29 changes: 29 additions & 0 deletions ui/v2.5/graphql/queries/folder.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions ui/v2.5/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -253,6 +254,7 @@ export const App: React.FC = () => {
<Switch>
<Route exact path="/" component={FrontPage} />
<Route path="/scenes" component={Scenes} />
<Route path="/folders" component={Folders} />
<Route path="/images" component={Images} />
<Route path="/galleries" component={Galleries} />
<Route path="/performers" component={Performers} />
Expand Down
22 changes: 22 additions & 0 deletions ui/v2.5/src/components/Folders/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div className="mb-3">
<Link to="/folders">
<FormattedMessage id="root_folders" />
</Link>
{folder.parent_folders.toReversed().map((parent) => (
<React.Fragment key={parent.id}>
<span className="mx-2">/</span>
<Link to={`/folders/${parent.id}`}>{parent.basename}</Link>
</React.Fragment>
))}
<span className="mx-2">/</span>
<span>{folder.basename}</span>
</div>
);
80 changes: 80 additions & 0 deletions ui/v2.5/src/components/Folders/FolderDetails.tsx
Original file line number Diff line number Diff line change
@@ -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 <LoadingIndicator />;
}

if (error) {
return <ErrorMessage error={error.message} />;
}

if (data === undefined) {
// should never happen
return (
<ErrorMessage error="Unexpected error: No data returned from query." />
);
}

const folder = data.findFolder;

if (folder.zip_file) {
return (
<ErrorMessage
error={intl.formatMessage({ id: "folder_browser.zip_not_supported" })}
/>
);
}

return (
<>
<Helmet {...titleProps} />
<Breadcrumbs folder={folder} />
<h3>{folder.basename}</h3>

<FolderNav
folders={folder.sub_folders.filter((folder) => !folder.zip_file)}
/>

<h4>
<FormattedMessage id="scenes" />
</h4>
<SceneResults
key={`scenes-${folder.id}`}
folderId={folder.id}
scenesPerPage={40}
/>

<h4>
<FormattedMessage id="images" />
</h4>
<ImageResults
key={`images-${folder.id}`}
folderId={folder.id}
imagesPerPage={40}
/>
</>
);
};
21 changes: 21 additions & 0 deletions ui/v2.5/src/components/Folders/FolderNav.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div className="folder-browser-folders mb-4">
{folders.map((folder) => (
<Link key={folder.id} to={`/folders/${folder.id}`}>
<Button variant="secondary" className="folder-browser-folder">
<Icon icon={faFolder} />
<span className="ml-2">{folder.basename}</span>
</Button>
</Link>
))}
</div>
);
13 changes: 13 additions & 0 deletions ui/v2.5/src/components/Folders/Folders.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<Switch>
<Route exact path="/folders" component={RootFolders} />
<Route exact path="/folders/:folderId" component={FolderDetails} />
</Switch>
);

export default FoldersRoute;
83 changes: 83 additions & 0 deletions ui/v2.5/src/components/Folders/ImageResults.tsx
Original file line number Diff line number Diff line change
@@ -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 <LoadingIndicator />;
}

if (error) {
return <ErrorMessage error={error.message} />;
}

if (data === undefined) {
// should never happen
return <ErrorMessage error="Query unexpectedly has no data" />;
}

if (data.findImages.count === 0) {
return (
<p className="text-muted">
<FormattedMessage id="folder_browser.no_images" />
</p>
);
}

return (
<>
<div className="pagination-index-container">
<Pagination
currentPage={page}
itemsPerPage={imagesPerPage}
totalItems={data.findImages.count}
onChangePage={setPage}
/>
<PaginationIndex
loading={loading}
currentPage={page}
itemsPerPage={imagesPerPage}
totalItems={data.findImages.count}
/>
</div>

<ImageCardGrid
images={data.findImages.images}
selectedIds={new Set()}
zoomIndex={0}
onSelectChange={() => {}}
onPreview={() => {}}
/>
</>
);
};
30 changes: 30 additions & 0 deletions ui/v2.5/src/components/Folders/RootFolders.tsx
Original file line number Diff line number Diff line change
@@ -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 <LoadingIndicator />;
}

if (error) {
return <ErrorMessage error={error.message} />;
}

return (
<>
<Helmet {...titleProps} />
<h3>{intl.formatMessage({ id: "root_folders" })}</h3>
<FolderNav folders={data?.findFolders.folders ?? []} />
</>
);
};
Loading