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:
DingDongSoLong4
2023-08-08 01:26:22 +02:00
committed by GitHub
parent 3ea233dc06
commit 5dbf1797e9
32 changed files with 817 additions and 715 deletions

View File

@@ -14,10 +14,10 @@ fragment SlimGalleryData on Gallery {
}
image_count
cover {
id
files {
...ImageFileData
}
paths {
thumbnail
}

View File

@@ -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 ${

View File

@@ -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);

View File

@@ -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;

View File

@@ -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} />;
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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]
);

View File

@@ -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} />;

View File

@@ -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;

View File

@@ -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;

View File

@@ -213,7 +213,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
fullWidth={fullWidth}
/>
<DetailItem
id="StashIDs"
id="stash_ids"
value={renderStashIDs()}
fullWidth={fullWidth}
/>

View File

@@ -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">

View File

@@ -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;

View File

@@ -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>();

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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">

View 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} />;
};

View File

@@ -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);
}
}

View File

@@ -1 +0,0 @@
export const TITLE_SUFFIX = " | Stash";

View File

@@ -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;

View File

@@ -47,7 +47,7 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
if (!collapsed) {
return (
<DetailItem
id="StashIDs"
id="stash_ids"
value={renderStashIDs()}
fullWidth={fullWidth}
/>

View File

@@ -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">

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 = {

View 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;
}

View File

@@ -0,0 +1,7 @@
import { useEffect } from "react";
export function useScrollToTopOnMount() {
useEffect(() => {
window.scrollTo(0, 0);
}, []);
}

View 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,
};
}

View File

@@ -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;