mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
Details redesign tweaks and refactoring (#3995)
* Move loadStickyHeader to src/hooks * intl stashIDs * Scroll to top on component mount * Add id to gallery cover image and tweak merge functions * Add useTitleProps hook * Also scroll to top on list pages * Refactor loaders and tabs * Use classnames * Add DetailImage
This commit is contained in:
@@ -14,10 +14,10 @@ fragment SlimGalleryData on Gallery {
|
|||||||
}
|
}
|
||||||
image_count
|
image_count
|
||||||
cover {
|
cover {
|
||||||
|
id
|
||||||
files {
|
files {
|
||||||
...ImageFileData
|
...ImageFileData
|
||||||
}
|
}
|
||||||
|
|
||||||
paths {
|
paths {
|
||||||
thumbnail
|
thumbnail
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { ErrorBoundary } from "./components/ErrorBoundary";
|
|||||||
import { MainNavbar } from "./components/MainNavbar";
|
import { MainNavbar } from "./components/MainNavbar";
|
||||||
import { PageNotFound } from "./components/PageNotFound";
|
import { PageNotFound } from "./components/PageNotFound";
|
||||||
import * as GQL from "./core/generated-graphql";
|
import * as GQL from "./core/generated-graphql";
|
||||||
import { TITLE_SUFFIX } from "./components/Shared/constants";
|
import { makeTitleProps } from "./hooks/title";
|
||||||
import { LoadingIndicator } from "./components/Shared/LoadingIndicator";
|
import { LoadingIndicator } from "./components/Shared/LoadingIndicator";
|
||||||
|
|
||||||
import { ConfigurationProvider } from "./hooks/Config";
|
import { ConfigurationProvider } from "./hooks/Config";
|
||||||
@@ -254,6 +254,8 @@ export const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const titleProps = makeTitleProps();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{messages ? (
|
{messages ? (
|
||||||
@@ -272,10 +274,7 @@ export const App: React.FC = () => {
|
|||||||
<LightboxProvider>
|
<LightboxProvider>
|
||||||
<ManualProvider>
|
<ManualProvider>
|
||||||
<InteractiveProvider>
|
<InteractiveProvider>
|
||||||
<Helmet
|
<Helmet {...titleProps} />
|
||||||
titleTemplate={`%s ${TITLE_SUFFIX}`}
|
|
||||||
defaultTitle="Stash"
|
|
||||||
/>
|
|
||||||
{maybeRenderNavbar()}
|
{maybeRenderNavbar()}
|
||||||
<div
|
<div
|
||||||
className={`main container-fluid ${
|
className={`main container-fluid ${
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
generateDefaultFrontPageContent,
|
generateDefaultFrontPageContent,
|
||||||
IUIConfig,
|
IUIConfig,
|
||||||
} from "src/core/config";
|
} from "src/core/config";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
const FrontPage: React.FC = () => {
|
const FrontPage: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -24,6 +25,8 @@ const FrontPage: React.FC = () => {
|
|||||||
|
|
||||||
const { configuration, loading } = React.useContext(ConfigurationContext);
|
const { configuration, loading } = React.useContext(ConfigurationContext);
|
||||||
|
|
||||||
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
async function onUpdateConfig(content?: FrontPageContent[]) {
|
async function onUpdateConfig(content?: FrontPageContent[]) {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,26 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Route, Switch } from "react-router-dom";
|
import { Route, Switch } from "react-router-dom";
|
||||||
import { useIntl } from "react-intl";
|
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { TITLE_SUFFIX } from "../Shared/constants";
|
import { useTitleProps } from "src/hooks/title";
|
||||||
import { PersistanceLevel } from "../List/ItemList";
|
import { PersistanceLevel } from "../List/ItemList";
|
||||||
import Gallery from "./GalleryDetails/Gallery";
|
import Gallery from "./GalleryDetails/Gallery";
|
||||||
import GalleryCreate from "./GalleryDetails/GalleryCreate";
|
import GalleryCreate from "./GalleryDetails/GalleryCreate";
|
||||||
import { GalleryList } from "./GalleryList";
|
import { GalleryList } from "./GalleryList";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
const Galleries: React.FC = () => {
|
const Galleries: React.FC = () => {
|
||||||
const intl = useIntl();
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
const title_template = `${intl.formatMessage({
|
return <GalleryList persistState={PersistanceLevel.ALL} />;
|
||||||
id: "galleries",
|
};
|
||||||
})} ${TITLE_SUFFIX}`;
|
|
||||||
|
const GalleryRoutes: React.FC = () => {
|
||||||
|
const titleProps = useTitleProps({ id: "galleries" });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet
|
<Helmet {...titleProps} />
|
||||||
defaultTitle={title_template}
|
|
||||||
titleTemplate={`%s | ${title_template}`}
|
|
||||||
/>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route
|
<Route exact path="/galleries" component={Galleries} />
|
||||||
exact
|
|
||||||
path="/galleries"
|
|
||||||
render={(props) => (
|
|
||||||
<GalleryList {...props} persistState={PersistanceLevel.ALL} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route exact path="/galleries/new" component={GalleryCreate} />
|
<Route exact path="/galleries/new" component={GalleryCreate} />
|
||||||
<Route path="/galleries/:id/:tab?" component={Gallery} />
|
<Route path="/galleries/:id/:tab?" component={Gallery} />
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -35,4 +28,4 @@ const Galleries: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Galleries;
|
export default GalleryRoutes;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Button, Tab, Nav, Dropdown } from "react-bootstrap";
|
import { Button, Tab, Nav, Dropdown } from "react-bootstrap";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams, useHistory, Link } from "react-router-dom";
|
import {
|
||||||
|
useHistory,
|
||||||
|
Link,
|
||||||
|
RouteComponentProps,
|
||||||
|
Redirect,
|
||||||
|
} from "react-router-dom";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
@@ -31,17 +36,19 @@ import {
|
|||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { galleryPath, galleryTitle } from "src/core/galleries";
|
import { galleryPath, galleryTitle } from "src/core/galleries";
|
||||||
import { GalleryChapterPanel } from "./GalleryChaptersPanel";
|
import { GalleryChapterPanel } from "./GalleryChaptersPanel";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
gallery: GQL.GalleryDataFragment;
|
gallery: GQL.GalleryDataFragment;
|
||||||
|
add?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IGalleryParams {
|
interface IGalleryParams {
|
||||||
|
id: string;
|
||||||
tab?: string;
|
tab?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
|
||||||
const { tab = "images" } = useParams<IGalleryParams>();
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -50,11 +57,12 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
const [activeTabKey, setActiveTabKey] = useState("gallery-details-panel");
|
const [activeTabKey, setActiveTabKey] = useState("gallery-details-panel");
|
||||||
const activeRightTabKey = tab === "images" || tab === "add" ? tab : "images";
|
|
||||||
const setActiveRightTabKey = (newTab: string | null) => {
|
const setMainTabKey = (newTabKey: string | null) => {
|
||||||
if (tab !== newTab) {
|
if (newTabKey === "add") {
|
||||||
const tabParam = newTab === "images" ? "" : `/${newTab}`;
|
history.replace(`/galleries/${gallery.id}/add`);
|
||||||
history.replace(`/galleries/${gallery.id}${tabParam}`);
|
} else {
|
||||||
|
history.replace(`/galleries/${gallery.id}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -281,9 +289,9 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Container
|
<Tab.Container
|
||||||
activeKey={activeRightTabKey}
|
activeKey={add ? "add" : "images"}
|
||||||
unmountOnExit
|
unmountOnExit
|
||||||
onSelect={(k) => k && setActiveRightTabKey(k)}
|
onSelect={setMainTabKey}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Nav variant="tabs" className="mr-auto">
|
<Nav variant="tabs" className="mr-auto">
|
||||||
@@ -302,16 +310,10 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
|||||||
|
|
||||||
<Tab.Content>
|
<Tab.Content>
|
||||||
<Tab.Pane eventKey="images">
|
<Tab.Pane eventKey="images">
|
||||||
<GalleryImagesPanel
|
<GalleryImagesPanel active={!add} gallery={gallery} />
|
||||||
active={activeRightTabKey == "images"}
|
|
||||||
gallery={gallery}
|
|
||||||
/>
|
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
<Tab.Pane eventKey="add">
|
<Tab.Pane eventKey="add">
|
||||||
<GalleryAddPanel
|
<GalleryAddPanel active={!!add} gallery={gallery} />
|
||||||
active={activeRightTabKey == "add"}
|
|
||||||
gallery={gallery}
|
|
||||||
/>
|
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
</Tab.Content>
|
</Tab.Content>
|
||||||
</Tab.Container>
|
</Tab.Container>
|
||||||
@@ -372,15 +374,35 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const GalleryLoader: React.FC = () => {
|
const GalleryLoader: React.FC<RouteComponentProps<IGalleryParams>> = ({
|
||||||
const { id } = useParams<{ id?: string }>();
|
location,
|
||||||
const { data, loading, error } = useFindGallery(id ?? "");
|
match,
|
||||||
|
}) => {
|
||||||
|
const { id, tab } = match.params;
|
||||||
|
const { data, loading, error } = useFindGallery(id);
|
||||||
|
|
||||||
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
if (loading) return <LoadingIndicator />;
|
if (loading) return <LoadingIndicator />;
|
||||||
if (error) return <ErrorMessage error={error.message} />;
|
if (error) return <ErrorMessage error={error.message} />;
|
||||||
if (!data?.findGallery)
|
if (!data?.findGallery)
|
||||||
return <ErrorMessage error={`No gallery found with id ${id}.`} />;
|
return <ErrorMessage error={`No gallery found with id ${id}.`} />;
|
||||||
|
|
||||||
|
if (tab === "add") {
|
||||||
|
return <GalleryPage add gallery={data.findGallery} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab) {
|
||||||
|
return (
|
||||||
|
<Redirect
|
||||||
|
to={{
|
||||||
|
...location,
|
||||||
|
pathname: `/galleries/${id}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <GalleryPage gallery={data.findGallery} />;
|
return <GalleryPage gallery={data.findGallery} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Tab, Nav, Dropdown } from "react-bootstrap";
|
import { Tab, Nav, Dropdown } from "react-bootstrap";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { useParams, useHistory, Link } from "react-router-dom";
|
import { useHistory, Link, RouteComponentProps } from "react-router-dom";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import {
|
import {
|
||||||
useFindImage,
|
useFindImage,
|
||||||
@@ -27,22 +27,24 @@ import { DeleteImagesDialog } from "../DeleteImagesDialog";
|
|||||||
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
|
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { objectPath, objectTitle } from "src/core/files";
|
import { objectPath, objectTitle } from "src/core/files";
|
||||||
import { isVideo } from "src/utils/visualFile";
|
import { isVideo } from "src/utils/visualFile";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
interface IImageParams {
|
interface IProps {
|
||||||
id?: string;
|
image: GQL.ImageDataFragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Image: React.FC = () => {
|
interface IImageParams {
|
||||||
const { id = "new" } = useParams<IImageParams>();
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImagePage: React.FC<IProps> = ({ image }) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { data, error, loading } = useFindImage(id);
|
const [incrementO] = useImageIncrementO(image.id);
|
||||||
const image = data?.findImage;
|
const [decrementO] = useImageDecrementO(image.id);
|
||||||
const [incrementO] = useImageIncrementO(image?.id ?? "0");
|
const [resetO] = useImageResetO(image.id);
|
||||||
const [decrementO] = useImageDecrementO(image?.id ?? "0");
|
|
||||||
const [resetO] = useImageResetO(image?.id ?? "0");
|
|
||||||
|
|
||||||
const [updateImage] = useImageUpdate();
|
const [updateImage] = useImageUpdate();
|
||||||
|
|
||||||
@@ -90,8 +92,8 @@ export const Image: React.FC = () => {
|
|||||||
await updateImage({
|
await updateImage({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
id: image?.id ?? "",
|
id: image.id,
|
||||||
organized: !image?.organized,
|
organized: !image.organized,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -262,16 +264,6 @@ export const Image: React.FC = () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <LoadingIndicator />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) return <ErrorMessage error={error.message} />;
|
|
||||||
|
|
||||||
if (!image) {
|
|
||||||
return <ErrorMessage error={`No image found with id ${id}.`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = objectTitle(image);
|
const title = objectTitle(image);
|
||||||
const ImageView = isVideo(image.visual_files[0]) ? "video" : "img";
|
const ImageView = isVideo(image.visual_files[0]) ? "video" : "img";
|
||||||
|
|
||||||
@@ -317,3 +309,21 @@ export const Image: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ImageLoader: React.FC<RouteComponentProps<IImageParams>> = ({
|
||||||
|
match,
|
||||||
|
}) => {
|
||||||
|
const { id } = match.params;
|
||||||
|
const { data, loading, error } = useFindImage(id);
|
||||||
|
|
||||||
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
|
if (loading) return <LoadingIndicator />;
|
||||||
|
if (error) return <ErrorMessage error={error.message} />;
|
||||||
|
if (!data?.findImage)
|
||||||
|
return <ErrorMessage error={`No image found with id ${id}.`} />;
|
||||||
|
|
||||||
|
return <ImagePage image={data.findImage} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageLoader;
|
||||||
|
|||||||
@@ -1,36 +1,29 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Route, Switch } from "react-router-dom";
|
import { Route, Switch } from "react-router-dom";
|
||||||
import { useIntl } from "react-intl";
|
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { TITLE_SUFFIX } from "../Shared/constants";
|
import { useTitleProps } from "src/hooks/title";
|
||||||
import { PersistanceLevel } from "../List/ItemList";
|
import { PersistanceLevel } from "../List/ItemList";
|
||||||
import { Image } from "./ImageDetails/Image";
|
import Image from "./ImageDetails/Image";
|
||||||
import { ImageList } from "./ImageList";
|
import { ImageList } from "./ImageList";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
const Images: React.FC = () => {
|
const Images: React.FC = () => {
|
||||||
const intl = useIntl();
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
const title_template = `${intl.formatMessage({
|
return <ImageList persistState={PersistanceLevel.ALL} />;
|
||||||
id: "images",
|
};
|
||||||
})} ${TITLE_SUFFIX}`;
|
|
||||||
|
const ImageRoutes: React.FC = () => {
|
||||||
|
const titleProps = useTitleProps({ id: "images" });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet
|
<Helmet {...titleProps} />
|
||||||
defaultTitle={title_template}
|
|
||||||
titleTemplate={`%s | ${title_template}`}
|
|
||||||
/>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route
|
<Route exact path="/images" component={Images} />
|
||||||
exact
|
|
||||||
path="/images"
|
|
||||||
render={(props) => (
|
|
||||||
<ImageList persistState={PersistanceLevel.ALL} {...props} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route path="/images/:id" component={Image} />
|
<Route path="/images/:id" component={Image} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Images;
|
export default ImageRoutes;
|
||||||
|
|||||||
@@ -153,9 +153,7 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
|
|||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [lastClickedId, setLastClickedId] = useState<string>();
|
const [lastClickedId, setLastClickedId] = useState<string>();
|
||||||
|
|
||||||
const [editingCriterion, setEditingCriterion] = useState<
|
const [editingCriterion, setEditingCriterion] = useState<string>();
|
||||||
string | undefined
|
|
||||||
>();
|
|
||||||
const [showEditFilter, setShowEditFilter] = useState(false);
|
const [showEditFilter, setShowEditFilter] = useState(false);
|
||||||
|
|
||||||
const result = useResult(filter);
|
const result = useResult(filter);
|
||||||
@@ -701,7 +699,15 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
|
|||||||
const newFilter = cloneDeep(filter);
|
const newFilter = cloneDeep(filter);
|
||||||
newFilter.currentPage = page;
|
newFilter.currentPage = page;
|
||||||
updateFilter(newFilter);
|
updateFilter(newFilter);
|
||||||
window.scrollTo(0, 0);
|
|
||||||
|
// if the current page has a detail-header, then
|
||||||
|
// scroll up relative to that rather than 0, 0
|
||||||
|
const detailHeader = document.querySelector(".detail-header");
|
||||||
|
if (detailHeader) {
|
||||||
|
window.scrollTo(0, detailHeader.scrollHeight - 50);
|
||||||
|
} else {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[filter, updateFilter]
|
[filter, updateFilter]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
|||||||
import { Button } from "react-bootstrap";
|
import { Button } from "react-bootstrap";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
|
import cx from "classnames";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +10,7 @@ import {
|
|||||||
useMovieUpdate,
|
useMovieUpdate,
|
||||||
useMovieDestroy,
|
useMovieDestroy,
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import { useParams, useHistory } from "react-router-dom";
|
import { useHistory, RouteComponentProps } from "react-router-dom";
|
||||||
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
|
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
|
||||||
import { ErrorMessage } from "src/components/Shared/ErrorMessage";
|
import { ErrorMessage } from "src/components/Shared/ErrorMessage";
|
||||||
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
||||||
@@ -33,13 +34,19 @@ import { Icon } from "src/components/Shared/Icon";
|
|||||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||||
import { ConfigurationContext } from "src/hooks/Config";
|
import { ConfigurationContext } from "src/hooks/Config";
|
||||||
import { IUIConfig } from "src/core/config";
|
import { IUIConfig } from "src/core/config";
|
||||||
import ImageUtils from "src/utils/image";
|
import { DetailImage } from "src/components/Shared/DetailImage";
|
||||||
import { useRatingKeybinds } from "src/hooks/keybinds";
|
import { useRatingKeybinds } from "src/hooks/keybinds";
|
||||||
|
import { useLoadStickyHeader } from "src/hooks/detailsPanel";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
movie: GQL.MovieDataFragment;
|
movie: GQL.MovieDataFragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IMovieParams {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
const MoviePage: React.FC<IProps> = ({ movie }) => {
|
const MoviePage: React.FC<IProps> = ({ movie }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -53,7 +60,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||||||
const showAllDetails = uiConfig?.showAllDetails ?? true;
|
const showAllDetails = uiConfig?.showAllDetails ?? true;
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||||
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
|
const loadStickyHeader = useLoadStickyHeader();
|
||||||
|
|
||||||
// Editing state
|
// Editing state
|
||||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||||
@@ -126,21 +133,6 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||||||
setRating
|
setRating
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const f = () => {
|
|
||||||
if (document.documentElement.scrollTop <= 50) {
|
|
||||||
setLoadStickyHeader(false);
|
|
||||||
} else {
|
|
||||||
setLoadStickyHeader(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", f);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", f);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onSave(input: GQL.MovieCreateInput) {
|
async function onSave(input: GQL.MovieCreateInput) {
|
||||||
await updateMovie({
|
await updateMovie({
|
||||||
variables: {
|
variables: {
|
||||||
@@ -240,11 +232,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||||||
if (image && defaultImage) {
|
if (image && defaultImage) {
|
||||||
return (
|
return (
|
||||||
<div className="movie-image-container">
|
<div className="movie-image-container">
|
||||||
<img
|
<DetailImage alt="Front Cover" src={image} />
|
||||||
alt="Front Cover"
|
|
||||||
src={image}
|
|
||||||
onLoad={ImageUtils.verifyImageSize}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (image) {
|
} else if (image) {
|
||||||
@@ -254,11 +242,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||||||
variant="link"
|
variant="link"
|
||||||
onClick={() => showLightbox()}
|
onClick={() => showLightbox()}
|
||||||
>
|
>
|
||||||
<img
|
<DetailImage alt="Front Cover" src={image} />
|
||||||
alt="Front Cover"
|
|
||||||
src={image}
|
|
||||||
onLoad={ImageUtils.verifyImageSize}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -281,11 +265,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||||||
variant="link"
|
variant="link"
|
||||||
onClick={() => showLightbox(index - 1)}
|
onClick={() => showLightbox(index - 1)}
|
||||||
>
|
>
|
||||||
<img
|
<DetailImage alt="Back Cover" src={image} />
|
||||||
alt="Back Cover"
|
|
||||||
src={image}
|
|
||||||
onLoad={ImageUtils.verifyImageSize}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -405,17 +385,19 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||||||
|
|
||||||
if (updating || deleting) return <LoadingIndicator />;
|
if (updating || deleting) return <LoadingIndicator />;
|
||||||
|
|
||||||
|
const headerClassName = cx("detail-header", {
|
||||||
|
edit: isEditing,
|
||||||
|
collapsed,
|
||||||
|
"full-width": !collapsed && !compactExpandedDetails,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="movie-page" className="row">
|
<div id="movie-page" className="row">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{movie?.name}</title>
|
<title>{movie?.name}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<div
|
<div className={headerClassName}>
|
||||||
className={`detail-header ${isEditing ? "edit" : ""} ${
|
|
||||||
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{maybeRenderHeaderBackgroundImage()}
|
{maybeRenderHeaderBackgroundImage()}
|
||||||
<div className="detail-container">
|
<div className="detail-container">
|
||||||
<div className="detail-header-image">
|
<div className="detail-header-image">
|
||||||
@@ -461,9 +443,13 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MovieLoader: React.FC = () => {
|
const MovieLoader: React.FC<RouteComponentProps<IMovieParams>> = ({
|
||||||
const { id } = useParams<{ id?: string }>();
|
match,
|
||||||
const { data, loading, error } = useFindMovie(id ?? "");
|
}) => {
|
||||||
|
const { id } = match.params;
|
||||||
|
const { data, loading, error } = useFindMovie(id);
|
||||||
|
|
||||||
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
if (loading) return <LoadingIndicator />;
|
if (loading) return <LoadingIndicator />;
|
||||||
if (error) return <ErrorMessage error={error.message} />;
|
if (error) return <ErrorMessage error={error.message} />;
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Route, Switch } from "react-router-dom";
|
import { Route, Switch } from "react-router-dom";
|
||||||
import { useIntl } from "react-intl";
|
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { TITLE_SUFFIX } from "src/components/Shared/constants";
|
import { useTitleProps } from "src/hooks/title";
|
||||||
import Movie from "./MovieDetails/Movie";
|
import Movie from "./MovieDetails/Movie";
|
||||||
import MovieCreate from "./MovieDetails/MovieCreate";
|
import MovieCreate from "./MovieDetails/MovieCreate";
|
||||||
import { MovieList } from "./MovieList";
|
import { MovieList } from "./MovieList";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
const Movies: React.FC = () => {
|
const Movies: React.FC = () => {
|
||||||
const intl = useIntl();
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
const title_template = `${intl.formatMessage({
|
return <MovieList />;
|
||||||
id: "movies",
|
};
|
||||||
})} ${TITLE_SUFFIX}`;
|
|
||||||
|
const MovieRoutes: React.FC = () => {
|
||||||
|
const titleProps = useTitleProps({ id: "movies" });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet
|
<Helmet {...titleProps} />
|
||||||
defaultTitle={title_template}
|
|
||||||
titleTemplate={`%s | ${title_template}`}
|
|
||||||
/>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/movies" component={MovieList} />
|
<Route exact path="/movies" component={Movies} />
|
||||||
<Route exact path="/movies/new" component={MovieCreate} />
|
<Route exact path="/movies/new" component={MovieCreate} />
|
||||||
<Route path="/movies/:id/:tab?" component={Movie} />
|
<Route path="/movies/:id/:tab?" component={Movie} />
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -28,4 +27,4 @@ const Movies: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Movies;
|
export default MovieRoutes;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Tabs, Tab, Col, Row } from "react-bootstrap";
|
import { Button, Tabs, Tab, Col, Row } from "react-bootstrap";
|
||||||
import { useIntl } from "react-intl";
|
import { useIntl } from "react-intl";
|
||||||
import { useParams, useHistory } from "react-router-dom";
|
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
@@ -42,20 +42,39 @@ import {
|
|||||||
import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons";
|
import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons";
|
||||||
import { IUIConfig } from "src/core/config";
|
import { IUIConfig } from "src/core/config";
|
||||||
import { useRatingKeybinds } from "src/hooks/keybinds";
|
import { useRatingKeybinds } from "src/hooks/keybinds";
|
||||||
import ImageUtils from "src/utils/image";
|
import { DetailImage } from "src/components/Shared/DetailImage";
|
||||||
|
import { useLoadStickyHeader } from "src/hooks/detailsPanel";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
performer: GQL.PerformerDataFragment;
|
performer: GQL.PerformerDataFragment;
|
||||||
|
tabKey: TabKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IPerformerParams {
|
interface IPerformerParams {
|
||||||
|
id: string;
|
||||||
tab?: string;
|
tab?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
const validTabs = [
|
||||||
|
"scenes",
|
||||||
|
"galleries",
|
||||||
|
"images",
|
||||||
|
"movies",
|
||||||
|
"appearswith",
|
||||||
|
] as const;
|
||||||
|
type TabKey = (typeof validTabs)[number];
|
||||||
|
|
||||||
|
const defaultTab: TabKey = "scenes";
|
||||||
|
|
||||||
|
function isTabKey(tab: string): tab is TabKey {
|
||||||
|
return validTabs.includes(tab as TabKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { tab = "details" } = useParams<IPerformerParams>();
|
|
||||||
|
|
||||||
// Configuration settings
|
// Configuration settings
|
||||||
const { configuration } = React.useContext(ConfigurationContext);
|
const { configuration } = React.useContext(ConfigurationContext);
|
||||||
@@ -70,7 +89,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||||
const [image, setImage] = useState<string | null>();
|
const [image, setImage] = useState<string | null>();
|
||||||
const [encodingImage, setEncodingImage] = useState<boolean>(false);
|
const [encodingImage, setEncodingImage] = useState<boolean>(false);
|
||||||
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
|
const loadStickyHeader = useLoadStickyHeader();
|
||||||
|
|
||||||
const activeImage = useMemo(() => {
|
const activeImage = useMemo(() => {
|
||||||
const performerImage = performer.image_path;
|
const performerImage = performer.image_path;
|
||||||
@@ -98,20 +117,16 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||||||
const [updatePerformer] = usePerformerUpdate();
|
const [updatePerformer] = usePerformerUpdate();
|
||||||
const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy();
|
const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy();
|
||||||
|
|
||||||
const activeTabKey =
|
function setTabKey(newTabKey: string | null) {
|
||||||
tab === "scenes" ||
|
if (!newTabKey) newTabKey = defaultTab;
|
||||||
tab === "galleries" ||
|
if (newTabKey === tabKey) return;
|
||||||
tab === "images" ||
|
|
||||||
tab === "movies" ||
|
if (newTabKey === defaultTab) {
|
||||||
tab == "appearswith"
|
history.replace(`/performers/${performer.id}`);
|
||||||
? tab
|
} else if (isTabKey(newTabKey)) {
|
||||||
: "scenes";
|
history.replace(`/performers/${performer.id}/${newTabKey}`);
|
||||||
const setActiveTabKey = (newTab: string | null) => {
|
|
||||||
if (tab !== newTab) {
|
|
||||||
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
|
|
||||||
history.replace(`/performers/${performer.id}${tabParam}`);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
async function onAutoTag() {
|
async function onAutoTag() {
|
||||||
try {
|
try {
|
||||||
@@ -133,18 +148,18 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||||||
// set up hotkeys
|
// set up hotkeys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Mousetrap.bind("e", () => toggleEditing());
|
Mousetrap.bind("e", () => toggleEditing());
|
||||||
Mousetrap.bind("c", () => setActiveTabKey("scenes"));
|
Mousetrap.bind("c", () => setTabKey("scenes"));
|
||||||
Mousetrap.bind("g", () => setActiveTabKey("galleries"));
|
Mousetrap.bind("g", () => setTabKey("galleries"));
|
||||||
Mousetrap.bind("m", () => setActiveTabKey("movies"));
|
Mousetrap.bind("m", () => setTabKey("movies"));
|
||||||
Mousetrap.bind("f", () => setFavorite(!performer.favorite));
|
Mousetrap.bind("f", () => setFavorite(!performer.favorite));
|
||||||
Mousetrap.bind(",", () => setCollapsed(!collapsed));
|
Mousetrap.bind(",", () => setCollapsed(!collapsed));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
Mousetrap.unbind("a");
|
|
||||||
Mousetrap.unbind("e");
|
Mousetrap.unbind("e");
|
||||||
Mousetrap.unbind("c");
|
Mousetrap.unbind("c");
|
||||||
|
Mousetrap.unbind("g");
|
||||||
|
Mousetrap.unbind("m");
|
||||||
Mousetrap.unbind("f");
|
Mousetrap.unbind("f");
|
||||||
Mousetrap.unbind("o");
|
|
||||||
Mousetrap.unbind(",");
|
Mousetrap.unbind(",");
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -191,116 +206,114 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||||||
if (activeImage) {
|
if (activeImage) {
|
||||||
return (
|
return (
|
||||||
<Button variant="link" onClick={() => showLightbox()}>
|
<Button variant="link" onClick={() => showLightbox()}>
|
||||||
<img
|
<DetailImage
|
||||||
className="performer"
|
className="performer"
|
||||||
src={activeImage}
|
src={activeImage}
|
||||||
alt={performer.name}
|
alt={performer.name}
|
||||||
onLoad={ImageUtils.verifyImageSize}
|
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const renderTabs = () => (
|
const renderTabs = () => (
|
||||||
<React.Fragment>
|
<Tabs
|
||||||
<Tabs
|
id="performer-tabs"
|
||||||
activeKey={activeTabKey}
|
mountOnEnter
|
||||||
onSelect={setActiveTabKey}
|
unmountOnExit
|
||||||
id="performer-details"
|
activeKey={tabKey}
|
||||||
unmountOnExit
|
onSelect={setTabKey}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
eventKey="scenes"
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{intl.formatMessage({ id: "scenes" })}
|
||||||
|
<Counter
|
||||||
|
abbreviateCounter={abbreviateCounter}
|
||||||
|
count={performer.scene_count}
|
||||||
|
hideZero
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Tab
|
<PerformerScenesPanel
|
||||||
eventKey="scenes"
|
active={tabKey === "scenes"}
|
||||||
title={
|
performer={performer}
|
||||||
<>
|
/>
|
||||||
{intl.formatMessage({ id: "scenes" })}
|
</Tab>
|
||||||
<Counter
|
<Tab
|
||||||
abbreviateCounter={abbreviateCounter}
|
eventKey="galleries"
|
||||||
count={performer.scene_count}
|
title={
|
||||||
hideZero
|
<>
|
||||||
/>
|
{intl.formatMessage({ id: "galleries" })}
|
||||||
</>
|
<Counter
|
||||||
}
|
abbreviateCounter={abbreviateCounter}
|
||||||
>
|
count={performer.gallery_count}
|
||||||
<PerformerScenesPanel
|
hideZero
|
||||||
active={activeTabKey == "scenes"}
|
/>
|
||||||
performer={performer}
|
</>
|
||||||
/>
|
}
|
||||||
</Tab>
|
>
|
||||||
<Tab
|
<PerformerGalleriesPanel
|
||||||
eventKey="galleries"
|
active={tabKey === "galleries"}
|
||||||
title={
|
performer={performer}
|
||||||
<>
|
/>
|
||||||
{intl.formatMessage({ id: "galleries" })}
|
</Tab>
|
||||||
<Counter
|
<Tab
|
||||||
abbreviateCounter={abbreviateCounter}
|
eventKey="images"
|
||||||
count={performer.gallery_count}
|
title={
|
||||||
hideZero
|
<>
|
||||||
/>
|
{intl.formatMessage({ id: "images" })}
|
||||||
</>
|
<Counter
|
||||||
}
|
abbreviateCounter={abbreviateCounter}
|
||||||
>
|
count={performer.image_count}
|
||||||
<PerformerGalleriesPanel
|
hideZero
|
||||||
active={activeTabKey == "galleries"}
|
/>
|
||||||
performer={performer}
|
</>
|
||||||
/>
|
}
|
||||||
</Tab>
|
>
|
||||||
<Tab
|
<PerformerImagesPanel
|
||||||
eventKey="images"
|
active={tabKey === "images"}
|
||||||
title={
|
performer={performer}
|
||||||
<>
|
/>
|
||||||
{intl.formatMessage({ id: "images" })}
|
</Tab>
|
||||||
<Counter
|
<Tab
|
||||||
abbreviateCounter={abbreviateCounter}
|
eventKey="movies"
|
||||||
count={performer.image_count}
|
title={
|
||||||
hideZero
|
<>
|
||||||
/>
|
{intl.formatMessage({ id: "movies" })}
|
||||||
</>
|
<Counter
|
||||||
}
|
abbreviateCounter={abbreviateCounter}
|
||||||
>
|
count={performer.movie_count}
|
||||||
<PerformerImagesPanel
|
hideZero
|
||||||
active={activeTabKey == "images"}
|
/>
|
||||||
performer={performer}
|
</>
|
||||||
/>
|
}
|
||||||
</Tab>
|
>
|
||||||
<Tab
|
<PerformerMoviesPanel
|
||||||
eventKey="movies"
|
active={tabKey === "movies"}
|
||||||
title={
|
performer={performer}
|
||||||
<>
|
/>
|
||||||
{intl.formatMessage({ id: "movies" })}
|
</Tab>
|
||||||
<Counter
|
<Tab
|
||||||
abbreviateCounter={abbreviateCounter}
|
eventKey="appearswith"
|
||||||
count={performer.movie_count}
|
title={
|
||||||
hideZero
|
<>
|
||||||
/>
|
{intl.formatMessage({ id: "appears_with" })}
|
||||||
</>
|
<Counter
|
||||||
}
|
abbreviateCounter={abbreviateCounter}
|
||||||
>
|
count={performer.performer_count}
|
||||||
<PerformerMoviesPanel
|
hideZero
|
||||||
active={activeTabKey == "movies"}
|
/>
|
||||||
performer={performer}
|
</>
|
||||||
/>
|
}
|
||||||
</Tab>
|
>
|
||||||
<Tab
|
<PerformerAppearsWithPanel
|
||||||
eventKey="appearswith"
|
active={tabKey === "appearswith"}
|
||||||
title={
|
performer={performer}
|
||||||
<>
|
/>
|
||||||
{intl.formatMessage({ id: "appears_with" })}
|
</Tab>
|
||||||
<Counter
|
</Tabs>
|
||||||
abbreviateCounter={abbreviateCounter}
|
|
||||||
count={performer.performer_count}
|
|
||||||
hideZero
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PerformerAppearsWithPanel
|
|
||||||
active={activeTabKey == "appearswith"}
|
|
||||||
performer={performer}
|
|
||||||
/>
|
|
||||||
</Tab>
|
|
||||||
</Tabs>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function maybeRenderHeaderBackgroundImage() {
|
function maybeRenderHeaderBackgroundImage() {
|
||||||
@@ -365,21 +378,6 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||||||
return collapsed ? faChevronDown : faChevronUp;
|
return collapsed ? faChevronDown : faChevronUp;
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const f = () => {
|
|
||||||
if (document.documentElement.scrollTop <= 50) {
|
|
||||||
setLoadStickyHeader(false);
|
|
||||||
} else {
|
|
||||||
setLoadStickyHeader(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", f);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", f);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function maybeRenderDetails() {
|
function maybeRenderDetails() {
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
return (
|
return (
|
||||||
@@ -537,17 +535,19 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const headerClassName = cx("detail-header", {
|
||||||
|
edit: isEditing,
|
||||||
|
collapsed,
|
||||||
|
"full-width": !collapsed && !compactExpandedDetails,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="performer-page" className="row">
|
<div id="performer-page" className="row">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{performer.name}</title>
|
<title>{performer.name}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<div
|
<div className={headerClassName}>
|
||||||
className={`detail-header ${isEditing ? "edit" : ""} ${
|
|
||||||
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{maybeRenderHeaderBackgroundImage()}
|
{maybeRenderHeaderBackgroundImage()}
|
||||||
<div className="detail-container">
|
<div className="detail-container">
|
||||||
<div className="detail-header-image">
|
<div className="detail-header-image">
|
||||||
@@ -592,16 +592,36 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PerformerLoader: React.FC = () => {
|
const PerformerLoader: React.FC<RouteComponentProps<IPerformerParams>> = ({
|
||||||
const { id } = useParams<{ id?: string }>();
|
location,
|
||||||
const { data, loading, error } = useFindPerformer(id ?? "");
|
match,
|
||||||
|
}) => {
|
||||||
|
const { id, tab } = match.params;
|
||||||
|
const { data, loading, error } = useFindPerformer(id);
|
||||||
|
|
||||||
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
if (loading) return <LoadingIndicator />;
|
if (loading) return <LoadingIndicator />;
|
||||||
if (error) return <ErrorMessage error={error.message} />;
|
if (error) return <ErrorMessage error={error.message} />;
|
||||||
if (!data?.findPerformer)
|
if (!data?.findPerformer)
|
||||||
return <ErrorMessage error={`No performer found with id ${id}.`} />;
|
return <ErrorMessage error={`No performer found with id ${id}.`} />;
|
||||||
|
|
||||||
return <PerformerPage performer={data.findPerformer} />;
|
if (!tab) {
|
||||||
|
return <PerformerPage performer={data.findPerformer} tabKey={defaultTab} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTabKey(tab)) {
|
||||||
|
return (
|
||||||
|
<Redirect
|
||||||
|
to={{
|
||||||
|
...location,
|
||||||
|
pathname: `/performers/${id}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PerformerPage performer={data.findPerformer} tabKey={tab} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PerformerLoader;
|
export default PerformerLoader;
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
/>
|
/>
|
||||||
<DetailItem
|
<DetailItem
|
||||||
id="StashIDs"
|
id="stash_ids"
|
||||||
value={renderStashIDs()}
|
value={renderStashIDs()}
|
||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -789,7 +789,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||||||
return (
|
return (
|
||||||
<Row>
|
<Row>
|
||||||
<Form.Label column sm={labelXS} xl={labelXL}>
|
<Form.Label column sm={labelXS} xl={labelXL}>
|
||||||
StashIDs
|
{intl.formatMessage({ id: "stash_ids" })}
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
<Col sm={fieldXS} xl={fieldXL}>
|
<Col sm={fieldXS} xl={fieldXL}>
|
||||||
<ul className="pl-0">
|
<ul className="pl-0">
|
||||||
|
|||||||
@@ -1,37 +1,31 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Route, Switch } from "react-router-dom";
|
import { Route, Switch } from "react-router-dom";
|
||||||
import { useIntl } from "react-intl";
|
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { TITLE_SUFFIX } from "src/components/Shared/constants";
|
import { useTitleProps } from "src/hooks/title";
|
||||||
import { PersistanceLevel } from "../List/ItemList";
|
import { PersistanceLevel } from "../List/ItemList";
|
||||||
import Performer from "./PerformerDetails/Performer";
|
import Performer from "./PerformerDetails/Performer";
|
||||||
import PerformerCreate from "./PerformerDetails/PerformerCreate";
|
import PerformerCreate from "./PerformerDetails/PerformerCreate";
|
||||||
import { PerformerList } from "./PerformerList";
|
import { PerformerList } from "./PerformerList";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
const Performers: React.FC = () => {
|
const Performers: React.FC = () => {
|
||||||
const intl = useIntl();
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
const title_template = `${intl.formatMessage({
|
return <PerformerList persistState={PersistanceLevel.ALL} />;
|
||||||
id: "performers",
|
};
|
||||||
})} ${TITLE_SUFFIX}`;
|
|
||||||
|
const PerformerRoutes: React.FC = () => {
|
||||||
|
const titleProps = useTitleProps({ id: "performers" });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet
|
<Helmet {...titleProps} />
|
||||||
defaultTitle={title_template}
|
|
||||||
titleTemplate={`%s | ${title_template}`}
|
|
||||||
/>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route
|
<Route exact path="/performers" component={Performers} />
|
||||||
exact
|
|
||||||
path="/performers"
|
|
||||||
render={(props) => (
|
|
||||||
<PerformerList persistState={PersistanceLevel.ALL} {...props} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route path="/performers/new" component={PerformerCreate} />
|
<Route path="/performers/new" component={PerformerCreate} />
|
||||||
<Route path="/performers/:id/:tab?" component={Performer} />
|
<Route path="/performers/:id/:tab?" component={Performer} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default Performers;
|
|
||||||
|
export default PerformerRoutes;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import React, {
|
|||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { useParams, useLocation, useHistory, Link } from "react-router-dom";
|
import { Link, RouteComponentProps } from "react-router-dom";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import {
|
import {
|
||||||
@@ -93,6 +93,10 @@ interface IProps {
|
|||||||
setContinuePlaylist: (value: boolean) => void;
|
setContinuePlaylist: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ISceneParams {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
const ScenePage: React.FC<IProps> = ({
|
const ScenePage: React.FC<IProps> = ({
|
||||||
scene,
|
scene,
|
||||||
setTimestamp,
|
setTimestamp,
|
||||||
@@ -539,12 +543,14 @@ const ScenePage: React.FC<IProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SceneLoader: React.FC = () => {
|
const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
|
||||||
const { id } = useParams<{ id?: string }>();
|
location,
|
||||||
const location = useLocation();
|
history,
|
||||||
const history = useHistory();
|
match,
|
||||||
|
}) => {
|
||||||
|
const { id } = match.params;
|
||||||
const { configuration } = useContext(ConfigurationContext);
|
const { configuration } = useContext(ConfigurationContext);
|
||||||
const { data, loading, error } = useFindScene(id ?? "");
|
const { data, loading, error } = useFindScene(id);
|
||||||
|
|
||||||
const [scene, setScene] = useState<GQL.SceneDataFragment>();
|
const [scene, setScene] = useState<GQL.SceneDataFragment>();
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<dt>StashIDs</dt>
|
<dt>
|
||||||
|
<FormattedMessage id="stash_ids" />
|
||||||
|
</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<dl>
|
<dl>
|
||||||
{props.scene.stash_ids.map((stashID) => {
|
{props.scene.stash_ids.map((stashID) => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Route, Switch } from "react-router-dom";
|
import { Route, Switch } from "react-router-dom";
|
||||||
import { useIntl } from "react-intl";
|
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { TITLE_SUFFIX } from "src/components/Shared/constants";
|
import { useTitleProps } from "src/hooks/title";
|
||||||
import { PersistanceLevel } from "../List/ItemList";
|
import { PersistanceLevel } from "../List/ItemList";
|
||||||
import { lazyComponent } from "src/utils/lazyComponent";
|
import { lazyComponent } from "src/utils/lazyComponent";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
const SceneList = lazyComponent(() => import("./SceneList"));
|
const SceneList = lazyComponent(() => import("./SceneList"));
|
||||||
const SceneMarkerList = lazyComponent(() => import("./SceneMarkerList"));
|
const SceneMarkerList = lazyComponent(() => import("./SceneMarkerList"));
|
||||||
@@ -12,46 +12,36 @@ const Scene = lazyComponent(() => import("./SceneDetails/Scene"));
|
|||||||
const SceneCreate = lazyComponent(() => import("./SceneDetails/SceneCreate"));
|
const SceneCreate = lazyComponent(() => import("./SceneDetails/SceneCreate"));
|
||||||
|
|
||||||
const Scenes: React.FC = () => {
|
const Scenes: React.FC = () => {
|
||||||
const intl = useIntl();
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
const title_template = `${intl.formatMessage({
|
return <SceneList persistState={PersistanceLevel.ALL} />;
|
||||||
id: "scenes",
|
};
|
||||||
})} ${TITLE_SUFFIX}`;
|
|
||||||
const marker_title_template = `${intl.formatMessage({
|
|
||||||
id: "markers",
|
|
||||||
})} ${TITLE_SUFFIX}`;
|
|
||||||
|
|
||||||
|
const SceneMarkers: React.FC = () => {
|
||||||
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
|
const titleProps = useTitleProps({ id: "markers" });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet
|
<Helmet {...titleProps} />
|
||||||
defaultTitle={title_template}
|
<SceneMarkerList />
|
||||||
titleTemplate={`%s | ${title_template}`}
|
</>
|
||||||
/>
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SceneRoutes: React.FC = () => {
|
||||||
|
const titleProps = useTitleProps({ id: "scenes" });
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet {...titleProps} />
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route
|
<Route exact path="/scenes" component={Scenes} />
|
||||||
exact
|
<Route exact path="/scenes/markers" component={SceneMarkers} />
|
||||||
path="/scenes"
|
|
||||||
render={(props) => (
|
|
||||||
<SceneList persistState={PersistanceLevel.ALL} {...props} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
path="/scenes/markers"
|
|
||||||
render={() => (
|
|
||||||
<>
|
|
||||||
<Helmet
|
|
||||||
defaultTitle={marker_title_template}
|
|
||||||
titleTemplate={`%s | ${marker_title_template}`}
|
|
||||||
/>
|
|
||||||
<SceneMarkerList />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route exact path="/scenes/new" component={SceneCreate} />
|
<Route exact path="/scenes/new" component={SceneCreate} />
|
||||||
<Route path="/scenes/:id" component={Scene} />
|
<Route path="/scenes/:id" component={Scene} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default Scenes;
|
|
||||||
|
export default SceneRoutes;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Tab, Nav, Row, Col } from "react-bootstrap";
|
import { Tab, Nav, Row, Col } from "react-bootstrap";
|
||||||
import { useHistory, useLocation } from "react-router-dom";
|
import { useHistory, useLocation } from "react-router-dom";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { TITLE_SUFFIX } from "src/components/Shared/constants";
|
import { useTitleProps } from "src/hooks/title";
|
||||||
import { SettingsAboutPanel } from "./SettingsAboutPanel";
|
import { SettingsAboutPanel } from "./SettingsAboutPanel";
|
||||||
import { SettingsConfigurationPanel } from "./SettingsSystemPanel";
|
import { SettingsConfigurationPanel } from "./SettingsSystemPanel";
|
||||||
import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel";
|
import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel";
|
||||||
@@ -19,26 +19,20 @@ import { SettingsSecurityPanel } from "./SettingsSecurityPanel";
|
|||||||
import Changelog from "../Changelog/Changelog";
|
import Changelog from "../Changelog/Changelog";
|
||||||
|
|
||||||
export const Settings: React.FC = () => {
|
export const Settings: React.FC = () => {
|
||||||
const intl = useIntl();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const defaultTab = new URLSearchParams(location.search).get("tab") ?? "tasks";
|
const defaultTab = new URLSearchParams(location.search).get("tab") ?? "tasks";
|
||||||
|
|
||||||
const onSelect = (val: string) => history.push(`?tab=${val}`);
|
const onSelect = (val: string) => history.push(`?tab=${val}`);
|
||||||
|
|
||||||
const title_template = `${intl.formatMessage({
|
const titleProps = useTitleProps({ id: "settings" });
|
||||||
id: "settings",
|
|
||||||
})} ${TITLE_SUFFIX}`;
|
|
||||||
return (
|
return (
|
||||||
<Tab.Container
|
<Tab.Container
|
||||||
activeKey={defaultTab}
|
activeKey={defaultTab}
|
||||||
id="configuration-tabs"
|
id="configuration-tabs"
|
||||||
onSelect={(tab) => tab && onSelect(tab)}
|
onSelect={(tab) => tab && onSelect(tab)}
|
||||||
>
|
>
|
||||||
<Helmet
|
<Helmet {...titleProps} />
|
||||||
defaultTitle={title_template}
|
|
||||||
titleTemplate={`%s | ${title_template}`}
|
|
||||||
/>
|
|
||||||
<Row>
|
<Row>
|
||||||
<Col id="settings-menu-container" sm={3} md={3} xl={2}>
|
<Col id="settings-menu-container" sm={3} md={3} xl={2}>
|
||||||
<Nav variant="pills" className="flex-column">
|
<Nav variant="pills" className="flex-column">
|
||||||
|
|||||||
38
ui/v2.5/src/components/Shared/DetailImage.tsx
Normal file
38
ui/v2.5/src/components/Shared/DetailImage.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const DEFAULT_WIDTH = "200";
|
||||||
|
|
||||||
|
// Props used by the <img> element
|
||||||
|
type IDetailImageProps = JSX.IntrinsicElements["img"];
|
||||||
|
|
||||||
|
export const DetailImage = (props: IDetailImageProps) => {
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
|
function fixWidth() {
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img) return;
|
||||||
|
|
||||||
|
// prevent SVG's w/o intrinsic size from rendering as 0x0
|
||||||
|
if (img.naturalWidth === 0) {
|
||||||
|
// If the naturalWidth is zero, it means the image either hasn't loaded yet
|
||||||
|
// or we're on Firefox and it is an SVG w/o an intrinsic size.
|
||||||
|
// So set the width to our fallback width.
|
||||||
|
img.setAttribute("width", DEFAULT_WIDTH);
|
||||||
|
} else {
|
||||||
|
// If we have a `naturalWidth`, this could either be the actual intrinsic width
|
||||||
|
// of the image, or the image is an SVG w/o an intrinsic size and we're on Chrome or Safari,
|
||||||
|
// which seem to return a size calculated in some browser-specific way.
|
||||||
|
// Worse yet, once rendered, Safari will then return the value of `img.width` as `img.naturalWidth`,
|
||||||
|
// so we need to clone the image to disconnect it from the DOM, and then get the `naturalWidth` of the clone,
|
||||||
|
// in order to always return the same `naturalWidth` for a given src.
|
||||||
|
const i = img.cloneNode() as HTMLImageElement;
|
||||||
|
img.setAttribute("width", (i.naturalWidth || DEFAULT_WIDTH).toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
fixWidth();
|
||||||
|
}, [props.src]);
|
||||||
|
|
||||||
|
return <img ref={imgRef} onLoad={() => fixWidth()} {...props} />;
|
||||||
|
};
|
||||||
@@ -34,8 +34,6 @@ export const GridCard: React.FC<ICardProps> = (props: ICardProps) => {
|
|||||||
if (props.selecting) {
|
if (props.selecting) {
|
||||||
props.onSelectedChanged(!props.selected, shiftKey);
|
props.onSelectedChanged(!props.selected, shiftKey);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const TITLE_SUFFIX = " | Stash";
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Button, Tabs, Tab } from "react-bootstrap";
|
import { Button, Tabs, Tab } from "react-bootstrap";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useParams, useHistory } from "react-router-dom";
|
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
|
import cx from "classnames";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
@@ -40,22 +41,41 @@ import {
|
|||||||
import { IUIConfig } from "src/core/config";
|
import { IUIConfig } from "src/core/config";
|
||||||
import TextUtils from "src/utils/text";
|
import TextUtils from "src/utils/text";
|
||||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||||
import ImageUtils from "src/utils/image";
|
import { DetailImage } from "src/components/Shared/DetailImage";
|
||||||
import { useRatingKeybinds } from "src/hooks/keybinds";
|
import { useRatingKeybinds } from "src/hooks/keybinds";
|
||||||
|
import { useLoadStickyHeader } from "src/hooks/detailsPanel";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
studio: GQL.StudioDataFragment;
|
studio: GQL.StudioDataFragment;
|
||||||
|
tabKey: TabKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IStudioParams {
|
interface IStudioParams {
|
||||||
|
id: string;
|
||||||
tab?: string;
|
tab?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StudioPage: React.FC<IProps> = ({ studio }) => {
|
const validTabs = [
|
||||||
|
"scenes",
|
||||||
|
"galleries",
|
||||||
|
"images",
|
||||||
|
"performers",
|
||||||
|
"movies",
|
||||||
|
"childstudios",
|
||||||
|
] as const;
|
||||||
|
type TabKey = (typeof validTabs)[number];
|
||||||
|
|
||||||
|
const defaultTab: TabKey = "scenes";
|
||||||
|
|
||||||
|
function isTabKey(tab: string): tab is TabKey {
|
||||||
|
return validTabs.includes(tab as TabKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { tab = "details" } = useParams<IStudioParams>();
|
|
||||||
|
|
||||||
// Configuration settings
|
// Configuration settings
|
||||||
const { configuration } = React.useContext(ConfigurationContext);
|
const { configuration } = React.useContext(ConfigurationContext);
|
||||||
@@ -66,7 +86,7 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
|||||||
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
|
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||||
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
|
const loadStickyHeader = useLoadStickyHeader();
|
||||||
|
|
||||||
// Editing state
|
// Editing state
|
||||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||||
@@ -113,21 +133,6 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
|||||||
setRating
|
setRating
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const f = () => {
|
|
||||||
if (document.documentElement.scrollTop <= 50) {
|
|
||||||
setLoadStickyHeader(false);
|
|
||||||
} else {
|
|
||||||
setLoadStickyHeader(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", f);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", f);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onSave(input: GQL.StudioCreateInput) {
|
async function onSave(input: GQL.StudioCreateInput) {
|
||||||
await updateStudio({
|
await updateStudio({
|
||||||
variables: {
|
variables: {
|
||||||
@@ -232,30 +237,21 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
|||||||
|
|
||||||
if (studioImage) {
|
if (studioImage) {
|
||||||
return (
|
return (
|
||||||
<img
|
<DetailImage className="logo" alt={studio.name} src={studioImage} />
|
||||||
className="logo"
|
|
||||||
alt={studio.name}
|
|
||||||
src={studioImage}
|
|
||||||
onLoad={ImageUtils.verifyImageSize}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTabKey =
|
function setTabKey(newTabKey: string | null) {
|
||||||
tab === "childstudios" ||
|
if (!newTabKey) newTabKey = defaultTab;
|
||||||
tab === "images" ||
|
if (newTabKey === tabKey) return;
|
||||||
tab === "galleries" ||
|
|
||||||
tab === "performers" ||
|
if (newTabKey === defaultTab) {
|
||||||
tab === "movies"
|
history.replace(`/studios/${studio.id}`);
|
||||||
? tab
|
} else if (isTabKey(newTabKey)) {
|
||||||
: "scenes";
|
history.replace(`/studios/${studio.id}/${newTabKey}`);
|
||||||
const setActiveTabKey = (newTab: string | null) => {
|
|
||||||
if (tab !== newTab) {
|
|
||||||
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
|
|
||||||
history.replace(`/studios/${studio.id}${tabParam}`);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const renderClickableIcons = () => (
|
const renderClickableIcons = () => (
|
||||||
<span className="name-icons">
|
<span className="name-icons">
|
||||||
@@ -321,124 +317,110 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderTabs = () => (
|
const renderTabs = () => (
|
||||||
<React.Fragment>
|
<Tabs
|
||||||
<Tabs
|
id="studio-tabs"
|
||||||
id="studio-tabs"
|
mountOnEnter
|
||||||
mountOnEnter
|
unmountOnExit
|
||||||
unmountOnExit
|
activeKey={tabKey}
|
||||||
activeKey={activeTabKey}
|
onSelect={setTabKey}
|
||||||
onSelect={setActiveTabKey}
|
>
|
||||||
|
<Tab
|
||||||
|
eventKey="scenes"
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{intl.formatMessage({ id: "scenes" })}
|
||||||
|
<Counter
|
||||||
|
abbreviateCounter={abbreviateCounter}
|
||||||
|
count={sceneCount}
|
||||||
|
hideZero
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Tab
|
<StudioScenesPanel active={tabKey === "scenes"} studio={studio} />
|
||||||
eventKey="scenes"
|
</Tab>
|
||||||
title={
|
<Tab
|
||||||
<>
|
eventKey="galleries"
|
||||||
{intl.formatMessage({ id: "scenes" })}
|
title={
|
||||||
<Counter
|
<>
|
||||||
abbreviateCounter={abbreviateCounter}
|
{intl.formatMessage({ id: "galleries" })}
|
||||||
count={sceneCount}
|
<Counter
|
||||||
hideZero
|
abbreviateCounter={abbreviateCounter}
|
||||||
/>
|
count={galleryCount}
|
||||||
</>
|
hideZero
|
||||||
}
|
/>
|
||||||
>
|
</>
|
||||||
<StudioScenesPanel
|
}
|
||||||
active={activeTabKey == "scenes"}
|
>
|
||||||
studio={studio}
|
<StudioGalleriesPanel active={tabKey === "galleries"} studio={studio} />
|
||||||
/>
|
</Tab>
|
||||||
</Tab>
|
<Tab
|
||||||
<Tab
|
eventKey="images"
|
||||||
eventKey="galleries"
|
title={
|
||||||
title={
|
<>
|
||||||
<>
|
{intl.formatMessage({ id: "images" })}
|
||||||
{intl.formatMessage({ id: "galleries" })}
|
<Counter
|
||||||
<Counter
|
abbreviateCounter={abbreviateCounter}
|
||||||
abbreviateCounter={abbreviateCounter}
|
count={imageCount}
|
||||||
count={galleryCount}
|
hideZero
|
||||||
hideZero
|
/>
|
||||||
/>
|
</>
|
||||||
</>
|
}
|
||||||
}
|
>
|
||||||
>
|
<StudioImagesPanel active={tabKey === "images"} studio={studio} />
|
||||||
<StudioGalleriesPanel
|
</Tab>
|
||||||
active={activeTabKey == "galleries"}
|
<Tab
|
||||||
studio={studio}
|
eventKey="performers"
|
||||||
/>
|
title={
|
||||||
</Tab>
|
<>
|
||||||
<Tab
|
{intl.formatMessage({ id: "performers" })}
|
||||||
eventKey="images"
|
<Counter
|
||||||
title={
|
abbreviateCounter={abbreviateCounter}
|
||||||
<>
|
count={performerCount}
|
||||||
{intl.formatMessage({ id: "images" })}
|
hideZero
|
||||||
<Counter
|
/>
|
||||||
abbreviateCounter={abbreviateCounter}
|
</>
|
||||||
count={imageCount}
|
}
|
||||||
hideZero
|
>
|
||||||
/>
|
<StudioPerformersPanel
|
||||||
</>
|
active={tabKey === "performers"}
|
||||||
}
|
studio={studio}
|
||||||
>
|
/>
|
||||||
<StudioImagesPanel
|
</Tab>
|
||||||
active={activeTabKey == "images"}
|
<Tab
|
||||||
studio={studio}
|
eventKey="movies"
|
||||||
/>
|
title={
|
||||||
</Tab>
|
<>
|
||||||
<Tab
|
{intl.formatMessage({ id: "movies" })}
|
||||||
eventKey="performers"
|
<Counter
|
||||||
title={
|
abbreviateCounter={abbreviateCounter}
|
||||||
<>
|
count={movieCount}
|
||||||
{intl.formatMessage({ id: "performers" })}
|
hideZero
|
||||||
<Counter
|
/>
|
||||||
abbreviateCounter={abbreviateCounter}
|
</>
|
||||||
count={performerCount}
|
}
|
||||||
hideZero
|
>
|
||||||
/>
|
<StudioMoviesPanel active={tabKey === "movies"} studio={studio} />
|
||||||
</>
|
</Tab>
|
||||||
}
|
<Tab
|
||||||
>
|
eventKey="childstudios"
|
||||||
<StudioPerformersPanel
|
title={
|
||||||
active={activeTabKey == "performers"}
|
<>
|
||||||
studio={studio}
|
{intl.formatMessage({ id: "subsidiary_studios" })}
|
||||||
/>
|
<Counter
|
||||||
</Tab>
|
abbreviateCounter={false}
|
||||||
<Tab
|
count={studio.child_studios.length}
|
||||||
eventKey="movies"
|
hideZero
|
||||||
title={
|
/>
|
||||||
<>
|
</>
|
||||||
{intl.formatMessage({ id: "movies" })}
|
}
|
||||||
<Counter
|
>
|
||||||
abbreviateCounter={abbreviateCounter}
|
<StudioChildrenPanel
|
||||||
count={movieCount}
|
active={tabKey === "childstudios"}
|
||||||
hideZero
|
studio={studio}
|
||||||
/>
|
/>
|
||||||
</>
|
</Tab>
|
||||||
}
|
</Tabs>
|
||||||
>
|
|
||||||
<StudioMoviesPanel
|
|
||||||
active={activeTabKey == "movies"}
|
|
||||||
studio={studio}
|
|
||||||
/>
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
eventKey="childstudios"
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
{intl.formatMessage({ id: "subsidiary_studios" })}
|
|
||||||
<Counter
|
|
||||||
abbreviateCounter={false}
|
|
||||||
count={studio.child_studios.length}
|
|
||||||
hideZero
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<StudioChildrenPanel
|
|
||||||
active={activeTabKey == "childstudios"}
|
|
||||||
studio={studio}
|
|
||||||
/>
|
|
||||||
</Tab>
|
|
||||||
</Tabs>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function maybeRenderHeaderBackgroundImage() {
|
function maybeRenderHeaderBackgroundImage() {
|
||||||
@@ -495,17 +477,19 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headerClassName = cx("detail-header", {
|
||||||
|
edit: isEditing,
|
||||||
|
collapsed,
|
||||||
|
"full-width": !collapsed && !compactExpandedDetails,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="studio-page" className="row">
|
<div id="studio-page" className="row">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{studio.name ?? intl.formatMessage({ id: "studio" })}</title>
|
<title>{studio.name ?? intl.formatMessage({ id: "studio" })}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<div
|
<div className={headerClassName}>
|
||||||
className={`detail-header ${isEditing ? "edit" : ""} ${
|
|
||||||
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{maybeRenderHeaderBackgroundImage()}
|
{maybeRenderHeaderBackgroundImage()}
|
||||||
<div className="detail-container">
|
<div className="detail-container">
|
||||||
<div className="detail-header-image">
|
<div className="detail-header-image">
|
||||||
@@ -546,16 +530,36 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StudioLoader: React.FC = () => {
|
const StudioLoader: React.FC<RouteComponentProps<IStudioParams>> = ({
|
||||||
const { id } = useParams<{ id?: string }>();
|
location,
|
||||||
const { data, loading, error } = useFindStudio(id ?? "");
|
match,
|
||||||
|
}) => {
|
||||||
|
const { id, tab } = match.params;
|
||||||
|
const { data, loading, error } = useFindStudio(id);
|
||||||
|
|
||||||
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
if (loading) return <LoadingIndicator />;
|
if (loading) return <LoadingIndicator />;
|
||||||
if (error) return <ErrorMessage error={error.message} />;
|
if (error) return <ErrorMessage error={error.message} />;
|
||||||
if (!data?.findStudio)
|
if (!data?.findStudio)
|
||||||
return <ErrorMessage error={`No studio found with id ${id}.`} />;
|
return <ErrorMessage error={`No studio found with id ${id}.`} />;
|
||||||
|
|
||||||
return <StudioPage studio={data.findStudio} />;
|
if (!tab) {
|
||||||
|
return <StudioPage studio={data.findStudio} tabKey={defaultTab} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTabKey(tab)) {
|
||||||
|
return (
|
||||||
|
<Redirect
|
||||||
|
to={{
|
||||||
|
...location,
|
||||||
|
pathname: `/studios/${id}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <StudioPage studio={data.findStudio} tabKey={tab} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default StudioLoader;
|
export default StudioLoader;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
|
|||||||
if (!collapsed) {
|
if (!collapsed) {
|
||||||
return (
|
return (
|
||||||
<DetailItem
|
<DetailItem
|
||||||
id="StashIDs"
|
id="stash_ids"
|
||||||
value={renderStashIDs()}
|
value={renderStashIDs()}
|
||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||||||
return (
|
return (
|
||||||
<Row>
|
<Row>
|
||||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||||
StashIDs
|
{intl.formatMessage({ id: "stash_ids" })}
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
<Col xs={fieldXS} xl={fieldXL}>
|
<Col xs={fieldXS} xl={fieldXL}>
|
||||||
<ul className="pl-0">
|
<ul className="pl-0">
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Route, Switch } from "react-router-dom";
|
import { Route, Switch } from "react-router-dom";
|
||||||
import { useIntl } from "react-intl";
|
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { TITLE_SUFFIX } from "src/components/Shared/constants";
|
import { useTitleProps } from "src/hooks/title";
|
||||||
import Studio from "./StudioDetails/Studio";
|
import Studio from "./StudioDetails/Studio";
|
||||||
import StudioCreate from "./StudioDetails/StudioCreate";
|
import StudioCreate from "./StudioDetails/StudioCreate";
|
||||||
import { StudioList } from "./StudioList";
|
import { StudioList } from "./StudioList";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
const Studios: React.FC = () => {
|
const Studios: React.FC = () => {
|
||||||
const intl = useIntl();
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
const title_template = `${intl.formatMessage({
|
return <StudioList />;
|
||||||
id: "studios",
|
};
|
||||||
})} ${TITLE_SUFFIX}`;
|
|
||||||
|
const StudioRoutes: React.FC = () => {
|
||||||
|
const titleProps = useTitleProps({ id: "studios" });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet
|
<Helmet {...titleProps} />
|
||||||
defaultTitle={title_template}
|
|
||||||
titleTemplate={`%s | ${title_template}`}
|
|
||||||
/>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/studios" component={StudioList} />
|
<Route exact path="/studios" component={Studios} />
|
||||||
<Route exact path="/studios/new" component={StudioCreate} />
|
<Route exact path="/studios/new" component={StudioCreate} />
|
||||||
<Route path="/studios/:id/:tab?" component={Studio} />
|
<Route path="/studios/:id/:tab?" component={Studio} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default Studios;
|
|
||||||
|
export default StudioRoutes;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Tabs, Tab, Dropdown, Button } from "react-bootstrap";
|
import { Tabs, Tab, Dropdown, Button } from "react-bootstrap";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useParams, useHistory } from "react-router-dom";
|
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
|
import cx from "classnames";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
@@ -37,17 +38,36 @@ import {
|
|||||||
faTrashAlt,
|
faTrashAlt,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { IUIConfig } from "src/core/config";
|
import { IUIConfig } from "src/core/config";
|
||||||
import ImageUtils from "src/utils/image";
|
import { DetailImage } from "src/components/Shared/DetailImage";
|
||||||
|
import { useLoadStickyHeader } from "src/hooks/detailsPanel";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
tag: GQL.TagDataFragment;
|
tag: GQL.TagDataFragment;
|
||||||
|
tabKey: TabKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ITabParams {
|
interface ITagParams {
|
||||||
|
id: string;
|
||||||
tab?: string;
|
tab?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TagPage: React.FC<IProps> = ({ tag }) => {
|
const validTabs = [
|
||||||
|
"scenes",
|
||||||
|
"images",
|
||||||
|
"galleries",
|
||||||
|
"markers",
|
||||||
|
"performers",
|
||||||
|
] as const;
|
||||||
|
type TabKey = (typeof validTabs)[number];
|
||||||
|
|
||||||
|
const defaultTab: TabKey = "scenes";
|
||||||
|
|
||||||
|
function isTabKey(tab: string): tab is TabKey {
|
||||||
|
return validTabs.includes(tab as TabKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -61,9 +81,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||||||
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
|
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||||
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
|
const loadStickyHeader = useLoadStickyHeader();
|
||||||
|
|
||||||
const { tab = "scenes" } = useParams<ITabParams>();
|
|
||||||
|
|
||||||
// Editing state
|
// Editing state
|
||||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||||
@@ -89,19 +107,16 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||||||
const performerCount =
|
const performerCount =
|
||||||
(showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0;
|
(showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0;
|
||||||
|
|
||||||
const activeTabKey =
|
function setTabKey(newTabKey: string | null) {
|
||||||
tab === "markers" ||
|
if (!newTabKey) newTabKey = defaultTab;
|
||||||
tab === "images" ||
|
if (newTabKey === tabKey) return;
|
||||||
tab === "performers" ||
|
|
||||||
tab === "galleries"
|
if (newTabKey === defaultTab) {
|
||||||
? tab
|
history.replace(`/tags/${tag.id}`);
|
||||||
: "scenes";
|
} else if (isTabKey(newTabKey)) {
|
||||||
const setActiveTabKey = (newTab: string | null) => {
|
history.replace(`/tags/${tag.id}/${newTabKey}`);
|
||||||
if (tab !== newTab) {
|
|
||||||
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
|
|
||||||
history.replace(`/tags/${tag.id}${tabParam}`);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// set up hotkeys
|
// set up hotkeys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,21 +137,6 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const f = () => {
|
|
||||||
if (document.documentElement.scrollTop <= 50) {
|
|
||||||
setLoadStickyHeader(false);
|
|
||||||
} else {
|
|
||||||
setLoadStickyHeader(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", f);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", f);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onSave(input: GQL.TagCreateInput) {
|
async function onSave(input: GQL.TagCreateInput) {
|
||||||
const oldRelations = {
|
const oldRelations = {
|
||||||
parents: tag.parents ?? [],
|
parents: tag.parents ?? [],
|
||||||
@@ -274,14 +274,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tagImage) {
|
if (tagImage) {
|
||||||
return (
|
return <DetailImage className="logo" alt={tag.name} src={tagImage} />;
|
||||||
<img
|
|
||||||
className="logo"
|
|
||||||
alt={tag.name}
|
|
||||||
src={tagImage}
|
|
||||||
onLoad={ImageUtils.verifyImageSize}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,91 +363,89 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderTabs = () => (
|
const renderTabs = () => (
|
||||||
<React.Fragment>
|
<Tabs
|
||||||
<Tabs
|
id="tag-tabs"
|
||||||
id="tag-tabs"
|
mountOnEnter
|
||||||
mountOnEnter
|
unmountOnExit
|
||||||
unmountOnExit
|
activeKey={tabKey}
|
||||||
activeKey={activeTabKey}
|
onSelect={setTabKey}
|
||||||
onSelect={setActiveTabKey}
|
>
|
||||||
|
<Tab
|
||||||
|
eventKey="scenes"
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{intl.formatMessage({ id: "scenes" })}
|
||||||
|
<Counter
|
||||||
|
abbreviateCounter={abbreviateCounter}
|
||||||
|
count={sceneCount}
|
||||||
|
hideZero
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Tab
|
<TagScenesPanel active={tabKey === "scenes"} tag={tag} />
|
||||||
eventKey="scenes"
|
</Tab>
|
||||||
title={
|
<Tab
|
||||||
<>
|
eventKey="images"
|
||||||
{intl.formatMessage({ id: "scenes" })}
|
title={
|
||||||
<Counter
|
<>
|
||||||
abbreviateCounter={abbreviateCounter}
|
{intl.formatMessage({ id: "images" })}
|
||||||
count={sceneCount}
|
<Counter
|
||||||
hideZero
|
abbreviateCounter={abbreviateCounter}
|
||||||
/>
|
count={imageCount}
|
||||||
</>
|
hideZero
|
||||||
}
|
/>
|
||||||
>
|
</>
|
||||||
<TagScenesPanel active={activeTabKey == "scenes"} tag={tag} />
|
}
|
||||||
</Tab>
|
>
|
||||||
<Tab
|
<TagImagesPanel active={tabKey === "images"} tag={tag} />
|
||||||
eventKey="images"
|
</Tab>
|
||||||
title={
|
<Tab
|
||||||
<>
|
eventKey="galleries"
|
||||||
{intl.formatMessage({ id: "images" })}
|
title={
|
||||||
<Counter
|
<>
|
||||||
abbreviateCounter={abbreviateCounter}
|
{intl.formatMessage({ id: "galleries" })}
|
||||||
count={imageCount}
|
<Counter
|
||||||
hideZero
|
abbreviateCounter={abbreviateCounter}
|
||||||
/>
|
count={galleryCount}
|
||||||
</>
|
hideZero
|
||||||
}
|
/>
|
||||||
>
|
</>
|
||||||
<TagImagesPanel active={activeTabKey == "images"} tag={tag} />
|
}
|
||||||
</Tab>
|
>
|
||||||
<Tab
|
<TagGalleriesPanel active={tabKey === "galleries"} tag={tag} />
|
||||||
eventKey="galleries"
|
</Tab>
|
||||||
title={
|
<Tab
|
||||||
<>
|
eventKey="markers"
|
||||||
{intl.formatMessage({ id: "galleries" })}
|
title={
|
||||||
<Counter
|
<>
|
||||||
abbreviateCounter={abbreviateCounter}
|
{intl.formatMessage({ id: "markers" })}
|
||||||
count={galleryCount}
|
<Counter
|
||||||
hideZero
|
abbreviateCounter={abbreviateCounter}
|
||||||
/>
|
count={sceneMarkerCount}
|
||||||
</>
|
hideZero
|
||||||
}
|
/>
|
||||||
>
|
</>
|
||||||
<TagGalleriesPanel active={activeTabKey == "galleries"} tag={tag} />
|
}
|
||||||
</Tab>
|
>
|
||||||
<Tab
|
<TagMarkersPanel active={tabKey === "markers"} tag={tag} />
|
||||||
eventKey="markers"
|
</Tab>
|
||||||
title={
|
<Tab
|
||||||
<>
|
eventKey="performers"
|
||||||
{intl.formatMessage({ id: "markers" })}
|
title={
|
||||||
<Counter
|
<>
|
||||||
abbreviateCounter={abbreviateCounter}
|
{intl.formatMessage({ id: "performers" })}
|
||||||
count={sceneMarkerCount}
|
<Counter
|
||||||
hideZero
|
abbreviateCounter={abbreviateCounter}
|
||||||
/>
|
count={performerCount}
|
||||||
</>
|
hideZero
|
||||||
}
|
/>
|
||||||
>
|
</>
|
||||||
<TagMarkersPanel active={activeTabKey == "markers"} tag={tag} />
|
}
|
||||||
</Tab>
|
>
|
||||||
<Tab
|
<TagPerformersPanel active={tabKey === "performers"} tag={tag} />
|
||||||
eventKey="performers"
|
</Tab>
|
||||||
title={
|
</Tabs>
|
||||||
<>
|
|
||||||
{intl.formatMessage({ id: "performers" })}
|
|
||||||
<Counter
|
|
||||||
abbreviateCounter={abbreviateCounter}
|
|
||||||
count={performerCount}
|
|
||||||
hideZero
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TagPerformersPanel active={activeTabKey == "performers"} tag={tag} />
|
|
||||||
</Tab>
|
|
||||||
</Tabs>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function maybeRenderHeaderBackgroundImage() {
|
function maybeRenderHeaderBackgroundImage() {
|
||||||
@@ -487,17 +478,19 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headerClassName = cx("detail-header", {
|
||||||
|
edit: isEditing,
|
||||||
|
collapsed,
|
||||||
|
"full-width": !collapsed && !compactExpandedDetails,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="tag-page" className="row">
|
<div id="tag-page" className="row">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{tag.name}</title>
|
<title>{tag.name}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<div
|
<div className={headerClassName}>
|
||||||
className={`detail-header ${isEditing ? "edit" : ""} ${
|
|
||||||
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{maybeRenderHeaderBackgroundImage()}
|
{maybeRenderHeaderBackgroundImage()}
|
||||||
<div className="detail-container">
|
<div className="detail-container">
|
||||||
<div className="detail-header-image">
|
<div className="detail-header-image">
|
||||||
@@ -534,16 +527,36 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TagLoader: React.FC = () => {
|
const TagLoader: React.FC<RouteComponentProps<ITagParams>> = ({
|
||||||
const { id } = useParams<{ id?: string }>();
|
location,
|
||||||
const { data, loading, error } = useFindTag(id ?? "");
|
match,
|
||||||
|
}) => {
|
||||||
|
const { id, tab } = match.params;
|
||||||
|
const { data, loading, error } = useFindTag(id);
|
||||||
|
|
||||||
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
if (loading) return <LoadingIndicator />;
|
if (loading) return <LoadingIndicator />;
|
||||||
if (error) return <ErrorMessage error={error.message} />;
|
if (error) return <ErrorMessage error={error.message} />;
|
||||||
if (!data?.findTag)
|
if (!data?.findTag)
|
||||||
return <ErrorMessage error={`No tag found with id ${id}.`} />;
|
return <ErrorMessage error={`No tag found with id ${id}.`} />;
|
||||||
|
|
||||||
return <TagPage tag={data.findTag} />;
|
if (!tab) {
|
||||||
|
return <TagPage tag={data.findTag} tabKey={defaultTab} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTabKey(tab)) {
|
||||||
|
return (
|
||||||
|
<Redirect
|
||||||
|
to={{
|
||||||
|
...location,
|
||||||
|
pathname: `/tags/${id}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TagPage tag={data.findTag} tabKey={tab} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TagLoader;
|
export default TagLoader;
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Route, Switch } from "react-router-dom";
|
import { Route, Switch } from "react-router-dom";
|
||||||
import { useIntl } from "react-intl";
|
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { TITLE_SUFFIX } from "src/components/Shared/constants";
|
import { useTitleProps } from "src/hooks/title";
|
||||||
import Tag from "./TagDetails/Tag";
|
import Tag from "./TagDetails/Tag";
|
||||||
import TagCreate from "./TagDetails/TagCreate";
|
import TagCreate from "./TagDetails/TagCreate";
|
||||||
import { TagList } from "./TagList";
|
import { TagList } from "./TagList";
|
||||||
|
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||||
|
|
||||||
const Tags: React.FC = () => {
|
const Tags: React.FC = () => {
|
||||||
const intl = useIntl();
|
useScrollToTopOnMount();
|
||||||
|
|
||||||
const title_template = `${intl.formatMessage({
|
return <TagList />;
|
||||||
id: "tags",
|
};
|
||||||
})} ${TITLE_SUFFIX}`;
|
|
||||||
|
const TagRoutes: React.FC = () => {
|
||||||
|
const titleProps = useTitleProps({ id: "tags" });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet
|
<Helmet {...titleProps} />
|
||||||
defaultTitle={title_template}
|
|
||||||
titleTemplate={`%s | ${title_template}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/tags" component={TagList} />
|
<Route exact path="/tags" component={Tags} />
|
||||||
<Route exact path="/tags/new" component={TagCreate} />
|
<Route exact path="/tags/new" component={TagCreate} />
|
||||||
<Route path="/tags/:id/:tab?" component={Tag} />
|
<Route path="/tags/:id/:tab?" component={Tag} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default Tags;
|
|
||||||
|
export default TagRoutes;
|
||||||
|
|||||||
@@ -68,9 +68,6 @@ const typePolicies: TypePolicies = {
|
|||||||
},
|
},
|
||||||
Scene: {
|
Scene: {
|
||||||
fields: {
|
fields: {
|
||||||
scene_markers: {
|
|
||||||
merge: false,
|
|
||||||
},
|
|
||||||
studio: {
|
studio: {
|
||||||
read: readDanglingNull,
|
read: readDanglingNull,
|
||||||
},
|
},
|
||||||
@@ -81,6 +78,9 @@ const typePolicies: TypePolicies = {
|
|||||||
studio: {
|
studio: {
|
||||||
read: readDanglingNull,
|
read: readDanglingNull,
|
||||||
},
|
},
|
||||||
|
paths: {
|
||||||
|
merge: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Movie: {
|
Movie: {
|
||||||
@@ -104,16 +104,6 @@ const typePolicies: TypePolicies = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Tag: {
|
|
||||||
fields: {
|
|
||||||
parents: {
|
|
||||||
merge: false,
|
|
||||||
},
|
|
||||||
children: {
|
|
||||||
merge: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const possibleTypes = {
|
const possibleTypes = {
|
||||||
|
|||||||
22
ui/v2.5/src/hooks/detailsPanel.ts
Normal file
22
ui/v2.5/src/hooks/detailsPanel.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
function shouldLoadStickyHeader() {
|
||||||
|
return document.documentElement.scrollTop > 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoadStickyHeader() {
|
||||||
|
const [load, setLoad] = useState(shouldLoadStickyHeader());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
setLoad(shouldLoadStickyHeader());
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", onScroll);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return load;
|
||||||
|
}
|
||||||
7
ui/v2.5/src/hooks/scrollToTop.ts
Normal file
7
ui/v2.5/src/hooks/scrollToTop.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function useScrollToTopOnMount() {
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
26
ui/v2.5/src/hooks/title.ts
Normal file
26
ui/v2.5/src/hooks/title.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { MessageDescriptor, useIntl } from "react-intl";
|
||||||
|
|
||||||
|
export const TITLE = "Stash";
|
||||||
|
export const TITLE_SEPARATOR = " | ";
|
||||||
|
|
||||||
|
export function useTitleProps(...messages: (string | MessageDescriptor)[]) {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const parts = messages.map((msg) => {
|
||||||
|
if (typeof msg === "object") {
|
||||||
|
return intl.formatMessage(msg);
|
||||||
|
} else {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return makeTitleProps(...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeTitleProps(...parts: string[]) {
|
||||||
|
const title = [...parts, TITLE].join(TITLE_SEPARATOR);
|
||||||
|
return {
|
||||||
|
titleTemplate: `%s | ${title}`,
|
||||||
|
defaultTitle: title,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -70,17 +70,6 @@ const ImageUtils = {
|
|||||||
onImageChange,
|
onImageChange,
|
||||||
usePasteImage,
|
usePasteImage,
|
||||||
imageToDataURL,
|
imageToDataURL,
|
||||||
verifyImageSize,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function verifyImageSize(e: React.UIEvent<HTMLImageElement>) {
|
|
||||||
const img = e.target as HTMLImageElement;
|
|
||||||
// set width = 200px if zero-sized image (SVG w/o intrinsic size)
|
|
||||||
if (img.width === 0 && img.height === 0) {
|
|
||||||
img.setAttribute("width", "200");
|
|
||||||
} else {
|
|
||||||
img.removeAttribute("width");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImageUtils;
|
export default ImageUtils;
|
||||||
|
|||||||
Reference in New Issue
Block a user