Fix links and styling of File Info tabs (#1622)

* Refactor HTML of movie and performer details panels

Refactor HTML of the movie and performer details panels so that the
items are contained in a single list (`<dl/>`). This allows using a grid
layout which means that the styling is easier to get right for multiple
form factors, fixing issues where "values" would overlap the "labels"
(for instance on my phone performers "Measurements" almost overlaps the
actual value, while there is a lot of space for the value itself).

This refactor also allows reusing the `TextField` and `URLField`
components as they don't have any styling related classes anymore (i.e.:
the `col-` classes are gone). Which means they can be used in more dense
places, like the SceneFileInfoPanel, as well. As the width of the label
/ value doesn't rely on the viewport size anymore (as happened due to
the `col-xl` usage, but for example the scene sidebar being small, and
16% being to small).

* Rebuild SceneFileInfoPanel

Completely rebuild the SceneFileInfoPanel to make use of the `TextField`
and `URLField` components. Using these components means that the URLs
automatically get `target="_blank" rel="noopener noreferrer"`.
Furhermore this should also improve the styling a bit, as described in
the previous commit.

* Rebuild ImageFileInfoPanel

Completely rebuild the ImageFileInfoPanel to make use of `TextField` and
`URLField` components. Furthermore it should resolve some small styling
issues.

* Rebuild GalleryFileInfoPanel

Rebuild the GalleryFileInfoPanel to make use of `TextField` and
`URLField` components. Using these components means that for example the
URLs automatically get `target="blank" rel="noopener noreferrer"`.

Also adds the url property as 1. at the moment it is nowhere accessible,
and 2. scenes also has it in this panel.

* Truncate links on the file info tabs at latest opportunity

On the File Info tabs links always have the link destination as text for
the link as well. But these texts can be long and without whitespace.
This means that the default applied `word-wrap: break-word` doesn't
really work as URLs (and paths) don't contain spaces that ofter. So
apply `word-break: break-all` instead so that the text will be as long
as possible and just cut off in the middle, instead of only at
whitespace. This thus means that the fully available width will be used
to display the URL.
This commit is contained in:
gitgiggety 2021-08-10 06:39:09 +02:00 committed by GitHub
parent d31b6841d0
commit 59c7dd622b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 227 additions and 358 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)) * Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568))
### 🎨 Improvements ### 🎨 Improvements
* 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)) * 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)) * Moved scraping settings into the Scraping settings page. ([#1548](https://github.com/stashapp/stash/pull/1548))
* Show current scene details in tagger view. ([#1605](https://github.com/stashapp/stash/pull/1605)) * Show current scene details in tagger view. ([#1605](https://github.com/stashapp/stash/pull/1605))

View file

@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TruncatedText } from "src/components/Shared"; import { TextField, URLField } from "src/utils/field";
import { FormattedMessage } from "react-intl";
interface IGalleryFileInfoPanelProps { interface IGalleryFileInfoPanelProps {
gallery: GQL.GalleryDataFragment; gallery: GQL.GalleryDataFragment;
@ -10,36 +9,25 @@ interface IGalleryFileInfoPanelProps {
export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = ( export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
props: IGalleryFileInfoPanelProps props: IGalleryFileInfoPanelProps
) => { ) => {
function renderChecksum() {
return ( return (
<div className="row"> <dl className="container gallery-file-info details-list">
<span className="col-4"> <TextField
<FormattedMessage id="media_info.checksum" /> id="media_info.checksum"
</span> value={props.gallery.checksum}
<TruncatedText className="col-8" text={props.gallery.checksum} /> truncate
</div> />
); <URLField
} id="path"
url={`file://${props.gallery.path}`}
function renderPath() { value={`file://${props.gallery.path}`}
const filePath = `file://${props.gallery.path}`; truncate
/>
return ( <URLField
<div className="row"> id="path"
<span className="col-4"> url={props.gallery.url}
<FormattedMessage id="path" /> value={props.gallery.url}
</span> truncate
<a href={filePath} className="col-8"> />
<TruncatedText text={filePath} /> </dl>
</a>
</div>
);
}
return (
<div className="container gallery-file-info">
{renderChecksum()}
{renderPath()}
</div>
); );
}; };

View file

@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import { FormattedMessage, FormattedNumber } from "react-intl"; import { FormattedNumber } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { TruncatedText } from "src/components/Shared"; import { TextField, URLField } from "src/utils/field";
interface IImageFileInfoPanelProps { interface IImageFileInfoPanelProps {
image: GQL.ImageDataFragment; image: GQL.ImageDataFragment;
@ -11,33 +11,6 @@ interface IImageFileInfoPanelProps {
export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = ( export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
props: IImageFileInfoPanelProps props: IImageFileInfoPanelProps
) => { ) => {
function renderChecksum() {
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="media_info.checksum" />
</span>
<TruncatedText className="col-8" text={props.image.checksum} />
</div>
);
}
function renderPath() {
const {
image: { path },
} = props;
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="path" />
</span>
<a href={`file://${path}`} className="col-8">
<TruncatedText text={`file://${props.image.path}`} />
</a>{" "}
</div>
);
}
function renderFileSize() { function renderFileSize() {
if (props.image.file.size === undefined) { if (props.image.file.size === undefined) {
return; return;
@ -46,9 +19,8 @@ export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
const { size, unit } = TextUtils.fileSize(props.image.file.size ?? 0); const { size, unit } = TextUtils.fileSize(props.image.file.size ?? 0);
return ( return (
<div className="row"> <TextField id="filesize">
<span className="col-4">File Size</span> <span className="text-truncate">
<span className="col-8 text-truncate">
<FormattedNumber <FormattedNumber
value={size} value={size}
// eslint-disable-next-line react/style-prop-object // eslint-disable-next-line react/style-prop-object
@ -58,29 +30,29 @@ export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
maximumFractionDigits={2} maximumFractionDigits={2}
/> />
</span> </span>
</div> </TextField>
); );
} }
function renderDimensions() {
if (props.image.file.height && props.image.file.width) {
return ( return (
<div className="row"> <dl className="container image-file-info details-list">
<span className="col-4">Dimensions</span> <TextField
<span className="col-8 text-truncate"> id="media_info.checksum"
{props.image.file.width} x {props.image.file.height} value={props.image.checksum}
</span> truncate
</div> />
); <URLField
} id="path"
} url={`file://${props.image.path}`}
value={`file://${props.image.path}`}
return ( truncate
<div className="container image-file-info"> />
{renderChecksum()}
{renderPath()}
{renderFileSize()} {renderFileSize()}
{renderDimensions()} <TextField
</div> id="dimensions"
value={`${props.image.file.width} x ${props.image.file.height}`}
truncate
/>
</dl>
); );
}; };

View file

@ -32,14 +32,12 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
} }
return ( return (
<dl className="row"> <>
<dt className="col-3 col-xl-2"> <dt>{intl.formatMessage({ id: "rating" })}</dt>
{intl.formatMessage({ id: "rating" })} <dd>
</dt>
<dd className="col-9 col-xl-10">
<RatingStars value={movie.rating} disabled /> <RatingStars value={movie.rating} disabled />
</dd> </dd>
</dl> </>
); );
} }
@ -52,7 +50,7 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
{maybeRenderAliases()} {maybeRenderAliases()}
<div> <dl className="details-list">
<TextField <TextField
id="duration" id="duration"
value={ value={
@ -79,7 +77,7 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
/> />
<TextField id="synopsis" value={movie.synopsis} /> <TextField id="synopsis" value={movie.synopsis} />
</div> </dl>
</div> </div>
); );
}; };

View file

@ -23,18 +23,18 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
} }
return ( return (
<dl className="row"> <>
<dt className="col-3 col-xl-2"> <dt>
<FormattedMessage id="tags" /> <FormattedMessage id="tags" />
</dt> </dt>
<dd className="col-9 col-xl-10"> <dd>
<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} tagType="performer" tag={tag} />
))} ))}
</ul> </ul>
</dd> </dd>
</dl> </>
); );
} }
@ -44,14 +44,14 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
} }
return ( return (
<dl className="row mb-0"> <>
<dt className="col-3 col-xl-2"> <dt>
<FormattedMessage id="rating" />: <FormattedMessage id="rating" />:
</dt> </dt>
<dd className="col-9 col-xl-10"> <dd>
<RatingStars value={performer.rating} /> <RatingStars value={performer.rating} />
</dd> </dd>
</dl> </>
); );
} }
@ -61,9 +61,9 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
} }
return ( return (
<dl className="row"> <>
<dt className="col-3 col-xl-2">StashIDs</dt> <dt>StashIDs</dt>
<dd className="col-9 col-xl-10"> <dd>
<ul className="pl-0"> <ul className="pl-0">
{performer.stash_ids.map((stashID) => { {performer.stash_ids.map((stashID) => {
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
@ -86,7 +86,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
})} })}
</ul> </ul>
</dd> </dd>
</dl> </>
); );
} }
@ -113,7 +113,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
}; };
return ( return (
<> <dl className="details-list">
<TextField <TextField
id="gender" id="gender"
value={genderToString(performer.gender ?? undefined)} value={genderToString(performer.gender ?? undefined)}
@ -162,6 +162,6 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
{renderRating()} {renderRating()}
{renderTagsField()} {renderTagsField()}
{renderStashIDs()} {renderStashIDs()}
</> </dl>
); );
}; };

View file

@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import { FormattedMessage, FormattedNumber } from "react-intl"; import { FormattedNumber } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { TruncatedText } from "src/components/Shared"; import { TextField, URLField } from "src/utils/field";
interface ISceneFileInfoPanelProps { interface ISceneFileInfoPanelProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
@ -11,61 +11,6 @@ interface ISceneFileInfoPanelProps {
export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = ( export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
props: ISceneFileInfoPanelProps props: ISceneFileInfoPanelProps
) => { ) => {
function renderOSHash() {
if (props.scene.oshash) {
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="media_info.hash" />
</span>
<TruncatedText className="col-8" text={props.scene.oshash} />
</div>
);
}
}
function renderChecksum() {
if (props.scene.checksum) {
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="media_info.checksum" />
</span>
<TruncatedText className="col-8" text={props.scene.checksum} />
</div>
);
}
}
function renderPath() {
const {
scene: { path },
} = props;
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="path" />
</span>
<a href={`file://${path}`} className="col-8">
<TruncatedText text={`file://${props.scene.path}`} />
</a>{" "}
</div>
);
}
function renderStream() {
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="media_info.stream" />
</span>
<a href={props.scene.paths.stream ?? ""} className="col-8">
<TruncatedText text={props.scene.paths.stream} />
</a>{" "}
</div>
);
}
function renderFileSize() { function renderFileSize() {
if (props.scene.file.size === undefined) { if (props.scene.file.size === undefined) {
return; return;
@ -76,11 +21,8 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
); );
return ( return (
<div className="row"> <TextField id="filesize">
<span className="col-4"> <span className="text-truncate">
<FormattedMessage id="filesize" />
</span>
<span className="col-8 text-truncate">
<FormattedNumber <FormattedNumber
value={size} value={size}
// eslint-disable-next-line react/style-prop-object // eslint-disable-next-line react/style-prop-object
@ -90,123 +32,7 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
maximumFractionDigits={2} maximumFractionDigits={2}
/> />
</span> </span>
</div> </TextField>
);
}
function renderDuration() {
if (props.scene.file.duration === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="duration" />
</span>
<TruncatedText
className="col-8"
text={TextUtils.secondsToTimestamp(props.scene.file.duration ?? 0)}
/>
</div>
);
}
function renderDimensions() {
if (props.scene.file.duration === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="dimensions" />
</span>
<TruncatedText
className="col-8"
text={`${props.scene.file.width} x ${props.scene.file.height}`}
/>
</div>
);
}
function renderFrameRate() {
if (props.scene.file.framerate === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="framerate" />
</span>
<span className="col-8 text-truncate">
<FormattedNumber value={props.scene.file.framerate ?? 0} /> frames per
second
</span>
</div>
);
}
function renderbitrate() {
// TODO: An upcoming react-intl version will support compound units, megabits-per-second
if (props.scene.file.bitrate === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="bitrate" />
</span>
<span className="col-8 text-truncate">
<FormattedNumber
value={(props.scene.file.bitrate ?? 0) / 1000000}
maximumFractionDigits={2}
/>
&nbsp;megabits per second
</span>
</div>
);
}
function renderVideoCodec() {
if (props.scene.file.video_codec === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="media_info.video_codec" />
</span>
<TruncatedText className="col-8" text={props.scene.file.video_codec} />
</div>
);
}
function renderAudioCodec() {
if (props.scene.file.audio_codec === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="media_info.audio_codec" />
</span>
<TruncatedText className="col-8" text={props.scene.file.audio_codec} />
</div>
);
}
function renderUrl() {
if (!props.scene.url || props.scene.url === "") {
return;
}
return (
<div className="row">
<span className="col-4">
<FormattedMessage id="media_info.downloaded_from" />
</span>
<a href={TextUtils.sanitiseURL(props.scene.url)} className="col-8">
<TruncatedText text={props.scene.url} />
</a>
</div>
); );
} }
@ -216,9 +42,10 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
} }
return ( return (
<div className="row"> <>
<span className="col-4">StashIDs</span> <dt>StashIDs</dt>
<ul className="col-8"> <dd>
<ul>
{props.scene.stash_ids.map((stashID) => { {props.scene.stash_ids.map((stashID) => {
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
const link = base ? ( const link = base ? (
@ -239,53 +66,90 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
); );
})} })}
</ul> </ul>
</div> </dd>
</>
); );
} }
function renderPhash() {
if (props.scene.phash) {
return (
<div className="row">
<abbr className="col-4" title="Perceptual hash">
<FormattedMessage id="media_info.phash" />
</abbr>
<TruncatedText className="col-8" text={props.scene.phash} />
</div>
);
}
}
function renderFunscript() { function renderFunscript() {
if (props.scene.interactive) { if (props.scene.interactive) {
return ( return (
<div className="row"> <URLField
<span className="col-4">Funscript</span> name="Funscript"
<a href={props.scene.paths.funscript ?? ""} className="col-8"> url={props.scene.paths.funscript}
<TruncatedText text={props.scene.paths.funscript} /> value={props.scene.paths.funscript}
</a>{" "} truncate
</div> />
); );
} }
} }
return ( return (
<div className="container scene-file-info"> <dl className="container scene-file-info details-list">
{renderOSHash()} <TextField id="media_info.hash" value={props.scene.oshash} truncate />
{renderChecksum()} <TextField
{renderPhash()} id="media_info.checksum"
{renderPath()} value={props.scene.checksum}
{renderStream()} truncate
/>
<TextField
id="media_info.phash"
abbr="Perceptual hash"
value={props.scene.phash}
truncate
/>
<URLField
id="path"
url={`file://${props.scene.path}`}
value={`file://${props.scene.path}`}
truncate
/>
<URLField
id="media_info.stream"
url={props.scene.paths.stream}
value={props.scene.paths.stream}
truncate
/>
{renderFunscript()} {renderFunscript()}
{renderFileSize()} {renderFileSize()}
{renderDuration()} <TextField
{renderDimensions()} id="duration"
{renderFrameRate()} value={TextUtils.secondsToTimestamp(props.scene.file.duration ?? 0)}
{renderbitrate()} truncate
{renderVideoCodec()} />
{renderAudioCodec()} <TextField
{renderUrl()} id="dimensions"
value={`${props.scene.file.width} x ${props.scene.file.height}`}
truncate
/>
<TextField id="framerate">
<FormattedNumber value={props.scene.file.framerate ?? 0} /> frames per
second
</TextField>
<TextField id="bitrate">
<FormattedNumber
value={(props.scene.file.bitrate ?? 0) / 1000000}
maximumFractionDigits={2}
/>{" "}
frames per second
</TextField>
<TextField
id="media_info.video_codec"
value={props.scene.file.video_codec}
truncate
/>
<TextField
id="media_info.audio_codec"
value={props.scene.file.audio_codec}
truncate
/>
<URLField
id="media_info.downloaded_from"
url={props.scene.url}
value={props.scene.url}
truncate
/>
{renderStashIDs()} {renderStashIDs()}
</div> </dl>
); );
}; };

View file

@ -186,6 +186,10 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active {
max-width: 300px; max-width: 300px;
white-space: pre-line; white-space: pre-line;
} }
.file-info-panel a > & {
word-break: break-all;
}
} }
.RatingStars { .RatingStars {

View file

@ -643,3 +643,9 @@ div.dropdown-menu {
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }
} }
dl.details-list {
display: grid;
grid-column-gap: 10px;
grid-template-columns: minmax(16.67%, auto) 1fr;
}

View file

@ -1,49 +1,85 @@
import React from "react"; import React from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { TruncatedText } from "../components/Shared";
interface ITextField { interface ITextField {
id?: string; id?: string;
name?: string; name?: string;
abbr?: string | null;
value?: string | null; value?: string | null;
truncate?: boolean | null;
} }
export const TextField: React.FC<ITextField> = ({ id, name, value }) => { export const TextField: React.FC<ITextField> = ({
if (!value) { id,
name,
value,
abbr,
truncate,
children,
}) => {
if (!value && !children) {
return null; return null;
} }
const message = (
<>{id ? <FormattedMessage id={id} defaultMessage={name} /> : name}:</>
);
return ( return (
<dl className="row mb-0"> <>
<dt className="col-3 col-xl-2"> <dt>{abbr ? <abbr title={abbr}>{message}</abbr> : message}</dt>
{id ? <FormattedMessage id={id} defaultMessage={name} /> : name}: <dd>
</dt> {value ? truncate ? <TruncatedText text={value} /> : value : children}
<dd className="col-9 col-xl-10">{value ?? undefined}</dd> </dd>
</dl> </>
); );
}; };
interface IURLField { interface IURLField {
id?: string; id?: string;
name?: string; name?: string;
abbr?: string | null;
value?: string | null; value?: string | null;
url?: string | null; url?: string | null;
truncate?: boolean | null;
} }
export const URLField: React.FC<IURLField> = ({ id, name, value, url }) => { export const URLField: React.FC<IURLField> = ({
if (!value) { id,
name,
value,
url,
abbr,
truncate,
children,
}) => {
if (!value && !children) {
return null; return null;
} }
const message = (
<>{id ? <FormattedMessage id={id} defaultMessage={name} /> : name}:</>
);
return ( return (
<dl className="row mb-0"> <>
<dt className="col-3 col-xl-2"> <dt>{abbr ? <abbr title={abbr}>{message}</abbr> : message}</dt>
{id ? <FormattedMessage id={id} defaultMessage={name} /> : name}: <dd>
</dt>
<dd className="col-9 col-xl-10">
{url ? ( {url ? (
<a href={url} target="_blank" rel="noopener noreferrer"> <a href={url} target="_blank" rel="noopener noreferrer">
{value} {value ? (
truncate ? (
<TruncatedText text={value} />
) : (
value
)
) : (
children
)}
</a> </a>
) : undefined} ) : undefined}
</dd> </dd>
</dl> </>
); );
}; };