diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index d759ac730..53566fdfc 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -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 = () => { URL - ) => - setUrl(newValue.currentTarget.value) - } - value={url} - /> +
+ {EditableTextUtils.renderInputGroup({ + isEditing, + onChange: setUrl, + value: url, + url: TextUtils.sanitiseURL(url), + })} +
diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index c8fd663aa..b8581efb8 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -177,7 +177,7 @@ export const Performer: React.FC = () => { {performer.url && ( -

{performer.name} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 1ab045cb5..b9dda1de6 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -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 = ({ {maybeRenderScrapeButton()} - ) => - setUrl(event.currentTarget.value) - } - /> + {EditableTextUtils.renderInputGroup({ + title: "URL", + value: url, + url: TextUtils.sanitiseURL(url), + isEditing: !!isEditing, + onChange: setUrl, + })} ); @@ -486,12 +483,14 @@ export const PerformerDetailsPanel: React.FC = ({ {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, })} diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 04c212cd5..7575b926a 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -6,10 +6,6 @@ } } -#url-field { - line-height: 30px; -} - #performer-page { flex-direction: row; margin: 10px auto; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index 881030fac..c10310d2c 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -150,7 +150,11 @@ export const SceneFileInfoPanel: React.FC = ( return (
Downloaded From - {props.scene.url} + + + {props.scene.url} + +
); } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index c9a8082dc..7833cc466 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -84,6 +84,7 @@ export const SceneMarkerForm: React.FC = ({ ) } numericValue={Number.parseInt(fieldProps.field.value ?? "0", 10)} + mandatory={true} />

); diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel.tsx index d6d5d67d0..9def1e190 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel.tsx @@ -119,7 +119,7 @@ export const SettingsInterfacePanel: React.FC = () => { setMaximumLoopDuration(duration)} + onValueChange={(duration) => setMaximumLoopDuration(duration ?? 0)} /> Maximum scene duration where scene player will loop the video - 0 to diff --git a/ui/v2.5/src/components/Shared/DurationInput.tsx b/ui/v2.5/src/components/Shared/DurationInput.tsx index bc3d37214..422e08ce4 100644 --- a/ui/v2.5/src/components/Shared/DurationInput.tsx +++ b/ui/v2.5/src/components/Shared/DurationInput.tsx @@ -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 = (props: IProps) => { - const [value, setValue] = useState( - DurationUtils.secondsToString(props.numericValue) + const [value, setValue] = useState( + 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 ( - - - - - ); + if (!props.disabled) { + return ( + + + + + ); + } } function onReset() { @@ -81,10 +96,14 @@ export const DurationInput: React.FC = (props: IProps) => { onChange={(e: React.FormEvent) => 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} /> {maybeRenderReset()} diff --git a/ui/v2.5/src/utils/duration.ts b/ui/v2.5/src/utils/duration.ts index a2daf4826..ca81863d6 100644 --- a/ui/v2.5/src/utils/duration.ts +++ b/ui/v2.5/src/utils/duration.ts @@ -14,7 +14,7 @@ const secondsToString = (seconds: number) => { return ret; }; -const stringToSeconds = (v: string) => { +const stringToSeconds = (v?: string) => { if (!v) { return 0; } diff --git a/ui/v2.5/src/utils/editabletext.tsx b/ui/v2.5/src/utils/editabletext.tsx new file mode 100644 index 000000000..27d5e6aa3 --- /dev/null +++ b/ui/v2.5/src/utils/editabletext.tsx @@ -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 ( + ) => + options.onChange(event.currentTarget.value) + } + value={options.value} + /> + ); +} + +const renderEditableText = (options: { + title?: string; + value?: string | number; + isEditing: boolean; + onChange: (value: string) => void; +}) => { + return ( + ) => + 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 ( + + {options.value} + + ); + } else { + return ( + ) => + 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 ( + + ); + } else { + return ( + { + 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; +}) => { + if (!options.isEditing) { + return ( + + ); + } else { + return ( + ) => + options.onChange(event.currentTarget.value) + } + > + {options.selectOptions.map((opt) => ( + + ))} + + ); + } +} + +// TODO: isediting +const renderFilterSelect = (options: { + type: "performers" | "studios" | "tags"; + initialId: string | undefined; + onChange: (id: string | undefined) => void; +}) => ( + 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; +}) => ( + options.onChange(items.map((i) => i.id))} + initialIds={options.initialIds ?? []} + /> +); + +const EditableTextUtils = { + renderTextArea, + renderEditableText, + renderInputGroup, + renderDurationInput, + renderHtmlSelect, + renderFilterSelect, + renderMultiSelect, +}; +export default EditableTextUtils; \ No newline at end of file diff --git a/ui/v2.5/src/utils/index.ts b/ui/v2.5/src/utils/index.ts index 45379953d..5fc853d12 100644 --- a/ui/v2.5/src/utils/index.ts +++ b/ui/v2.5/src/utils/index.ts @@ -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"; diff --git a/ui/v2.5/src/utils/table.tsx b/ui/v2.5/src/utils/table.tsx index 201c3d178..5e3347647 100644 --- a/ui/v2.5/src/utils/table.tsx +++ b/ui/v2.5/src/utils/table.tsx @@ -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: { {options.title} - ) => - options.onChange(event.currentTarget.value) - } - value={ - typeof options.value === "number" - ? options.value.toString() - : options.value - } - placeholder={options.title} - /> + {EditableTextUtils.renderEditableText(options)} ); @@ -37,16 +24,7 @@ const renderTextArea = (options: { {options.title} - ) => - options.onChange(event.currentTarget.value) - } - value={options.value} - /> + {EditableTextUtils.renderTextArea(options)} ); @@ -56,27 +34,35 @@ const renderInputGroup = (options: { placeholder?: string; value: string | undefined; isEditing: boolean; - asURL?: boolean; - urlPrefix?: string; + url?: string; onChange: (value: string) => void; }) => ( {options.title} - ) => - options.onChange(event.currentTarget.value) - } - /> + {EditableTextUtils.renderInputGroup(options)} ); +const renderDurationInput = (options: { + title: string; + placeholder?: string; + value: string | undefined; + isEditing: boolean; + asString?: boolean; + onChange: (value: string | undefined) => void; +}) => { + return ( + + {options.title} + + {EditableTextUtils.renderDurationInput(options)} + + + ); +}; + const renderHtmlSelect = (options: { title: string; value?: string | number; @@ -87,22 +73,7 @@ const renderHtmlSelect = (options: { {options.title} - ) => - options.onChange(event.currentTarget.value) - } - > - {options.selectOptions.map((opt) => ( - - ))} - + {EditableTextUtils.renderHtmlSelect(options)} ); @@ -117,11 +88,7 @@ const renderFilterSelect = (options: { {options.title} - options.onChange(items[0]?.id)} - initialIds={options.initialId ? [options.initialId] : []} - /> + {EditableTextUtils.renderFilterSelect(options)} ); @@ -136,12 +103,7 @@ const renderMultiSelect = (options: { {options.title} - options.onChange(items.map((i) => i.id))} - initialIds={options.initialIds ?? []} - /> + {EditableTextUtils.renderMultiSelect(options)} ); @@ -150,6 +112,7 @@ const Table = { renderEditableTextTableRow, renderTextArea, renderInputGroup, + renderDurationInput, renderHtmlSelect, renderFilterSelect, renderMultiSelect, diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index 3e8bfed7c..b897c25f1 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -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;