Various detail page refactoring (#5037)

* Refactor repeated code into BackgroundImage
* Move BackgroundImage into Details folder
* Refactor performer tabs
* Refactor studio tabs
* Refactor tag tabs
* Refactor repeated code into DetailTitle
* Refactor repeated collapse button code into component
* Reuse FavoriteIcon in details pages
* Refactor performer urls into component
* Refactor alias list into component
* Refactor repeated image code into HeaderImage and LightboxLink components
* Replace render functions with inline conditional rendering
* Support new twitter hostname
This commit is contained in:
WithoutPants
2024-07-04 10:52:46 +10:00
committed by GitHub
parent ec23b26c60
commit a0b082a36d
17 changed files with 1045 additions and 1222 deletions

View File

@@ -1,5 +1,5 @@
import { Button, Tabs, Tab } from "react-bootstrap";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Tabs, Tab } from "react-bootstrap";
import React, { useEffect, useMemo, useState } from "react";
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import { Helmet } from "react-helmet";
@@ -13,14 +13,12 @@ import {
useStudioDestroy,
mutateMetadataAutoTag,
} from "src/core/StashService";
import { Counter } from "src/components/Shared/Counter";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { ModalComponent } from "src/components/Shared/Modal";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { ErrorMessage } from "src/components/Shared/ErrorMessage";
import { useToast } from "src/hooks/Toast";
import { ConfigurationContext } from "src/hooks/Config";
import { Icon } from "src/components/Shared/Icon";
import { StudioScenesPanel } from "./StudioScenesPanel";
import { StudioGalleriesPanel } from "./StudioGalleriesPanel";
import { StudioImagesPanel } from "./StudioImagesPanel";
@@ -32,20 +30,23 @@ import {
StudioDetailsPanel,
} from "./StudioDetailsPanel";
import { StudioGroupsPanel } from "./StudioGroupsPanel";
import {
faTrashAlt,
faLink,
faChevronDown,
faChevronUp,
faHeart,
} from "@fortawesome/free-solid-svg-icons";
import TextUtils from "src/utils/text";
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
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";
import { ExternalLink } from "src/components/Shared/ExternalLink";
import { BackgroundImage } from "src/components/Shared/DetailsPage/BackgroundImage";
import {
TabTitleCounter,
useTabKey,
} from "src/components/Shared/DetailsPage/Tabs";
import { DetailTitle } from "src/components/Shared/DetailsPage/DetailTitle";
import { ExpandCollapseButton } from "src/components/Shared/CollapseButton";
import { FavoriteIcon } from "src/components/Shared/FavoriteIcon";
import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton";
import { AliasList } from "src/components/Shared/DetailsPage/AliasList";
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
interface IProps {
studio: GQL.StudioDataFragment;
@@ -72,34 +73,12 @@ 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();
// Configuration settings
const { configuration } = React.useContext(ConfigurationContext);
const uiConfig = configuration?.ui;
const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;
const enableBackgroundImage = uiConfig?.enableStudioBackgroundImage ?? false;
const showAllDetails = uiConfig?.showAllDetails ?? true;
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
const loadStickyHeader = useLoadStickyHeader();
// Editing state
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Editing studio state
const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const [updateStudio] = useStudioUpdate();
const [deleteStudio] = useStudioDestroy({ id: studio.id });
const showAllCounts = uiConfig?.showChildStudioContent;
const StudioTabs: React.FC<{
tabKey?: TabKey;
studio: GQL.StudioDataFragment;
abbreviateCounter: boolean;
showAllCounts?: boolean;
}> = ({ tabKey, studio, abbreviateCounter, showAllCounts = false }) => {
const sceneCount =
(showAllCounts ? studio.scene_count_all : studio.scene_count) ?? 0;
const galleryCount =
@@ -137,23 +116,151 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
studio,
]);
const setTabKey = useCallback(
(newTabKey: string | null) => {
if (!newTabKey) newTabKey = populatedDefaultTab;
if (newTabKey === tabKey) return;
const { setTabKey } = useTabKey({
tabKey,
validTabs,
defaultTabKey: populatedDefaultTab,
baseURL: `/studios/${studio.id}`,
});
if (isTabKey(newTabKey)) {
history.replace(`/studios/${studio.id}/${newTabKey}`);
}
},
[populatedDefaultTab, tabKey, history, studio.id]
return (
<Tabs
id="studio-tabs"
mountOnEnter
unmountOnExit
activeKey={tabKey}
onSelect={setTabKey}
>
<Tab
eventKey="scenes"
title={
<TabTitleCounter
messageID="scenes"
count={sceneCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<StudioScenesPanel active={tabKey === "scenes"} studio={studio} />
</Tab>
<Tab
eventKey="galleries"
title={
<TabTitleCounter
messageID="galleries"
count={galleryCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<StudioGalleriesPanel active={tabKey === "galleries"} studio={studio} />
</Tab>
<Tab
eventKey="images"
title={
<TabTitleCounter
messageID="images"
count={imageCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<StudioImagesPanel active={tabKey === "images"} studio={studio} />
</Tab>
<Tab
eventKey="performers"
title={
<TabTitleCounter
messageID="performers"
count={performerCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<StudioPerformersPanel
active={tabKey === "performers"}
studio={studio}
/>
</Tab>
<Tab
eventKey="groups"
title={
<TabTitleCounter
messageID="groups"
count={groupCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<StudioGroupsPanel active={tabKey === "groups"} studio={studio} />
</Tab>
<Tab
eventKey="childstudios"
title={
<TabTitleCounter
messageID="subsidiary_studios"
count={studio.child_studios.length}
abbreviateCounter={abbreviateCounter}
/>
}
>
<StudioChildrenPanel
active={tabKey === "childstudios"}
studio={studio}
/>
</Tab>
</Tabs>
);
};
useEffect(() => {
if (!tabKey) {
setTabKey(populatedDefaultTab);
const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
const history = useHistory();
const Toast = useToast();
const intl = useIntl();
// Configuration settings
const { configuration } = React.useContext(ConfigurationContext);
const uiConfig = configuration?.ui;
const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;
const enableBackgroundImage = uiConfig?.enableStudioBackgroundImage ?? false;
const showAllDetails = uiConfig?.showAllDetails ?? true;
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
const loadStickyHeader = useLoadStickyHeader();
// Editing state
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Editing studio state
const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const [updateStudio] = useStudioUpdate();
const [deleteStudio] = useStudioDestroy({ id: studio.id });
const showAllCounts = uiConfig?.showChildStudioContent;
// make array of url so that it doesn't re-render on every change
const urls = useMemo(() => {
return studio?.url ? [studio.url] : [];
}, [studio.url]);
const studioImage = useMemo(() => {
const existingPath = studio.image_path;
if (isEditing) {
if (image === null && existingPath) {
const studioImageURL = new URL(existingPath);
studioImageURL.searchParams.set("default", "true");
return studioImageURL.toString();
} else if (image) {
return image;
}
}
}, [setTabKey, populatedDefaultTab, tabKey]);
return existingPath;
}, [isEditing, image, studio.image_path]);
function setFavorite(v: boolean) {
if (studio.id) {
@@ -256,20 +363,6 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
);
}
function maybeRenderAliases() {
if (studio?.aliases?.length) {
return (
<div>
<span className="alias-head">{studio?.aliases?.join(", ")}</span>
</div>
);
}
}
function getCollapseButtonIcon() {
return collapsed ? faChevronDown : faChevronUp;
}
function toggleEditing(value?: boolean) {
if (value !== undefined) {
setIsEditing(value);
@@ -279,46 +372,6 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
setImage(undefined);
}
function renderImage() {
let studioImage = studio.image_path;
if (isEditing) {
if (image === null && studioImage) {
const studioImageURL = new URL(studioImage);
studioImageURL.searchParams.set("default", "true");
studioImage = studioImageURL.toString();
} else if (image) {
studioImage = image;
}
}
if (studioImage) {
return (
<DetailImage className="logo" alt={studio.name} src={studioImage} />
);
}
}
const renderClickableIcons = () => (
<span className="name-icons">
<Button
className={cx("minimal", studio.favorite ? "favorite" : "not-favorite")}
onClick={() => setFavorite(!studio.favorite)}
>
<Icon icon={faHeart} />
</Button>
{studio.url && (
<Button
as={ExternalLink}
href={TextUtils.sanitiseURL(studio.url)}
className="minimal link"
title={studio.url}
>
<Icon icon={faLink} />
</Button>
)}
</span>
);
function setRating(v: number | null) {
if (studio.id) {
updateStudio({
@@ -332,205 +385,6 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
}
}
function maybeRenderDetails() {
if (!isEditing) {
return (
<StudioDetailsPanel
studio={studio}
collapsed={collapsed}
fullWidth={!collapsed && !compactExpandedDetails}
/>
);
}
}
function maybeRenderShowCollapseButton() {
if (!isEditing) {
return (
<span className="detail-expand-collapse">
<Button
className="minimal expand-collapse"
onClick={() => setCollapsed(!collapsed)}
>
<Icon className="fa-fw" icon={getCollapseButtonIcon()} />
</Button>
</span>
);
}
}
function maybeRenderCompressedDetails() {
if (!isEditing && loadStickyHeader) {
return <CompressedStudioDetailsPanel studio={studio} />;
}
}
const renderTabs = () => (
<Tabs
id="studio-tabs"
mountOnEnter
unmountOnExit
activeKey={tabKey}
onSelect={setTabKey}
>
<Tab
eventKey="scenes"
title={
<>
{intl.formatMessage({ id: "scenes" })}
<Counter
abbreviateCounter={abbreviateCounter}
count={sceneCount}
hideZero
/>
</>
}
>
<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="groups"
title={
<>
{intl.formatMessage({ id: "groups" })}
<Counter
abbreviateCounter={abbreviateCounter}
count={groupCount}
hideZero
/>
</>
}
>
<StudioGroupsPanel active={tabKey === "groups"} 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() {
let studioImage = studio.image_path;
if (enableBackgroundImage && !isEditing && studioImage) {
const studioImageURL = new URL(studioImage);
let isDefaultImage = studioImageURL.searchParams.get("default");
if (!isDefaultImage) {
return (
<div className="background-image-container">
<picture>
<source src={studioImage} />
<img
className="background-image"
src={studioImage}
alt={`${studio.name} background`}
/>
</picture>
</div>
);
}
}
}
function maybeRenderTab() {
if (!isEditing) {
return renderTabs();
}
}
function maybeRenderEditPanel() {
if (isEditing) {
return (
<StudioEditPanel
studio={studio}
onSubmit={onSave}
onCancel={() => toggleEditing()}
onDelete={onDelete}
setImage={setImage}
setEncodingImage={setEncodingImage}
/>
);
}
{
return (
<DetailsEditNavbar
objectName={studio.name ?? intl.formatMessage({ id: "studio" })}
isNew={false}
isEditing={isEditing}
onToggleEdit={() => toggleEditing()}
onSave={() => {}}
onImageChange={() => {}}
onClearImage={() => {}}
onAutoTag={onAutoTag}
autoTagDisabled={studio.ignore_auto_tag}
onDelete={onDelete}
/>
);
}
}
const headerClassName = cx("detail-header", {
edit: isEditing,
collapsed,
@@ -544,41 +398,98 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
</Helmet>
<div className={headerClassName}>
{maybeRenderHeaderBackgroundImage()}
<BackgroundImage
imagePath={studio.image_path ?? undefined}
show={!enableBackgroundImage && !isEditing}
/>
<div className="detail-container">
<div className="detail-header-image">
{encodingImage ? (
<LoadingIndicator
message={intl.formatMessage({ id: "actions.encoding_image" })}
<HeaderImage encodingImage={encodingImage}>
{studioImage && (
<DetailImage
className="logo"
alt={studio.name}
src={studioImage}
/>
) : (
renderImage()
)}
</div>
</HeaderImage>
<div className="row">
<div className="studio-head col">
<h2>
<span className="studio-name">{studio.name}</span>
{maybeRenderShowCollapseButton()}
{renderClickableIcons()}
</h2>
{maybeRenderAliases()}
<DetailTitle name={studio.name ?? ""} classNamePrefix="studio">
{!isEditing && (
<ExpandCollapseButton
collapsed={collapsed}
setCollapsed={(v) => setCollapsed(v)}
/>
)}
<span className="name-icons">
<FavoriteIcon
favorite={studio.favorite}
onToggleFavorite={(v) => setFavorite(v)}
/>
<ExternalLinkButtons urls={urls} />
</span>
</DetailTitle>
<AliasList aliases={studio.aliases} />
<RatingSystem
value={studio.rating100}
onSetRating={(value) => setRating(value)}
clickToRate
withoutContext
/>
{maybeRenderDetails()}
{maybeRenderEditPanel()}
{!isEditing && (
<StudioDetailsPanel
studio={studio}
collapsed={collapsed}
fullWidth={!collapsed && !compactExpandedDetails}
/>
)}
{isEditing ? (
<StudioEditPanel
studio={studio}
onSubmit={onSave}
onCancel={() => toggleEditing()}
onDelete={onDelete}
setImage={setImage}
setEncodingImage={setEncodingImage}
/>
) : (
<DetailsEditNavbar
objectName={
studio.name ?? intl.formatMessage({ id: "studio" })
}
isNew={false}
isEditing={isEditing}
onToggleEdit={() => toggleEditing()}
onSave={() => {}}
onImageChange={() => {}}
onClearImage={() => {}}
onAutoTag={onAutoTag}
autoTagDisabled={studio.ignore_auto_tag}
onDelete={onDelete}
/>
)}
</div>
</div>
</div>
</div>
{maybeRenderCompressedDetails()}
{!isEditing && loadStickyHeader && (
<CompressedStudioDetailsPanel studio={studio} />
)}
<div className="detail-body">
<div className="studio-body">
<div className="studio-tabs">{maybeRenderTab()}</div>
<div className="studio-tabs">
{!isEditing && (
<StudioTabs
studio={studio}
tabKey={tabKey}
abbreviateCounter={abbreviateCounter}
showAllCounts={showAllCounts}
/>
)}
</div>
</div>
</div>
{renderDeleteAlert()}