Rebuild Studio page by splitting view and edit (#1629)

* Rebuild Studio page by splitting view and edit
* Fix parent studio id, open studio link in same tab

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
gitgiggety 2021-08-11 06:44:18 +02:00 committed by GitHub
parent 53489106a6
commit d4d45d5a06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 450 additions and 259 deletions

View File

@ -6,6 +6,7 @@
* Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568))
### 🎨 Improvements
* Improve Studio UI. ([#1629](https://github.com/stashapp/stash/pull/1629))
* Improve link styling and ensure links open in a new tab. ([#1622](https://github.com/stashapp/stash/pull/1622))
* Added zh-CN language option. ([#1620](https://github.com/stashapp/stash/pull/1620))
* Moved scraping settings into the Scraping settings page. ([#1548](https://github.com/stashapp/stash/pull/1548))

View File

@ -259,7 +259,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
new ScrapeResult<string>(props.gallery.details, props.scraped.details)
);
const [createStudio] = useStudioCreate({ name: "" });
const [createStudio] = useStudioCreate();
const [createPerformer] = usePerformerCreate();
const [createTag] = useTagCreate();

View File

@ -321,7 +321,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
new ScrapeResult<string>(props.scene.cover_image, props.scraped.image)
);
const [createStudio] = useStudioCreate({ name: "" });
const [createStudio] = useStudioCreate();
const [createPerformer] = usePerformerCreate();
const [createMovie] = useMovieCreate();
const [createTag] = useTagCreate();

View File

@ -428,7 +428,7 @@ export const StudioSelect: React.FC<
IFilterProps & { excludeIds?: string[] }
> = (props) => {
const { data, loading } = useAllStudiosForFilter();
const [createStudio] = useStudioCreate({ name: "" });
const [createStudio] = useStudioCreate();
const exclude = props.excludeIds ?? [];
const studios = (data?.allStudios ?? []).filter(

View File

@ -1,6 +1,6 @@
import { Button, Table, Tabs, Tab } from "react-bootstrap";
import { Tabs, Tab } from "react-bootstrap";
import React, { useEffect, useState } from "react";
import { useParams, useHistory, Link } from "react-router-dom";
import { useParams, useHistory } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import cx from "classnames";
import Mousetrap from "mousetrap";
@ -13,21 +13,21 @@ import {
useStudioDestroy,
mutateMetadataAutoTag,
} from "src/core/StashService";
import { ImageUtils, TableUtils } from "src/utils";
import { ImageUtils } from "src/utils";
import {
Icon,
DetailsEditNavbar,
Modal,
LoadingIndicator,
StudioSelect,
ErrorMessage,
} from "src/components/Shared";
import { useToast } from "src/hooks";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { StudioScenesPanel } from "./StudioScenesPanel";
import { StudioGalleriesPanel } from "./StudioGalleriesPanel";
import { StudioImagesPanel } from "./StudioImagesPanel";
import { StudioChildrenPanel } from "./StudioChildrenPanel";
import { StudioPerformersPanel } from "./StudioPerformersPanel";
import { StudioEditPanel } from "./StudioEditPanel";
import { StudioDetailsPanel } from "./StudioDetailsPanel";
interface IStudioParams {
id?: string;
@ -45,83 +45,23 @@ export const Studio: React.FC = () => {
const [isEditing, setIsEditing] = useState<boolean>(isNew);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Editing studio state
const [image, setImage] = useState<string | null>();
const [name, setName] = useState<string>();
const [url, setUrl] = useState<string>();
const [parentStudioId, setParentStudioId] = useState<string>();
const [rating, setRating] = useState<number | undefined>(undefined);
const [details, setDetails] = useState<string>();
const [stashIDs, setStashIDs] = useState<GQL.StashIdInput[]>([]);
// Studio state
const [studio, setStudio] = useState<Partial<GQL.StudioDataFragment>>({});
const [imagePreview, setImagePreview] = useState<string | null>();
const [image, setImage] = useState<string | null>();
const { data, error, loading } = useFindStudio(id);
const { data, error } = useFindStudio(id);
const studio = data?.findStudio;
const [isLoading, setIsLoading] = useState(false);
const [updateStudio] = useStudioUpdate();
const [createStudio] = useStudioCreate(
getStudioInput() as GQL.StudioCreateInput
);
const [deleteStudio] = useStudioDestroy(
getStudioInput() as GQL.StudioDestroyInput
);
function updateStudioEditState(state: Partial<GQL.StudioDataFragment>) {
setName(state.name);
setUrl(state.url ?? undefined);
setParentStudioId(state?.parent_studio?.id ?? undefined);
setRating(state.rating ?? undefined);
setDetails(state.details ?? undefined);
setStashIDs(state.stash_ids ?? []);
}
function updateStudioData(studioData: Partial<GQL.StudioDataFragment>) {
setImage(undefined);
updateStudioEditState(studioData);
setImagePreview(studioData.image_path ?? undefined);
setStudio(studioData);
setRating(studioData.rating ?? undefined);
}
const [createStudio] = useStudioCreate();
const [deleteStudio] = useStudioDestroy({ id });
// set up hotkeys
useEffect(() => {
if (isEditing) {
Mousetrap.bind("s s", () => onSave());
}
Mousetrap.bind("e", () => setIsEditing(true));
Mousetrap.bind("d d", () => onDelete());
// numeric keypresses get caught by jwplayer, so blur the element
// if the rating sequence is started
Mousetrap.bind("r", () => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
Mousetrap.bind("0", () => setRating(NaN));
Mousetrap.bind("1", () => setRating(1));
Mousetrap.bind("2", () => setRating(2));
Mousetrap.bind("3", () => setRating(3));
Mousetrap.bind("4", () => setRating(4));
Mousetrap.bind("5", () => setRating(5));
setTimeout(() => {
Mousetrap.unbind("0");
Mousetrap.unbind("1");
Mousetrap.unbind("2");
Mousetrap.unbind("3");
Mousetrap.unbind("4");
Mousetrap.unbind("5");
}, 1000);
});
return () => {
if (isEditing) {
Mousetrap.unbind("s s");
}
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
};
@ -130,58 +70,36 @@ export const Studio: React.FC = () => {
useEffect(() => {
if (data && data.findStudio) {
setImage(undefined);
updateStudioEditState(data.findStudio);
setImagePreview(data.findStudio.image_path ?? undefined);
setStudio(data.findStudio);
}
}, [data]);
function onImageLoad(imageData: string) {
setImagePreview(imageData);
setImage(imageData);
}
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, isEditing);
if (!isNew && !isEditing) {
if (!data?.findStudio || loading || !studio.id) return <LoadingIndicator />;
if (error) return <div>{error.message}</div>;
}
function getStudioInput() {
const input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput> = {
name,
url,
image,
details,
parent_id: parentStudioId ?? null,
rating: rating ?? null,
stash_ids: stashIDs.map((s) => ({
stash_id: s.stash_id,
endpoint: s.endpoint,
})),
};
if (!isNew) {
(input as GQL.StudioUpdateInput).id = id;
}
return input;
}
async function onSave() {
async function onSave(
input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput>
) {
try {
setIsLoading(true);
if (!isNew) {
const result = await updateStudio({
variables: {
input: getStudioInput() as GQL.StudioUpdateInput,
input: input as GQL.StudioUpdateInput,
},
});
if (result.data?.studioUpdate) {
updateStudioData(result.data.studioUpdate);
setIsEditing(false);
}
} else {
const result = await createStudio();
const result = await createStudio({
variables: {
input: input as GQL.StudioCreateInput,
},
});
if (result.data?.studioCreate?.id) {
history.push(`/studios/${result.data.studioCreate.id}`);
setIsEditing(false);
@ -189,11 +107,13 @@ export const Studio: React.FC = () => {
}
} catch (e) {
Toast.error(e);
} finally {
setIsLoading(false);
}
}
async function onAutoTag() {
if (!studio.id) return;
if (!studio?.id) return;
try {
await mutateMetadataAutoTag({ studios: [studio.id] });
Toast.success({
@ -215,19 +135,6 @@ export const Studio: React.FC = () => {
history.push(`/studios`);
}
const removeStashID = (stashID: GQL.StashIdInput) => {
setStashIDs(
stashIDs.filter(
(s) =>
!(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id)
)
);
};
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
function renderDeleteAlert() {
return (
<Modal
@ -245,7 +152,7 @@ export const Studio: React.FC = () => {
id="dialogs.delete_confirm"
values={{
entityName:
name ??
studio?.name ??
intl.formatMessage({ id: "studio" }).toLocaleLowerCase(),
}}
/>
@ -254,64 +161,25 @@ export const Studio: React.FC = () => {
);
}
function renderStashIDs() {
if (!studio.stash_ids?.length) {
return;
}
return (
<tr>
<td>StashIDs</td>
<td>
<ul className="pl-0">
{stashIDs.map((stashID) => {
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
const link = base ? (
<a
href={`${base}studios/${stashID.stash_id}`}
target="_blank"
rel="noopener noreferrer"
>
{stashID.stash_id}
</a>
) : (
stashID.stash_id
);
return (
<li key={stashID.stash_id} className="row no-gutters">
{isEditing && (
<Button
variant="danger"
className="mr-2 py-0"
title={intl.formatMessage(
{ id: "actions.delete_entity" },
{ entityType: intl.formatMessage({ id: "stash_id" }) }
)}
onClick={() => removeStashID(stashID)}
>
<Icon icon="trash-alt" />
</Button>
)}
{link}
</li>
);
})}
</ul>
</td>
</tr>
);
}
function onToggleEdit() {
setIsEditing(!isEditing);
updateStudioData(studio);
}
function onClearImage() {
setImage(null);
setImagePreview(
studio.image_path ? `${studio.image_path}&default=true` : undefined
);
function renderImage() {
let studioImage = studio?.image_path;
if (isEditing) {
if (image === null) {
studioImage = `${studioImage}&default=true`;
} else if (image) {
studioImage = image;
}
}
if (studioImage) {
return (
<img className="logo" alt={studio?.name ?? ""} src={studioImage} />
);
}
}
const activeTabKey =
@ -328,28 +196,10 @@ export const Studio: React.FC = () => {
}
};
function renderStudio() {
if (isEditing || !parentStudioId) {
return (
<StudioSelect
onSelect={(items) =>
setParentStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={parentStudioId ? [parentStudioId] : []}
isDisabled={!isEditing}
excludeIds={studio.id ? [studio.id] : []}
/>
);
}
if (studio.parent_studio) {
return (
<Link to={`/studios/${studio.parent_studio.id}`}>
{studio.parent_studio.name}
</Link>
);
}
}
if (isLoading) return <LoadingIndicator />;
if (error) return <ErrorMessage error={error.message} />;
if (!studio?.id && !isNew)
return <ErrorMessage error={`No studio found with id ${id}.`} />;
return (
<div className="row">
@ -370,66 +220,36 @@ export const Studio: React.FC = () => {
<div className="text-center">
{imageEncoding ? (
<LoadingIndicator message="Encoding image..." />
) : imagePreview ? (
<img className="logo" alt={name} src={imagePreview} />
) : (
""
renderImage()
)}
</div>
<Table>
<tbody>
{TableUtils.renderInputGroup({
title: intl.formatMessage({ id: "name" }),
value: name ?? "",
isEditing: !!isEditing,
onChange: setName,
})}
{TableUtils.renderInputGroup({
title: intl.formatMessage({ id: "url" }),
value: url,
isEditing: !!isEditing,
onChange: setUrl,
})}
{TableUtils.renderTextArea({
title: intl.formatMessage({ id: "details" }),
value: details,
isEditing: !!isEditing,
onChange: setDetails,
})}
<tr>
<td>{intl.formatMessage({ id: "parent_studios" })}</td>
<td>{renderStudio()}</td>
</tr>
<tr>
<td>{intl.formatMessage({ id: "rating" })}:</td>
<td>
<RatingStars
value={rating}
disabled={!isEditing}
onSetRating={(value) => setRating(value ?? NaN)}
/>
</td>
</tr>
{renderStashIDs()}
</tbody>
</Table>
<DetailsEditNavbar
objectName={name ?? "studio"}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={onToggleEdit}
onSave={onSave}
onImageChange={onImageChangeHandler}
onImageChangeURL={onImageLoad}
onClearImage={() => {
onClearImage();
}}
onAutoTag={onAutoTag}
onDelete={onDelete}
acceptSVG
/>
{!isEditing && !isNew && studio ? (
<>
<StudioDetailsPanel studio={studio} />
<DetailsEditNavbar
objectName={studio.name ?? intl.formatMessage({ id: "studio" })}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={onToggleEdit}
onSave={() => {}}
onImageChange={() => {}}
onClearImage={() => {}}
onAutoTag={onAutoTag}
onDelete={onDelete}
/>
</>
) : (
<StudioEditPanel
studio={studio ?? ({} as Partial<GQL.Studio>)}
onSubmit={onSave}
onCancel={onToggleEdit}
onDelete={onDelete}
onImageChange={setImage}
/>
)}
</div>
{!isNew && (
{studio?.id && (
<div className="col col-md-8">
<Tabs
id="studio-tabs"

View File

@ -0,0 +1,59 @@
import React from "react";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { TextField, URLField } from "src/utils/field";
interface IStudioDetailsPanel {
studio: Partial<GQL.StudioDataFragment>;
}
export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
studio,
}) => {
const intl = useIntl();
function renderRatingField() {
if (!studio.rating) {
return;
}
return (
<>
<dt>{intl.formatMessage({ id: "rating" })}</dt>
<dd>
<RatingStars value={studio.rating} disabled />
</dd>
</>
);
}
return (
<div className="studio-details">
<div>
<h2>{studio.name}</h2>
</div>
<dl className="details-list">
<URLField
id="url"
value={studio.url}
url={TextUtils.sanitiseURL(studio.url ?? "")}
/>
<TextField id="details" value={studio.details} />
<URLField
id="parent_studios"
value={studio.parent_studio?.name}
url={`/studios/${studio.parent_studio?.id}`}
trusted
target="_self"
/>
{renderRatingField()}
</dl>
</div>
);
};

View File

@ -0,0 +1,305 @@
import React, { useEffect } from "react";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import Mousetrap from "mousetrap";
import { Icon, StudioSelect, DetailsEditNavbar } from "src/components/Shared";
import { Button, Form, Col, Row } from "react-bootstrap";
import { FormUtils, ImageUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { useFormik } from "formik";
import { Prompt } from "react-router-dom";
interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>;
onSubmit: (
studio: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput>
) => void;
onCancel: () => void;
onDelete: () => void;
onImageChange?: (image?: string | null) => void;
onImageEncoding?: (loading?: boolean) => void;
}
export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
studio,
onSubmit,
onCancel,
onDelete,
onImageChange,
onImageEncoding,
}) => {
const intl = useIntl();
const isNew = !studio || !studio.id;
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
const schema = yup.object({
name: yup.string().required(),
url: yup.string().optional().nullable(),
details: yup.string().optional().nullable(),
image: yup.string().optional().nullable(),
rating: yup.number().optional().nullable(),
parent_id: yup.string().optional().nullable(),
stash_ids: yup.mixed<GQL.StashIdInput>().optional().nullable(),
});
const initialValues = {
name: studio.name ?? "",
url: studio.url ?? "",
details: studio.details ?? "",
image: undefined,
rating: studio.rating ?? null,
parent_id: studio.parent_studio?.id,
stash_ids: studio.stash_ids ?? undefined,
};
type InputValues = typeof initialValues;
const formik = useFormik({
initialValues,
validationSchema: schema,
onSubmit: (values) => onSubmit(getStudioInput(values)),
});
function setRating(v: number) {
formik.setFieldValue("rating", v);
}
function onImageLoad(imageData: string) {
formik.setFieldValue("image", imageData);
}
function getStudioInput(values: InputValues) {
const input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput> = {
...values,
};
if (studio && studio.id) {
(input as GQL.StudioUpdateInput).id = studio.id;
}
return input;
}
// set up hotkeys
useEffect(() => {
Mousetrap.bind("s s", () => formik.handleSubmit());
// numeric keypresses get caught by jwplayer, so blur the element
// if the rating sequence is started
Mousetrap.bind("r", () => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
Mousetrap.bind("0", () => setRating(NaN));
Mousetrap.bind("1", () => setRating(1));
Mousetrap.bind("2", () => setRating(2));
Mousetrap.bind("3", () => setRating(3));
Mousetrap.bind("4", () => setRating(4));
Mousetrap.bind("5", () => setRating(5));
setTimeout(() => {
Mousetrap.unbind("0");
Mousetrap.unbind("1");
Mousetrap.unbind("2");
Mousetrap.unbind("3");
Mousetrap.unbind("4");
Mousetrap.unbind("5");
}, 1000);
});
return () => {
Mousetrap.unbind("s s");
Mousetrap.unbind("e");
};
});
useEffect(() => {
if (onImageChange) {
onImageChange(formik.values.image);
}
return () => onImageChange?.();
}, [formik.values.image, onImageChange]);
useEffect(() => onImageEncoding?.(imageEncoding), [
onImageEncoding,
imageEncoding,
]);
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
function onImageChangeURL(url: string) {
formik.setFieldValue("image", url);
}
const removeStashID = (stashID: GQL.StashIdInput) => {
formik.setFieldValue(
"stash_ids",
(formik.values.stash_ids ?? []).filter(
(s) =>
!(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id)
)
);
};
function renderStashIDs() {
if (!formik.values.stash_ids?.length) {
return;
}
return (
<Row>
<Form.Label column>StashIDs</Form.Label>
<Col xs={9}>
<ul className="pl-0">
{formik.values.stash_ids.map((stashID) => {
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
const link = base ? (
<a
href={`${base}studios/${stashID.stash_id}`}
target="_blank"
rel="noopener noreferrer"
>
{stashID.stash_id}
</a>
) : (
stashID.stash_id
);
return (
<li key={stashID.stash_id} className="row no-gutters">
<Button
variant="danger"
className="mr-2 py-0"
title={intl.formatMessage(
{ id: "actions.delete_entity" },
{ entityType: intl.formatMessage({ id: "stash_id" }) }
)}
onClick={() => removeStashID(stashID)}
>
<Icon icon="trash-alt" />
</Button>
{link}
</li>
);
})}
</ul>
</Col>
</Row>
);
}
return (
<>
<Prompt
when={formik.dirty}
message="Unsaved changes. Are you sure you want to leave?"
/>
<Form noValidate onSubmit={formik.handleSubmit} id="studio-edit">
<Form.Group controlId="name" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "name" }),
})}
<Col xs={9}>
<Form.Control
className="text-input"
{...formik.getFieldProps("name")}
isInvalid={!!formik.errors.name}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.name}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group controlId="url" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "url" }),
})}
<Col xs={9}>
<Form.Control
className="text-input"
{...formik.getFieldProps("url")}
isInvalid={!!formik.errors.url}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.url}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group controlId="details" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "details" }),
})}
<Col xs={9}>
<Form.Control
as="textarea"
className="text-input"
{...formik.getFieldProps("details")}
isInvalid={!!formik.errors.details}
/>
<Form.Control.Feedback type="invalid">
{formik.errors.details}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group controlId="parent_studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "parent_studios" }),
})}
<Col xs={9}>
<StudioSelect
onSelect={(items) =>
formik.setFieldValue(
"parent_id",
items.length > 0 ? items[0]?.id : null
)
}
ids={formik.values.parent_id ? [formik.values.parent_id] : []}
excludeIds={studio.id ? [studio.id] : []}
/>
</Col>
</Form.Group>
<Form.Group controlId="rating" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingStars
value={formik.values.rating ?? undefined}
onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null)
}
/>
</Col>
</Form.Group>
{renderStashIDs()}
</Form>
<DetailsEditNavbar
objectName={studio?.name ?? "studio"}
isNew={isNew}
isEditing
onToggleEdit={onCancel}
onSave={() => formik.handleSubmit()}
saveDisabled={!formik.dirty}
onImageChange={onImageChangeHandler}
onImageChangeURL={onImageChangeURL}
onClearImage={() => {
formik.setFieldValue("image", null);
}}
onDelete={onDelete}
acceptSVG
/>
</>
);
};

View File

@ -598,9 +598,8 @@ export const studioMutationImpactedQueries = [
GQL.AllStudiosForFilterDocument,
];
export const useStudioCreate = (input: GQL.StudioCreateInput) =>
export const useStudioCreate = () =>
GQL.useStudioCreateMutation({
variables: { input },
refetchQueries: getQueryNames([GQL.AllStudiosForFilterDocument]),
update: deleteCache([
GQL.FindStudiosDocument,

View File

@ -43,6 +43,9 @@ interface IURLField {
value?: string | null;
url?: string | null;
truncate?: boolean | null;
target?: string;
// use for internal links
trusted?: boolean;
}
export const URLField: React.FC<IURLField> = ({
@ -53,6 +56,8 @@ export const URLField: React.FC<IURLField> = ({
abbr,
truncate,
children,
target,
trusted,
}) => {
if (!value && !children) {
return null;
@ -62,12 +67,14 @@ export const URLField: React.FC<IURLField> = ({
<>{id ? <FormattedMessage id={id} defaultMessage={name} /> : name}:</>
);
const rel = !trusted ? "noopener noreferrer" : undefined;
return (
<>
<dt>{abbr ? <abbr title={abbr}>{message}</abbr> : message}</dt>
<dd>
{url ? (
<a href={url} target="_blank" rel="noopener noreferrer">
<a href={url} target={target || "_blank"} rel={rel}>
{value ? (
truncate ? (
<TruncatedText text={value} />