Add Icons to tags if they have parent/child tags (#3931)

* Add Icons to tags if they have parent/child tags
* Refactor TagLink
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
elkorol
2023-09-20 05:08:00 +01:00
committed by GitHub
parent 36e9ed7a6c
commit 636b0a3167
27 changed files with 421 additions and 137 deletions

View File

@@ -13,12 +13,10 @@ fragment SceneMarkerData on SceneMarker {
primary_tag { primary_tag {
id id
name name
aliases
} }
tags { tags {
id id
name name
aliases
} }
} }

View File

@@ -3,4 +3,6 @@ fragment SlimTagData on Tag {
name name
aliases aliases
image_path image_path
parent_count
child_count
} }

View File

@@ -15,6 +15,9 @@ type Tag {
performer_count(depth: Int): Int! # Resolver performer_count(depth: Int): Int! # Resolver
parents: [Tag!]! parents: [Tag!]!
children: [Tag!]! children: [Tag!]!
parent_count: Int! # Resolver
child_count: Int! # Resolver
} }
input TagCreateInput { input TagCreateInput {

View File

@@ -113,3 +113,25 @@ func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string,
imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj).GetTagImageURL(hasImage) imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj).GetTagImageURL(hasImage)
return &imagePath, nil return &imagePath, nil
} }
func (r *tagResolver) ParentCount(ctx context.Context, obj *models.Tag) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Tag.CountByParentTagID(ctx, obj.ID)
return err
}); err != nil {
return ret, err
}
return ret, nil
}
func (r *tagResolver) ChildCount(ctx context.Context, obj *models.Tag) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Tag.CountByChildTagID(ctx, obj.ID)
return err
}); err != nil {
return ret, err
}
return ret, nil
}

View File

@@ -58,6 +58,48 @@ func (_m *TagReaderWriter) Count(ctx context.Context) (int, error) {
return r0, r1 return r0, r1
} }
// CountByChildTagID provides a mock function with given fields: ctx, childID
func (_m *TagReaderWriter) CountByChildTagID(ctx context.Context, childID int) (int, error) {
ret := _m.Called(ctx, childID)
var r0 int
if rf, ok := ret.Get(0).(func(context.Context, int) int); ok {
r0 = rf(ctx, childID)
} else {
r0 = ret.Get(0).(int)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, childID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CountByParentTagID provides a mock function with given fields: ctx, parentID
func (_m *TagReaderWriter) CountByParentTagID(ctx context.Context, parentID int) (int, error) {
ret := _m.Called(ctx, parentID)
var r0 int
if rf, ok := ret.Get(0).(func(context.Context, int) int); ok {
r0 = rf(ctx, parentID)
} else {
r0 = ret.Get(0).(int)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, parentID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Create provides a mock function with given fields: ctx, newTag // Create provides a mock function with given fields: ctx, newTag
func (_m *TagReaderWriter) Create(ctx context.Context, newTag *models.Tag) error { func (_m *TagReaderWriter) Create(ctx context.Context, newTag *models.Tag) error {
ret := _m.Called(ctx, newTag) ret := _m.Called(ctx, newTag)

View File

@@ -42,6 +42,8 @@ type TagAutoTagQueryer interface {
// TagCounter provides methods to count tags. // TagCounter provides methods to count tags.
type TagCounter interface { type TagCounter interface {
Count(ctx context.Context) (int, error) Count(ctx context.Context) (int, error)
CountByParentTagID(ctx context.Context, parentID int) (int, error)
CountByChildTagID(ctx context.Context, childID int) (int, error)
} }
// TagCreator provides methods to create tags. // TagCreator provides methods to create tags.

View File

@@ -396,6 +396,20 @@ func (qb *TagStore) FindByChildTagID(ctx context.Context, parentID int) ([]*mode
return qb.queryTags(ctx, query, args) return qb.queryTags(ctx, query, args)
} }
func (qb *TagStore) CountByParentTagID(ctx context.Context, parentID int) (int, error) {
q := dialect.Select(goqu.COUNT("*")).From(goqu.T("tags")).
InnerJoin(goqu.T("tags_relations"), goqu.On(goqu.I("tags_relations.parent_id").Eq(goqu.I("tags.id")))).
Where(goqu.I("tags_relations.child_id").Eq(goqu.V(parentID))) // Pass the parentID here
return count(ctx, q)
}
func (qb *TagStore) CountByChildTagID(ctx context.Context, childID int) (int, error) {
q := dialect.Select(goqu.COUNT("*")).From(goqu.T("tags")).
InnerJoin(goqu.T("tags_relations"), goqu.On(goqu.I("tags_relations.child_id").Eq(goqu.I("tags.id")))).
Where(goqu.I("tags_relations.parent_id").Eq(goqu.V(childID))) // Pass the childID here
return count(ctx, q)
}
func (qb *TagStore) Count(ctx context.Context) (int, error) { func (qb *TagStore) Count(ctx context.Context) (int, error) {
q := dialect.Select(goqu.COUNT("*")).From(qb.table()) q := dialect.Select(goqu.COUNT("*")).From(qb.table())
return count(ctx, q) return count(ctx, q)

View File

@@ -5,7 +5,7 @@ import * as GQL from "src/core/generated-graphql";
import { GridCard } from "../Shared/GridCard"; import { GridCard } from "../Shared/GridCard";
import { HoverPopover } from "../Shared/HoverPopover"; import { HoverPopover } from "../Shared/HoverPopover";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { TagLink } from "../Shared/TagLink"; import { SceneLink, TagLink } from "../Shared/TagLink";
import { TruncatedText } from "../Shared/TruncatedText"; import { TruncatedText } from "../Shared/TruncatedText";
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { PopoverCountButton } from "../Shared/PopoverCountButton";
@@ -31,7 +31,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
if (props.gallery.scenes.length === 0) return; if (props.gallery.scenes.length === 0) return;
const popoverContent = props.gallery.scenes.map((scene) => ( const popoverContent = props.gallery.scenes.map((scene) => (
<TagLink key={scene.id} scene={scene} /> <SceneLink key={scene.id} scene={scene} />
)); ));
return ( return (
@@ -52,7 +52,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
if (props.gallery.tags.length <= 0) return; if (props.gallery.tags.length <= 0) return;
const popoverContent = props.gallery.tags.map((tag) => ( const popoverContent = props.gallery.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} tagType="gallery" /> <TagLink key={tag.id} tag={tag} linkType="gallery" />
)); ));
return ( return (

View File

@@ -34,7 +34,7 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
function renderTags() { function renderTags() {
if (gallery.tags.length === 0) return; if (gallery.tags.length === 0) return;
const tags = gallery.tags.map((tag) => ( const tags = gallery.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} tagType="gallery" /> <TagLink key={tag.id} tag={tag} linkType="gallery" />
)); ));
return ( return (
<> <>

View File

@@ -3,7 +3,7 @@ import { Button, ButtonGroup } from "react-bootstrap";
import cx from "classnames"; import cx from "classnames";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Icon } from "src/components/Shared/Icon"; import { Icon } from "src/components/Shared/Icon";
import { TagLink } from "src/components/Shared/TagLink"; import { GalleryLink, TagLink } from "src/components/Shared/TagLink";
import { HoverPopover } from "src/components/Shared/HoverPopover"; import { HoverPopover } from "src/components/Shared/HoverPopover";
import { SweatDrops } from "src/components/Shared/SweatDrops"; import { SweatDrops } from "src/components/Shared/SweatDrops";
import { PerformerPopoverButton } from "src/components/Shared/PerformerPopoverButton"; import { PerformerPopoverButton } from "src/components/Shared/PerformerPopoverButton";
@@ -41,7 +41,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
if (props.image.tags.length <= 0) return; if (props.image.tags.length <= 0) return;
const popoverContent = props.image.tags.map((tag) => ( const popoverContent = props.image.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} tagType="image" /> <TagLink key={tag.id} tag={tag} linkType="image" />
)); ));
return ( return (
@@ -83,7 +83,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
if (props.image.galleries.length <= 0) return; if (props.image.galleries.length <= 0) return;
const popoverContent = props.image.galleries.map((gallery) => ( const popoverContent = props.image.galleries.map((gallery) => (
<TagLink key={gallery.id} gallery={gallery} /> <GalleryLink key={gallery.id} gallery={gallery} />
)); ));
return ( return (

View File

@@ -2,7 +2,7 @@ import React, { useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { TagLink } from "src/components/Shared/TagLink"; import { GalleryLink, TagLink } from "src/components/Shared/TagLink";
import { TruncatedText } from "src/components/Shared/TruncatedText"; import { TruncatedText } from "src/components/Shared/TruncatedText";
import { PerformerCard } from "src/components/Performers/PerformerCard"; import { PerformerCard } from "src/components/Performers/PerformerCard";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
@@ -24,7 +24,7 @@ export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
function renderTags() { function renderTags() {
if (props.image.tags.length === 0) return; if (props.image.tags.length === 0) return;
const tags = props.image.tags.map((tag) => ( const tags = props.image.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} tagType="image" /> <TagLink key={tag.id} tag={tag} linkType="image" />
)); ));
return ( return (
<> <>
@@ -67,8 +67,8 @@ export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
function renderGalleries() { function renderGalleries() {
if (props.image.galleries.length === 0) return; if (props.image.galleries.length === 0) return;
const tags = props.image.galleries.map((gallery) => ( const galleries = props.image.galleries.map((gallery) => (
<TagLink key={gallery.id} gallery={gallery} /> <GalleryLink key={gallery.id} gallery={gallery} />
)); ));
return ( return (
<> <>
@@ -78,7 +78,7 @@ export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
values={{ count: props.image.galleries.length }} values={{ count: props.image.galleries.length }}
/> />
</h6> </h6>
{tags} {galleries}
</> </>
); );
} }

View File

@@ -4,7 +4,7 @@ import * as GQL from "src/core/generated-graphql";
import { GridCard } from "../Shared/GridCard"; import { GridCard } from "../Shared/GridCard";
import { HoverPopover } from "../Shared/HoverPopover"; import { HoverPopover } from "../Shared/HoverPopover";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { TagLink } from "../Shared/TagLink"; import { SceneLink } from "../Shared/TagLink";
import { TruncatedText } from "../Shared/TruncatedText"; import { TruncatedText } from "../Shared/TruncatedText";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { RatingBanner } from "../Shared/RatingBanner"; import { RatingBanner } from "../Shared/RatingBanner";
@@ -36,7 +36,7 @@ export const MovieCard: React.FC<IProps> = (props: IProps) => {
if (props.movie.scenes.length === 0) return; if (props.movie.scenes.length === 0) return;
const popoverContent = props.movie.scenes.map((scene) => ( const popoverContent = props.movie.scenes.map((scene) => (
<TagLink key={scene.id} scene={scene} /> <SceneLink key={scene.id} scene={scene} />
)); ));
return ( return (

View File

@@ -168,7 +168,7 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
if (performer.tags.length <= 0) return; if (performer.tags.length <= 0) return;
const popoverContent = performer.tags.map((tag) => ( const popoverContent = performer.tags.map((tag) => (
<TagLink key={tag.id} tagType="performer" tag={tag} /> <TagLink key={tag.id} linkType="performer" tag={tag} />
)); ));
return ( return (

View File

@@ -29,7 +29,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
return ( return (
<ul className="pl-0"> <ul className="pl-0">
{(performer.tags ?? []).map((tag) => ( {(performer.tags ?? []).map((tag) => (
<TagLink key={tag.id} tagType="performer" tag={tag} /> <TagLink key={tag.id} linkType="performer" tag={tag} />
))} ))}
</ul> </ul>
); );

View File

@@ -19,7 +19,12 @@ import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { ErrorMessage } from "../Shared/ErrorMessage"; import { ErrorMessage } from "../Shared/ErrorMessage";
import { HoverPopover } from "../Shared/HoverPopover"; import { HoverPopover } from "../Shared/HoverPopover";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { TagLink } from "../Shared/TagLink"; import {
GalleryLink,
MovieLink,
SceneMarkerLink,
TagLink,
} from "../Shared/TagLink";
import { SweatDrops } from "../Shared/SweatDrops"; import { SweatDrops } from "../Shared/SweatDrops";
import { Pagination } from "src/components/List/Pagination"; import { Pagination } from "src/components/List/Pagination";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
@@ -349,7 +354,7 @@ export const SceneDuplicateChecker: React.FC = () => {
src={sceneMovie.movie.front_image_path ?? ""} src={sceneMovie.movie.front_image_path ?? ""}
/> />
</Link> </Link>
<TagLink <MovieLink
key={sceneMovie.movie.id} key={sceneMovie.movie.id}
movie={sceneMovie.movie} movie={sceneMovie.movie}
className="d-block" className="d-block"
@@ -377,8 +382,8 @@ export const SceneDuplicateChecker: React.FC = () => {
if (scene.scene_markers.length <= 0) return; if (scene.scene_markers.length <= 0) return;
const popoverContent = scene.scene_markers.map((marker) => { const popoverContent = scene.scene_markers.map((marker) => {
const markerPopover = { ...marker, scene: { id: scene.id } }; const markerWithScene = { ...marker, scene: { id: scene.id } };
return <TagLink key={marker.id} marker={markerPopover} />; return <SceneMarkerLink key={marker.id} marker={markerWithScene} />;
}); });
return ( return (
@@ -410,7 +415,7 @@ export const SceneDuplicateChecker: React.FC = () => {
if (scene.galleries.length <= 0) return; if (scene.galleries.length <= 0) return;
const popoverContent = scene.galleries.map((gallery) => ( const popoverContent = scene.galleries.map((gallery) => (
<TagLink key={gallery.id} gallery={gallery} /> <GalleryLink key={gallery.id} gallery={gallery} />
)); ));
return ( return (

View File

@@ -4,7 +4,12 @@ import { Link, useHistory } from "react-router-dom";
import cx from "classnames"; import cx from "classnames";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { TagLink } from "../Shared/TagLink"; import {
GalleryLink,
TagLink,
MovieLink,
SceneMarkerLink,
} from "../Shared/TagLink";
import { HoverPopover } from "../Shared/HoverPopover"; import { HoverPopover } from "../Shared/HoverPopover";
import { SweatDrops } from "../Shared/SweatDrops"; import { SweatDrops } from "../Shared/SweatDrops";
import { TruncatedText } from "../Shared/TruncatedText"; import { TruncatedText } from "../Shared/TruncatedText";
@@ -219,7 +224,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
src={sceneMovie.movie.front_image_path ?? ""} src={sceneMovie.movie.front_image_path ?? ""}
/> />
</Link> </Link>
<TagLink <MovieLink
key={sceneMovie.movie.id} key={sceneMovie.movie.id}
movie={sceneMovie.movie} movie={sceneMovie.movie}
className="d-block" className="d-block"
@@ -245,8 +250,8 @@ export const SceneCard: React.FC<ISceneCardProps> = (
if (props.scene.scene_markers.length <= 0) return; if (props.scene.scene_markers.length <= 0) return;
const popoverContent = props.scene.scene_markers.map((marker) => { const popoverContent = props.scene.scene_markers.map((marker) => {
const markerPopover = { ...marker, scene: { id: props.scene.id } }; const markerWithScene = { ...marker, scene: { id: props.scene.id } };
return <TagLink key={marker.id} marker={markerPopover} />; return <SceneMarkerLink key={marker.id} marker={markerWithScene} />;
}); });
return ( return (
@@ -282,7 +287,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
if (props.scene.galleries.length <= 0) return; if (props.scene.galleries.length <= 0) return;
const popoverContent = props.scene.galleries.map((gallery) => ( const popoverContent = props.scene.galleries.map((gallery) => (
<TagLink key={gallery.id} gallery={gallery} /> <GalleryLink key={gallery.id} gallery={gallery} />
)); ));
return ( return (

View File

@@ -18,18 +18,19 @@ export const PrimaryTags: React.FC<IPrimaryTags> = ({
}) => { }) => {
if (!sceneMarkers?.length) return <div />; if (!sceneMarkers?.length) return <div />;
const primaries: Record<string, GQL.SlimTagDataFragment> = {}; const primaryTagNames: Record<string, string> = {};
const primaryTags: Record<string, GQL.SceneMarkerDataFragment[]> = {}; const markersByTag: Record<string, GQL.SceneMarkerDataFragment[]> = {};
sceneMarkers.forEach((m) => { sceneMarkers.forEach((m) => {
if (primaryTags[m.primary_tag.id]) primaryTags[m.primary_tag.id].push(m); if (primaryTagNames[m.primary_tag.id]) {
else { markersByTag[m.primary_tag.id].push(m);
primaryTags[m.primary_tag.id] = [m]; } else {
primaries[m.primary_tag.id] = m.primary_tag; primaryTagNames[m.primary_tag.id] = m.primary_tag.name;
markersByTag[m.primary_tag.id] = [m];
} }
}); });
const primaryCards = Object.keys(primaryTags).map((id) => { const primaryCards = Object.keys(markersByTag).map((id) => {
const markers = primaryTags[id].map((marker) => { const markers = markersByTag[id].map((marker) => {
const tags = marker.tags.map((tag) => ( const tags = marker.tags.map((tag) => (
<Badge key={tag.id} variant="secondary" className="tag-item"> <Badge key={tag.id} variant="secondary" className="tag-item">
{tag.name} {tag.name}
@@ -59,7 +60,7 @@ export const PrimaryTags: React.FC<IPrimaryTags> = ({
return ( return (
<Card className="primary-card col-12 col-sm-6 col-xl-6" key={id}> <Card className="primary-card col-12 col-sm-6 col-xl-6" key={id}>
<h3>{primaries[id].name}</h3> <h3>{primaryTagNames[id]}</h3>
<Card.Body className="primary-card-body">{markers}</Card.Body> <Card.Body className="primary-card-body">{markers}</Card.Body>
</Card> </Card>
); );

View File

@@ -6,7 +6,7 @@ import * as GQL from "src/core/generated-graphql";
import { sortPerformers } from "src/core/performers"; import { sortPerformers } from "src/core/performers";
import { HoverPopover } from "./HoverPopover"; import { HoverPopover } from "./HoverPopover";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { TagLink } from "./TagLink"; import { PerformerLink } from "./TagLink";
interface IProps { interface IProps {
performers: Partial<GQL.PerformerDataFragment>[]; performers: Partial<GQL.PerformerDataFragment>[];
@@ -26,7 +26,11 @@ export const PerformerPopoverButton: React.FC<IProps> = ({ performers }) => {
src={performer.image_path ?? ""} src={performer.image_path ?? ""}
/> />
</Link> </Link>
<TagLink key={performer.id} performer={performer} className="d-block" /> <PerformerLink
key={performer.id}
performer={performer}
className="d-block"
/>
</div> </div>
)); ));

View File

@@ -767,12 +767,12 @@ export const TagSelect: React.FC<
}; };
} }
const id = (optionProps.data as Option & { __isNew__: boolean }).__isNew__ const id = optionProps.data.value;
? "" const hide = (optionProps.data as Option & { __isNew__: boolean })
: optionProps.data.value; .__isNew__;
return ( return (
<TagPopover id={id} placement={props.hoverPlacement}> <TagPopover id={id} hide={hide} placement={props.hoverPlacement}>
<reactSelectComponents.Option {...thisOptionProps} /> <reactSelectComponents.Option {...thisOptionProps} />
</TagPopover> </TagPopover>
); );

View File

@@ -1,97 +1,252 @@
import { Badge } from "react-bootstrap"; import { Badge, OverlayTrigger, Tooltip } from "react-bootstrap";
import React from "react"; import React, { useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import cx from "classnames"; import cx from "classnames";
import { import NavUtils, { INamedObject } from "src/utils/navigation";
PerformerDataFragment,
TagDataFragment,
MovieDataFragment,
SceneDataFragment,
} from "src/core/generated-graphql";
import NavUtils from "src/utils/navigation";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { objectTitle } from "src/core/files"; import { IFile, IObjectWithTitleFiles, objectTitle } from "src/core/files";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TagPopover } from "../Tags/TagPopover"; import { TagPopover } from "../Tags/TagPopover";
import { markerTitle } from "src/core/markers"; import { markerTitle } from "src/core/markers";
import { Placement } from "react-bootstrap/esm/Overlay"; import { Placement } from "react-bootstrap/esm/Overlay";
import { faFolderTree } from "@fortawesome/free-solid-svg-icons";
interface IFile { import { Icon } from "../Shared/Icon";
path: string; import { FormattedMessage } from "react-intl";
}
interface IGallery {
id: string;
files: IFile[];
folder?: GQL.Maybe<IFile>;
title: GQL.Maybe<string>;
}
type SceneMarkerFragment = Pick<GQL.SceneMarker, "id" | "title" | "seconds"> & { type SceneMarkerFragment = Pick<GQL.SceneMarker, "id" | "title" | "seconds"> & {
scene: Pick<GQL.Scene, "id">; scene: Pick<GQL.Scene, "id">;
primary_tag: Pick<GQL.Tag, "id" | "name">; primary_tag: Pick<GQL.Tag, "id" | "name">;
}; };
interface IProps { interface ICommonLinkProps {
tag?: Partial<TagDataFragment>; link: string;
tagType?: "performer" | "scene" | "gallery" | "image" | "details";
performer?: Partial<PerformerDataFragment>;
marker?: SceneMarkerFragment;
movie?: Partial<MovieDataFragment>;
scene?: Partial<Pick<SceneDataFragment, "id" | "title" | "files">>;
gallery?: Partial<IGallery>;
className?: string; className?: string;
hoverPlacement?: Placement;
} }
export const TagLink: React.FC<IProps> = (props: IProps) => { const CommonLinkComponent: React.FC<ICommonLinkProps> = ({
let id: string = ""; link,
let link: string = "#"; className,
let title: string = ""; children,
if (props.tag) { }) => {
id = props.tag.id || "";
switch (props.tagType) {
case "scene":
case undefined:
link = NavUtils.makeTagScenesUrl(props.tag);
break;
case "performer":
link = NavUtils.makeTagPerformersUrl(props.tag);
break;
case "gallery":
link = NavUtils.makeTagGalleriesUrl(props.tag);
break;
case "image":
link = NavUtils.makeTagImagesUrl(props.tag);
break;
case "details":
link = NavUtils.makeTagUrl(id);
break;
}
title = props.tag.name || "";
} else if (props.performer) {
link = NavUtils.makePerformerScenesUrl(props.performer);
title = props.performer.name || "";
} else if (props.movie) {
link = NavUtils.makeMovieScenesUrl(props.movie);
title = props.movie.name || "";
} else if (props.marker) {
link = NavUtils.makeSceneMarkerUrl(props.marker);
title = `${markerTitle(props.marker)} - ${TextUtils.secondsToTimestamp(
props.marker.seconds || 0
)}`;
} else if (props.gallery) {
link = `/galleries/${props.gallery.id}`;
title = galleryTitle(props.gallery);
} else if (props.scene) {
link = `/scenes/${props.scene.id}`;
title = objectTitle(props.scene);
}
return ( return (
<Badge className={cx("tag-item", props.className)} variant="secondary"> <Badge className={cx("tag-item", className)} variant="secondary">
<TagPopover id={id} placement={props.hoverPlacement}> <Link to={link}>{children}</Link>
<Link to={link}>{title}</Link>
</TagPopover>
</Badge> </Badge>
); );
}; };
interface IPerformerLinkProps {
performer: INamedObject;
linkType?: "scene" | "gallery" | "image";
className?: string;
}
export const PerformerLink: React.FC<IPerformerLinkProps> = ({
performer,
linkType = "scene",
className,
}) => {
const link = useMemo(() => {
switch (linkType) {
case "gallery":
return NavUtils.makePerformerGalleriesUrl(performer);
case "image":
return NavUtils.makePerformerImagesUrl(performer);
case "scene":
default:
return NavUtils.makePerformerScenesUrl(performer);
}
}, [performer, linkType]);
const title = performer.name || "";
return (
<CommonLinkComponent link={link} className={className}>
{title}
</CommonLinkComponent>
);
};
interface IMovieLinkProps {
movie: INamedObject;
linkType?: "scene";
className?: string;
}
export const MovieLink: React.FC<IMovieLinkProps> = ({
movie,
linkType = "scene",
className,
}) => {
const link = useMemo(() => {
switch (linkType) {
case "scene":
return NavUtils.makeMovieScenesUrl(movie);
}
}, [movie, linkType]);
const title = movie.name || "";
return (
<CommonLinkComponent link={link} className={className}>
{title}
</CommonLinkComponent>
);
};
interface ISceneMarkerLinkProps {
marker: SceneMarkerFragment;
linkType?: "scene";
className?: string;
}
export const SceneMarkerLink: React.FC<ISceneMarkerLinkProps> = ({
marker,
linkType = "scene",
className,
}) => {
const link = useMemo(() => {
switch (linkType) {
case "scene":
return NavUtils.makeSceneMarkerUrl(marker);
}
}, [marker, linkType]);
const title = `${markerTitle(marker)} - ${TextUtils.secondsToTimestamp(
marker.seconds || 0
)}`;
return (
<CommonLinkComponent link={link} className={className}>
{title}
</CommonLinkComponent>
);
};
interface IObjectWithIDTitleFiles extends IObjectWithTitleFiles {
id: string;
}
interface ISceneLinkProps {
scene: IObjectWithIDTitleFiles;
linkType?: "details";
className?: string;
}
export const SceneLink: React.FC<ISceneLinkProps> = ({
scene,
linkType = "details",
className,
}) => {
const link = useMemo(() => {
switch (linkType) {
case "details":
return `/scenes/${scene.id}`;
}
}, [scene, linkType]);
const title = objectTitle(scene);
return (
<CommonLinkComponent link={link} className={className}>
{title}
</CommonLinkComponent>
);
};
interface IGallery extends IObjectWithIDTitleFiles {
folder?: GQL.Maybe<IFile>;
}
interface IGalleryLinkProps {
gallery: IGallery;
linkType?: "details";
className?: string;
}
export const GalleryLink: React.FC<IGalleryLinkProps> = ({
gallery,
linkType = "details",
className,
}) => {
const link = useMemo(() => {
switch (linkType) {
case "details":
return `/galleries/${gallery.id}`;
}
}, [gallery, linkType]);
const title = galleryTitle(gallery);
return (
<CommonLinkComponent link={link} className={className}>
{title}
</CommonLinkComponent>
);
};
interface ITagLinkProps {
tag: INamedObject;
linkType?: "scene" | "gallery" | "image" | "details" | "performer";
className?: string;
hoverPlacement?: Placement;
showHierarchyIcon?: boolean;
hierarchyTooltipID?: string;
}
export const TagLink: React.FC<ITagLinkProps> = ({
tag,
linkType = "scene",
className,
hoverPlacement,
showHierarchyIcon = false,
hierarchyTooltipID,
}) => {
const link = useMemo(() => {
switch (linkType) {
case "scene":
return NavUtils.makeTagScenesUrl(tag);
case "performer":
return NavUtils.makeTagPerformersUrl(tag);
case "gallery":
return NavUtils.makeTagGalleriesUrl(tag);
case "image":
return NavUtils.makeTagImagesUrl(tag);
case "details":
return NavUtils.makeTagUrl(tag.id ?? "");
}
}, [tag, linkType]);
const title = tag.name || "";
const tooltip = useMemo(() => {
if (!hierarchyTooltipID) {
return <></>;
}
return (
<Tooltip id="tag-hierarchy-tooltip">
<FormattedMessage id={hierarchyTooltipID} />
</Tooltip>
);
}, [hierarchyTooltipID]);
return (
<CommonLinkComponent link={link} className={className}>
<TagPopover id={tag.id ?? ""} placement={hoverPlacement}>
<Link to={link}>
{title}
{showHierarchyIcon && (
<OverlayTrigger placement="top" overlay={tooltip}>
<span className="icon-wrapper">
<span className="vertical-line">|</span>
<Icon icon={faFolderTree} className="tag-icon" />
</span>
</OverlayTrigger>
)}
</Link>
</TagPopover>
</CommonLinkComponent>
);
};

View File

@@ -7,7 +7,7 @@ import { FormattedMessage } from "react-intl";
import { sortPerformers } from "src/core/performers"; import { sortPerformers } from "src/core/performers";
import { Icon } from "src/components/Shared/Icon"; import { Icon } from "src/components/Shared/Icon";
import { OperationButton } from "src/components/Shared/OperationButton"; import { OperationButton } from "src/components/Shared/OperationButton";
import { TagLink } from "src/components/Shared/TagLink"; import { PerformerLink, TagLink } from "src/components/Shared/TagLink";
import { TruncatedText } from "src/components/Shared/TruncatedText"; import { TruncatedText } from "src/components/Shared/TruncatedText";
import { parsePath, prepareQueryString } from "src/components/Tagger/utils"; import { parsePath, prepareQueryString } from "src/components/Tagger/utils";
import { ScenePreview } from "src/components/Scenes/SceneCard"; import { ScenePreview } from "src/components/Scenes/SceneCard";
@@ -54,7 +54,7 @@ const TaggerSceneDetails: React.FC<ITaggerSceneDetails> = ({ scene }) => {
src={performer.image_path ?? ""} src={performer.image_path ?? ""}
/> />
</Link> </Link>
<TagLink <PerformerLink
key={performer.id} key={performer.id}
performer={performer} performer={performer}
className="d-block" className="d-block"

View File

@@ -21,7 +21,9 @@ export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag, fullWidth }) => {
key={p.id} key={p.id}
tag={p} tag={p}
hoverPlacement="bottom" hoverPlacement="bottom"
tagType="details" linkType="details"
showHierarchyIcon={p.parent_count !== 0}
hierarchyTooltipID="tag_parent_tooltip"
/> />
))} ))}
</> </>
@@ -40,7 +42,9 @@ export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag, fullWidth }) => {
key={c.id} key={c.id}
tag={c} tag={c}
hoverPlacement="bottom" hoverPlacement="bottom"
tagType="details" linkType="details"
showHierarchyIcon={c.child_count !== 0}
hierarchyTooltipID="tag_sub_tag_tooltip"
/> />
))} ))}
</> </>

View File

@@ -8,13 +8,12 @@ import { ConfigurationContext } from "../../hooks/Config";
import { IUIConfig } from "src/core/config"; import { IUIConfig } from "src/core/config";
import { Placement } from "react-bootstrap/esm/Overlay"; import { Placement } from "react-bootstrap/esm/Overlay";
interface ITagPopoverProps { interface ITagPopoverCardProps {
id?: string; id: string;
placement?: Placement;
} }
export const TagPopoverCard: React.FC<ITagPopoverCardProps> = ({ id }) => { export const TagPopoverCard: React.FC<ITagPopoverCardProps> = ({ id }) => {
const { data, loading, error } = useFindTag(id ?? ""); const { data, loading, error } = useFindTag(id);
if (loading) if (loading)
return ( return (
@@ -35,8 +34,15 @@ export const TagPopoverCard: React.FC<ITagPopoverCardProps> = ({ id }) => {
); );
}; };
interface ITagPopoverProps {
id: string;
hide?: boolean;
placement?: Placement;
}
export const TagPopover: React.FC<ITagPopoverProps> = ({ export const TagPopover: React.FC<ITagPopoverProps> = ({
id, id,
hide,
children, children,
placement = "top", placement = "top",
}) => { }) => {
@@ -45,7 +51,7 @@ export const TagPopover: React.FC<ITagPopoverProps> = ({
const showTagCardOnHover = const showTagCardOnHover =
(config?.ui as IUIConfig)?.showTagCardOnHover ?? true; (config?.ui as IUIConfig)?.showTagCardOnHover ?? true;
if (!id || !showTagCardOnHover) { if (hide || !showTagCardOnHover) {
return <>{children}</>; return <>{children}</>;
} }
@@ -60,7 +66,3 @@ export const TagPopover: React.FC<ITagPopoverProps> = ({
</HoverPopover> </HoverPopover>
); );
}; };
interface ITagPopoverCardProps {
id?: string;
}

View File

@@ -72,3 +72,21 @@
padding: 0; padding: 0;
} }
} }
.tag-item {
.icon-wrapper {
color: #202b33;
opacity: 0.5;
padding-left: 6px;
}
}
.tag-item {
.tag-icon {
color: #202b33;
margin: 0;
opacity: 0.5;
padding-left: 3px;
transform: scale(0.7);
}
}

View File

@@ -1,16 +1,16 @@
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
interface IFile { export interface IFile {
path: string; path: string;
} }
interface IObjectWithFiles { interface IObjectWithFiles {
files: IFile[]; files?: IFile[];
} }
interface IObjectWithTitleFiles extends IObjectWithFiles { export interface IObjectWithTitleFiles extends IObjectWithFiles {
title: GQL.Maybe<string>; title?: GQL.Maybe<string>;
} }
export function objectTitle(s: Partial<IObjectWithTitleFiles>) { export function objectTitle(s: Partial<IObjectWithTitleFiles>) {

View File

@@ -1319,6 +1319,8 @@
"synopsis": "Synopsis", "synopsis": "Synopsis",
"tag": "Tag", "tag": "Tag",
"tag_count": "Tag Count", "tag_count": "Tag Count",
"tag_parent_tooltip": "Has parent tags",
"tag_sub_tag_tooltip": "Has sub-tags",
"tags": "Tags", "tags": "Tags",
"tattoos": "Tattoos", "tattoos": "Tattoos",
"title": "Title", "title": "Title",

View File

@@ -73,8 +73,13 @@ const makePerformerImagesUrl = (
return `/images?${filter.makeQueryParameters()}`; return `/images?${filter.makeQueryParameters()}`;
}; };
export interface INamedObject {
id?: string;
name?: string;
}
const makePerformerGalleriesUrl = ( const makePerformerGalleriesUrl = (
performer: Partial<GQL.PerformerDataFragment>, performer: INamedObject,
extraPerformer?: ILabeledId, extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[] extraCriteria?: Criterion<CriterionValue>[]
) => { ) => {