mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Add descriptions to tags and display tag cards on hover (#2708)
* add descriptions to tags * display tag description and tag image on hover Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
parent
480ae46dde
commit
4c73f2f845
23 changed files with 185 additions and 8 deletions
|
|
@ -1,6 +1,7 @@
|
|||
fragment TagData on Tag {
|
||||
id
|
||||
name
|
||||
description
|
||||
aliases
|
||||
ignore_auto_tag
|
||||
image_path
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
type Tag {
|
||||
id: ID!
|
||||
name: String!
|
||||
description: String
|
||||
aliases: [String!]!
|
||||
ignore_auto_tag: Boolean!
|
||||
created_at: Time!
|
||||
|
|
@ -19,6 +20,7 @@ type Tag {
|
|||
|
||||
input TagCreateInput {
|
||||
name: String!
|
||||
description: String
|
||||
aliases: [String!]
|
||||
ignore_auto_tag: Boolean
|
||||
|
||||
|
|
@ -32,6 +34,7 @@ input TagCreateInput {
|
|||
input TagUpdateInput {
|
||||
id: ID!
|
||||
name: String
|
||||
description: String
|
||||
aliases: [String!]
|
||||
ignore_auto_tag: Boolean
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ import (
|
|||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func (r *tagResolver) Description(ctx context.Context, obj *models.Tag) (*string, error) {
|
||||
if obj.Description.Valid {
|
||||
return &obj.Description.String, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) {
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Tag.FindByChildTagID(ctx, obj.ID)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
|
@ -34,6 +35,10 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
|||
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
}
|
||||
|
||||
if input.Description != nil {
|
||||
newTag.Description = sql.NullString{String: *input.Description, Valid: true}
|
||||
}
|
||||
|
||||
if input.IgnoreAutoTag != nil {
|
||||
newTag.IgnoreAutoTag = *input.IgnoreAutoTag
|
||||
}
|
||||
|
|
@ -195,6 +200,8 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
|||
updatedTag.Name = input.Name
|
||||
}
|
||||
|
||||
updatedTag.Description = translator.nullString(input.Description, "description")
|
||||
|
||||
t, err = qb.Update(ctx, updatedTag)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
type Tag struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Parents []string `json:"parents,omitempty"`
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"` // TODO make schema not null
|
||||
Description sql.NullString `db:"description" json:"description"`
|
||||
IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"`
|
||||
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
|
||||
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
|
||||
|
|
@ -13,6 +17,7 @@ type Tag struct {
|
|||
type TagPartial struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Name *string `db:"name" json:"name"` // TODO make schema not null
|
||||
Description *sql.NullString `db:"description" json:"description"`
|
||||
IgnoreAutoTag *bool `db:"ignore_auto_tag" json:"ignore_auto_tag"`
|
||||
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
|
||||
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import (
|
|||
"github.com/stashapp/stash/pkg/logger"
|
||||
)
|
||||
|
||||
var appSchemaVersion uint = 35
|
||||
var appSchemaVersion uint = 36
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsBox embed.FS
|
||||
|
|
|
|||
1
pkg/sqlite/migrations/36_tags_description.up.sql
Normal file
1
pkg/sqlite/migrations/36_tags_description.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `tags` ADD COLUMN `description` text;
|
||||
|
|
@ -20,6 +20,7 @@ type FinderAliasImageGetter interface {
|
|||
func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) (*jsonschema.Tag, error) {
|
||||
newTagJSON := jsonschema.Tag{
|
||||
Name: tag.Name,
|
||||
Description: tag.Description.String,
|
||||
IgnoreAutoTag: tag.IgnoreAutoTag,
|
||||
CreatedAt: json.JSONTime{Time: tag.CreatedAt.Timestamp},
|
||||
UpdatedAt: json.JSONTime{Time: tag.UpdatedAt.Timestamp},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package tag
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
|
|
@ -23,7 +24,10 @@ const (
|
|||
errParentsID = 6
|
||||
)
|
||||
|
||||
const tagName = "testTag"
|
||||
const (
|
||||
tagName = "testTag"
|
||||
description = "description"
|
||||
)
|
||||
|
||||
var (
|
||||
autoTagIgnored = true
|
||||
|
|
@ -35,6 +39,10 @@ func createTag(id int) models.Tag {
|
|||
return models.Tag{
|
||||
ID: id,
|
||||
Name: tagName,
|
||||
Description: sql.NullString{
|
||||
String: description,
|
||||
Valid: true,
|
||||
},
|
||||
IgnoreAutoTag: autoTagIgnored,
|
||||
CreatedAt: models.SQLiteTimestamp{
|
||||
Timestamp: createTime,
|
||||
|
|
@ -48,6 +56,7 @@ func createTag(id int) models.Tag {
|
|||
func createJSONTag(aliases []string, image string, parents []string) *jsonschema.Tag {
|
||||
return &jsonschema.Tag{
|
||||
Name: tagName,
|
||||
Description: description,
|
||||
Aliases: aliases,
|
||||
IgnoreAutoTag: autoTagIgnored,
|
||||
CreatedAt: json.JSONTime{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package tag
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
|
|
@ -42,6 +43,7 @@ type Importer struct {
|
|||
func (i *Importer) PreImport(ctx context.Context) error {
|
||||
i.tag = models.Tag{
|
||||
Name: i.Input.Name,
|
||||
Description: sql.NullString{String: i.Input.Description, Valid: true},
|
||||
IgnoreAutoTag: i.Input.IgnoreAutoTag,
|
||||
CreatedAt: models.SQLiteTimestamp{Timestamp: i.Input.CreatedAt.GetTime()},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: i.Input.UpdatedAt.GetTime()},
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ func TestImporterPreImport(t *testing.T) {
|
|||
i := Importer{
|
||||
Input: jsonschema.Tag{
|
||||
Name: tagName,
|
||||
Description: description,
|
||||
Image: invalidImage,
|
||||
IgnoreAutoTag: autoTagIgnored,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -247,6 +247,13 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||
/>
|
||||
</SettingSection>
|
||||
<SettingSection headingID="config.ui.tag_panel.heading">
|
||||
<BooleanSetting
|
||||
id="show-tag-card-on-hover"
|
||||
headingID="config.ui.show_tag_card_on_hover.heading"
|
||||
subHeadingID="config.ui.show_tag_card_on_hover.description"
|
||||
checked={ui.showTagCardOnHover ?? true}
|
||||
onChange={(v) => saveUI({ showTagCardOnHover: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="show-child-tagged-content"
|
||||
headingID="config.ui.tag_panel.options.show_child_tagged_content.heading"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { ConfigurationContext } from "src/hooks/Config";
|
|||
import { useIntl } from "react-intl";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import { galleryTitle } from "src/core/galleries";
|
||||
import { TagPopover } from "../Tags/TagPopover";
|
||||
|
||||
export type ValidTypes =
|
||||
| GQL.SlimPerformerDataFragment
|
||||
|
|
@ -659,7 +660,11 @@ export const TagSelect: React.FC<IFilterProps & { excludeIds?: string[] }> = (
|
|||
};
|
||||
}
|
||||
|
||||
return <reactSelectComponents.Option {...thisOptionProps} />;
|
||||
return (
|
||||
<TagPopover id={optionProps.data.value}>
|
||||
<reactSelectComponents.Option {...thisOptionProps} />
|
||||
</TagPopover>
|
||||
);
|
||||
};
|
||||
|
||||
const filterOption = (option: Option, rawInput: string): boolean => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import TextUtils from "src/utils/text";
|
|||
import { objectTitle } from "src/core/files";
|
||||
import { galleryTitle } from "src/core/galleries";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { TagPopover } from "../Tags/TagPopover";
|
||||
|
||||
interface IFile {
|
||||
path: string;
|
||||
|
|
@ -37,6 +38,7 @@ interface IProps {
|
|||
}
|
||||
|
||||
export const TagLink: React.FC<IProps> = (props: IProps) => {
|
||||
let id: string = "";
|
||||
let link: string = "#";
|
||||
let title: string = "";
|
||||
if (props.tag) {
|
||||
|
|
@ -55,6 +57,7 @@ export const TagLink: React.FC<IProps> = (props: IProps) => {
|
|||
link = NavUtils.makeTagImagesUrl(props.tag);
|
||||
break;
|
||||
}
|
||||
id = props.tag.id || "";
|
||||
title = props.tag.name || "";
|
||||
} else if (props.performer) {
|
||||
link = NavUtils.makePerformerScenesUrl(props.performer);
|
||||
|
|
@ -76,7 +79,9 @@ export const TagLink: React.FC<IProps> = (props: IProps) => {
|
|||
}
|
||||
return (
|
||||
<Badge className={cx("tag-item", props.className)} variant="secondary">
|
||||
<TagPopover id={id}>
|
||||
<Link to={link}>{title}</Link>
|
||||
</TagPopover>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Link } from "react-router-dom";
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
import { NavUtils } from "src/utils";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Icon } from "../Shared";
|
||||
import { Icon, TruncatedText } from "../Shared";
|
||||
import { GridCard } from "../Shared/GridCard";
|
||||
import { PopoverCountButton } from "../Shared/PopoverCountButton";
|
||||
import { faMapMarkerAlt, faUser } from "@fortawesome/free-solid-svg-icons";
|
||||
|
|
@ -24,6 +24,18 @@ export const TagCard: React.FC<IProps> = ({
|
|||
selected,
|
||||
onSelectedChanged,
|
||||
}) => {
|
||||
function maybeRenderDescription() {
|
||||
if (tag.description) {
|
||||
return (
|
||||
<TruncatedText
|
||||
className="tag-description"
|
||||
text={tag.description}
|
||||
lineCount={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderParents() {
|
||||
if (tag.parents.length === 1) {
|
||||
const parent = tag.parents[0];
|
||||
|
|
@ -181,6 +193,7 @@ export const TagCard: React.FC<IProps> = ({
|
|||
}
|
||||
details={
|
||||
<>
|
||||
{maybeRenderDescription()}
|
||||
{maybeRenderParents()}
|
||||
{maybeRenderChildren()}
|
||||
{maybeRenderPopoverButtonGroup()}
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||
renderImage()
|
||||
)}
|
||||
<h2>{tag.name}</h2>
|
||||
<p>{tag.description}</p>
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
|
||||
const schema = yup.object({
|
||||
name: yup.string().required(),
|
||||
description: yup.string().optional().nullable(),
|
||||
aliases: yup
|
||||
.array(yup.string().required())
|
||||
.optional()
|
||||
|
|
@ -65,6 +66,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
|
||||
const initialValues = {
|
||||
name: tag?.name,
|
||||
description: tag?.description,
|
||||
aliases: tag?.aliases,
|
||||
parent_ids: (tag?.parents ?? []).map((t) => t.id),
|
||||
child_ids: (tag?.children ?? []).map((t) => t.id),
|
||||
|
|
@ -167,6 +169,20 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="description" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "description" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
className="text-input"
|
||||
placeholder={intl.formatMessage({ id: "description" })}
|
||||
{...formik.getFieldProps("description")}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="parent_tags" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "parent_tags" }),
|
||||
|
|
|
|||
59
ui/v2.5/src/components/Tags/TagPopover.tsx
Normal file
59
ui/v2.5/src/components/Tags/TagPopover.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import React from "react";
|
||||
import { ErrorMessage, LoadingIndicator } from "../Shared";
|
||||
import { HoverPopover } from "src/components/Shared";
|
||||
import { useFindTag } from "../../core/StashService";
|
||||
import { TagCard } from "./TagCard";
|
||||
import { ConfigurationContext } from "../../hooks/Config";
|
||||
import { IUIConfig } from "src/core/config";
|
||||
|
||||
interface ITagPopoverProps {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const TagPopoverCard: React.FC<ITagPopoverCardProps> = ({ id }) => {
|
||||
const { data, loading, error } = useFindTag(id ?? "");
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="tag-popover-card-placeholder">
|
||||
<LoadingIndicator card={true} message={""} />
|
||||
</div>
|
||||
);
|
||||
if (error) return <ErrorMessage error={error.message} />;
|
||||
if (!data?.findTag)
|
||||
return <ErrorMessage error={`No tag found with id ${id}.`} />;
|
||||
|
||||
const tag = data.findTag;
|
||||
|
||||
return (
|
||||
<div className="tag-popover-card">
|
||||
<TagCard tag={tag} zoomIndex={0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagPopover: React.FC<ITagPopoverProps> = ({ id, children }) => {
|
||||
const { configuration: config } = React.useContext(ConfigurationContext);
|
||||
|
||||
const showTagCardOnHover =
|
||||
(config?.ui as IUIConfig)?.showTagCardOnHover ?? true;
|
||||
|
||||
if (!id || !showTagCardOnHover) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
placement={"top"}
|
||||
enterDelay={500}
|
||||
leaveDelay={100}
|
||||
content={<TagPopoverCard id={id} />}
|
||||
>
|
||||
{children}
|
||||
</HoverPopover>
|
||||
);
|
||||
};
|
||||
|
||||
interface ITagPopoverCardProps {
|
||||
id?: string;
|
||||
}
|
||||
|
|
@ -43,3 +43,28 @@
|
|||
#tag-merge-menu .dropdown-item {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-card {
|
||||
.tag-description + div {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-popover-card-placeholder {
|
||||
display: flex;
|
||||
max-width: 240px;
|
||||
min-height: 314px;
|
||||
width: calc(100vw - 2rem);
|
||||
}
|
||||
|
||||
.tag-popover-card {
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
|
||||
.card {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
max-width: calc(100vw - 2rem);
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface IUIConfig {
|
|||
lastNoteSeen?: number;
|
||||
showChildTagContent?: boolean;
|
||||
showChildStudioContent?: boolean;
|
||||
showTagCardOnHover?: boolean;
|
||||
}
|
||||
|
||||
function recentlyReleased(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ After migrating, please run a scan on your entire library to populate missing da
|
|||
* Import/export schema has changed and is incompatible with the previous version.
|
||||
|
||||
### ✨ New Features
|
||||
* Added description field to Tags. ([#2708](https://github.com/stashapp/stash/pull/2708))
|
||||
* Added option to include sub-studio/sub-tag content in Studio/Tag page. ([#2832](https://github.com/stashapp/stash/pull/2832))
|
||||
* Added backup location configuration setting. ([#2953](https://github.com/stashapp/stash/pull/2953))
|
||||
* Allow overriding UI localisation strings. ([#2837](https://github.com/stashapp/stash/pull/2837))
|
||||
|
|
@ -14,6 +15,7 @@ After migrating, please run a scan on your entire library to populate missing da
|
|||
* Added release notes dialog. ([#2726](https://github.com/stashapp/stash/pull/2726))
|
||||
|
||||
### 🎨 Improvements
|
||||
* Optionally show Tag card when hovering over tag badge. ([#2708](https://github.com/stashapp/stash/pull/2708))
|
||||
* Show default thumbnails for scenes and images where the actual image is not found. ([#2949](https://github.com/stashapp/stash/pull/2949))
|
||||
* Added unix timestamp parsing in the `parseDate` scraper post processor. ([#2817](https://github.com/stashapp/stash/pull/2817))
|
||||
* Improve matching scene order in the tagger to prioritise matching phashes and durations. ([#2840](https://github.com/stashapp/stash/pull/2840))
|
||||
|
|
|
|||
|
|
@ -500,6 +500,10 @@
|
|||
"description": "Show or hide different types of content on the navigation bar",
|
||||
"heading": "Menu Items"
|
||||
},
|
||||
"show_tag_card_on_hover": {
|
||||
"description": "Show tag card when hovering tag badges",
|
||||
"heading": "Tag card tooltips"
|
||||
},
|
||||
"performers": {
|
||||
"options": {
|
||||
"image_location": {
|
||||
|
|
@ -615,6 +619,7 @@
|
|||
"death_date": "Death Date",
|
||||
"death_year": "Death Year",
|
||||
"descending": "Descending",
|
||||
"description": "Description",
|
||||
"detail": "Detail",
|
||||
"details": "Details",
|
||||
"developmentVersion": "Development Version",
|
||||
|
|
|
|||
Loading…
Reference in a new issue