mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34: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
|
||||
cover {
|
||||
id
|
||||
files {
|
||||
...ImageFileData
|
||||
}
|
||||
|
||||
paths {
|
||||
thumbnail
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { MainNavbar } from "./components/MainNavbar";
|
||||
import { PageNotFound } from "./components/PageNotFound";
|
||||
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 { ConfigurationProvider } from "./hooks/Config";
|
||||
@@ -254,6 +254,8 @@ export const App: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const titleProps = makeTitleProps();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{messages ? (
|
||||
@@ -272,10 +274,7 @@ export const App: React.FC = () => {
|
||||
<LightboxProvider>
|
||||
<ManualProvider>
|
||||
<InteractiveProvider>
|
||||
<Helmet
|
||||
titleTemplate={`%s ${TITLE_SUFFIX}`}
|
||||
defaultTitle="Stash"
|
||||
/>
|
||||
<Helmet {...titleProps} />
|
||||
{maybeRenderNavbar()}
|
||||
<div
|
||||
className={`main container-fluid ${
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
generateDefaultFrontPageContent,
|
||||
IUIConfig,
|
||||
} from "src/core/config";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
|
||||
const FrontPage: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
@@ -24,6 +25,8 @@ const FrontPage: React.FC = () => {
|
||||
|
||||
const { configuration, loading } = React.useContext(ConfigurationContext);
|
||||
|
||||
useScrollToTopOnMount();
|
||||
|
||||
async function onUpdateConfig(content?: FrontPageContent[]) {
|
||||
setIsEditing(false);
|
||||
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { TITLE_SUFFIX } from "../Shared/constants";
|
||||
import { useTitleProps } from "src/hooks/title";
|
||||
import { PersistanceLevel } from "../List/ItemList";
|
||||
import Gallery from "./GalleryDetails/Gallery";
|
||||
import GalleryCreate from "./GalleryDetails/GalleryCreate";
|
||||
import { GalleryList } from "./GalleryList";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
|
||||
const Galleries: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
useScrollToTopOnMount();
|
||||
|
||||
const title_template = `${intl.formatMessage({
|
||||
id: "galleries",
|
||||
})} ${TITLE_SUFFIX}`;
|
||||
return <GalleryList persistState={PersistanceLevel.ALL} />;
|
||||
};
|
||||
|
||||
const GalleryRoutes: React.FC = () => {
|
||||
const titleProps = useTitleProps({ id: "galleries" });
|
||||
return (
|
||||
<>
|
||||
<Helmet
|
||||
defaultTitle={title_template}
|
||||
titleTemplate={`%s | ${title_template}`}
|
||||
/>
|
||||
<Helmet {...titleProps} />
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/galleries"
|
||||
render={(props) => (
|
||||
<GalleryList {...props} persistState={PersistanceLevel.ALL} />
|
||||
)}
|
||||
/>
|
||||
<Route exact path="/galleries" component={Galleries} />
|
||||
<Route exact path="/galleries/new" component={GalleryCreate} />
|
||||
<Route path="/galleries/:id/:tab?" component={Gallery} />
|
||||
</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 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 { Helmet } from "react-helmet";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
@@ -31,17 +36,19 @@ import {
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { galleryPath, galleryTitle } from "src/core/galleries";
|
||||
import { GalleryChapterPanel } from "./GalleryChaptersPanel";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
|
||||
interface IProps {
|
||||
gallery: GQL.GalleryDataFragment;
|
||||
add?: boolean;
|
||||
}
|
||||
|
||||
interface IGalleryParams {
|
||||
id: string;
|
||||
tab?: string;
|
||||
}
|
||||
|
||||
export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
||||
const { tab = "images" } = useParams<IGalleryParams>();
|
||||
export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
|
||||
const history = useHistory();
|
||||
const Toast = useToast();
|
||||
const intl = useIntl();
|
||||
@@ -50,11 +57,12 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const [activeTabKey, setActiveTabKey] = useState("gallery-details-panel");
|
||||
const activeRightTabKey = tab === "images" || tab === "add" ? tab : "images";
|
||||
const setActiveRightTabKey = (newTab: string | null) => {
|
||||
if (tab !== newTab) {
|
||||
const tabParam = newTab === "images" ? "" : `/${newTab}`;
|
||||
history.replace(`/galleries/${gallery.id}${tabParam}`);
|
||||
|
||||
const setMainTabKey = (newTabKey: string | null) => {
|
||||
if (newTabKey === "add") {
|
||||
history.replace(`/galleries/${gallery.id}/add`);
|
||||
} else {
|
||||
history.replace(`/galleries/${gallery.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -281,9 +289,9 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
||||
|
||||
return (
|
||||
<Tab.Container
|
||||
activeKey={activeRightTabKey}
|
||||
activeKey={add ? "add" : "images"}
|
||||
unmountOnExit
|
||||
onSelect={(k) => k && setActiveRightTabKey(k)}
|
||||
onSelect={setMainTabKey}
|
||||
>
|
||||
<div>
|
||||
<Nav variant="tabs" className="mr-auto">
|
||||
@@ -302,16 +310,10 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
||||
|
||||
<Tab.Content>
|
||||
<Tab.Pane eventKey="images">
|
||||
<GalleryImagesPanel
|
||||
active={activeRightTabKey == "images"}
|
||||
gallery={gallery}
|
||||
/>
|
||||
<GalleryImagesPanel active={!add} gallery={gallery} />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="add">
|
||||
<GalleryAddPanel
|
||||
active={activeRightTabKey == "add"}
|
||||
gallery={gallery}
|
||||
/>
|
||||
<GalleryAddPanel active={!!add} gallery={gallery} />
|
||||
</Tab.Pane>
|
||||
</Tab.Content>
|
||||
</Tab.Container>
|
||||
@@ -372,15 +374,35 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const GalleryLoader: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const { data, loading, error } = useFindGallery(id ?? "");
|
||||
const GalleryLoader: React.FC<RouteComponentProps<IGalleryParams>> = ({
|
||||
location,
|
||||
match,
|
||||
}) => {
|
||||
const { id, tab } = match.params;
|
||||
const { data, loading, error } = useFindGallery(id);
|
||||
|
||||
useScrollToTopOnMount();
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
if (error) return <ErrorMessage error={error.message} />;
|
||||
if (!data?.findGallery)
|
||||
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} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tab, Nav, Dropdown } from "react-bootstrap";
|
||||
import React, { useEffect, useState } from "react";
|
||||
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 {
|
||||
useFindImage,
|
||||
@@ -27,22 +27,24 @@ import { DeleteImagesDialog } from "../DeleteImagesDialog";
|
||||
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
|
||||
import { objectPath, objectTitle } from "src/core/files";
|
||||
import { isVideo } from "src/utils/visualFile";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
|
||||
interface IImageParams {
|
||||
id?: string;
|
||||
interface IProps {
|
||||
image: GQL.ImageDataFragment;
|
||||
}
|
||||
|
||||
export const Image: React.FC = () => {
|
||||
const { id = "new" } = useParams<IImageParams>();
|
||||
interface IImageParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const ImagePage: React.FC<IProps> = ({ image }) => {
|
||||
const history = useHistory();
|
||||
const Toast = useToast();
|
||||
const intl = useIntl();
|
||||
|
||||
const { data, error, loading } = useFindImage(id);
|
||||
const image = data?.findImage;
|
||||
const [incrementO] = useImageIncrementO(image?.id ?? "0");
|
||||
const [decrementO] = useImageDecrementO(image?.id ?? "0");
|
||||
const [resetO] = useImageResetO(image?.id ?? "0");
|
||||
const [incrementO] = useImageIncrementO(image.id);
|
||||
const [decrementO] = useImageDecrementO(image.id);
|
||||
const [resetO] = useImageResetO(image.id);
|
||||
|
||||
const [updateImage] = useImageUpdate();
|
||||
|
||||
@@ -90,8 +92,8 @@ export const Image: React.FC = () => {
|
||||
await updateImage({
|
||||
variables: {
|
||||
input: {
|
||||
id: image?.id ?? "",
|
||||
organized: !image?.organized,
|
||||
id: image.id,
|
||||
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 ImageView = isVideo(image.visual_files[0]) ? "video" : "img";
|
||||
|
||||
@@ -317,3 +309,21 @@ export const Image: React.FC = () => {
|
||||
</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 { Route, Switch } from "react-router-dom";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { TITLE_SUFFIX } from "../Shared/constants";
|
||||
import { useTitleProps } from "src/hooks/title";
|
||||
import { PersistanceLevel } from "../List/ItemList";
|
||||
import { Image } from "./ImageDetails/Image";
|
||||
import Image from "./ImageDetails/Image";
|
||||
import { ImageList } from "./ImageList";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
|
||||
const Images: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
useScrollToTopOnMount();
|
||||
|
||||
const title_template = `${intl.formatMessage({
|
||||
id: "images",
|
||||
})} ${TITLE_SUFFIX}`;
|
||||
return <ImageList persistState={PersistanceLevel.ALL} />;
|
||||
};
|
||||
|
||||
const ImageRoutes: React.FC = () => {
|
||||
const titleProps = useTitleProps({ id: "images" });
|
||||
return (
|
||||
<>
|
||||
<Helmet
|
||||
defaultTitle={title_template}
|
||||
titleTemplate={`%s | ${title_template}`}
|
||||
/>
|
||||
<Helmet {...titleProps} />
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/images"
|
||||
render={(props) => (
|
||||
<ImageList persistState={PersistanceLevel.ALL} {...props} />
|
||||
)}
|
||||
/>
|
||||
<Route exact path="/images" component={Images} />
|
||||
<Route path="/images/:id" component={Image} />
|
||||
</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 [lastClickedId, setLastClickedId] = useState<string>();
|
||||
|
||||
const [editingCriterion, setEditingCriterion] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [editingCriterion, setEditingCriterion] = useState<string>();
|
||||
const [showEditFilter, setShowEditFilter] = useState(false);
|
||||
|
||||
const result = useResult(filter);
|
||||
@@ -701,7 +699,15 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
|
||||
const newFilter = cloneDeep(filter);
|
||||
newFilter.currentPage = page;
|
||||
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]
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Helmet } from "react-helmet";
|
||||
import cx from "classnames";
|
||||
import Mousetrap from "mousetrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
useMovieUpdate,
|
||||
useMovieDestroy,
|
||||
} 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 { ErrorMessage } from "src/components/Shared/ErrorMessage";
|
||||
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 { ConfigurationContext } from "src/hooks/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 { useLoadStickyHeader } from "src/hooks/detailsPanel";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
|
||||
interface IProps {
|
||||
movie: GQL.MovieDataFragment;
|
||||
}
|
||||
|
||||
interface IMovieParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const MoviePage: React.FC<IProps> = ({ movie }) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
@@ -53,7 +60,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
||||
const showAllDetails = uiConfig?.showAllDetails ?? true;
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
|
||||
const loadStickyHeader = useLoadStickyHeader();
|
||||
|
||||
// Editing state
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
@@ -126,21 +133,6 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
||||
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) {
|
||||
await updateMovie({
|
||||
variables: {
|
||||
@@ -240,11 +232,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
||||
if (image && defaultImage) {
|
||||
return (
|
||||
<div className="movie-image-container">
|
||||
<img
|
||||
alt="Front Cover"
|
||||
src={image}
|
||||
onLoad={ImageUtils.verifyImageSize}
|
||||
/>
|
||||
<DetailImage alt="Front Cover" src={image} />
|
||||
</div>
|
||||
);
|
||||
} else if (image) {
|
||||
@@ -254,11 +242,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
||||
variant="link"
|
||||
onClick={() => showLightbox()}
|
||||
>
|
||||
<img
|
||||
alt="Front Cover"
|
||||
src={image}
|
||||
onLoad={ImageUtils.verifyImageSize}
|
||||
/>
|
||||
<DetailImage alt="Front Cover" src={image} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -281,11 +265,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
||||
variant="link"
|
||||
onClick={() => showLightbox(index - 1)}
|
||||
>
|
||||
<img
|
||||
alt="Back Cover"
|
||||
src={image}
|
||||
onLoad={ImageUtils.verifyImageSize}
|
||||
/>
|
||||
<DetailImage alt="Back Cover" src={image} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -405,17 +385,19 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
||||
|
||||
if (updating || deleting) return <LoadingIndicator />;
|
||||
|
||||
const headerClassName = cx("detail-header", {
|
||||
edit: isEditing,
|
||||
collapsed,
|
||||
"full-width": !collapsed && !compactExpandedDetails,
|
||||
});
|
||||
|
||||
return (
|
||||
<div id="movie-page" className="row">
|
||||
<Helmet>
|
||||
<title>{movie?.name}</title>
|
||||
</Helmet>
|
||||
|
||||
<div
|
||||
className={`detail-header ${isEditing ? "edit" : ""} ${
|
||||
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={headerClassName}>
|
||||
{maybeRenderHeaderBackgroundImage()}
|
||||
<div className="detail-container">
|
||||
<div className="detail-header-image">
|
||||
@@ -461,9 +443,13 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const MovieLoader: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const { data, loading, error } = useFindMovie(id ?? "");
|
||||
const MovieLoader: React.FC<RouteComponentProps<IMovieParams>> = ({
|
||||
match,
|
||||
}) => {
|
||||
const { id } = match.params;
|
||||
const { data, loading, error } = useFindMovie(id);
|
||||
|
||||
useScrollToTopOnMount();
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
if (error) return <ErrorMessage error={error.message} />;
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { useIntl } from "react-intl";
|
||||
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 MovieCreate from "./MovieDetails/MovieCreate";
|
||||
import { MovieList } from "./MovieList";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
|
||||
const Movies: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
useScrollToTopOnMount();
|
||||
|
||||
const title_template = `${intl.formatMessage({
|
||||
id: "movies",
|
||||
})} ${TITLE_SUFFIX}`;
|
||||
return <MovieList />;
|
||||
};
|
||||
|
||||
const MovieRoutes: React.FC = () => {
|
||||
const titleProps = useTitleProps({ id: "movies" });
|
||||
return (
|
||||
<>
|
||||
<Helmet
|
||||
defaultTitle={title_template}
|
||||
titleTemplate={`%s | ${title_template}`}
|
||||
/>
|
||||
<Helmet {...titleProps} />
|
||||
<Switch>
|
||||
<Route exact path="/movies" component={MovieList} />
|
||||
<Route exact path="/movies" component={Movies} />
|
||||
<Route exact path="/movies/new" component={MovieCreate} />
|
||||
<Route path="/movies/:id/:tab?" component={Movie} />
|
||||
</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 { Button, Tabs, Tab, Col, Row } from "react-bootstrap";
|
||||
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 cx from "classnames";
|
||||
import Mousetrap from "mousetrap";
|
||||
@@ -42,20 +42,39 @@ import {
|
||||
import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons";
|
||||
import { IUIConfig } from "src/core/config";
|
||||
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 {
|
||||
performer: GQL.PerformerDataFragment;
|
||||
tabKey: TabKey;
|
||||
}
|
||||
|
||||
interface IPerformerParams {
|
||||
id: 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 history = useHistory();
|
||||
const intl = useIntl();
|
||||
const { tab = "details" } = useParams<IPerformerParams>();
|
||||
|
||||
// Configuration settings
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
@@ -70,7 +89,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [image, setImage] = useState<string | null>();
|
||||
const [encodingImage, setEncodingImage] = useState<boolean>(false);
|
||||
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
|
||||
const loadStickyHeader = useLoadStickyHeader();
|
||||
|
||||
const activeImage = useMemo(() => {
|
||||
const performerImage = performer.image_path;
|
||||
@@ -98,20 +117,16 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
||||
const [updatePerformer] = usePerformerUpdate();
|
||||
const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy();
|
||||
|
||||
const activeTabKey =
|
||||
tab === "scenes" ||
|
||||
tab === "galleries" ||
|
||||
tab === "images" ||
|
||||
tab === "movies" ||
|
||||
tab == "appearswith"
|
||||
? tab
|
||||
: "scenes";
|
||||
const setActiveTabKey = (newTab: string | null) => {
|
||||
if (tab !== newTab) {
|
||||
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
|
||||
history.replace(`/performers/${performer.id}${tabParam}`);
|
||||
function setTabKey(newTabKey: string | null) {
|
||||
if (!newTabKey) newTabKey = defaultTab;
|
||||
if (newTabKey === tabKey) return;
|
||||
|
||||
if (newTabKey === defaultTab) {
|
||||
history.replace(`/performers/${performer.id}`);
|
||||
} else if (isTabKey(newTabKey)) {
|
||||
history.replace(`/performers/${performer.id}/${newTabKey}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function onAutoTag() {
|
||||
try {
|
||||
@@ -133,18 +148,18 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
||||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("e", () => toggleEditing());
|
||||
Mousetrap.bind("c", () => setActiveTabKey("scenes"));
|
||||
Mousetrap.bind("g", () => setActiveTabKey("galleries"));
|
||||
Mousetrap.bind("m", () => setActiveTabKey("movies"));
|
||||
Mousetrap.bind("c", () => setTabKey("scenes"));
|
||||
Mousetrap.bind("g", () => setTabKey("galleries"));
|
||||
Mousetrap.bind("m", () => setTabKey("movies"));
|
||||
Mousetrap.bind("f", () => setFavorite(!performer.favorite));
|
||||
Mousetrap.bind(",", () => setCollapsed(!collapsed));
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("a");
|
||||
Mousetrap.unbind("e");
|
||||
Mousetrap.unbind("c");
|
||||
Mousetrap.unbind("g");
|
||||
Mousetrap.unbind("m");
|
||||
Mousetrap.unbind("f");
|
||||
Mousetrap.unbind("o");
|
||||
Mousetrap.unbind(",");
|
||||
};
|
||||
});
|
||||
@@ -191,116 +206,114 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
||||
if (activeImage) {
|
||||
return (
|
||||
<Button variant="link" onClick={() => showLightbox()}>
|
||||
<img
|
||||
<DetailImage
|
||||
className="performer"
|
||||
src={activeImage}
|
||||
alt={performer.name}
|
||||
onLoad={ImageUtils.verifyImageSize}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
const renderTabs = () => (
|
||||
<React.Fragment>
|
||||
<Tabs
|
||||
activeKey={activeTabKey}
|
||||
onSelect={setActiveTabKey}
|
||||
id="performer-details"
|
||||
unmountOnExit
|
||||
<Tabs
|
||||
id="performer-tabs"
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
activeKey={tabKey}
|
||||
onSelect={setTabKey}
|
||||
>
|
||||
<Tab
|
||||
eventKey="scenes"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "scenes" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performer.scene_count}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Tab
|
||||
eventKey="scenes"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "scenes" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performer.scene_count}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PerformerScenesPanel
|
||||
active={activeTabKey == "scenes"}
|
||||
performer={performer}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="galleries"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "galleries" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performer.gallery_count}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PerformerGalleriesPanel
|
||||
active={activeTabKey == "galleries"}
|
||||
performer={performer}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="images"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "images" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performer.image_count}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PerformerImagesPanel
|
||||
active={activeTabKey == "images"}
|
||||
performer={performer}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="movies"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "movies" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performer.movie_count}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PerformerMoviesPanel
|
||||
active={activeTabKey == "movies"}
|
||||
performer={performer}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="appearswith"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "appears_with" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performer.performer_count}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PerformerAppearsWithPanel
|
||||
active={activeTabKey == "appearswith"}
|
||||
performer={performer}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</React.Fragment>
|
||||
<PerformerScenesPanel
|
||||
active={tabKey === "scenes"}
|
||||
performer={performer}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="galleries"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "galleries" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performer.gallery_count}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PerformerGalleriesPanel
|
||||
active={tabKey === "galleries"}
|
||||
performer={performer}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="images"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "images" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performer.image_count}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PerformerImagesPanel
|
||||
active={tabKey === "images"}
|
||||
performer={performer}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="movies"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "movies" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performer.movie_count}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PerformerMoviesPanel
|
||||
active={tabKey === "movies"}
|
||||
performer={performer}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="appearswith"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "appears_with" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performer.performer_count}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PerformerAppearsWithPanel
|
||||
active={tabKey === "appearswith"}
|
||||
performer={performer}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
function maybeRenderHeaderBackgroundImage() {
|
||||
@@ -365,21 +378,6 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
||||
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() {
|
||||
if (!isEditing) {
|
||||
return (
|
||||
@@ -537,17 +535,19 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
||||
/>
|
||||
);
|
||||
|
||||
const headerClassName = cx("detail-header", {
|
||||
edit: isEditing,
|
||||
collapsed,
|
||||
"full-width": !collapsed && !compactExpandedDetails,
|
||||
});
|
||||
|
||||
return (
|
||||
<div id="performer-page" className="row">
|
||||
<Helmet>
|
||||
<title>{performer.name}</title>
|
||||
</Helmet>
|
||||
|
||||
<div
|
||||
className={`detail-header ${isEditing ? "edit" : ""} ${
|
||||
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={headerClassName}>
|
||||
{maybeRenderHeaderBackgroundImage()}
|
||||
<div className="detail-container">
|
||||
<div className="detail-header-image">
|
||||
@@ -592,16 +592,36 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const PerformerLoader: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const { data, loading, error } = useFindPerformer(id ?? "");
|
||||
const PerformerLoader: React.FC<RouteComponentProps<IPerformerParams>> = ({
|
||||
location,
|
||||
match,
|
||||
}) => {
|
||||
const { id, tab } = match.params;
|
||||
const { data, loading, error } = useFindPerformer(id);
|
||||
|
||||
useScrollToTopOnMount();
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
if (error) return <ErrorMessage error={error.message} />;
|
||||
if (!data?.findPerformer)
|
||||
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;
|
||||
|
||||
@@ -213,7 +213,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="StashIDs"
|
||||
id="stash_ids"
|
||||
value={renderStashIDs()}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
|
||||
@@ -789,7 +789,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
||||
return (
|
||||
<Row>
|
||||
<Form.Label column sm={labelXS} xl={labelXL}>
|
||||
StashIDs
|
||||
{intl.formatMessage({ id: "stash_ids" })}
|
||||
</Form.Label>
|
||||
<Col sm={fieldXS} xl={fieldXL}>
|
||||
<ul className="pl-0">
|
||||
|
||||
@@ -1,37 +1,31 @@
|
||||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { useIntl } from "react-intl";
|
||||
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 Performer from "./PerformerDetails/Performer";
|
||||
import PerformerCreate from "./PerformerDetails/PerformerCreate";
|
||||
import { PerformerList } from "./PerformerList";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
|
||||
const Performers: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
useScrollToTopOnMount();
|
||||
|
||||
const title_template = `${intl.formatMessage({
|
||||
id: "performers",
|
||||
})} ${TITLE_SUFFIX}`;
|
||||
return <PerformerList persistState={PersistanceLevel.ALL} />;
|
||||
};
|
||||
|
||||
const PerformerRoutes: React.FC = () => {
|
||||
const titleProps = useTitleProps({ id: "performers" });
|
||||
return (
|
||||
<>
|
||||
<Helmet
|
||||
defaultTitle={title_template}
|
||||
titleTemplate={`%s | ${title_template}`}
|
||||
/>
|
||||
<Helmet {...titleProps} />
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/performers"
|
||||
render={(props) => (
|
||||
<PerformerList persistState={PersistanceLevel.ALL} {...props} />
|
||||
)}
|
||||
/>
|
||||
<Route exact path="/performers" component={Performers} />
|
||||
<Route path="/performers/new" component={PerformerCreate} />
|
||||
<Route path="/performers/:id/:tab?" component={Performer} />
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default Performers;
|
||||
|
||||
export default PerformerRoutes;
|
||||
|
||||
@@ -8,7 +8,7 @@ import React, {
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
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 * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
@@ -93,6 +93,10 @@ interface IProps {
|
||||
setContinuePlaylist: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface ISceneParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const ScenePage: React.FC<IProps> = ({
|
||||
scene,
|
||||
setTimestamp,
|
||||
@@ -539,12 +543,14 @@ const ScenePage: React.FC<IProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const SceneLoader: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
|
||||
location,
|
||||
history,
|
||||
match,
|
||||
}) => {
|
||||
const { id } = match.params;
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
const { data, loading, error } = useFindScene(id ?? "");
|
||||
const { data, loading, error } = useFindScene(id);
|
||||
|
||||
const [scene, setScene] = useState<GQL.SceneDataFragment>();
|
||||
|
||||
|
||||
@@ -191,7 +191,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
||||
|
||||
return (
|
||||
<>
|
||||
<dt>StashIDs</dt>
|
||||
<dt>
|
||||
<FormattedMessage id="stash_ids" />
|
||||
</dt>
|
||||
<dd>
|
||||
<dl>
|
||||
{props.scene.stash_ids.map((stashID) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { useIntl } from "react-intl";
|
||||
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 { lazyComponent } from "src/utils/lazyComponent";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
|
||||
const SceneList = lazyComponent(() => import("./SceneList"));
|
||||
const SceneMarkerList = lazyComponent(() => import("./SceneMarkerList"));
|
||||
@@ -12,46 +12,36 @@ const Scene = lazyComponent(() => import("./SceneDetails/Scene"));
|
||||
const SceneCreate = lazyComponent(() => import("./SceneDetails/SceneCreate"));
|
||||
|
||||
const Scenes: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
useScrollToTopOnMount();
|
||||
|
||||
const title_template = `${intl.formatMessage({
|
||||
id: "scenes",
|
||||
})} ${TITLE_SUFFIX}`;
|
||||
const marker_title_template = `${intl.formatMessage({
|
||||
id: "markers",
|
||||
})} ${TITLE_SUFFIX}`;
|
||||
return <SceneList persistState={PersistanceLevel.ALL} />;
|
||||
};
|
||||
|
||||
const SceneMarkers: React.FC = () => {
|
||||
useScrollToTopOnMount();
|
||||
|
||||
const titleProps = useTitleProps({ id: "markers" });
|
||||
return (
|
||||
<>
|
||||
<Helmet
|
||||
defaultTitle={title_template}
|
||||
titleTemplate={`%s | ${title_template}`}
|
||||
/>
|
||||
<Helmet {...titleProps} />
|
||||
<SceneMarkerList />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SceneRoutes: React.FC = () => {
|
||||
const titleProps = useTitleProps({ id: "scenes" });
|
||||
return (
|
||||
<>
|
||||
<Helmet {...titleProps} />
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
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" component={Scenes} />
|
||||
<Route exact path="/scenes/markers" component={SceneMarkers} />
|
||||
<Route exact path="/scenes/new" component={SceneCreate} />
|
||||
<Route path="/scenes/:id" component={Scene} />
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default Scenes;
|
||||
|
||||
export default SceneRoutes;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
import { Tab, Nav, Row, Col } from "react-bootstrap";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { TITLE_SUFFIX } from "src/components/Shared/constants";
|
||||
import { useTitleProps } from "src/hooks/title";
|
||||
import { SettingsAboutPanel } from "./SettingsAboutPanel";
|
||||
import { SettingsConfigurationPanel } from "./SettingsSystemPanel";
|
||||
import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel";
|
||||
@@ -19,26 +19,20 @@ import { SettingsSecurityPanel } from "./SettingsSecurityPanel";
|
||||
import Changelog from "../Changelog/Changelog";
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const defaultTab = new URLSearchParams(location.search).get("tab") ?? "tasks";
|
||||
|
||||
const onSelect = (val: string) => history.push(`?tab=${val}`);
|
||||
|
||||
const title_template = `${intl.formatMessage({
|
||||
id: "settings",
|
||||
})} ${TITLE_SUFFIX}`;
|
||||
const titleProps = useTitleProps({ id: "settings" });
|
||||
return (
|
||||
<Tab.Container
|
||||
activeKey={defaultTab}
|
||||
id="configuration-tabs"
|
||||
onSelect={(tab) => tab && onSelect(tab)}
|
||||
>
|
||||
<Helmet
|
||||
defaultTitle={title_template}
|
||||
titleTemplate={`%s | ${title_template}`}
|
||||
/>
|
||||
<Helmet {...titleProps} />
|
||||
<Row>
|
||||
<Col id="settings-menu-container" sm={3} md={3} xl={2}>
|
||||
<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) {
|
||||
props.onSelectedChanged(!props.selected, shiftKey);
|
||||
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 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 { Helmet } from "react-helmet";
|
||||
import cx from "classnames";
|
||||
import Mousetrap from "mousetrap";
|
||||
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
@@ -40,22 +41,41 @@ import {
|
||||
import { IUIConfig } from "src/core/config";
|
||||
import TextUtils from "src/utils/text";
|
||||
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 { useLoadStickyHeader } from "src/hooks/detailsPanel";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
|
||||
interface IProps {
|
||||
studio: GQL.StudioDataFragment;
|
||||
tabKey: TabKey;
|
||||
}
|
||||
|
||||
interface IStudioParams {
|
||||
id: 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 Toast = useToast();
|
||||
const intl = useIntl();
|
||||
const { tab = "details" } = useParams<IStudioParams>();
|
||||
|
||||
// Configuration settings
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
@@ -66,7 +86,7 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
||||
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
|
||||
const loadStickyHeader = useLoadStickyHeader();
|
||||
|
||||
// Editing state
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
@@ -113,21 +133,6 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
||||
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) {
|
||||
await updateStudio({
|
||||
variables: {
|
||||
@@ -232,30 +237,21 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
||||
|
||||
if (studioImage) {
|
||||
return (
|
||||
<img
|
||||
className="logo"
|
||||
alt={studio.name}
|
||||
src={studioImage}
|
||||
onLoad={ImageUtils.verifyImageSize}
|
||||
/>
|
||||
<DetailImage className="logo" alt={studio.name} src={studioImage} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const activeTabKey =
|
||||
tab === "childstudios" ||
|
||||
tab === "images" ||
|
||||
tab === "galleries" ||
|
||||
tab === "performers" ||
|
||||
tab === "movies"
|
||||
? tab
|
||||
: "scenes";
|
||||
const setActiveTabKey = (newTab: string | null) => {
|
||||
if (tab !== newTab) {
|
||||
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
|
||||
history.replace(`/studios/${studio.id}${tabParam}`);
|
||||
function setTabKey(newTabKey: string | null) {
|
||||
if (!newTabKey) newTabKey = defaultTab;
|
||||
if (newTabKey === tabKey) return;
|
||||
|
||||
if (newTabKey === defaultTab) {
|
||||
history.replace(`/studios/${studio.id}`);
|
||||
} else if (isTabKey(newTabKey)) {
|
||||
history.replace(`/studios/${studio.id}/${newTabKey}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const renderClickableIcons = () => (
|
||||
<span className="name-icons">
|
||||
@@ -321,124 +317,110 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
||||
}
|
||||
|
||||
const renderTabs = () => (
|
||||
<React.Fragment>
|
||||
<Tabs
|
||||
id="studio-tabs"
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
activeKey={activeTabKey}
|
||||
onSelect={setActiveTabKey}
|
||||
<Tabs
|
||||
id="studio-tabs"
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
activeKey={tabKey}
|
||||
onSelect={setTabKey}
|
||||
>
|
||||
<Tab
|
||||
eventKey="scenes"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "scenes" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={sceneCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Tab
|
||||
eventKey="scenes"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "scenes" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={sceneCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioScenesPanel
|
||||
active={activeTabKey == "scenes"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="galleries"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "galleries" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={galleryCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioGalleriesPanel
|
||||
active={activeTabKey == "galleries"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="images"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "images" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={imageCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioImagesPanel
|
||||
active={activeTabKey == "images"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="performers"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "performers" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performerCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioPerformersPanel
|
||||
active={activeTabKey == "performers"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="movies"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "movies" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={movieCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<StudioScenesPanel active={tabKey === "scenes"} studio={studio} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="galleries"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "galleries" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={galleryCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioGalleriesPanel active={tabKey === "galleries"} studio={studio} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="images"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "images" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={imageCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioImagesPanel active={tabKey === "images"} studio={studio} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="performers"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "performers" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performerCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioPerformersPanel
|
||||
active={tabKey === "performers"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="movies"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "movies" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={movieCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioMoviesPanel active={tabKey === "movies"} studio={studio} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="childstudios"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "subsidiary_studios" })}
|
||||
<Counter
|
||||
abbreviateCounter={false}
|
||||
count={studio.child_studios.length}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioChildrenPanel
|
||||
active={tabKey === "childstudios"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div id="studio-page" className="row">
|
||||
<Helmet>
|
||||
<title>{studio.name ?? intl.formatMessage({ id: "studio" })}</title>
|
||||
</Helmet>
|
||||
|
||||
<div
|
||||
className={`detail-header ${isEditing ? "edit" : ""} ${
|
||||
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={headerClassName}>
|
||||
{maybeRenderHeaderBackgroundImage()}
|
||||
<div className="detail-container">
|
||||
<div className="detail-header-image">
|
||||
@@ -546,16 +530,36 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const StudioLoader: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const { data, loading, error } = useFindStudio(id ?? "");
|
||||
const StudioLoader: React.FC<RouteComponentProps<IStudioParams>> = ({
|
||||
location,
|
||||
match,
|
||||
}) => {
|
||||
const { id, tab } = match.params;
|
||||
const { data, loading, error } = useFindStudio(id);
|
||||
|
||||
useScrollToTopOnMount();
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
if (error) return <ErrorMessage error={error.message} />;
|
||||
if (!data?.findStudio)
|
||||
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;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
|
||||
if (!collapsed) {
|
||||
return (
|
||||
<DetailItem
|
||||
id="StashIDs"
|
||||
id="stash_ids"
|
||||
value={renderStashIDs()}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
|
||||
@@ -160,7 +160,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
||||
return (
|
||||
<Row>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
StashIDs
|
||||
{intl.formatMessage({ id: "stash_ids" })}
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<ul className="pl-0">
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { useIntl } from "react-intl";
|
||||
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 StudioCreate from "./StudioDetails/StudioCreate";
|
||||
import { StudioList } from "./StudioList";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
|
||||
const Studios: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
useScrollToTopOnMount();
|
||||
|
||||
const title_template = `${intl.formatMessage({
|
||||
id: "studios",
|
||||
})} ${TITLE_SUFFIX}`;
|
||||
return <StudioList />;
|
||||
};
|
||||
|
||||
const StudioRoutes: React.FC = () => {
|
||||
const titleProps = useTitleProps({ id: "studios" });
|
||||
return (
|
||||
<>
|
||||
<Helmet
|
||||
defaultTitle={title_template}
|
||||
titleTemplate={`%s | ${title_template}`}
|
||||
/>
|
||||
<Helmet {...titleProps} />
|
||||
<Switch>
|
||||
<Route exact path="/studios" component={StudioList} />
|
||||
<Route exact path="/studios" component={Studios} />
|
||||
<Route exact path="/studios/new" component={StudioCreate} />
|
||||
<Route path="/studios/:id/:tab?" component={Studio} />
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default Studios;
|
||||
|
||||
export default StudioRoutes;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Tabs, Tab, Dropdown, Button } from "react-bootstrap";
|
||||
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 { Helmet } from "react-helmet";
|
||||
import cx from "classnames";
|
||||
import Mousetrap from "mousetrap";
|
||||
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
@@ -37,17 +38,36 @@ import {
|
||||
faTrashAlt,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
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 {
|
||||
tag: GQL.TagDataFragment;
|
||||
tabKey: TabKey;
|
||||
}
|
||||
|
||||
interface ITabParams {
|
||||
interface ITagParams {
|
||||
id: 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 Toast = useToast();
|
||||
const intl = useIntl();
|
||||
@@ -61,9 +81,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
||||
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
|
||||
|
||||
const { tab = "scenes" } = useParams<ITabParams>();
|
||||
const loadStickyHeader = useLoadStickyHeader();
|
||||
|
||||
// Editing state
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
@@ -89,19 +107,16 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
||||
const performerCount =
|
||||
(showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0;
|
||||
|
||||
const activeTabKey =
|
||||
tab === "markers" ||
|
||||
tab === "images" ||
|
||||
tab === "performers" ||
|
||||
tab === "galleries"
|
||||
? tab
|
||||
: "scenes";
|
||||
const setActiveTabKey = (newTab: string | null) => {
|
||||
if (tab !== newTab) {
|
||||
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
|
||||
history.replace(`/tags/${tag.id}${tabParam}`);
|
||||
function setTabKey(newTabKey: string | null) {
|
||||
if (!newTabKey) newTabKey = defaultTab;
|
||||
if (newTabKey === tabKey) return;
|
||||
|
||||
if (newTabKey === defaultTab) {
|
||||
history.replace(`/tags/${tag.id}`);
|
||||
} else if (isTabKey(newTabKey)) {
|
||||
history.replace(`/tags/${tag.id}/${newTabKey}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// set up hotkeys
|
||||
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) {
|
||||
const oldRelations = {
|
||||
parents: tag.parents ?? [],
|
||||
@@ -274,14 +274,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
||||
}
|
||||
|
||||
if (tagImage) {
|
||||
return (
|
||||
<img
|
||||
className="logo"
|
||||
alt={tag.name}
|
||||
src={tagImage}
|
||||
onLoad={ImageUtils.verifyImageSize}
|
||||
/>
|
||||
);
|
||||
return <DetailImage className="logo" alt={tag.name} src={tagImage} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,91 +363,89 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
||||
}
|
||||
|
||||
const renderTabs = () => (
|
||||
<React.Fragment>
|
||||
<Tabs
|
||||
id="tag-tabs"
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
activeKey={activeTabKey}
|
||||
onSelect={setActiveTabKey}
|
||||
<Tabs
|
||||
id="tag-tabs"
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
activeKey={tabKey}
|
||||
onSelect={setTabKey}
|
||||
>
|
||||
<Tab
|
||||
eventKey="scenes"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "scenes" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={sceneCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Tab
|
||||
eventKey="scenes"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "scenes" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={sceneCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagScenesPanel active={activeTabKey == "scenes"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="images"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "images" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={imageCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagImagesPanel active={activeTabKey == "images"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="galleries"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "galleries" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={galleryCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagGalleriesPanel active={activeTabKey == "galleries"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="markers"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "markers" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={sceneMarkerCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagMarkersPanel active={activeTabKey == "markers"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="performers"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "performers" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performerCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagPerformersPanel active={activeTabKey == "performers"} tag={tag} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</React.Fragment>
|
||||
<TagScenesPanel active={tabKey === "scenes"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="images"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "images" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={imageCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagImagesPanel active={tabKey === "images"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="galleries"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "galleries" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={galleryCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagGalleriesPanel active={tabKey === "galleries"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="markers"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "markers" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={sceneMarkerCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagMarkersPanel active={tabKey === "markers"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="performers"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "performers" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performerCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagPerformersPanel active={tabKey === "performers"} tag={tag} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div id="tag-page" className="row">
|
||||
<Helmet>
|
||||
<title>{tag.name}</title>
|
||||
</Helmet>
|
||||
|
||||
<div
|
||||
className={`detail-header ${isEditing ? "edit" : ""} ${
|
||||
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={headerClassName}>
|
||||
{maybeRenderHeaderBackgroundImage()}
|
||||
<div className="detail-container">
|
||||
<div className="detail-header-image">
|
||||
@@ -534,16 +527,36 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const TagLoader: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const { data, loading, error } = useFindTag(id ?? "");
|
||||
const TagLoader: React.FC<RouteComponentProps<ITagParams>> = ({
|
||||
location,
|
||||
match,
|
||||
}) => {
|
||||
const { id, tab } = match.params;
|
||||
const { data, loading, error } = useFindTag(id);
|
||||
|
||||
useScrollToTopOnMount();
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
if (error) return <ErrorMessage error={error.message} />;
|
||||
if (!data?.findTag)
|
||||
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;
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { useIntl } from "react-intl";
|
||||
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 TagCreate from "./TagDetails/TagCreate";
|
||||
import { TagList } from "./TagList";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
|
||||
const Tags: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
useScrollToTopOnMount();
|
||||
|
||||
const title_template = `${intl.formatMessage({
|
||||
id: "tags",
|
||||
})} ${TITLE_SUFFIX}`;
|
||||
return <TagList />;
|
||||
};
|
||||
|
||||
const TagRoutes: React.FC = () => {
|
||||
const titleProps = useTitleProps({ id: "tags" });
|
||||
return (
|
||||
<>
|
||||
<Helmet
|
||||
defaultTitle={title_template}
|
||||
titleTemplate={`%s | ${title_template}`}
|
||||
/>
|
||||
|
||||
<Helmet {...titleProps} />
|
||||
<Switch>
|
||||
<Route exact path="/tags" component={TagList} />
|
||||
<Route exact path="/tags" component={Tags} />
|
||||
<Route exact path="/tags/new" component={TagCreate} />
|
||||
<Route path="/tags/:id/:tab?" component={Tag} />
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default Tags;
|
||||
|
||||
export default TagRoutes;
|
||||
|
||||
@@ -68,9 +68,6 @@ const typePolicies: TypePolicies = {
|
||||
},
|
||||
Scene: {
|
||||
fields: {
|
||||
scene_markers: {
|
||||
merge: false,
|
||||
},
|
||||
studio: {
|
||||
read: readDanglingNull,
|
||||
},
|
||||
@@ -81,6 +78,9 @@ const typePolicies: TypePolicies = {
|
||||
studio: {
|
||||
read: readDanglingNull,
|
||||
},
|
||||
paths: {
|
||||
merge: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
Movie: {
|
||||
@@ -104,16 +104,6 @@ const typePolicies: TypePolicies = {
|
||||
},
|
||||
},
|
||||
},
|
||||
Tag: {
|
||||
fields: {
|
||||
parents: {
|
||||
merge: false,
|
||||
},
|
||||
children: {
|
||||
merge: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
usePasteImage,
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user