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 image_count
cover { cover {
id
files { files {
...ImageFileData ...ImageFileData
} }
paths { paths {
thumbnail thumbnail
} }

View File

@@ -28,7 +28,7 @@ import { ErrorBoundary } from "./components/ErrorBoundary";
import { MainNavbar } from "./components/MainNavbar"; import { MainNavbar } from "./components/MainNavbar";
import { PageNotFound } from "./components/PageNotFound"; import { PageNotFound } from "./components/PageNotFound";
import * as GQL from "./core/generated-graphql"; import * as GQL from "./core/generated-graphql";
import { TITLE_SUFFIX } from "./components/Shared/constants"; import { makeTitleProps } from "./hooks/title";
import { LoadingIndicator } from "./components/Shared/LoadingIndicator"; import { LoadingIndicator } from "./components/Shared/LoadingIndicator";
import { ConfigurationProvider } from "./hooks/Config"; import { ConfigurationProvider } from "./hooks/Config";
@@ -254,6 +254,8 @@ export const App: React.FC = () => {
); );
} }
const titleProps = makeTitleProps();
return ( return (
<ErrorBoundary> <ErrorBoundary>
{messages ? ( {messages ? (
@@ -272,10 +274,7 @@ export const App: React.FC = () => {
<LightboxProvider> <LightboxProvider>
<ManualProvider> <ManualProvider>
<InteractiveProvider> <InteractiveProvider>
<Helmet <Helmet {...titleProps} />
titleTemplate={`%s ${TITLE_SUFFIX}`}
defaultTitle="Stash"
/>
{maybeRenderNavbar()} {maybeRenderNavbar()}
<div <div
className={`main container-fluid ${ className={`main container-fluid ${

View File

@@ -12,6 +12,7 @@ import {
generateDefaultFrontPageContent, generateDefaultFrontPageContent,
IUIConfig, IUIConfig,
} from "src/core/config"; } from "src/core/config";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
const FrontPage: React.FC = () => { const FrontPage: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
@@ -24,6 +25,8 @@ const FrontPage: React.FC = () => {
const { configuration, loading } = React.useContext(ConfigurationContext); const { configuration, loading } = React.useContext(ConfigurationContext);
useScrollToTopOnMount();
async function onUpdateConfig(content?: FrontPageContent[]) { async function onUpdateConfig(content?: FrontPageContent[]) {
setIsEditing(false); setIsEditing(false);

View File

@@ -1,33 +1,26 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { useIntl } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "../Shared/constants"; import { useTitleProps } from "src/hooks/title";
import { PersistanceLevel } from "../List/ItemList"; import { PersistanceLevel } from "../List/ItemList";
import Gallery from "./GalleryDetails/Gallery"; import Gallery from "./GalleryDetails/Gallery";
import GalleryCreate from "./GalleryDetails/GalleryCreate"; import GalleryCreate from "./GalleryDetails/GalleryCreate";
import { GalleryList } from "./GalleryList"; import { GalleryList } from "./GalleryList";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
const Galleries: React.FC = () => { const Galleries: React.FC = () => {
const intl = useIntl(); useScrollToTopOnMount();
const title_template = `${intl.formatMessage({ return <GalleryList persistState={PersistanceLevel.ALL} />;
id: "galleries", };
})} ${TITLE_SUFFIX}`;
const GalleryRoutes: React.FC = () => {
const titleProps = useTitleProps({ id: "galleries" });
return ( return (
<> <>
<Helmet <Helmet {...titleProps} />
defaultTitle={title_template}
titleTemplate={`%s | ${title_template}`}
/>
<Switch> <Switch>
<Route <Route exact path="/galleries" component={Galleries} />
exact
path="/galleries"
render={(props) => (
<GalleryList {...props} persistState={PersistanceLevel.ALL} />
)}
/>
<Route exact path="/galleries/new" component={GalleryCreate} /> <Route exact path="/galleries/new" component={GalleryCreate} />
<Route path="/galleries/:id/:tab?" component={Gallery} /> <Route path="/galleries/:id/:tab?" component={Gallery} />
</Switch> </Switch>
@@ -35,4 +28,4 @@ const Galleries: React.FC = () => {
); );
}; };
export default Galleries; export default GalleryRoutes;

View File

@@ -1,6 +1,11 @@
import { Button, Tab, Nav, Dropdown } from "react-bootstrap"; import { Button, Tab, Nav, Dropdown } from "react-bootstrap";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useParams, useHistory, Link } from "react-router-dom"; import {
useHistory,
Link,
RouteComponentProps,
Redirect,
} from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -31,17 +36,19 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { galleryPath, galleryTitle } from "src/core/galleries"; import { galleryPath, galleryTitle } from "src/core/galleries";
import { GalleryChapterPanel } from "./GalleryChaptersPanel"; import { GalleryChapterPanel } from "./GalleryChaptersPanel";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
interface IProps { interface IProps {
gallery: GQL.GalleryDataFragment; gallery: GQL.GalleryDataFragment;
add?: boolean;
} }
interface IGalleryParams { interface IGalleryParams {
id: string;
tab?: string; tab?: string;
} }
export const GalleryPage: React.FC<IProps> = ({ gallery }) => { export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
const { tab = "images" } = useParams<IGalleryParams>();
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
@@ -50,11 +57,12 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [activeTabKey, setActiveTabKey] = useState("gallery-details-panel"); const [activeTabKey, setActiveTabKey] = useState("gallery-details-panel");
const activeRightTabKey = tab === "images" || tab === "add" ? tab : "images";
const setActiveRightTabKey = (newTab: string | null) => { const setMainTabKey = (newTabKey: string | null) => {
if (tab !== newTab) { if (newTabKey === "add") {
const tabParam = newTab === "images" ? "" : `/${newTab}`; history.replace(`/galleries/${gallery.id}/add`);
history.replace(`/galleries/${gallery.id}${tabParam}`); } else {
history.replace(`/galleries/${gallery.id}`);
} }
}; };
@@ -281,9 +289,9 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
return ( return (
<Tab.Container <Tab.Container
activeKey={activeRightTabKey} activeKey={add ? "add" : "images"}
unmountOnExit unmountOnExit
onSelect={(k) => k && setActiveRightTabKey(k)} onSelect={setMainTabKey}
> >
<div> <div>
<Nav variant="tabs" className="mr-auto"> <Nav variant="tabs" className="mr-auto">
@@ -302,16 +310,10 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
<Tab.Content> <Tab.Content>
<Tab.Pane eventKey="images"> <Tab.Pane eventKey="images">
<GalleryImagesPanel <GalleryImagesPanel active={!add} gallery={gallery} />
active={activeRightTabKey == "images"}
gallery={gallery}
/>
</Tab.Pane> </Tab.Pane>
<Tab.Pane eventKey="add"> <Tab.Pane eventKey="add">
<GalleryAddPanel <GalleryAddPanel active={!!add} gallery={gallery} />
active={activeRightTabKey == "add"}
gallery={gallery}
/>
</Tab.Pane> </Tab.Pane>
</Tab.Content> </Tab.Content>
</Tab.Container> </Tab.Container>
@@ -372,15 +374,35 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
); );
}; };
const GalleryLoader: React.FC = () => { const GalleryLoader: React.FC<RouteComponentProps<IGalleryParams>> = ({
const { id } = useParams<{ id?: string }>(); location,
const { data, loading, error } = useFindGallery(id ?? ""); match,
}) => {
const { id, tab } = match.params;
const { data, loading, error } = useFindGallery(id);
useScrollToTopOnMount();
if (loading) return <LoadingIndicator />; if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />; if (error) return <ErrorMessage error={error.message} />;
if (!data?.findGallery) if (!data?.findGallery)
return <ErrorMessage error={`No gallery found with id ${id}.`} />; return <ErrorMessage error={`No gallery found with id ${id}.`} />;
if (tab === "add") {
return <GalleryPage add gallery={data.findGallery} />;
}
if (tab) {
return (
<Redirect
to={{
...location,
pathname: `/galleries/${id}`,
}}
/>
);
}
return <GalleryPage gallery={data.findGallery} />; return <GalleryPage gallery={data.findGallery} />;
}; };

View File

@@ -1,7 +1,7 @@
import { Tab, Nav, Dropdown } from "react-bootstrap"; import { Tab, Nav, Dropdown } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useParams, useHistory, Link } from "react-router-dom"; import { useHistory, Link, RouteComponentProps } from "react-router-dom";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { import {
useFindImage, useFindImage,
@@ -27,22 +27,24 @@ import { DeleteImagesDialog } from "../DeleteImagesDialog";
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons"; import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
import { objectPath, objectTitle } from "src/core/files"; import { objectPath, objectTitle } from "src/core/files";
import { isVideo } from "src/utils/visualFile"; import { isVideo } from "src/utils/visualFile";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
interface IImageParams { interface IProps {
id?: string; image: GQL.ImageDataFragment;
} }
export const Image: React.FC = () => { interface IImageParams {
const { id = "new" } = useParams<IImageParams>(); id: string;
}
const ImagePage: React.FC<IProps> = ({ image }) => {
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const { data, error, loading } = useFindImage(id); const [incrementO] = useImageIncrementO(image.id);
const image = data?.findImage; const [decrementO] = useImageDecrementO(image.id);
const [incrementO] = useImageIncrementO(image?.id ?? "0"); const [resetO] = useImageResetO(image.id);
const [decrementO] = useImageDecrementO(image?.id ?? "0");
const [resetO] = useImageResetO(image?.id ?? "0");
const [updateImage] = useImageUpdate(); const [updateImage] = useImageUpdate();
@@ -90,8 +92,8 @@ export const Image: React.FC = () => {
await updateImage({ await updateImage({
variables: { variables: {
input: { input: {
id: image?.id ?? "", id: image.id,
organized: !image?.organized, organized: !image.organized,
}, },
}, },
}); });
@@ -262,16 +264,6 @@ export const Image: React.FC = () => {
}; };
}); });
if (loading) {
return <LoadingIndicator />;
}
if (error) return <ErrorMessage error={error.message} />;
if (!image) {
return <ErrorMessage error={`No image found with id ${id}.`} />;
}
const title = objectTitle(image); const title = objectTitle(image);
const ImageView = isVideo(image.visual_files[0]) ? "video" : "img"; const ImageView = isVideo(image.visual_files[0]) ? "video" : "img";
@@ -317,3 +309,21 @@ export const Image: React.FC = () => {
</div> </div>
); );
}; };
const ImageLoader: React.FC<RouteComponentProps<IImageParams>> = ({
match,
}) => {
const { id } = match.params;
const { data, loading, error } = useFindImage(id);
useScrollToTopOnMount();
if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (!data?.findImage)
return <ErrorMessage error={`No image found with id ${id}.`} />;
return <ImagePage image={data.findImage} />;
};
export default ImageLoader;

View File

@@ -1,36 +1,29 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { useIntl } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "../Shared/constants"; import { useTitleProps } from "src/hooks/title";
import { PersistanceLevel } from "../List/ItemList"; import { PersistanceLevel } from "../List/ItemList";
import { Image } from "./ImageDetails/Image"; import Image from "./ImageDetails/Image";
import { ImageList } from "./ImageList"; import { ImageList } from "./ImageList";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
const Images: React.FC = () => { const Images: React.FC = () => {
const intl = useIntl(); useScrollToTopOnMount();
const title_template = `${intl.formatMessage({ return <ImageList persistState={PersistanceLevel.ALL} />;
id: "images", };
})} ${TITLE_SUFFIX}`;
const ImageRoutes: React.FC = () => {
const titleProps = useTitleProps({ id: "images" });
return ( return (
<> <>
<Helmet <Helmet {...titleProps} />
defaultTitle={title_template}
titleTemplate={`%s | ${title_template}`}
/>
<Switch> <Switch>
<Route <Route exact path="/images" component={Images} />
exact
path="/images"
render={(props) => (
<ImageList persistState={PersistanceLevel.ALL} {...props} />
)}
/>
<Route path="/images/:id" component={Image} /> <Route path="/images/:id" component={Image} />
</Switch> </Switch>
</> </>
); );
}; };
export default Images; export default ImageRoutes;

View File

@@ -153,9 +153,7 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string>(); const [lastClickedId, setLastClickedId] = useState<string>();
const [editingCriterion, setEditingCriterion] = useState< const [editingCriterion, setEditingCriterion] = useState<string>();
string | undefined
>();
const [showEditFilter, setShowEditFilter] = useState(false); const [showEditFilter, setShowEditFilter] = useState(false);
const result = useResult(filter); const result = useResult(filter);
@@ -701,7 +699,15 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
const newFilter = cloneDeep(filter); const newFilter = cloneDeep(filter);
newFilter.currentPage = page; newFilter.currentPage = page;
updateFilter(newFilter); updateFilter(newFilter);
// 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); window.scrollTo(0, 0);
}
}, },
[filter, updateFilter] [filter, updateFilter]
); );

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import cx from "classnames";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
@@ -9,7 +10,7 @@ import {
useMovieUpdate, useMovieUpdate,
useMovieDestroy, useMovieDestroy,
} from "src/core/StashService"; } from "src/core/StashService";
import { useParams, useHistory } from "react-router-dom"; import { useHistory, RouteComponentProps } from "react-router-dom";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { ErrorMessage } from "src/components/Shared/ErrorMessage";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
@@ -33,13 +34,19 @@ import { Icon } from "src/components/Shared/Icon";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import { IUIConfig } from "src/core/config"; import { IUIConfig } from "src/core/config";
import ImageUtils from "src/utils/image"; import { DetailImage } from "src/components/Shared/DetailImage";
import { useRatingKeybinds } from "src/hooks/keybinds"; import { useRatingKeybinds } from "src/hooks/keybinds";
import { useLoadStickyHeader } from "src/hooks/detailsPanel";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
interface IProps { interface IProps {
movie: GQL.MovieDataFragment; movie: GQL.MovieDataFragment;
} }
interface IMovieParams {
id: string;
}
const MoviePage: React.FC<IProps> = ({ movie }) => { const MoviePage: React.FC<IProps> = ({ movie }) => {
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
@@ -53,7 +60,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
const showAllDetails = uiConfig?.showAllDetails ?? true; const showAllDetails = uiConfig?.showAllDetails ?? true;
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails); const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false); const loadStickyHeader = useLoadStickyHeader();
// Editing state // Editing state
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
@@ -126,21 +133,6 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
setRating setRating
); );
useEffect(() => {
const f = () => {
if (document.documentElement.scrollTop <= 50) {
setLoadStickyHeader(false);
} else {
setLoadStickyHeader(true);
}
};
window.addEventListener("scroll", f);
return () => {
window.removeEventListener("scroll", f);
};
});
async function onSave(input: GQL.MovieCreateInput) { async function onSave(input: GQL.MovieCreateInput) {
await updateMovie({ await updateMovie({
variables: { variables: {
@@ -240,11 +232,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
if (image && defaultImage) { if (image && defaultImage) {
return ( return (
<div className="movie-image-container"> <div className="movie-image-container">
<img <DetailImage alt="Front Cover" src={image} />
alt="Front Cover"
src={image}
onLoad={ImageUtils.verifyImageSize}
/>
</div> </div>
); );
} else if (image) { } else if (image) {
@@ -254,11 +242,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
variant="link" variant="link"
onClick={() => showLightbox()} onClick={() => showLightbox()}
> >
<img <DetailImage alt="Front Cover" src={image} />
alt="Front Cover"
src={image}
onLoad={ImageUtils.verifyImageSize}
/>
</Button> </Button>
); );
} }
@@ -281,11 +265,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
variant="link" variant="link"
onClick={() => showLightbox(index - 1)} onClick={() => showLightbox(index - 1)}
> >
<img <DetailImage alt="Back Cover" src={image} />
alt="Back Cover"
src={image}
onLoad={ImageUtils.verifyImageSize}
/>
</Button> </Button>
); );
} }
@@ -405,17 +385,19 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
if (updating || deleting) return <LoadingIndicator />; if (updating || deleting) return <LoadingIndicator />;
const headerClassName = cx("detail-header", {
edit: isEditing,
collapsed,
"full-width": !collapsed && !compactExpandedDetails,
});
return ( return (
<div id="movie-page" className="row"> <div id="movie-page" className="row">
<Helmet> <Helmet>
<title>{movie?.name}</title> <title>{movie?.name}</title>
</Helmet> </Helmet>
<div <div className={headerClassName}>
className={`detail-header ${isEditing ? "edit" : ""} ${
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
}`}
>
{maybeRenderHeaderBackgroundImage()} {maybeRenderHeaderBackgroundImage()}
<div className="detail-container"> <div className="detail-container">
<div className="detail-header-image"> <div className="detail-header-image">
@@ -461,9 +443,13 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
); );
}; };
const MovieLoader: React.FC = () => { const MovieLoader: React.FC<RouteComponentProps<IMovieParams>> = ({
const { id } = useParams<{ id?: string }>(); match,
const { data, loading, error } = useFindMovie(id ?? ""); }) => {
const { id } = match.params;
const { data, loading, error } = useFindMovie(id);
useScrollToTopOnMount();
if (loading) return <LoadingIndicator />; if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />; if (error) return <ErrorMessage error={error.message} />;

View File

@@ -1,26 +1,25 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { useIntl } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "src/components/Shared/constants"; import { useTitleProps } from "src/hooks/title";
import Movie from "./MovieDetails/Movie"; import Movie from "./MovieDetails/Movie";
import MovieCreate from "./MovieDetails/MovieCreate"; import MovieCreate from "./MovieDetails/MovieCreate";
import { MovieList } from "./MovieList"; import { MovieList } from "./MovieList";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
const Movies: React.FC = () => { const Movies: React.FC = () => {
const intl = useIntl(); useScrollToTopOnMount();
const title_template = `${intl.formatMessage({ return <MovieList />;
id: "movies", };
})} ${TITLE_SUFFIX}`;
const MovieRoutes: React.FC = () => {
const titleProps = useTitleProps({ id: "movies" });
return ( return (
<> <>
<Helmet <Helmet {...titleProps} />
defaultTitle={title_template}
titleTemplate={`%s | ${title_template}`}
/>
<Switch> <Switch>
<Route exact path="/movies" component={MovieList} /> <Route exact path="/movies" component={Movies} />
<Route exact path="/movies/new" component={MovieCreate} /> <Route exact path="/movies/new" component={MovieCreate} />
<Route path="/movies/:id/:tab?" component={Movie} /> <Route path="/movies/:id/:tab?" component={Movie} />
</Switch> </Switch>
@@ -28,4 +27,4 @@ const Movies: React.FC = () => {
); );
}; };
export default Movies; export default MovieRoutes;

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Button, Tabs, Tab, Col, Row } from "react-bootstrap"; import { Button, Tabs, Tab, Col, Row } from "react-bootstrap";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { useParams, useHistory } from "react-router-dom"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import cx from "classnames"; import cx from "classnames";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
@@ -42,20 +42,39 @@ import {
import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons"; import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons";
import { IUIConfig } from "src/core/config"; import { IUIConfig } from "src/core/config";
import { useRatingKeybinds } from "src/hooks/keybinds"; import { useRatingKeybinds } from "src/hooks/keybinds";
import ImageUtils from "src/utils/image"; import { DetailImage } from "src/components/Shared/DetailImage";
import { useLoadStickyHeader } from "src/hooks/detailsPanel";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
interface IProps { interface IProps {
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
tabKey: TabKey;
} }
interface IPerformerParams { interface IPerformerParams {
id: string;
tab?: string; tab?: string;
} }
const PerformerPage: React.FC<IProps> = ({ performer }) => { const validTabs = [
"scenes",
"galleries",
"images",
"movies",
"appearswith",
] as const;
type TabKey = (typeof validTabs)[number];
const defaultTab: TabKey = "scenes";
function isTabKey(tab: string): tab is TabKey {
return validTabs.includes(tab as TabKey);
}
const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
const Toast = useToast(); const Toast = useToast();
const history = useHistory(); const history = useHistory();
const intl = useIntl(); const intl = useIntl();
const { tab = "details" } = useParams<IPerformerParams>();
// Configuration settings // Configuration settings
const { configuration } = React.useContext(ConfigurationContext); const { configuration } = React.useContext(ConfigurationContext);
@@ -70,7 +89,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
const [image, setImage] = useState<string | null>(); const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false); const [encodingImage, setEncodingImage] = useState<boolean>(false);
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false); const loadStickyHeader = useLoadStickyHeader();
const activeImage = useMemo(() => { const activeImage = useMemo(() => {
const performerImage = performer.image_path; const performerImage = performer.image_path;
@@ -98,20 +117,16 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
const [updatePerformer] = usePerformerUpdate(); const [updatePerformer] = usePerformerUpdate();
const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy(); const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy();
const activeTabKey = function setTabKey(newTabKey: string | null) {
tab === "scenes" || if (!newTabKey) newTabKey = defaultTab;
tab === "galleries" || if (newTabKey === tabKey) return;
tab === "images" ||
tab === "movies" || if (newTabKey === defaultTab) {
tab == "appearswith" history.replace(`/performers/${performer.id}`);
? tab } else if (isTabKey(newTabKey)) {
: "scenes"; history.replace(`/performers/${performer.id}/${newTabKey}`);
const setActiveTabKey = (newTab: string | null) => { }
if (tab !== newTab) {
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
history.replace(`/performers/${performer.id}${tabParam}`);
} }
};
async function onAutoTag() { async function onAutoTag() {
try { try {
@@ -133,18 +148,18 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("e", () => toggleEditing());
Mousetrap.bind("c", () => setActiveTabKey("scenes")); Mousetrap.bind("c", () => setTabKey("scenes"));
Mousetrap.bind("g", () => setActiveTabKey("galleries")); Mousetrap.bind("g", () => setTabKey("galleries"));
Mousetrap.bind("m", () => setActiveTabKey("movies")); Mousetrap.bind("m", () => setTabKey("movies"));
Mousetrap.bind("f", () => setFavorite(!performer.favorite)); Mousetrap.bind("f", () => setFavorite(!performer.favorite));
Mousetrap.bind(",", () => setCollapsed(!collapsed)); Mousetrap.bind(",", () => setCollapsed(!collapsed));
return () => { return () => {
Mousetrap.unbind("a");
Mousetrap.unbind("e"); Mousetrap.unbind("e");
Mousetrap.unbind("c"); Mousetrap.unbind("c");
Mousetrap.unbind("g");
Mousetrap.unbind("m");
Mousetrap.unbind("f"); Mousetrap.unbind("f");
Mousetrap.unbind("o");
Mousetrap.unbind(","); Mousetrap.unbind(",");
}; };
}); });
@@ -191,23 +206,22 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
if (activeImage) { if (activeImage) {
return ( return (
<Button variant="link" onClick={() => showLightbox()}> <Button variant="link" onClick={() => showLightbox()}>
<img <DetailImage
className="performer" className="performer"
src={activeImage} src={activeImage}
alt={performer.name} alt={performer.name}
onLoad={ImageUtils.verifyImageSize}
/> />
</Button> </Button>
); );
} }
} }
const renderTabs = () => ( const renderTabs = () => (
<React.Fragment>
<Tabs <Tabs
activeKey={activeTabKey} id="performer-tabs"
onSelect={setActiveTabKey} mountOnEnter
id="performer-details"
unmountOnExit unmountOnExit
activeKey={tabKey}
onSelect={setTabKey}
> >
<Tab <Tab
eventKey="scenes" eventKey="scenes"
@@ -223,7 +237,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
} }
> >
<PerformerScenesPanel <PerformerScenesPanel
active={activeTabKey == "scenes"} active={tabKey === "scenes"}
performer={performer} performer={performer}
/> />
</Tab> </Tab>
@@ -241,7 +255,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
} }
> >
<PerformerGalleriesPanel <PerformerGalleriesPanel
active={activeTabKey == "galleries"} active={tabKey === "galleries"}
performer={performer} performer={performer}
/> />
</Tab> </Tab>
@@ -259,7 +273,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
} }
> >
<PerformerImagesPanel <PerformerImagesPanel
active={activeTabKey == "images"} active={tabKey === "images"}
performer={performer} performer={performer}
/> />
</Tab> </Tab>
@@ -277,7 +291,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
} }
> >
<PerformerMoviesPanel <PerformerMoviesPanel
active={activeTabKey == "movies"} active={tabKey === "movies"}
performer={performer} performer={performer}
/> />
</Tab> </Tab>
@@ -295,12 +309,11 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
} }
> >
<PerformerAppearsWithPanel <PerformerAppearsWithPanel
active={activeTabKey == "appearswith"} active={tabKey === "appearswith"}
performer={performer} performer={performer}
/> />
</Tab> </Tab>
</Tabs> </Tabs>
</React.Fragment>
); );
function maybeRenderHeaderBackgroundImage() { function maybeRenderHeaderBackgroundImage() {
@@ -365,21 +378,6 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
return collapsed ? faChevronDown : faChevronUp; return collapsed ? faChevronDown : faChevronUp;
} }
useEffect(() => {
const f = () => {
if (document.documentElement.scrollTop <= 50) {
setLoadStickyHeader(false);
} else {
setLoadStickyHeader(true);
}
};
window.addEventListener("scroll", f);
return () => {
window.removeEventListener("scroll", f);
};
});
function maybeRenderDetails() { function maybeRenderDetails() {
if (!isEditing) { if (!isEditing) {
return ( return (
@@ -537,17 +535,19 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
/> />
); );
const headerClassName = cx("detail-header", {
edit: isEditing,
collapsed,
"full-width": !collapsed && !compactExpandedDetails,
});
return ( return (
<div id="performer-page" className="row"> <div id="performer-page" className="row">
<Helmet> <Helmet>
<title>{performer.name}</title> <title>{performer.name}</title>
</Helmet> </Helmet>
<div <div className={headerClassName}>
className={`detail-header ${isEditing ? "edit" : ""} ${
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
}`}
>
{maybeRenderHeaderBackgroundImage()} {maybeRenderHeaderBackgroundImage()}
<div className="detail-container"> <div className="detail-container">
<div className="detail-header-image"> <div className="detail-header-image">
@@ -592,16 +592,36 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
); );
}; };
const PerformerLoader: React.FC = () => { const PerformerLoader: React.FC<RouteComponentProps<IPerformerParams>> = ({
const { id } = useParams<{ id?: string }>(); location,
const { data, loading, error } = useFindPerformer(id ?? ""); match,
}) => {
const { id, tab } = match.params;
const { data, loading, error } = useFindPerformer(id);
useScrollToTopOnMount();
if (loading) return <LoadingIndicator />; if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />; if (error) return <ErrorMessage error={error.message} />;
if (!data?.findPerformer) if (!data?.findPerformer)
return <ErrorMessage error={`No performer found with id ${id}.`} />; return <ErrorMessage error={`No performer found with id ${id}.`} />;
return <PerformerPage performer={data.findPerformer} />; if (!tab) {
return <PerformerPage performer={data.findPerformer} tabKey={defaultTab} />;
}
if (!isTabKey(tab)) {
return (
<Redirect
to={{
...location,
pathname: `/performers/${id}`,
}}
/>
);
}
return <PerformerPage performer={data.findPerformer} tabKey={tab} />;
}; };
export default PerformerLoader; export default PerformerLoader;

View File

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

View File

@@ -789,7 +789,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
return ( return (
<Row> <Row>
<Form.Label column sm={labelXS} xl={labelXL}> <Form.Label column sm={labelXS} xl={labelXL}>
StashIDs {intl.formatMessage({ id: "stash_ids" })}
</Form.Label> </Form.Label>
<Col sm={fieldXS} xl={fieldXL}> <Col sm={fieldXS} xl={fieldXL}>
<ul className="pl-0"> <ul className="pl-0">

View File

@@ -1,37 +1,31 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { useIntl } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "src/components/Shared/constants"; import { useTitleProps } from "src/hooks/title";
import { PersistanceLevel } from "../List/ItemList"; import { PersistanceLevel } from "../List/ItemList";
import Performer from "./PerformerDetails/Performer"; import Performer from "./PerformerDetails/Performer";
import PerformerCreate from "./PerformerDetails/PerformerCreate"; import PerformerCreate from "./PerformerDetails/PerformerCreate";
import { PerformerList } from "./PerformerList"; import { PerformerList } from "./PerformerList";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
const Performers: React.FC = () => { const Performers: React.FC = () => {
const intl = useIntl(); useScrollToTopOnMount();
const title_template = `${intl.formatMessage({ return <PerformerList persistState={PersistanceLevel.ALL} />;
id: "performers", };
})} ${TITLE_SUFFIX}`;
const PerformerRoutes: React.FC = () => {
const titleProps = useTitleProps({ id: "performers" });
return ( return (
<> <>
<Helmet <Helmet {...titleProps} />
defaultTitle={title_template}
titleTemplate={`%s | ${title_template}`}
/>
<Switch> <Switch>
<Route <Route exact path="/performers" component={Performers} />
exact
path="/performers"
render={(props) => (
<PerformerList persistState={PersistanceLevel.ALL} {...props} />
)}
/>
<Route path="/performers/new" component={PerformerCreate} /> <Route path="/performers/new" component={PerformerCreate} />
<Route path="/performers/:id/:tab?" component={Performer} /> <Route path="/performers/:id/:tab?" component={Performer} />
</Switch> </Switch>
</> </>
); );
}; };
export default Performers;
export default PerformerRoutes;

View File

@@ -8,7 +8,7 @@ import React, {
useLayoutEffect, useLayoutEffect,
} from "react"; } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useParams, useLocation, useHistory, Link } from "react-router-dom"; import { Link, RouteComponentProps } from "react-router-dom";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
@@ -93,6 +93,10 @@ interface IProps {
setContinuePlaylist: (value: boolean) => void; setContinuePlaylist: (value: boolean) => void;
} }
interface ISceneParams {
id: string;
}
const ScenePage: React.FC<IProps> = ({ const ScenePage: React.FC<IProps> = ({
scene, scene,
setTimestamp, setTimestamp,
@@ -539,12 +543,14 @@ const ScenePage: React.FC<IProps> = ({
); );
}; };
const SceneLoader: React.FC = () => { const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
const { id } = useParams<{ id?: string }>(); location,
const location = useLocation(); history,
const history = useHistory(); match,
}) => {
const { id } = match.params;
const { configuration } = useContext(ConfigurationContext); const { configuration } = useContext(ConfigurationContext);
const { data, loading, error } = useFindScene(id ?? ""); const { data, loading, error } = useFindScene(id);
const [scene, setScene] = useState<GQL.SceneDataFragment>(); const [scene, setScene] = useState<GQL.SceneDataFragment>();

View File

@@ -191,7 +191,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
return ( return (
<> <>
<dt>StashIDs</dt> <dt>
<FormattedMessage id="stash_ids" />
</dt>
<dd> <dd>
<dl> <dl>
{props.scene.stash_ids.map((stashID) => { {props.scene.stash_ids.map((stashID) => {

View File

@@ -1,10 +1,10 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { useIntl } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "src/components/Shared/constants"; import { useTitleProps } from "src/hooks/title";
import { PersistanceLevel } from "../List/ItemList"; import { PersistanceLevel } from "../List/ItemList";
import { lazyComponent } from "src/utils/lazyComponent"; import { lazyComponent } from "src/utils/lazyComponent";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
const SceneList = lazyComponent(() => import("./SceneList")); const SceneList = lazyComponent(() => import("./SceneList"));
const SceneMarkerList = lazyComponent(() => import("./SceneMarkerList")); const SceneMarkerList = lazyComponent(() => import("./SceneMarkerList"));
@@ -12,46 +12,36 @@ const Scene = lazyComponent(() => import("./SceneDetails/Scene"));
const SceneCreate = lazyComponent(() => import("./SceneDetails/SceneCreate")); const SceneCreate = lazyComponent(() => import("./SceneDetails/SceneCreate"));
const Scenes: React.FC = () => { const Scenes: React.FC = () => {
const intl = useIntl(); useScrollToTopOnMount();
const title_template = `${intl.formatMessage({ return <SceneList persistState={PersistanceLevel.ALL} />;
id: "scenes", };
})} ${TITLE_SUFFIX}`;
const marker_title_template = `${intl.formatMessage({
id: "markers",
})} ${TITLE_SUFFIX}`;
const SceneMarkers: React.FC = () => {
useScrollToTopOnMount();
const titleProps = useTitleProps({ id: "markers" });
return ( return (
<> <>
<Helmet <Helmet {...titleProps} />
defaultTitle={title_template}
titleTemplate={`%s | ${title_template}`}
/>
<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 /> <SceneMarkerList />
</> </>
)} );
/> };
const SceneRoutes: React.FC = () => {
const titleProps = useTitleProps({ id: "scenes" });
return (
<>
<Helmet {...titleProps} />
<Switch>
<Route exact path="/scenes" component={Scenes} />
<Route exact path="/scenes/markers" component={SceneMarkers} />
<Route exact path="/scenes/new" component={SceneCreate} /> <Route exact path="/scenes/new" component={SceneCreate} />
<Route path="/scenes/:id" component={Scene} /> <Route path="/scenes/:id" component={Scene} />
</Switch> </Switch>
</> </>
); );
}; };
export default Scenes;
export default SceneRoutes;

View File

@@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import { Tab, Nav, Row, Col } from "react-bootstrap"; import { Tab, Nav, Row, Col } from "react-bootstrap";
import { useHistory, useLocation } from "react-router-dom"; import { useHistory, useLocation } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "src/components/Shared/constants"; import { useTitleProps } from "src/hooks/title";
import { SettingsAboutPanel } from "./SettingsAboutPanel"; import { SettingsAboutPanel } from "./SettingsAboutPanel";
import { SettingsConfigurationPanel } from "./SettingsSystemPanel"; import { SettingsConfigurationPanel } from "./SettingsSystemPanel";
import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel"; import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel";
@@ -19,26 +19,20 @@ import { SettingsSecurityPanel } from "./SettingsSecurityPanel";
import Changelog from "../Changelog/Changelog"; import Changelog from "../Changelog/Changelog";
export const Settings: React.FC = () => { export const Settings: React.FC = () => {
const intl = useIntl();
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const defaultTab = new URLSearchParams(location.search).get("tab") ?? "tasks"; const defaultTab = new URLSearchParams(location.search).get("tab") ?? "tasks";
const onSelect = (val: string) => history.push(`?tab=${val}`); const onSelect = (val: string) => history.push(`?tab=${val}`);
const title_template = `${intl.formatMessage({ const titleProps = useTitleProps({ id: "settings" });
id: "settings",
})} ${TITLE_SUFFIX}`;
return ( return (
<Tab.Container <Tab.Container
activeKey={defaultTab} activeKey={defaultTab}
id="configuration-tabs" id="configuration-tabs"
onSelect={(tab) => tab && onSelect(tab)} onSelect={(tab) => tab && onSelect(tab)}
> >
<Helmet <Helmet {...titleProps} />
defaultTitle={title_template}
titleTemplate={`%s | ${title_template}`}
/>
<Row> <Row>
<Col id="settings-menu-container" sm={3} md={3} xl={2}> <Col id="settings-menu-container" sm={3} md={3} xl={2}>
<Nav variant="pills" className="flex-column"> <Nav variant="pills" className="flex-column">

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) { if (props.selecting) {
props.onSelectedChanged(!props.selected, shiftKey); props.onSelectedChanged(!props.selected, shiftKey);
event.preventDefault(); 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 { Button, Tabs, Tab } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory } from "react-router-dom"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import cx from "classnames";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -40,22 +41,41 @@ import {
import { IUIConfig } from "src/core/config"; import { IUIConfig } from "src/core/config";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import ImageUtils from "src/utils/image"; import { DetailImage } from "src/components/Shared/DetailImage";
import { useRatingKeybinds } from "src/hooks/keybinds"; import { useRatingKeybinds } from "src/hooks/keybinds";
import { useLoadStickyHeader } from "src/hooks/detailsPanel";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
interface IProps { interface IProps {
studio: GQL.StudioDataFragment; studio: GQL.StudioDataFragment;
tabKey: TabKey;
} }
interface IStudioParams { interface IStudioParams {
id: string;
tab?: string; tab?: string;
} }
const StudioPage: React.FC<IProps> = ({ studio }) => { const validTabs = [
"scenes",
"galleries",
"images",
"performers",
"movies",
"childstudios",
] as const;
type TabKey = (typeof validTabs)[number];
const defaultTab: TabKey = "scenes";
function isTabKey(tab: string): tab is TabKey {
return validTabs.includes(tab as TabKey);
}
const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const { tab = "details" } = useParams<IStudioParams>();
// Configuration settings // Configuration settings
const { configuration } = React.useContext(ConfigurationContext); const { configuration } = React.useContext(ConfigurationContext);
@@ -66,7 +86,7 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails); const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false); const loadStickyHeader = useLoadStickyHeader();
// Editing state // Editing state
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
@@ -113,21 +133,6 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
setRating setRating
); );
useEffect(() => {
const f = () => {
if (document.documentElement.scrollTop <= 50) {
setLoadStickyHeader(false);
} else {
setLoadStickyHeader(true);
}
};
window.addEventListener("scroll", f);
return () => {
window.removeEventListener("scroll", f);
};
});
async function onSave(input: GQL.StudioCreateInput) { async function onSave(input: GQL.StudioCreateInput) {
await updateStudio({ await updateStudio({
variables: { variables: {
@@ -232,30 +237,21 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
if (studioImage) { if (studioImage) {
return ( return (
<img <DetailImage className="logo" alt={studio.name} src={studioImage} />
className="logo"
alt={studio.name}
src={studioImage}
onLoad={ImageUtils.verifyImageSize}
/>
); );
} }
} }
const activeTabKey = function setTabKey(newTabKey: string | null) {
tab === "childstudios" || if (!newTabKey) newTabKey = defaultTab;
tab === "images" || if (newTabKey === tabKey) return;
tab === "galleries" ||
tab === "performers" || if (newTabKey === defaultTab) {
tab === "movies" history.replace(`/studios/${studio.id}`);
? tab } else if (isTabKey(newTabKey)) {
: "scenes"; history.replace(`/studios/${studio.id}/${newTabKey}`);
const setActiveTabKey = (newTab: string | null) => { }
if (tab !== newTab) {
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
history.replace(`/studios/${studio.id}${tabParam}`);
} }
};
const renderClickableIcons = () => ( const renderClickableIcons = () => (
<span className="name-icons"> <span className="name-icons">
@@ -321,13 +317,12 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
} }
const renderTabs = () => ( const renderTabs = () => (
<React.Fragment>
<Tabs <Tabs
id="studio-tabs" id="studio-tabs"
mountOnEnter mountOnEnter
unmountOnExit unmountOnExit
activeKey={activeTabKey} activeKey={tabKey}
onSelect={setActiveTabKey} onSelect={setTabKey}
> >
<Tab <Tab
eventKey="scenes" eventKey="scenes"
@@ -342,10 +337,7 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
</> </>
} }
> >
<StudioScenesPanel <StudioScenesPanel active={tabKey === "scenes"} studio={studio} />
active={activeTabKey == "scenes"}
studio={studio}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="galleries" eventKey="galleries"
@@ -360,10 +352,7 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
</> </>
} }
> >
<StudioGalleriesPanel <StudioGalleriesPanel active={tabKey === "galleries"} studio={studio} />
active={activeTabKey == "galleries"}
studio={studio}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="images" eventKey="images"
@@ -378,10 +367,7 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
</> </>
} }
> >
<StudioImagesPanel <StudioImagesPanel active={tabKey === "images"} studio={studio} />
active={activeTabKey == "images"}
studio={studio}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="performers" eventKey="performers"
@@ -397,7 +383,7 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
} }
> >
<StudioPerformersPanel <StudioPerformersPanel
active={activeTabKey == "performers"} active={tabKey === "performers"}
studio={studio} studio={studio}
/> />
</Tab> </Tab>
@@ -414,10 +400,7 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
</> </>
} }
> >
<StudioMoviesPanel <StudioMoviesPanel active={tabKey === "movies"} studio={studio} />
active={activeTabKey == "movies"}
studio={studio}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="childstudios" eventKey="childstudios"
@@ -433,12 +416,11 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
} }
> >
<StudioChildrenPanel <StudioChildrenPanel
active={activeTabKey == "childstudios"} active={tabKey === "childstudios"}
studio={studio} studio={studio}
/> />
</Tab> </Tab>
</Tabs> </Tabs>
</React.Fragment>
); );
function maybeRenderHeaderBackgroundImage() { function maybeRenderHeaderBackgroundImage() {
@@ -495,17 +477,19 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
} }
} }
const headerClassName = cx("detail-header", {
edit: isEditing,
collapsed,
"full-width": !collapsed && !compactExpandedDetails,
});
return ( return (
<div id="studio-page" className="row"> <div id="studio-page" className="row">
<Helmet> <Helmet>
<title>{studio.name ?? intl.formatMessage({ id: "studio" })}</title> <title>{studio.name ?? intl.formatMessage({ id: "studio" })}</title>
</Helmet> </Helmet>
<div <div className={headerClassName}>
className={`detail-header ${isEditing ? "edit" : ""} ${
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
}`}
>
{maybeRenderHeaderBackgroundImage()} {maybeRenderHeaderBackgroundImage()}
<div className="detail-container"> <div className="detail-container">
<div className="detail-header-image"> <div className="detail-header-image">
@@ -546,16 +530,36 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
); );
}; };
const StudioLoader: React.FC = () => { const StudioLoader: React.FC<RouteComponentProps<IStudioParams>> = ({
const { id } = useParams<{ id?: string }>(); location,
const { data, loading, error } = useFindStudio(id ?? ""); match,
}) => {
const { id, tab } = match.params;
const { data, loading, error } = useFindStudio(id);
useScrollToTopOnMount();
if (loading) return <LoadingIndicator />; if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />; if (error) return <ErrorMessage error={error.message} />;
if (!data?.findStudio) if (!data?.findStudio)
return <ErrorMessage error={`No studio found with id ${id}.`} />; return <ErrorMessage error={`No studio found with id ${id}.`} />;
return <StudioPage studio={data.findStudio} />; if (!tab) {
return <StudioPage studio={data.findStudio} tabKey={defaultTab} />;
}
if (!isTabKey(tab)) {
return (
<Redirect
to={{
...location,
pathname: `/studios/${id}`,
}}
/>
);
}
return <StudioPage studio={data.findStudio} tabKey={tab} />;
}; };
export default StudioLoader; export default StudioLoader;

View File

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

View File

@@ -160,7 +160,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
return ( return (
<Row> <Row>
<Form.Label column xs={labelXS} xl={labelXL}> <Form.Label column xs={labelXS} xl={labelXL}>
StashIDs {intl.formatMessage({ id: "stash_ids" })}
</Form.Label> </Form.Label>
<Col xs={fieldXS} xl={fieldXL}> <Col xs={fieldXS} xl={fieldXL}>
<ul className="pl-0"> <ul className="pl-0">

View File

@@ -1,30 +1,30 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { useIntl } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "src/components/Shared/constants"; import { useTitleProps } from "src/hooks/title";
import Studio from "./StudioDetails/Studio"; import Studio from "./StudioDetails/Studio";
import StudioCreate from "./StudioDetails/StudioCreate"; import StudioCreate from "./StudioDetails/StudioCreate";
import { StudioList } from "./StudioList"; import { StudioList } from "./StudioList";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
const Studios: React.FC = () => { const Studios: React.FC = () => {
const intl = useIntl(); useScrollToTopOnMount();
const title_template = `${intl.formatMessage({ return <StudioList />;
id: "studios", };
})} ${TITLE_SUFFIX}`;
const StudioRoutes: React.FC = () => {
const titleProps = useTitleProps({ id: "studios" });
return ( return (
<> <>
<Helmet <Helmet {...titleProps} />
defaultTitle={title_template}
titleTemplate={`%s | ${title_template}`}
/>
<Switch> <Switch>
<Route exact path="/studios" component={StudioList} /> <Route exact path="/studios" component={Studios} />
<Route exact path="/studios/new" component={StudioCreate} /> <Route exact path="/studios/new" component={StudioCreate} />
<Route path="/studios/:id/:tab?" component={Studio} /> <Route path="/studios/:id/:tab?" component={Studio} />
</Switch> </Switch>
</> </>
); );
}; };
export default Studios;
export default StudioRoutes;

View File

@@ -1,8 +1,9 @@
import { Tabs, Tab, Dropdown, Button } from "react-bootstrap"; import { Tabs, Tab, Dropdown, Button } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory } from "react-router-dom"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import cx from "classnames";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -37,17 +38,36 @@ import {
faTrashAlt, faTrashAlt,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { IUIConfig } from "src/core/config"; import { IUIConfig } from "src/core/config";
import ImageUtils from "src/utils/image"; import { DetailImage } from "src/components/Shared/DetailImage";
import { useLoadStickyHeader } from "src/hooks/detailsPanel";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
interface IProps { interface IProps {
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
tabKey: TabKey;
} }
interface ITabParams { interface ITagParams {
id: string;
tab?: string; tab?: string;
} }
const TagPage: React.FC<IProps> = ({ tag }) => { const validTabs = [
"scenes",
"images",
"galleries",
"markers",
"performers",
] as const;
type TabKey = (typeof validTabs)[number];
const defaultTab: TabKey = "scenes";
function isTabKey(tab: string): tab is TabKey {
return validTabs.includes(tab as TabKey);
}
const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
@@ -61,9 +81,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails); const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false); const loadStickyHeader = useLoadStickyHeader();
const { tab = "scenes" } = useParams<ITabParams>();
// Editing state // Editing state
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
@@ -89,19 +107,16 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
const performerCount = const performerCount =
(showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0; (showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0;
const activeTabKey = function setTabKey(newTabKey: string | null) {
tab === "markers" || if (!newTabKey) newTabKey = defaultTab;
tab === "images" || if (newTabKey === tabKey) return;
tab === "performers" ||
tab === "galleries" if (newTabKey === defaultTab) {
? tab history.replace(`/tags/${tag.id}`);
: "scenes"; } else if (isTabKey(newTabKey)) {
const setActiveTabKey = (newTab: string | null) => { history.replace(`/tags/${tag.id}/${newTabKey}`);
if (tab !== newTab) { }
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
history.replace(`/tags/${tag.id}${tabParam}`);
} }
};
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
@@ -122,21 +137,6 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
}; };
}); });
useEffect(() => {
const f = () => {
if (document.documentElement.scrollTop <= 50) {
setLoadStickyHeader(false);
} else {
setLoadStickyHeader(true);
}
};
window.addEventListener("scroll", f);
return () => {
window.removeEventListener("scroll", f);
};
});
async function onSave(input: GQL.TagCreateInput) { async function onSave(input: GQL.TagCreateInput) {
const oldRelations = { const oldRelations = {
parents: tag.parents ?? [], parents: tag.parents ?? [],
@@ -274,14 +274,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
} }
if (tagImage) { if (tagImage) {
return ( return <DetailImage className="logo" alt={tag.name} src={tagImage} />;
<img
className="logo"
alt={tag.name}
src={tagImage}
onLoad={ImageUtils.verifyImageSize}
/>
);
} }
} }
@@ -370,13 +363,12 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
} }
const renderTabs = () => ( const renderTabs = () => (
<React.Fragment>
<Tabs <Tabs
id="tag-tabs" id="tag-tabs"
mountOnEnter mountOnEnter
unmountOnExit unmountOnExit
activeKey={activeTabKey} activeKey={tabKey}
onSelect={setActiveTabKey} onSelect={setTabKey}
> >
<Tab <Tab
eventKey="scenes" eventKey="scenes"
@@ -391,7 +383,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
</> </>
} }
> >
<TagScenesPanel active={activeTabKey == "scenes"} tag={tag} /> <TagScenesPanel active={tabKey === "scenes"} tag={tag} />
</Tab> </Tab>
<Tab <Tab
eventKey="images" eventKey="images"
@@ -406,7 +398,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
</> </>
} }
> >
<TagImagesPanel active={activeTabKey == "images"} tag={tag} /> <TagImagesPanel active={tabKey === "images"} tag={tag} />
</Tab> </Tab>
<Tab <Tab
eventKey="galleries" eventKey="galleries"
@@ -421,7 +413,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
</> </>
} }
> >
<TagGalleriesPanel active={activeTabKey == "galleries"} tag={tag} /> <TagGalleriesPanel active={tabKey === "galleries"} tag={tag} />
</Tab> </Tab>
<Tab <Tab
eventKey="markers" eventKey="markers"
@@ -436,7 +428,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
</> </>
} }
> >
<TagMarkersPanel active={activeTabKey == "markers"} tag={tag} /> <TagMarkersPanel active={tabKey === "markers"} tag={tag} />
</Tab> </Tab>
<Tab <Tab
eventKey="performers" eventKey="performers"
@@ -451,10 +443,9 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
</> </>
} }
> >
<TagPerformersPanel active={activeTabKey == "performers"} tag={tag} /> <TagPerformersPanel active={tabKey === "performers"} tag={tag} />
</Tab> </Tab>
</Tabs> </Tabs>
</React.Fragment>
); );
function maybeRenderHeaderBackgroundImage() { function maybeRenderHeaderBackgroundImage() {
@@ -487,17 +478,19 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
} }
} }
const headerClassName = cx("detail-header", {
edit: isEditing,
collapsed,
"full-width": !collapsed && !compactExpandedDetails,
});
return ( return (
<div id="tag-page" className="row"> <div id="tag-page" className="row">
<Helmet> <Helmet>
<title>{tag.name}</title> <title>{tag.name}</title>
</Helmet> </Helmet>
<div <div className={headerClassName}>
className={`detail-header ${isEditing ? "edit" : ""} ${
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
}`}
>
{maybeRenderHeaderBackgroundImage()} {maybeRenderHeaderBackgroundImage()}
<div className="detail-container"> <div className="detail-container">
<div className="detail-header-image"> <div className="detail-header-image">
@@ -534,16 +527,36 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
); );
}; };
const TagLoader: React.FC = () => { const TagLoader: React.FC<RouteComponentProps<ITagParams>> = ({
const { id } = useParams<{ id?: string }>(); location,
const { data, loading, error } = useFindTag(id ?? ""); match,
}) => {
const { id, tab } = match.params;
const { data, loading, error } = useFindTag(id);
useScrollToTopOnMount();
if (loading) return <LoadingIndicator />; if (loading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />; if (error) return <ErrorMessage error={error.message} />;
if (!data?.findTag) if (!data?.findTag)
return <ErrorMessage error={`No tag found with id ${id}.`} />; return <ErrorMessage error={`No tag found with id ${id}.`} />;
return <TagPage tag={data.findTag} />; if (!tab) {
return <TagPage tag={data.findTag} tabKey={defaultTab} />;
}
if (!isTabKey(tab)) {
return (
<Redirect
to={{
...location,
pathname: `/tags/${id}`,
}}
/>
);
}
return <TagPage tag={data.findTag} tabKey={tab} />;
}; };
export default TagLoader; export default TagLoader;

View File

@@ -1,31 +1,30 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { useIntl } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "src/components/Shared/constants"; import { useTitleProps } from "src/hooks/title";
import Tag from "./TagDetails/Tag"; import Tag from "./TagDetails/Tag";
import TagCreate from "./TagDetails/TagCreate"; import TagCreate from "./TagDetails/TagCreate";
import { TagList } from "./TagList"; import { TagList } from "./TagList";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
const Tags: React.FC = () => { const Tags: React.FC = () => {
const intl = useIntl(); useScrollToTopOnMount();
const title_template = `${intl.formatMessage({ return <TagList />;
id: "tags", };
})} ${TITLE_SUFFIX}`;
const TagRoutes: React.FC = () => {
const titleProps = useTitleProps({ id: "tags" });
return ( return (
<> <>
<Helmet <Helmet {...titleProps} />
defaultTitle={title_template}
titleTemplate={`%s | ${title_template}`}
/>
<Switch> <Switch>
<Route exact path="/tags" component={TagList} /> <Route exact path="/tags" component={Tags} />
<Route exact path="/tags/new" component={TagCreate} /> <Route exact path="/tags/new" component={TagCreate} />
<Route path="/tags/:id/:tab?" component={Tag} /> <Route path="/tags/:id/:tab?" component={Tag} />
</Switch> </Switch>
</> </>
); );
}; };
export default Tags;
export default TagRoutes;

View File

@@ -68,9 +68,6 @@ const typePolicies: TypePolicies = {
}, },
Scene: { Scene: {
fields: { fields: {
scene_markers: {
merge: false,
},
studio: { studio: {
read: readDanglingNull, read: readDanglingNull,
}, },
@@ -81,6 +78,9 @@ const typePolicies: TypePolicies = {
studio: { studio: {
read: readDanglingNull, read: readDanglingNull,
}, },
paths: {
merge: false,
},
}, },
}, },
Movie: { Movie: {
@@ -104,16 +104,6 @@ const typePolicies: TypePolicies = {
}, },
}, },
}, },
Tag: {
fields: {
parents: {
merge: false,
},
children: {
merge: false,
},
},
},
}; };
const possibleTypes = { const possibleTypes = {

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, onImageChange,
usePasteImage, usePasteImage,
imageToDataURL, imageToDataURL,
verifyImageSize,
}; };
function verifyImageSize(e: React.UIEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
// set width = 200px if zero-sized image (SVG w/o intrinsic size)
if (img.width === 0 && img.height === 0) {
img.setAttribute("width", "200");
} else {
img.removeAttribute("width");
}
}
export default ImageUtils; export default ImageUtils;