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,4 @@
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";
@@ -14,7 +13,6 @@ 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";
import { useLightbox } from "src/hooks/Lightbox/hooks";
import { ModalComponent } from "src/components/Shared/Modal";
import { useToast } from "src/hooks/Toast";
import { GroupScenesPanel } from "./GroupScenesPanel";
@@ -23,12 +21,7 @@ import {
GroupDetailsPanel,
} from "./GroupDetailsPanel";
import { GroupEditPanel } from "./GroupEditPanel";
import {
faChevronDown,
faChevronUp,
faTrashAlt,
} from "@fortawesome/free-solid-svg-icons";
import { Icon } from "src/components/Shared/Icon";
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { ConfigurationContext } from "src/hooks/Config";
import { DetailImage } from "src/components/Shared/DetailImage";
@@ -36,6 +29,12 @@ import { useRatingKeybinds } from "src/hooks/keybinds";
import { useLoadStickyHeader } from "src/hooks/detailsPanel";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
import { ExternalLinksButton } from "src/components/Shared/ExternalLinksButton";
import { BackgroundImage } from "src/components/Shared/DetailsPage/BackgroundImage";
import { DetailTitle } from "src/components/Shared/DetailsPage/DetailTitle";
import { ExpandCollapseButton } from "src/components/Shared/CollapseButton";
import { AliasList } from "src/components/Shared/DetailsPage/AliasList";
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
import { LightboxLink } from "src/hooks/Lightbox/LightboxLink";
interface IProps {
group: GQL.GroupDataFragment;
@@ -69,42 +68,64 @@ const GroupPage: React.FC<IProps> = ({ group }) => {
const [backImage, setBackImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const defaultImage =
group.front_image_path && group.front_image_path.includes("default=true")
? true
: false;
const aliases = useMemo(
() => (group.aliases ? [group.aliases] : []),
[group.aliases]
);
const isDefaultImage =
group.front_image_path && group.front_image_path.includes("default=true");
const lightboxImages = useMemo(() => {
const covers = [
...(group.front_image_path && !defaultImage
? [
{
const covers = [];
if (group.front_image_path && !isDefaultImage) {
covers.push({
paths: {
thumbnail: group.front_image_path,
image: group.front_image_path,
},
},
]
: []),
...(group.back_image_path
? [
{
});
}
if (group.back_image_path) {
covers.push({
paths: {
thumbnail: group.back_image_path,
image: group.back_image_path,
},
},
]
: []),
];
return covers;
}, [group.front_image_path, group.back_image_path, defaultImage]);
const index = lightboxImages.length;
const showLightbox = useLightbox({
images: lightboxImages,
});
}
return covers;
}, [group.front_image_path, group.back_image_path, isDefaultImage]);
const activeFrontImage = useMemo(() => {
let existingImage = group.front_image_path;
if (isEditing) {
if (frontImage === null && existingImage) {
const imageURL = new URL(existingImage);
imageURL.searchParams.set("default", "true");
return imageURL.toString();
} else if (frontImage) {
return frontImage;
}
}
return existingImage;
}, [isEditing, group.front_image_path, frontImage]);
const activeBackImage = useMemo(() => {
let existingImage = group.back_image_path;
if (isEditing) {
if (backImage === null) {
return undefined;
} else if (backImage) {
return backImage;
}
}
return existingImage;
}, [isEditing, group.back_image_path, backImage]);
const [updateGroup, { loading: updating }] = useGroupUpdate();
const [deleteGroup, { loading: deleting }] = useGroupDestroy({
@@ -196,95 +217,6 @@ const GroupPage: React.FC<IProps> = ({ group }) => {
);
}
function getCollapseButtonIcon() {
return collapsed ? faChevronDown : faChevronUp;
}
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 renderFrontImage() {
let image = group.front_image_path;
if (isEditing) {
if (frontImage === null && image) {
const imageURL = new URL(image);
imageURL.searchParams.set("default", "true");
image = imageURL.toString();
} else if (frontImage) {
image = frontImage;
}
}
if (image && defaultImage) {
return (
<div className="group-image-container">
<DetailImage alt="Front Cover" src={image} />
</div>
);
} else if (image) {
return (
<Button
className="group-image-container"
variant="link"
onClick={() => showLightbox()}
>
<DetailImage alt="Front Cover" src={image} />
</Button>
);
}
}
function renderBackImage() {
let image = group.back_image_path;
if (isEditing) {
if (backImage === null) {
image = undefined;
} else if (backImage) {
image = backImage;
}
}
if (image) {
return (
<Button
className="group-image-container"
variant="link"
onClick={() => showLightbox(index - 1)}
>
<DetailImage alt="Back Cover" src={image} />
</Button>
);
}
}
const renderClickableIcons = () => (
<span className="name-icons">
{group.urls.length > 0 && <ExternalLinksButton urls={group.urls} />}
</span>
);
function maybeRenderAliases() {
if (group?.aliases) {
return (
<div>
<span className="alias-head">{group?.aliases}</span>
</div>
);
}
}
function setRating(v: number | null) {
if (group.id) {
updateGroup({
@@ -300,75 +232,6 @@ const GroupPage: React.FC<IProps> = ({ group }) => {
const renderTabs = () => <GroupScenesPanel active={true} group={group} />;
function maybeRenderDetails() {
if (!isEditing) {
return (
<GroupDetailsPanel
group={group}
collapsed={collapsed}
fullWidth={!collapsed && !compactExpandedDetails}
/>
);
}
}
function maybeRenderEditPanel() {
if (isEditing) {
return (
<GroupEditPanel
group={group}
onSubmit={onSave}
onCancel={() => toggleEditing()}
onDelete={onDelete}
setFrontImage={setFrontImage}
setBackImage={setBackImage}
setEncodingImage={setEncodingImage}
/>
);
}
{
return (
<DetailsEditNavbar
objectName={group.name}
isNew={false}
isEditing={isEditing}
onToggleEdit={() => toggleEditing()}
onSave={() => {}}
onImageChange={() => {}}
onDelete={onDelete}
/>
);
}
}
function maybeRenderCompressedDetails() {
if (!isEditing && loadStickyHeader) {
return <CompressedGroupDetailsPanel group={group} />;
}
}
function maybeRenderHeaderBackgroundImage() {
let image = group.front_image_path;
if (enableBackgroundImage && !isEditing && image) {
const imageURL = new URL(image);
let isDefaultImage = imageURL.searchParams.get("default");
if (!isDefaultImage) {
return (
<div className="background-image-container">
<picture>
<source src={image} />
<img
className="background-image"
src={image}
alt={`${group.name} background`}
/>
</picture>
</div>
);
}
}
}
function maybeRenderTab() {
if (!isEditing) {
return renderTabs();
@@ -390,43 +253,86 @@ const GroupPage: React.FC<IProps> = ({ group }) => {
</Helmet>
<div className={headerClassName}>
{maybeRenderHeaderBackgroundImage()}
<div className="detail-container">
<div className="detail-header-image">
<div className="logo w-100">
{encodingImage ? (
<LoadingIndicator
message={intl.formatMessage({ id: "actions.encoding_image" })}
<BackgroundImage
imagePath={group.front_image_path ?? undefined}
show={!enableBackgroundImage && !isEditing}
/>
) : (
<div className="detail-container">
<HeaderImage encodingImage={encodingImage}>
<div className="group-images">
{renderFrontImage()}
{renderBackImage()}
</div>
{!!activeFrontImage && (
<LightboxLink images={lightboxImages}>
<DetailImage alt="Front Cover" src={activeFrontImage} />
</LightboxLink>
)}
{!!activeBackImage && (
<LightboxLink
images={lightboxImages}
index={lightboxImages.length - 1}
>
<DetailImage alt="Back Cover" src={activeBackImage} />
</LightboxLink>
)}
</div>
</div>
</HeaderImage>
<div className="row">
<div className="group-head col">
<h2>
<span className="group-name">{group.name}</span>
{maybeRenderShowCollapseButton()}
{renderClickableIcons()}
</h2>
{maybeRenderAliases()}
<DetailTitle name={group.name} classNamePrefix="group">
{!isEditing && (
<ExpandCollapseButton
collapsed={collapsed}
setCollapsed={(v) => setCollapsed(v)}
/>
)}
<span className="name-icons">
<ExternalLinksButton urls={group.urls} />
</span>
</DetailTitle>
<AliasList aliases={aliases} />
<RatingSystem
value={group.rating100}
onSetRating={(value) => setRating(value)}
clickToRate
withoutContext
/>
{maybeRenderDetails()}
{maybeRenderEditPanel()}
{!isEditing && (
<GroupDetailsPanel
group={group}
collapsed={collapsed}
fullWidth={!collapsed && !compactExpandedDetails}
/>
)}
{isEditing ? (
<GroupEditPanel
group={group}
onSubmit={onSave}
onCancel={() => toggleEditing()}
onDelete={onDelete}
setFrontImage={setFrontImage}
setBackImage={setBackImage}
setEncodingImage={setEncodingImage}
/>
) : (
<DetailsEditNavbar
objectName={group.name}
isNew={false}
isEditing={isEditing}
onToggleEdit={() => toggleEditing()}
onSave={() => {}}
onImageChange={() => {}}
onDelete={onDelete}
/>
)}
</div>
</div>
</div>
</div>
{maybeRenderCompressedDetails()}
{!isEditing && loadStickyHeader && (
<CompressedGroupDetailsPanel group={group} />
)}
<div className="detail-body">
<div className="group-body">
<div className="group-tabs">{maybeRenderTab()}</div>

View File

@@ -277,6 +277,8 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
<FavoriteIcon
favorite={performer.favorite}
onToggleFavorite={onToggleFavorite}
size="2x"
className="hide-not-favorite"
/>
{maybeRenderRatingBanner()}
{maybeRenderFlag()}

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Tabs, Tab, Col, Row } from "react-bootstrap";
import React, { useEffect, useMemo, useState } from "react";
import { Tabs, Tab, Col, Row } from "react-bootstrap";
import { useIntl } from "react-intl";
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
import { Helmet } from "react-helmet";
@@ -12,12 +12,9 @@ import {
usePerformerDestroy,
mutateMetadataAutoTag,
} from "src/core/StashService";
import { Counter } from "src/components/Shared/Counter";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { ErrorMessage } from "src/components/Shared/ErrorMessage";
import { Icon } from "src/components/Shared/Icon";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { useLightbox } from "src/hooks/Lightbox/hooks";
import { useToast } from "src/hooks/Toast";
import { ConfigurationContext } from "src/hooks/Config";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
@@ -32,18 +29,22 @@ import { PerformerImagesPanel } from "./PerformerImagesPanel";
import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel";
import { PerformerEditPanel } from "./PerformerEditPanel";
import { PerformerSubmitButton } from "./PerformerSubmitButton";
import {
faChevronDown,
faChevronUp,
faHeart,
faLink,
} from "@fortawesome/free-solid-svg-icons";
import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { DetailImage } from "src/components/Shared/DetailImage";
import { useLoadStickyHeader } from "src/hooks/detailsPanel";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
import { ExternalLinksButton } from "src/components/Shared/ExternalLinksButton";
import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton";
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 { AliasList } from "src/components/Shared/DetailsPage/AliasList";
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
import { LightboxLink } from "src/hooks/Lightbox/LightboxLink";
interface IProps {
performer: GQL.PerformerDataFragment;
@@ -69,6 +70,136 @@ function isTabKey(tab: string): tab is TabKey {
return validTabs.includes(tab as TabKey);
}
const PerformerTabs: React.FC<{
tabKey?: TabKey;
performer: GQL.PerformerDataFragment;
abbreviateCounter: boolean;
}> = ({ tabKey, performer, abbreviateCounter }) => {
const populatedDefaultTab = useMemo(() => {
let ret: TabKey = "scenes";
if (performer.scene_count == 0) {
if (performer.gallery_count != 0) {
ret = "galleries";
} else if (performer.image_count != 0) {
ret = "images";
} else if (performer.group_count != 0) {
ret = "groups";
}
}
return ret;
}, [performer]);
const { setTabKey } = useTabKey({
tabKey,
validTabs,
defaultTabKey: populatedDefaultTab,
baseURL: `/performers/${performer.id}`,
});
useEffect(() => {
Mousetrap.bind("c", () => setTabKey("scenes"));
Mousetrap.bind("g", () => setTabKey("galleries"));
Mousetrap.bind("m", () => setTabKey("groups"));
return () => {
Mousetrap.unbind("c");
Mousetrap.unbind("g");
Mousetrap.unbind("m");
};
});
return (
<Tabs
id="performer-tabs"
mountOnEnter
unmountOnExit
activeKey={tabKey}
onSelect={setTabKey}
>
<Tab
eventKey="scenes"
title={
<TabTitleCounter
messageID="scenes"
count={performer.scene_count}
abbreviateCounter={abbreviateCounter}
/>
}
>
<PerformerScenesPanel
active={tabKey === "scenes"}
performer={performer}
/>
</Tab>
<Tab
eventKey="galleries"
title={
<TabTitleCounter
messageID="galleries"
count={performer.gallery_count}
abbreviateCounter={abbreviateCounter}
/>
}
>
<PerformerGalleriesPanel
active={tabKey === "galleries"}
performer={performer}
/>
</Tab>
<Tab
eventKey="images"
title={
<TabTitleCounter
messageID="images"
count={performer.image_count}
abbreviateCounter={abbreviateCounter}
/>
}
>
<PerformerImagesPanel
active={tabKey === "images"}
performer={performer}
/>
</Tab>
<Tab
eventKey="groups"
title={
<TabTitleCounter
messageID="groups"
count={performer.group_count}
abbreviateCounter={abbreviateCounter}
/>
}
>
<PerformerGroupsPanel
active={tabKey === "groups"}
performer={performer}
/>
</Tab>
<Tab
eventKey="appearswith"
title={
<TabTitleCounter
messageID="appears_with"
count={performer.performer_count}
abbreviateCounter={abbreviateCounter}
/>
}
>
<PerformerAppearsWithPanel
active={tabKey === "appearswith"}
performer={performer}
/>
</Tab>
</Tabs>
);
};
const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
const Toast = useToast();
const history = useHistory();
@@ -89,29 +220,6 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const loadStickyHeader = useLoadStickyHeader();
// a list of urls to display in the performer details
const urls = useMemo(() => {
if (!performer.urls?.length) {
return [];
}
const twitter = performer.urls.filter((u) =>
u.match(/https?:\/\/(?:www\.)?twitter.com\//)
);
const instagram = performer.urls.filter((u) =>
u.match(/https?:\/\/(?:www\.)?instagram.com\//)
);
const others = performer.urls.filter(
(u) => !twitter.includes(u) && !instagram.includes(u)
);
return [
{ icon: faLink, className: "", urls: others },
{ icon: faTwitter, className: "twitter", urls: twitter },
{ icon: faInstagram, className: "instagram", urls: instagram },
];
}, [performer.urls]);
const activeImage = useMemo(() => {
const performerImage = performer.image_path;
if (isEditing) {
@@ -131,46 +239,9 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
[activeImage]
);
const showLightbox = useLightbox({
images: lightboxImages,
});
const [updatePerformer] = usePerformerUpdate();
const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy();
const populatedDefaultTab = useMemo(() => {
let ret: TabKey = "scenes";
if (performer.scene_count == 0) {
if (performer.gallery_count != 0) {
ret = "galleries";
} else if (performer.image_count != 0) {
ret = "images";
} else if (performer.group_count != 0) {
ret = "groups";
}
}
return ret;
}, [performer]);
const setTabKey = useCallback(
(newTabKey: string | null) => {
if (!newTabKey) newTabKey = populatedDefaultTab;
if (newTabKey === tabKey) return;
if (isTabKey(newTabKey)) {
history.replace(`/performers/${performer.id}/${newTabKey}`);
}
},
[populatedDefaultTab, tabKey, history, performer.id]
);
useEffect(() => {
if (!tabKey) {
setTabKey(populatedDefaultTab);
}
}, [setTabKey, populatedDefaultTab, tabKey]);
async function onAutoTag() {
try {
await mutateMetadataAutoTag({ performers: [performer.id] });
@@ -189,17 +260,11 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
// set up hotkeys
useEffect(() => {
Mousetrap.bind("e", () => toggleEditing());
Mousetrap.bind("c", () => setTabKey("scenes"));
Mousetrap.bind("g", () => setTabKey("galleries"));
Mousetrap.bind("m", () => setTabKey("groups"));
Mousetrap.bind("f", () => setFavorite(!performer.favorite));
Mousetrap.bind(",", () => setCollapsed(!collapsed));
return () => {
Mousetrap.unbind("e");
Mousetrap.unbind("c");
Mousetrap.unbind("g");
Mousetrap.unbind("m");
Mousetrap.unbind("f");
Mousetrap.unbind(",");
};
@@ -243,221 +308,6 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
setImage(undefined);
}
function renderImage() {
if (activeImage) {
return (
<Button variant="link" onClick={() => showLightbox()}>
<DetailImage
className="performer"
src={activeImage}
alt={performer.name}
/>
</Button>
);
}
}
const renderTabs = () => (
<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
/>
</>
}
>
<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="groups"
title={
<>
{intl.formatMessage({ id: "groups" })}
<Counter
abbreviateCounter={abbreviateCounter}
count={performer.group_count}
hideZero
/>
</>
}
>
<PerformerGroupsPanel
active={tabKey === "groups"}
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() {
if (enableBackgroundImage && !isEditing && activeImage) {
const activeImageURL = new URL(activeImage);
let isDefaultImage = activeImageURL.searchParams.get("default");
if (!isDefaultImage) {
return (
<div className="background-image-container">
<picture>
<source src={activeImage} />
<img
className="background-image"
src={activeImage}
alt={`${performer.name} background`}
/>
</picture>
</div>
);
}
}
}
function maybeRenderEditPanel() {
if (isEditing) {
return (
<PerformerEditPanel
performer={performer}
isVisible={isEditing}
onSubmit={onSave}
onCancel={() => toggleEditing()}
setImage={setImage}
setEncodingImage={setEncodingImage}
/>
);
}
{
return (
<Col>
<Row xs={8}>
<DetailsEditNavbar
objectName={
performer?.name ?? intl.formatMessage({ id: "performer" })
}
onToggleEdit={() => toggleEditing()}
onDelete={onDelete}
onAutoTag={onAutoTag}
autoTagDisabled={performer.ignore_auto_tag}
isNew={false}
isEditing={false}
onSave={() => {}}
onImageChange={() => {}}
classNames="mb-2"
customButtons={
<div>
<PerformerSubmitButton performer={performer} />
</div>
}
></DetailsEditNavbar>
</Row>
</Col>
);
}
}
function getCollapseButtonIcon() {
return collapsed ? faChevronDown : faChevronUp;
}
function maybeRenderDetails() {
if (!isEditing) {
return (
<PerformerDetailsPanel
performer={performer}
collapsed={collapsed}
fullWidth={!collapsed && !compactExpandedDetails}
/>
);
}
}
function maybeRenderCompressedDetails() {
if (!isEditing && loadStickyHeader) {
return <CompressedPerformerDetailsPanel performer={performer} />;
}
}
function maybeRenderTab() {
if (!isEditing) {
return renderTabs();
}
}
function maybeRenderAliases() {
if (performer?.alias_list?.length) {
return (
<div>
<span className="alias-head">{performer.alias_list?.join(", ")}</span>
</div>
);
}
}
function setFavorite(v: boolean) {
if (performer.id) {
updatePerformer({
@@ -484,45 +334,6 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
}
}
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 renderClickableIcons() {
return (
<span className="name-icons">
<Button
className={cx(
"minimal",
performer.favorite ? "favorite" : "not-favorite"
)}
onClick={() => setFavorite(!performer.favorite)}
>
<Icon icon={faHeart} />
</Button>
{urls.map((url) => (
<ExternalLinksButton
key={url.icon.iconName}
icon={url.icon}
className={url.className}
urls={url.urls}
/>
))}
</span>
);
}
if (isDestroying)
return (
<LoadingIndicator
@@ -543,46 +354,113 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
</Helmet>
<div className={headerClassName}>
{maybeRenderHeaderBackgroundImage()}
<div className="detail-container">
<div className="detail-header-image">
{encodingImage ? (
<LoadingIndicator
message={intl.formatMessage({ id: "actions.encoding_image" })}
<BackgroundImage
imagePath={activeImage ?? undefined}
show={enableBackgroundImage && !isEditing}
/>
) : (
renderImage()
<div className="detail-container">
<HeaderImage encodingImage={encodingImage}>
{!!activeImage && (
<LightboxLink images={lightboxImages}>
<DetailImage
className="performer"
src={activeImage}
alt={performer.name}
/>
</LightboxLink>
)}
</div>
</HeaderImage>
<div className="row">
<div className="performer-head col">
<h2>
<span className="performer-name">{performer.name}</span>
{performer.disambiguation && (
<span className="performer-disambiguation">
{` (${performer.disambiguation})`}
</span>
<DetailTitle
name={performer.name}
disambiguation={performer.disambiguation ?? undefined}
classNamePrefix="performer"
>
{!isEditing && (
<ExpandCollapseButton
collapsed={collapsed}
setCollapsed={(v) => setCollapsed(v)}
/>
)}
{maybeRenderShowCollapseButton()}
{renderClickableIcons()}
</h2>
{maybeRenderAliases()}
<span className="name-icons">
<FavoriteIcon
favorite={performer.favorite}
onToggleFavorite={(v) => setFavorite(v)}
/>
<ExternalLinkButtons urls={performer.urls ?? undefined} />
</span>
</DetailTitle>
<AliasList aliases={performer.alias_list} />
<RatingSystem
value={performer.rating100}
onSetRating={(value) => setRating(value)}
clickToRate
withoutContext
/>
{maybeRenderDetails()}
{maybeRenderEditPanel()}
{!isEditing && (
<PerformerDetailsPanel
performer={performer}
collapsed={collapsed}
fullWidth={!collapsed && !compactExpandedDetails}
/>
)}
{isEditing ? (
<PerformerEditPanel
performer={performer}
isVisible={isEditing}
onSubmit={onSave}
onCancel={() => toggleEditing()}
setImage={setImage}
setEncodingImage={setEncodingImage}
/>
) : (
<Col>
<Row xs={8}>
<DetailsEditNavbar
objectName={
performer?.name ??
intl.formatMessage({ id: "performer" })
}
onToggleEdit={() => toggleEditing()}
onDelete={onDelete}
onAutoTag={onAutoTag}
autoTagDisabled={performer.ignore_auto_tag}
isNew={false}
isEditing={false}
onSave={() => {}}
onImageChange={() => {}}
classNames="mb-2"
customButtons={
<div>
<PerformerSubmitButton performer={performer} />
</div>
}
></DetailsEditNavbar>
</Row>
</Col>
)}
</div>
</div>
</div>
</div>
{maybeRenderCompressedDetails()}
{!isEditing && loadStickyHeader && (
<CompressedPerformerDetailsPanel performer={performer} />
)}
<div className="detail-body">
<div className="performer-body">
<div className="performer-tabs">{maybeRenderTab()}</div>
<div className="performer-tabs">
{!isEditing && (
<PerformerTabs
tabKey={tabKey}
performer={performer}
abbreviateCounter={abbreviateCounter}
/>
)}
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import {
faChevronDown,
faChevronRight,
faChevronUp,
} from "@fortawesome/free-solid-svg-icons";
import React, { useState } from "react";
import { Button, Collapse } from "react-bootstrap";
@@ -30,3 +31,21 @@ export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
</div>
);
};
export const ExpandCollapseButton: React.FC<{
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}> = ({ collapsed, setCollapsed }) => {
const buttonIcon = collapsed ? faChevronDown : faChevronUp;
return (
<span className="detail-expand-collapse">
<Button
className="minimal expand-collapse"
onClick={() => setCollapsed(!collapsed)}
>
<Icon className="fa-fw" icon={buttonIcon} />
</Button>
</span>
);
};

View File

@@ -0,0 +1,13 @@
export const AliasList: React.FC<{ aliases: string[] | undefined }> = ({
aliases,
}) => {
if (!aliases?.length) {
return null;
}
return (
<div>
<span className="alias-head">{aliases.join(", ")}</span>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import React from "react";
export const BackgroundImage: React.FC<{
imagePath: string | undefined;
show: boolean;
alt?: string;
}> = ({ imagePath, show, alt }) => {
if (imagePath && show) {
const imageURL = new URL(imagePath);
let isDefaultImage = imageURL.searchParams.get("default");
if (!isDefaultImage) {
return (
<div className="background-image-container">
<picture>
<source src={imagePath} />
<img className="background-image" src={imagePath} alt={alt} />
</picture>
</div>
);
}
}
return null;
};

View File

@@ -0,0 +1,21 @@
import React, { PropsWithChildren } from "react";
export const DetailTitle: React.FC<
PropsWithChildren<{
name: string;
disambiguation?: string;
classNamePrefix: string;
}>
> = ({ name, disambiguation, classNamePrefix, children }) => {
return (
<h2>
<span className={`${classNamePrefix}-name`}>{name}</span>
{disambiguation && (
<span className={`${classNamePrefix}-disambiguation`}>
{` (${disambiguation})`}
</span>
)}
{children}
</h2>
);
};

View File

@@ -0,0 +1,21 @@
import { PropsWithChildren } from "react";
import { LoadingIndicator } from "../LoadingIndicator";
import { FormattedMessage } from "react-intl";
export const HeaderImage: React.FC<
PropsWithChildren<{
encodingImage: boolean;
}>
> = ({ encodingImage, children }) => {
return (
<div className="detail-header-image">
{encodingImage ? (
<LoadingIndicator
message={<FormattedMessage id="actions.encoding_image" />}
/>
) : (
children
)}
</div>
);
};

View File

@@ -0,0 +1,48 @@
import { FormattedMessage } from "react-intl";
import { Counter } from "../Counter";
import { useCallback, useEffect } from "react";
import { useHistory } from "react-router-dom";
export const TabTitleCounter: React.FC<{
messageID: string;
count: number;
abbreviateCounter: boolean;
}> = ({ messageID, count, abbreviateCounter }) => {
return (
<>
<FormattedMessage id={messageID} />
<Counter count={count} abbreviateCounter={abbreviateCounter} hideZero />
</>
);
};
export function useTabKey(props: {
tabKey: string | undefined;
validTabs: readonly string[];
defaultTabKey: string;
baseURL: string;
}) {
const { tabKey, validTabs, defaultTabKey, baseURL } = props;
const history = useHistory();
const setTabKey = useCallback(
(newTabKey: string | null) => {
if (!newTabKey) newTabKey = defaultTabKey;
if (newTabKey === tabKey) return;
if (validTabs.includes(newTabKey)) {
history.replace(`${baseURL}/${newTabKey}`);
}
},
[defaultTabKey, validTabs, tabKey, history, baseURL]
);
useEffect(() => {
if (!tabKey) {
setTabKey(defaultTabKey);
}
}, [setTabKey, defaultTabKey, tabKey]);
return { setTabKey };
}

View File

@@ -3,6 +3,8 @@ import { ExternalLink } from "./ExternalLink";
import TextUtils from "src/utils/text";
import { Icon } from "./Icon";
import { IconDefinition, faLink } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons";
export const ExternalLinksButton: React.FC<{
icon?: IconDefinition;
@@ -47,3 +49,37 @@ export const ExternalLinksButton: React.FC<{
</Dropdown>
);
};
export const ExternalLinkButtons: React.FC<{ urls: string[] | undefined }> = ({
urls,
}) => {
const urlSpecs = useMemo(() => {
if (!urls?.length) {
return [];
}
const twitter = urls.filter((u) =>
u.match(/https?:\/\/(?:www\.)?(?:twitter|x).com\//)
);
const instagram = urls.filter((u) =>
u.match(/https?:\/\/(?:www\.)?instagram.com\//)
);
const others = urls.filter(
(u) => !twitter.includes(u) && !instagram.includes(u)
);
return [
{ icon: faLink, className: "", urls: others },
{ icon: faTwitter, className: "twitter", urls: twitter },
{ icon: faInstagram, className: "instagram", urls: instagram },
];
}, [urls]);
return (
<>
{urlSpecs.map((spec, i) => (
<ExternalLinksButton key={i} {...spec} />
))}
</>
);
};

View File

@@ -3,22 +3,26 @@ import { Icon } from "../Shared/Icon";
import { Button } from "react-bootstrap";
import { faHeart } from "@fortawesome/free-solid-svg-icons";
import cx from "classnames";
import { SizeProp } from "@fortawesome/fontawesome-svg-core";
export const FavoriteIcon: React.FC<{
favorite: boolean;
onToggleFavorite: (v: boolean) => void;
}> = ({ favorite, onToggleFavorite }) => {
size?: SizeProp;
className?: string;
}> = ({ favorite, onToggleFavorite, size, className }) => {
return (
<Button
className={cx(
"minimal",
"mousetrap",
"favorite-button",
className,
favorite ? "favorite" : "not-favorite"
)}
onClick={() => onToggleFavorite!(!favorite)}
>
<Icon icon={faHeart} size="2x" />
<Icon icon={faHeart} size={size} />
</Button>
);
};

View File

@@ -4,7 +4,7 @@ import cx from "classnames";
import { useIntl } from "react-intl";
interface ILoadingProps {
message?: string;
message?: JSX.Element | string;
inline?: boolean;
small?: boolean;
card?: boolean;

View File

@@ -536,8 +536,11 @@ button.btn.favorite-button {
&.not-favorite {
color: rgba(191, 204, 214, 0.5);
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));
&.hide-not-favorite {
opacity: 0;
}
}
&.favorite {
color: #ff7373;

View File

@@ -236,6 +236,8 @@ export const StudioCard: React.FC<IProps> = ({
<FavoriteIcon
favorite={studio.favorite}
onToggleFavorite={(v) => onToggleFavorite(v)}
size="2x"
className="hide-not-favorite"
/>
}
popovers={maybeRenderPopoverButtonGroup()}

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}`);
return (
<Tabs
id="studio-tabs"
mountOnEnter
unmountOnExit
activeKey={tabKey}
onSelect={setTabKey}
>
<Tab
eventKey="scenes"
title={
<TabTitleCounter
messageID="scenes"
count={sceneCount}
abbreviateCounter={abbreviateCounter}
/>
}
},
[populatedDefaultTab, tabKey, history, studio.id]
>
<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()}
<div className="detail-container">
<div className="detail-header-image">
{encodingImage ? (
<LoadingIndicator
message={intl.formatMessage({ id: "actions.encoding_image" })}
<BackgroundImage
imagePath={studio.image_path ?? undefined}
show={!enableBackgroundImage && !isEditing}
/>
<div className="detail-container">
<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()}

View File

@@ -1,5 +1,5 @@
import { Tabs, Tab, Dropdown, Button } from "react-bootstrap";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Tabs, Tab, Dropdown } 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,7 +13,6 @@ import {
useTagDestroy,
mutateMetadataAutoTag,
} from "src/core/StashService";
import { Counter } from "src/components/Shared/Counter";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { ErrorMessage } from "src/components/Shared/ErrorMessage";
import { ModalComponent } from "src/components/Shared/Modal";
@@ -32,9 +31,6 @@ import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel";
import { TagEditPanel } from "./TagEditPanel";
import { TagMergeModal } from "./TagMergeDialog";
import {
faChevronDown,
faChevronUp,
faHeart,
faSignInAlt,
faSignOutAlt,
faTrashAlt,
@@ -43,6 +39,16 @@ import { DetailImage } from "src/components/Shared/DetailImage";
import { useLoadStickyHeader } from "src/hooks/detailsPanel";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
import { TagGroupsPanel } from "./TagGroupsPanel";
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 { AliasList } from "src/components/Shared/DetailsPage/AliasList";
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
interface IProps {
tag: GQL.TagDataFragment;
@@ -70,35 +76,12 @@ 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();
// Configuration settings
const { configuration } = React.useContext(ConfigurationContext);
const uiConfig = configuration?.ui;
const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;
const enableBackgroundImage = uiConfig?.enableTagBackgroundImage ?? 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);
const [mergeType, setMergeType] = useState<"from" | "into" | undefined>();
// Editing tag state
const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const [updateTag] = useTagUpdate();
const [deleteTag] = useTagDestroy({ id: tag.id });
const showAllCounts = uiConfig?.showChildTagContent;
const TagTabs: React.FC<{
tabKey?: TabKey;
tag: GQL.TagDataFragment;
abbreviateCounter: boolean;
showAllCounts?: boolean;
}> = ({ tabKey, tag, abbreviateCounter, showAllCounts = false }) => {
const sceneCount =
(showAllCounts ? tag.scene_count_all : tag.scene_count) ?? 0;
const imageCount =
@@ -143,23 +126,153 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
groupCount,
]);
const setTabKey = useCallback(
(newTabKey: string | null) => {
if (!newTabKey) newTabKey = populatedDefaultTab;
if (newTabKey === tabKey) return;
const { setTabKey } = useTabKey({
tabKey,
validTabs,
defaultTabKey: populatedDefaultTab,
baseURL: `/tags/${tag.id}`,
});
if (isTabKey(newTabKey)) {
history.replace(`/tags/${tag.id}/${newTabKey}`);
return (
<Tabs
id="tag-tabs"
mountOnEnter
unmountOnExit
activeKey={tabKey}
onSelect={setTabKey}
>
<Tab
eventKey="scenes"
title={
<TabTitleCounter
messageID="scenes"
count={sceneCount}
abbreviateCounter={abbreviateCounter}
/>
}
},
[populatedDefaultTab, tabKey, history, tag.id]
>
<TagScenesPanel active={tabKey === "scenes"} tag={tag} />
</Tab>
<Tab
eventKey="images"
title={
<TabTitleCounter
messageID="images"
count={imageCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<TagImagesPanel active={tabKey === "images"} tag={tag} />
</Tab>
<Tab
eventKey="galleries"
title={
<TabTitleCounter
messageID="galleries"
count={galleryCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<TagGalleriesPanel active={tabKey === "galleries"} tag={tag} />
</Tab>
<Tab
eventKey="groups"
title={
<TabTitleCounter
messageID="groups"
count={groupCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<TagGroupsPanel active={tabKey === "groups"} tag={tag} />
</Tab>
<Tab
eventKey="markers"
title={
<TabTitleCounter
messageID="markers"
count={sceneMarkerCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<TagMarkersPanel active={tabKey === "markers"} tag={tag} />
</Tab>
<Tab
eventKey="performers"
title={
<TabTitleCounter
messageID="performers"
count={performerCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<TagPerformersPanel active={tabKey === "performers"} tag={tag} />
</Tab>
<Tab
eventKey="studios"
title={
<TabTitleCounter
messageID="studios"
count={studioCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<TagStudiosPanel active={tabKey === "studios"} tag={tag} />
</Tab>
</Tabs>
);
};
useEffect(() => {
if (!tabKey) {
setTabKey(populatedDefaultTab);
const TagPage: React.FC<IProps> = ({ tag, 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?.enableTagBackgroundImage ?? 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);
const [mergeType, setMergeType] = useState<"from" | "into" | undefined>();
// Editing tag state
const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const [updateTag] = useTagUpdate();
const [deleteTag] = useTagDestroy({ id: tag.id });
const showAllCounts = uiConfig?.showChildTagContent;
const tagImage = useMemo(() => {
let existingImage = tag.image_path;
if (isEditing) {
if (image === null && existingImage) {
const tagImageURL = new URL(existingImage);
tagImageURL.searchParams.set("default", "true");
return tagImageURL.toString();
} else if (image) {
return image;
}
}, [setTabKey, populatedDefaultTab, tabKey]);
}
return existingImage;
}, [isEditing, tag.image_path, image]);
function setFavorite(v: boolean) {
if (tag.id) {
@@ -279,35 +392,6 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
);
}
function getCollapseButtonIcon() {
return collapsed ? faChevronDown : faChevronUp;
}
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 maybeRenderAliases() {
if (tag?.aliases?.length) {
return (
<div>
<span className="alias-head">{tag?.aliases?.join(", ")}</span>
</div>
);
}
}
function toggleEditing(value?: boolean) {
if (value !== undefined) {
setIsEditing(value);
@@ -317,34 +401,6 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
setImage(undefined);
}
function renderImage() {
let tagImage = tag.image_path;
if (isEditing) {
if (image === null && tagImage) {
const tagImageURL = new URL(tagImage);
tagImageURL.searchParams.set("default", "true");
tagImage = tagImageURL.toString();
} else if (image) {
tagImage = image;
}
}
if (tagImage) {
return <DetailImage className="logo" alt={tag.name} src={tagImage} />;
}
}
const renderClickableIcons = () => (
<span className="name-icons">
<Button
className={cx("minimal", tag.favorite ? "favorite" : "not-favorite")}
onClick={() => setFavorite(!tag.favorite)}
>
<Icon icon={faHeart} />
</Button>
</span>
);
function renderMergeButton() {
return (
<Dropdown>
@@ -386,20 +442,54 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
);
}
function maybeRenderDetails() {
if (!isEditing) {
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={headerClassName}>
<BackgroundImage
imagePath={tag.image_path ?? undefined}
show={enableBackgroundImage && !isEditing}
/>
<div className="detail-container">
<HeaderImage encodingImage={encodingImage}>
{tagImage && (
<DetailImage className="logo" alt={tag.name} src={tagImage} />
)}
</HeaderImage>
<div className="row">
<div className="tag-head col">
<DetailTitle name={tag.name} classNamePrefix="tag">
{!isEditing && (
<ExpandCollapseButton
collapsed={collapsed}
setCollapsed={(v) => setCollapsed(v)}
/>
)}
<span className="name-icons">
<FavoriteIcon
favorite={tag.favorite}
onToggleFavorite={(v) => setFavorite(v)}
/>
</span>
</DetailTitle>
<AliasList aliases={tag.aliases} />
{!isEditing && (
<TagDetailsPanel
tag={tag}
fullWidth={!collapsed && !compactExpandedDetails}
/>
);
}
}
function maybeRenderEditPanel() {
if (isEditing) {
return (
)}
{isEditing ? (
<TagEditPanel
tag={tag}
onSubmit={onSave}
@@ -408,10 +498,7 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
setImage={setImage}
setEncodingImage={setEncodingImage}
/>
);
}
{
return (
) : (
<DetailsEditNavbar
objectName={tag.name}
isNew={false}
@@ -426,202 +513,28 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
classNames="mb-2"
customButtons={renderMergeButton()}
/>
);
}
}
const renderTabs = () => (
<Tabs
id="tag-tabs"
mountOnEnter
unmountOnExit
activeKey={tabKey}
onSelect={setTabKey}
>
<Tab
eventKey="scenes"
title={
<>
{intl.formatMessage({ id: "scenes" })}
<Counter
abbreviateCounter={abbreviateCounter}
count={sceneCount}
hideZero
/>
</>
}
>
<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="groups"
title={
<>
{intl.formatMessage({ id: "groups" })}
<Counter
abbreviateCounter={abbreviateCounter}
count={groupCount}
hideZero
/>
</>
}
>
<TagGroupsPanel active={tabKey === "groups"} 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>
<Tab
eventKey="studios"
title={
<>
{intl.formatMessage({ id: "studios" })}
<Counter
abbreviateCounter={abbreviateCounter}
count={studioCount}
hideZero
/>
</>
}
>
<TagStudiosPanel active={tabKey === "studios"} tag={tag} />
</Tab>
</Tabs>
);
function maybeRenderHeaderBackgroundImage() {
let tagImage = tag.image_path;
if (enableBackgroundImage && !isEditing && tagImage) {
const tagImageURL = new URL(tagImage);
let isDefaultImage = tagImageURL.searchParams.get("default");
if (!isDefaultImage) {
return (
<div className="background-image-container">
<picture>
<source src={tagImage} />
<img
className="background-image"
src={tagImage}
alt={`${tag.name} background`}
/>
</picture>
</div>
);
}
}
}
function maybeRenderTab() {
if (!isEditing) {
return renderTabs();
}
}
function maybeRenderCompressedDetails() {
if (!isEditing && loadStickyHeader) {
return <CompressedTagDetailsPanel tag={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={headerClassName}>
{maybeRenderHeaderBackgroundImage()}
<div className="detail-container">
<div className="detail-header-image">
{encodingImage ? (
<LoadingIndicator
message={intl.formatMessage({ id: "actions.encoding_image" })}
/>
) : (
renderImage()
)}
</div>
<div className="row">
<div className="tag-head col">
<h2>
<span className="tag-name">{tag.name}</span>
{maybeRenderShowCollapseButton()}
{renderClickableIcons()}
</h2>
{maybeRenderAliases()}
{maybeRenderDetails()}
{maybeRenderEditPanel()}
</div>
</div>
</div>
</div>
{maybeRenderCompressedDetails()}
{!isEditing && loadStickyHeader && (
<CompressedTagDetailsPanel tag={tag} />
)}
<div className="detail-body">
<div className="tag-body">
<div className="tag-tabs">{maybeRenderTab()}</div>
<div className="tag-tabs">
{!isEditing && (
<TagTabs
tabKey={tabKey}
tag={tag}
abbreviateCounter={abbreviateCounter}
showAllCounts={showAllCounts}
/>
)}
</div>
</div>
</div>
{renderDeleteAlert()}

View File

@@ -0,0 +1,22 @@
import { PropsWithChildren } from "react";
import { useLightbox } from "./hooks";
import { ILightboxImage } from "./types";
import { Button } from "react-bootstrap";
export const LightboxLink: React.FC<
PropsWithChildren<{ images?: ILightboxImage[] | undefined; index?: number }>
> = ({ images, index, children }) => {
const showLightbox = useLightbox({
images,
});
if (!images || images.length === 0) {
return <>{children}</>;
}
return (
<Button variant="link" onClick={() => showLightbox(index)}>
{children}
</Button>
);
};