Performer and Movie UI fixes and improvements (#447)

* Improve gender display
* Sanitise performer URLs
* Refactor editable text into separate module
* Make movie duration DurationInput
* Fix clearing sometimes not firing onChange
* Set movie duration as string
* Fix TextUtil.fileSize
* Improve scene URL
This commit is contained in:
WithoutPants
2020-04-11 13:23:31 +10:00
committed by GitHub
parent aef31c8b50
commit 6764c1f545
13 changed files with 361 additions and 136 deletions

View File

@@ -11,7 +11,7 @@ import {
} from "src/components/Shared";
import { useToast } from "src/hooks";
import { Table, Form } from "react-bootstrap";
import { TableUtils, ImageUtils } from "src/utils";
import { TableUtils, ImageUtils, EditableTextUtils, TextUtils } from "src/utils";
import { MovieScenesPanel } from "./MovieScenesPanel";
export const Movie: React.FC = () => {
@@ -206,11 +206,14 @@ export const Movie: React.FC = () => {
isEditing,
onChange: setAliases,
})}
{TableUtils.renderInputGroup({
{TableUtils.renderDurationInput({
title: "Duration",
value: duration,
isEditing,
onChange: setDuration,
onChange: (value: string | undefined) => {
setDuration(value ?? "");
},
asString: true,
})}
{TableUtils.renderInputGroup({
title: "Date (YYYY-MM-DD)",
@@ -236,14 +239,14 @@ export const Movie: React.FC = () => {
<Form.Group controlId="url">
<Form.Label>URL</Form.Label>
<Form.Control
className="text-input"
readOnly={!isEditing}
onChange={(newValue: React.FormEvent<HTMLTextAreaElement>) =>
setUrl(newValue.currentTarget.value)
}
value={url}
/>
<div>
{EditableTextUtils.renderInputGroup({
isEditing,
onChange: setUrl,
value: url,
url: TextUtils.sanitiseURL(url),
})}
</div>
</Form.Group>
<Form.Group controlId="synopsis">

View File

@@ -177,7 +177,7 @@ export const Performer: React.FC = () => {
{performer.url && (
<Button className="minimal">
<a
href={performer.url}
href={TextUtils.sanitiseURL(performer.url)}
className="link"
target="_blank"
rel="noopener noreferrer"
@@ -189,7 +189,7 @@ export const Performer: React.FC = () => {
{performer.twitter && (
<Button className="minimal">
<a
href={`https://www.twitter.com/${performer.twitter}`}
href={TextUtils.sanitiseURL(performer.twitter, TextUtils.twitterURL)}
className="twitter"
target="_blank"
rel="noopener noreferrer"
@@ -201,7 +201,7 @@ export const Performer: React.FC = () => {
{performer.instagram && (
<Button className="minimal">
<a
href={`https://www.instagram.com/${performer.instagram}`}
href={TextUtils.sanitiseURL(performer.instagram, TextUtils.instagramURL)}
className="instagram"
target="_blank"
rel="noopener noreferrer"
@@ -213,11 +213,19 @@ export const Performer: React.FC = () => {
</span>
);
function renderPerformerImage() {
if (imagePreview) {
return (
<img className="photo" src={imagePreview} alt="Performer" />
);
}
}
function renderNewView() {
return (
<div className="row new-view">
<div className="col-4">
<img className="photo" src={imagePreview} alt="Performer" />
{renderPerformerImage()}
</div>
<div className="col-6">
<h2>Create Performer</h2>
@@ -242,11 +250,6 @@ export const Performer: React.FC = () => {
</div>
<div className="col col-sm-6">
<div className="row">
<div className="image-container col-6 d-block d-sm-none">
<Button variant="link" onClick={() => setLightboxIsOpen(true)}>
<img className="performer" src={imagePreview} alt="Performer" />
</Button>
</div>
<div className="performer-head col-6 col-sm-12">
<h2>
{performer.name}

View File

@@ -1,7 +1,7 @@
/* eslint-disable react/no-this-in-sfc */
import React, { useEffect, useState } from "react";
import { Button, Form, Popover, OverlayTrigger, Table } from "react-bootstrap";
import { Button, Popover, OverlayTrigger, Table } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import {
@@ -11,7 +11,7 @@ import {
ScrapePerformerSuggest,
LoadingIndicator,
} from "src/components/Shared";
import { ImageUtils, TableUtils } from "src/utils";
import { ImageUtils, TableUtils, TextUtils, EditableTextUtils } from "src/utils";
import { useToast } from "src/hooks";
interface IPerformerDetails {
@@ -323,16 +323,13 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
{maybeRenderScrapeButton()}
</td>
<td>
<Form.Control
className="text-input"
value={url ?? ""}
readOnly={!isEditing}
plaintext={!isEditing}
placeholder="URL"
onChange={(event: React.FormEvent<HTMLInputElement>) =>
setUrl(event.currentTarget.value)
}
/>
{EditableTextUtils.renderInputGroup({
title: "URL",
value: url,
url: TextUtils.sanitiseURL(url),
isEditing: !!isEditing,
onChange: setUrl,
})}
</td>
</tr>
);
@@ -486,12 +483,14 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
{TableUtils.renderInputGroup({
title: "Twitter",
value: twitter,
url: TextUtils.sanitiseURL(twitter, TextUtils.twitterURL),
isEditing: !!isEditing,
onChange: setTwitter,
})}
{TableUtils.renderInputGroup({
title: "Instagram",
value: instagram,
url: TextUtils.sanitiseURL(instagram, TextUtils.instagramURL),
isEditing: !!isEditing,
onChange: setInstagram,
})}

View File

@@ -6,10 +6,6 @@
}
}
#url-field {
line-height: 30px;
}
#performer-page {
flex-direction: row;
margin: 10px auto;

View File

@@ -150,7 +150,11 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
return (
<div className="row">
<span className="col-4">Downloaded From</span>
<span className="col-8 text-truncate">{props.scene.url}</span>
<span className="col-8 text-truncate">
<a href={TextUtils.sanitiseURL(props.scene.url)}>
{props.scene.url}
</a>
</span>
</div>
);
}

View File

@@ -84,6 +84,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
)
}
numericValue={Number.parseInt(fieldProps.field.value ?? "0", 10)}
mandatory={true}
/>
</div>
);

View File

@@ -119,7 +119,7 @@ export const SettingsInterfacePanel: React.FC = () => {
<DurationInput
className="row col col-4"
numericValue={maximumLoopDuration}
onValueChange={(duration) => setMaximumLoopDuration(duration)}
onValueChange={(duration) => setMaximumLoopDuration(duration ?? 0)}
/>
<Form.Text className="text-muted">
Maximum scene duration where scene player will loop the video - 0 to

View File

@@ -5,54 +5,69 @@ import { DurationUtils } from "src/utils";
interface IProps {
disabled?: boolean;
numericValue: number;
onValueChange(valueAsNumber: number): void;
numericValue: number | undefined;
mandatory?: boolean;
onValueChange(valueAsNumber: number | undefined, valueAsString?: string): void;
onReset?(): void;
className?: string;
}
export const DurationInput: React.FC<IProps> = (props: IProps) => {
const [value, setValue] = useState<string>(
DurationUtils.secondsToString(props.numericValue)
const [value, setValue] = useState<string | undefined>(
props.numericValue !== undefined ? DurationUtils.secondsToString(props.numericValue) : undefined
);
useEffect(() => {
setValue(DurationUtils.secondsToString(props.numericValue));
}, [props.numericValue]);
if (props.numericValue !== undefined || props.mandatory) {
setValue(DurationUtils.secondsToString(props.numericValue ?? 0));
} else {
setValue(undefined);
}
}, [props.numericValue, props.mandatory]);
function increment() {
if (value === undefined) {
return;
}
let seconds = DurationUtils.stringToSeconds(value);
seconds += 1;
props.onValueChange(seconds);
props.onValueChange(seconds, DurationUtils.secondsToString(seconds));
}
function decrement() {
if (value === undefined) {
return;
}
let seconds = DurationUtils.stringToSeconds(value);
seconds -= 1;
props.onValueChange(seconds);
props.onValueChange(seconds, DurationUtils.secondsToString(seconds));
}
function renderButtons() {
return (
<ButtonGroup vertical>
<Button
variant="secondary"
className="duration-button"
disabled={props.disabled}
onClick={() => increment()}
>
<Icon icon="chevron-up" />
</Button>
<Button
variant="secondary"
className="duration-button"
disabled={props.disabled}
onClick={() => decrement()}
>
<Icon icon="chevron-down" />
</Button>
</ButtonGroup>
);
if (!props.disabled) {
return (
<ButtonGroup vertical>
<Button
variant="secondary"
className="duration-button"
disabled={props.disabled}
onClick={() => increment()}
>
<Icon icon="chevron-up" />
</Button>
<Button
variant="secondary"
className="duration-button"
disabled={props.disabled}
onClick={() => decrement()}
>
<Icon icon="chevron-down" />
</Button>
</ButtonGroup>
);
}
}
function onReset() {
@@ -81,10 +96,14 @@ export const DurationInput: React.FC<IProps> = (props: IProps) => {
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setValue(e.currentTarget.value)
}
onBlur={() =>
props.onValueChange(DurationUtils.stringToSeconds(value))
}
placeholder="hh:mm:ss"
onBlur={() => {
if (props.mandatory || (value !== undefined && value !== "")) {
props.onValueChange(DurationUtils.stringToSeconds(value), value);
} else {
props.onValueChange(undefined);
}
}}
placeholder={!props.disabled ? "hh:mm:ss" : undefined}
/>
<InputGroup.Append>
{maybeRenderReset()}

View File

@@ -14,7 +14,7 @@ const secondsToString = (seconds: number) => {
return ret;
};
const stringToSeconds = (v: string) => {
const stringToSeconds = (v?: string) => {
if (!v) {
return 0;
}

View File

@@ -0,0 +1,206 @@
import React from "react";
import { Form } from "react-bootstrap";
import { FilterSelect, DurationInput } from "src/components/Shared";
import { DurationUtils } from ".";
const renderTextArea = (options: {
value: string | undefined;
isEditing: boolean;
onChange: (value: string) => void;
}) => {
return (
<Form.Control
className="text-input"
as="textarea"
readOnly={!options.isEditing}
plaintext={!options.isEditing}
onChange={(event: React.FormEvent<HTMLTextAreaElement>) =>
options.onChange(event.currentTarget.value)
}
value={options.value}
/>
);
}
const renderEditableText = (options: {
title?: string;
value?: string | number;
isEditing: boolean;
onChange: (value: string) => void;
}) => {
return (
<Form.Control
readOnly={!options.isEditing}
plaintext={!options.isEditing}
onChange={(event: React.FormEvent<HTMLInputElement>) =>
options.onChange(event.currentTarget.value)
}
value={
typeof options.value === "number"
? options.value.toString()
: options.value
}
placeholder={options.title}
/>
)
}
const renderInputGroup = (options: {
title?: string;
placeholder?: string;
value: string | undefined;
isEditing: boolean;
url?: string;
onChange: (value: string) => void;
}) => {
if (options.url && !options.isEditing) {
return (
<a
href={options.url}
target="_blank"
rel="noopener noreferrer"
>
{options.value}
</a>
);
} else {
return (
<Form.Control
className="text-input"
readOnly={!options.isEditing}
plaintext={!options.isEditing}
value={options.value}
placeholder={options.placeholder ?? options.title}
onChange={(event: React.FormEvent<HTMLInputElement>) =>
options.onChange(event.currentTarget.value)
}
/>
);
}
}
const renderDurationInput = (options: {
value: string | undefined;
isEditing: boolean;
url?: string;
asString?: boolean
onChange: (value: string | undefined) => void;
}) => {
let numericValue: number | undefined = undefined;
if (options.value) {
if (!options.asString) {
try {
numericValue = Number.parseInt(options.value, 10);
} catch {
// ignore
}
} else {
numericValue = DurationUtils.stringToSeconds(options.value);
}
}
if (!options.isEditing) {
let durationString = undefined;
if (numericValue !== undefined) {
durationString = DurationUtils.secondsToString(numericValue);
}
return (
<Form.Control
className="text-input"
readOnly={true}
plaintext={true}
defaultValue={durationString}
/>
);
} else {
return (
<DurationInput
disabled={!options.isEditing}
numericValue={numericValue}
onValueChange={(valueAsNumber: number, valueAsString? : string) => {
if (!options.asString) {
valueAsString = valueAsNumber !== undefined ? valueAsNumber.toString() : undefined;
}
options.onChange(valueAsString);
}}
/>
);
}
}
const renderHtmlSelect = (options: {
value?: string | number;
isEditing: boolean;
onChange: (value: string) => void;
selectOptions: Array<string | number>;
}) => {
if (!options.isEditing) {
return (
<Form.Control
className="text-input"
readOnly={true}
plaintext={true}
defaultValue={options.value}
/>
);
} else {
return (
<Form.Control
as="select"
className="input-control"
disabled={!options.isEditing}
plaintext={!options.isEditing}
value={options.value?.toString()}
onChange={(event: React.FormEvent<HTMLSelectElement>) =>
options.onChange(event.currentTarget.value)
}
>
{options.selectOptions.map((opt) => (
<option value={opt} key={opt}>
{opt}
</option>
))}
</Form.Control>
);
}
}
// TODO: isediting
const renderFilterSelect = (options: {
type: "performers" | "studios" | "tags";
initialId: string | undefined;
onChange: (id: string | undefined) => void;
}) => (
<FilterSelect
type={options.type}
onSelect={(items) => options.onChange(items[0]?.id)}
initialIds={options.initialId ? [options.initialId] : []}
/>
);
// TODO: isediting
const renderMultiSelect = (options: {
type: "performers" | "studios" | "tags";
initialIds: string[] | undefined;
onChange: (ids: string[]) => void;
}) => (
<FilterSelect
type={options.type}
isMulti
onSelect={(items) => options.onChange(items.map((i) => i.id))}
initialIds={options.initialIds ?? []}
/>
);
const EditableTextUtils = {
renderTextArea,
renderEditableText,
renderInputGroup,
renderDurationInput,
renderHtmlSelect,
renderFilterSelect,
renderMultiSelect,
};
export default EditableTextUtils;

View File

@@ -2,6 +2,7 @@ export { default as ImageUtils } from "./image";
export { default as NavUtils } from "./navigation";
export { default as TableUtils } from "./table";
export { default as TextUtils } from "./text";
export { default as EditableTextUtils } from "./editabletext";
export { default as DurationUtils } from "./duration";
export { default as JWUtils } from "./jwplayer";
export { default as SessionUtils } from "./session";

View File

@@ -1,6 +1,5 @@
import React from "react";
import { Form } from "react-bootstrap";
import { FilterSelect } from "src/components/Shared";
import EditableTextUtils from "./editabletext";
const renderEditableTextTableRow = (options: {
title: string;
@@ -11,19 +10,7 @@ const renderEditableTextTableRow = (options: {
<tr>
<td>{options.title}</td>
<td>
<Form.Control
readOnly={!options.isEditing}
plaintext={!options.isEditing}
onChange={(event: React.FormEvent<HTMLInputElement>) =>
options.onChange(event.currentTarget.value)
}
value={
typeof options.value === "number"
? options.value.toString()
: options.value
}
placeholder={options.title}
/>
{EditableTextUtils.renderEditableText(options)}
</td>
</tr>
);
@@ -37,16 +24,7 @@ const renderTextArea = (options: {
<tr>
<td>{options.title}</td>
<td>
<Form.Control
className="text-input"
as="textarea"
readOnly={!options.isEditing}
plaintext={!options.isEditing}
onChange={(event: React.FormEvent<HTMLTextAreaElement>) =>
options.onChange(event.currentTarget.value)
}
value={options.value}
/>
{EditableTextUtils.renderTextArea(options)}
</td>
</tr>
);
@@ -56,27 +34,35 @@ const renderInputGroup = (options: {
placeholder?: string;
value: string | undefined;
isEditing: boolean;
asURL?: boolean;
urlPrefix?: string;
url?: string;
onChange: (value: string) => void;
}) => (
<tr>
<td>{options.title}</td>
<td>
<Form.Control
className="text-input"
readOnly={!options.isEditing}
plaintext={!options.isEditing}
defaultValue={options.value}
placeholder={options.placeholder ?? options.title}
onChange={(event: React.FormEvent<HTMLInputElement>) =>
options.onChange(event.currentTarget.value)
}
/>
{EditableTextUtils.renderInputGroup(options)}
</td>
</tr>
);
const renderDurationInput = (options: {
title: string;
placeholder?: string;
value: string | undefined;
isEditing: boolean;
asString?: boolean;
onChange: (value: string | undefined) => void;
}) => {
return (
<tr>
<td>{options.title}</td>
<td>
{EditableTextUtils.renderDurationInput(options)}
</td>
</tr>
);
};
const renderHtmlSelect = (options: {
title: string;
value?: string | number;
@@ -87,22 +73,7 @@ const renderHtmlSelect = (options: {
<tr>
<td>{options.title}</td>
<td>
<Form.Control
as="select"
className="input-control"
disabled={!options.isEditing}
plaintext={!options.isEditing}
value={options.value?.toString()}
onChange={(event: React.FormEvent<HTMLSelectElement>) =>
options.onChange(event.currentTarget.value)
}
>
{options.selectOptions.map((opt) => (
<option value={opt} key={opt}>
{opt}
</option>
))}
</Form.Control>
{EditableTextUtils.renderHtmlSelect(options)}
</td>
</tr>
);
@@ -117,11 +88,7 @@ const renderFilterSelect = (options: {
<tr>
<td>{options.title}</td>
<td>
<FilterSelect
type={options.type}
onSelect={(items) => options.onChange(items[0]?.id)}
initialIds={options.initialId ? [options.initialId] : []}
/>
{EditableTextUtils.renderFilterSelect(options)}
</td>
</tr>
);
@@ -136,12 +103,7 @@ const renderMultiSelect = (options: {
<tr>
<td>{options.title}</td>
<td>
<FilterSelect
type={options.type}
isMulti
onSelect={(items) => options.onChange(items.map((i) => i.id))}
initialIds={options.initialIds ?? []}
/>
{EditableTextUtils.renderMultiSelect(options)}
</td>
</tr>
);
@@ -150,6 +112,7 @@ const Table = {
renderEditableTextTableRow,
renderTextArea,
renderInputGroup,
renderDurationInput,
renderHtmlSelect,
renderFilterSelect,
renderMultiSelect,

View File

@@ -20,7 +20,7 @@ const fileSize = (bytes: number = 0, precision: number = 2) => {
unit++;
}
return `${bytes.toFixed(+precision)} ${Units[unit]}`;
return `${count.toFixed(+precision)} ${Units[unit]}`;
};
const secondsToTimestamp = (seconds: number) => {
@@ -83,6 +83,33 @@ const resolution = (height: number) => {
}
};
const twitterURL = new URL("https://www.twitter.com");
const instagramURL = new URL("https://www.instagram.com");
const sanitiseURL = (url?: string, siteURL?: URL) => {
if (!url) {
return url;
}
if (url.startsWith("http://") || url.startsWith("https://")) {
// just return the entire URL
return url;
}
if (siteURL) {
// if url starts with the site host, then prepend the protocol
if (url.startsWith(siteURL.host)) {
return siteURL.protocol + url;
}
// otherwise, construct the url from the protocol, host and passed url
return siteURL.protocol + siteURL.host + "/" + url;
}
// just prepend the protocol - assume https
return "https://" + url;
}
const TextUtils = {
truncate,
fileSize,
@@ -91,6 +118,9 @@ const TextUtils = {
age: getAge,
bitRate,
resolution,
sanitiseURL,
twitterURL,
instagramURL,
};
export default TextUtils;