diff --git a/ui/v2.5/src/components/Changelog/versions/v030.tsx b/ui/v2.5/src/components/Changelog/versions/v030.tsx index 94cf6a922..bf50920d6 100644 --- a/ui/v2.5/src/components/Changelog/versions/v030.tsx +++ b/ui/v2.5/src/components/Changelog/versions/v030.tsx @@ -6,6 +6,7 @@ const markup = ` * Add support for parent/child studios. ### 🎨 Improvements +* Improved the layout of the scene page. * Show rating as stars in scene page. * Add reload scrapers button. diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 131ad8043..e924dea29 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -6,6 +6,7 @@ import { JWUtils } from "src/utils"; import { ScenePlayerScrubber } from "./ScenePlayerScrubber"; interface IScenePlayerProps { + className?: string; scene: GQL.SceneDataFragment; timestamp: number; autoplay?: boolean; @@ -161,8 +162,8 @@ export class ScenePlayerImpl extends React.Component< kind: "chapters", }, ], - aspectratio: "16:9", width: "100%", + height: "100%", floating: { dismissible: true, }, @@ -183,11 +184,20 @@ export class ScenePlayerImpl extends React.Component< } public render() { + let className = + this.props.className ?? "w-100 col-sm-9 m-sm-auto no-gutter"; + const sceneFile = this.props.scene.file; + + if ( + sceneFile.height && + sceneFile.width && + sceneFile.height > sceneFile.width + ) { + className += " portrait"; + } + return ( -
+
= ( } return ( -
+
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx index 15d6ffbd8..5241b5576 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx @@ -56,7 +56,7 @@ export const PrimaryTags: React.FC = ({ }); return ( - +

{primaries[id].name}

{markers}
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 9dab63315..f0c7c5ced 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -1,25 +1,26 @@ -import { Tab, Tabs } from "react-bootstrap"; +import { Tab, Nav, Dropdown, Form } from "react-bootstrap"; import queryString from "query-string"; import React, { useEffect, useState } from "react"; -import { useParams, useLocation, useHistory } from "react-router-dom"; +import { useParams, useLocation, useHistory, Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { useFindScene, useSceneIncrementO, useSceneDecrementO, useSceneResetO, + useSceneGenerateScreenshot, + useSceneDestroy, } from "src/core/StashService"; import { GalleryViewer } from "src/components/Galleries/GalleryViewer"; -import { LoadingIndicator } from "src/components/Shared"; +import { LoadingIndicator, Icon, Modal } from "src/components/Shared"; import { useToast } from "src/hooks"; import { ScenePlayer } from "src/components/ScenePlayer"; -import { ScenePerformerPanel } from "./ScenePerformerPanel"; +import { TextUtils, JWUtils } from "src/utils"; import { SceneMarkersPanel } from "./SceneMarkersPanel"; import { SceneFileInfoPanel } from "./SceneFileInfoPanel"; import { SceneEditPanel } from "./SceneEditPanel"; import { SceneDetailPanel } from "./SceneDetailPanel"; import { OCounterButton } from "./OCounterButton"; -import { SceneOperationsPanel } from "./SceneOperationsPanel"; import { SceneMoviePanel } from "./SceneMoviePanel"; export const Scene: React.FC = () => { @@ -27,7 +28,15 @@ export const Scene: React.FC = () => { const location = useLocation(); const history = useHistory(); const Toast = useToast(); + const [generateScreenshot] = useSceneGenerateScreenshot(); const [timestamp, setTimestamp] = useState(getInitialTimestamp()); + + const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + const [deleteFile, setDeleteFile] = useState(false); + const [deleteGenerated, setDeleteGenerated] = useState(true); + const [deleteLoading, setDeleteLoading] = useState(false); + const [deleteScene] = useSceneDestroy(getSceneDeleteInput()); + const [scene, setScene] = useState(); const { data, error, loading } = useFindScene(id); const [oLoading, setOLoading] = useState(false); @@ -97,72 +106,230 @@ export const Scene: React.FC = () => { setTimestamp(marker.seconds); } - if (loading || !scene || !data?.findScene) { + async function onGenerateScreenshot(at?: number) { + if (!scene) { + return; + } + + await generateScreenshot({ + variables: { + id: scene.id, + at, + }, + }); + Toast.success({ content: "Generating screenshot" }); + } + + function getSceneDeleteInput(): GQL.SceneDestroyInput { + return { + id: scene ? scene.id : "0", + delete_file: deleteFile, + delete_generated: deleteGenerated, + }; + } + + async function onDelete() { + setIsDeleteAlertOpen(false); + setDeleteLoading(true); + try { + await deleteScene(); + Toast.success({ content: "Deleted scene" }); + } catch (e) { + Toast.error(e); + } + setDeleteLoading(false); + history.push("/scenes"); + } + + function renderDeleteAlert() { + return ( + setIsDeleteAlertOpen(false), text: "Cancel" }} + > +

+ Are you sure you want to delete this scene? Unless the file is also + deleted, this scene will be re-added when scan is performed. +

+
+ setDeleteFile(!deleteFile)} + /> + setDeleteGenerated(!deleteGenerated)} + /> + +
+ ); + } + + function renderOperations() { + return ( + + + + + + + onGenerateScreenshot(JWUtils.getPlayer().getPosition()) + } + > + Generate thumbnail from current + + onGenerateScreenshot()} + > + Generate default thumbnail + + setIsDeleteAlertOpen(true)} + > + Delete Scene + + + + ); + } + + function renderTabs() { + if (!scene) { + return; + } + + return ( + +
+ +
+ + + + + + + + + + + + {scene.gallery ? ( + + + + ) : ( + "" + )} + + + + + setScene(newScene)} + onDelete={onDelete} + /> + + +
+ ); + } + + if (deleteLoading || loading || !scene || !data?.findScene) { return ; } if (error) return
{error.message}
; return ( - <> - -
-
- +
+ {renderDeleteAlert()} +
+
+ {scene.studio && ( +

+ + {`${scene.studio.name} + +

+ )} +

+ {scene.title ?? TextUtils.fileNameFromPath(scene.path)} +

- - - - - - - - {scene.performers.length > 0 ? ( - - - - ) : ( - "" - )} - {scene.movies.length > 0 ? ( - - - - ) : ( - "" - )} - {scene.gallery ? ( - - - - ) : ( - "" - )} - - - - - setScene(newScene)} - onDelete={() => history.push("/scenes")} - /> - - - - - + {renderTabs()}
- +
+ +
+
); }; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx index 7d2556e04..73538f357 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx @@ -4,6 +4,7 @@ import { FormattedDate } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { TextUtils } from "src/utils"; import { TagLink } from "src/components/Shared"; +import { PerformerCard } from "src/components/Performers/PerformerCard"; import { RatingStars } from "./RatingStars"; interface ISceneDetailProps { @@ -34,41 +35,74 @@ export const SceneDetailPanel: React.FC = (props) => { ); } + function renderPerformers() { + if (props.scene.performers.length === 0) return; + const cards = props.scene.performers.map((performer) => ( + + )); + + return ( + <> +
Performers
+
+ {cards} +
+ + ); + } + + // filename should use entire row if there is no studio + const sceneDetailsWidth = props.scene.studio ? "col-9" : "col-12"; + return ( -
-

- {props.scene.title ?? TextUtils.fileNameFromPath(props.scene.path)} -

-
- {props.scene.date ? ( -

- -

- ) : undefined} - {props.scene.rating ? ( -
- Rating: -
- ) : ( - "" - )} - {props.scene.file.height && ( -
Resolution: {TextUtils.resolution(props.scene.file.height)}
- )} - {renderDetails()} - {renderTags()} -
-
+ <> +
+
+
+

+ {props.scene.title ?? + TextUtils.fileNameFromPath(props.scene.path)} +

+
+ {props.scene.date ? ( +
+ +
+ ) : undefined} + {props.scene.rating ? ( +
+ Rating: +
+ ) : ( + "" + )} + {props.scene.file.height && ( +
Resolution: {TextUtils.resolution(props.scene.file.height)}
+ )} +
{props.scene.studio && ( - - {`${props.scene.studio.name} - +
+ + {`${props.scene.studio.name} + +
)}
-
+
+
+ {renderDetails()} + {renderTags()} + {renderPerformers()} +
+
+ ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 71ab839a9..6eda1a6e5 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -1,14 +1,20 @@ /* eslint-disable react/no-this-in-sfc */ import React, { useEffect, useState } from "react"; -import { Button, Dropdown, DropdownButton, Form, Table } from "react-bootstrap"; +import { + Button, + Dropdown, + DropdownButton, + Form, + Col, + Row, +} from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { queryScrapeScene, queryScrapeSceneURL, useListSceneScrapers, useSceneUpdate, - useSceneDestroy, mutateReloadScrapers, } from "src/core/StashService"; import { @@ -16,13 +22,12 @@ import { TagSelect, StudioSelect, SceneGallerySelect, - Modal, Icon, LoadingIndicator, ImageInput, } from "src/components/Shared"; import { useToast } from "src/hooks"; -import { ImageUtils, TableUtils } from "src/utils"; +import { ImageUtils, FormUtils, EditableTextUtils } from "src/utils"; import { MovieSelect } from "src/components/Shared/Select"; import { SceneMovieTable, MovieSceneIndexMap } from "./SceneMovieTable"; import { RatingStars } from "./RatingStars"; @@ -53,17 +58,12 @@ export const SceneEditPanel: React.FC = (props: IProps) => { const Scrapers = useListSceneScrapers(); const [queryableScrapers, setQueryableScrapers] = useState([]); - const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); - const [deleteFile, setDeleteFile] = useState(false); - const [deleteGenerated, setDeleteGenerated] = useState(true); - const [coverImagePreview, setCoverImagePreview] = useState(); // Network state const [isLoading, setIsLoading] = useState(true); const [updateScene] = useSceneUpdate(getSceneInput()); - const [deleteScene] = useSceneDestroy(getSceneDeleteInput()); useEffect(() => { const newQueryableScrapers = ( @@ -187,27 +187,6 @@ export const SceneEditPanel: React.FC = (props: IProps) => { setIsLoading(false); } - function getSceneDeleteInput(): GQL.SceneDestroyInput { - return { - id: props.scene.id, - delete_file: deleteFile, - delete_generated: deleteGenerated, - }; - } - - async function onDelete() { - setIsDeleteAlertOpen(false); - setIsLoading(true); - try { - await deleteScene(); - Toast.success({ content: "Deleted scene" }); - } catch (e) { - Toast.error(e); - } - setIsLoading(false); - props.onDelete(); - } - function renderTableMovies() { return ( = (props: IProps) => { ); } - function renderDeleteAlert() { - return ( - setIsDeleteAlertOpen(false), text: "Cancel" }} - > -

- Are you sure you want to delete this scene? Unless the file is also - deleted, this scene will be re-added when scan is performed. -

-
- setDeleteFile(!deleteFile)} - /> - setDeleteGenerated(!deleteGenerated)} - /> - -
- ); - } - function onImageLoad(imageData: string) { setCoverImagePreview(imageData); setCoverImage(imageData); @@ -402,7 +352,7 @@ export const SceneEditPanel: React.FC = (props: IProps) => { return undefined; } return ( - ); @@ -411,148 +361,175 @@ export const SceneEditPanel: React.FC = (props: IProps) => { if (isLoading) return ; return ( -
-
- - - {TableUtils.renderInputGroup({ - title: "Title", - value: title, - onChange: setTitle, - isEditing: true, - })} - - - - - {TableUtils.renderInputGroup({ - title: "Date", - value: date, - isEditing: true, - onChange: setDate, - placeholder: "YYYY-MM-DD", - })} - - - - - - - - - - - - - - - - - - - - - - - - - -
URL - ) => - setUrl(newValue.currentTarget.value) - } - value={url} - placeholder="URL" - className="text-input" - /> - {maybeRenderScrapeButton()} -
Rating - setRating(value)} - /> -
Gallery - setGalleryId(item ? item.id : undefined)} - /> -
Studio - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - /> -
Performers - - setPerformerIds(items.map((item) => item.id)) - } - ids={performerIds} - /> -
Movies/Scenes - - setMovieIds(items.map((item) => item.id)) - } - ids={movieIds} - /> - {renderTableMovies()} -
Tags - setTagIds(items.map((item) => item.id))} - ids={tagIds} - /> -
+ <> +
+
+ + +
+ {renderScraperMenu()}
-
- - Details - ) => - setDetails(newValue.currentTarget.value) - } - value={details} - /> - - -
- - Cover Image - {imageEncoding ? ( - - ) : ( - Scene cover +
+ {FormUtils.renderInputGroup({ + title: "Title", + value: title, + onChange: setTitle, + isEditing: true, + })} + + {FormUtils.renderLabel({ + title: "URL", + })} + + {EditableTextUtils.renderInputGroup({ + title: "URL", + value: url, + onChange: setUrl, + isEditing: true, + })} + {maybeRenderScrapeButton()} + + + {FormUtils.renderInputGroup({ + title: "Date", + value: date, + isEditing: true, + onChange: setDate, + placeholder: "YYYY-MM-DD", + })} + + {FormUtils.renderLabel({ + title: "Rating", + })} + + setRating(value)} /> - )} - + + + + {FormUtils.renderLabel({ + title: "Gallery", + })} + + setGalleryId(item ? item.id : undefined)} + /> + + + + + {FormUtils.renderLabel({ + title: "Studio", + })} + + + setStudioId(items.length > 0 ? items[0]?.id : undefined) + } + ids={studioId ? [studioId] : []} + /> + + + + + {FormUtils.renderLabel({ + title: "Performers", + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + + setPerformerIds(items.map((item) => item.id)) + } + ids={performerIds} + /> + + + + + {FormUtils.renderLabel({ + title: "Movies/Scenes", + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setMovieIds(items.map((item) => item.id))} + ids={movieIds} + /> + {renderTableMovies()} + + + + + {FormUtils.renderLabel({ + title: "Tags", + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setTagIds(items.map((item) => item.id))} + ids={tagIds} + /> +
+
+ + Details + ) => + setDetails(newValue.currentTarget.value) + } + value={details} + /> + +
+ + Cover Image + {imageEncoding ? ( + + ) : ( + Scene cover + )} + + +
+
-
- - -
- {renderScraperMenu()} - {renderDeleteAlert()} -
+ ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index 9e9568207..682c51def 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -67,7 +67,7 @@ export const SceneMarkerForm: React.FC = ({ .catch((err) => Toast.error(err)); }; const renderTitleField = (fieldProps: FieldProps) => ( -
+
@@ -78,7 +78,7 @@ export const SceneMarkerForm: React.FC = ({ ); const renderSecondsField = (fieldProps: FieldProps) => ( -
+
fieldProps.form.setFieldValue("seconds", s)} onReset={() => @@ -132,53 +132,55 @@ export const SceneMarkerForm: React.FC = ({
- + Scene Marker Title {renderTitleField} - + Primary Tag -
+
{renderPrimaryTagField}
- + Time {renderSecondsField} - + Tags -
+
{renderTagsField}
- - - {editingMarker && ( - - )} + + {editingMarker && ( + + )} +
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneOperationsPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneOperationsPanel.tsx deleted file mode 100644 index 033967a46..000000000 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneOperationsPanel.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Button } from "react-bootstrap"; -import React, { FunctionComponent } from "react"; -import * as GQL from "src/core/generated-graphql"; -import { useSceneGenerateScreenshot } from "src/core/StashService"; -import { useToast } from "src/hooks"; -import { JWUtils } from "src/utils"; - -interface IOperationsPanelProps { - scene: GQL.SceneDataFragment; -} - -export const SceneOperationsPanel: FunctionComponent = ( - props: IOperationsPanelProps -) => { - const Toast = useToast(); - const [generateScreenshot] = useSceneGenerateScreenshot(); - - async function onGenerateScreenshot(at?: number) { - await generateScreenshot({ - variables: { - id: props.scene.id, - at, - }, - }); - Toast.success({ content: "Generating screenshot" }); - } - - return ( - <> - - - - ); -}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/ScenePerformerPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/ScenePerformerPanel.tsx deleted file mode 100644 index 39c76388c..000000000 --- a/ui/v2.5/src/components/Scenes/SceneDetails/ScenePerformerPanel.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React, { FunctionComponent } from "react"; -import * as GQL from "src/core/generated-graphql"; -import { PerformerCard } from "src/components/Performers/PerformerCard"; - -interface IScenePerformerPanelProps { - scene: GQL.SceneDataFragment; -} - -export const ScenePerformerPanel: FunctionComponent = ( - props: IScenePerformerPanelProps -) => { - const cards = props.scene.performers.map((performer) => ( - - )); - - return ( - <> -
{cards}
- - ); -}; diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index a7b32164d..609ca1218 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -76,11 +76,13 @@ .studio-logo { margin-top: 1rem; + max-height: 8rem; max-width: 100%; } .scene-header { flex-basis: auto; + margin-top: 30px; } #scene-details-container { @@ -215,8 +217,31 @@ max-width: 100%; } -.movie-table td { - vertical-align: middle; +.movie-table { + width: 100%; + + td { + vertical-align: middle; + } +} + +.scene-tabs { + max-height: calc(100vh - 4rem); + + overflow-wrap: break-word; + word-wrap: break-word; +} + +@media (min-width: 1200px), (max-width: 575px) { + .performer-card .flag-icon { + height: 1.33rem; + width: 2rem; + } + + .scene-performers .card-image { + height: 22.5rem; + width: 15rem; + } } .rating-stars { @@ -250,11 +275,6 @@ } } -.rating td { - padding-bottom: 0; - padding-top: 0; -} - #scene-edit-details .rating-stars { font-size: 1.3em; height: calc(1.5em + 0.75rem + 2px); diff --git a/ui/v2.5/src/components/Shared/ImageInput.tsx b/ui/v2.5/src/components/Shared/ImageInput.tsx index e2d3e39f4..9bb4b46a8 100644 --- a/ui/v2.5/src/components/Shared/ImageInput.tsx +++ b/ui/v2.5/src/components/Shared/ImageInput.tsx @@ -17,7 +17,7 @@ export const ImageInput: React.FC = ({ if (!isEditing) return
; return ( - + ( + + {options.title} + +); + +const renderEditableText = (options: { + title: string; + value?: string | number; + isEditing: boolean; + onChange: (value: string) => void; + labelProps?: FormLabelProps; + inputProps?: ColProps; +}) => ( + + {renderLabel(options)} + + {EditableTextUtils.renderEditableText(options)} + + +); + +const renderTextArea = (options: { + title: string; + value: string | undefined; + isEditing: boolean; + onChange: (value: string) => void; + labelProps?: FormLabelProps; + inputProps?: ColProps; +}) => ( + + {renderLabel(options)} + + {EditableTextUtils.renderTextArea(options)} + + +); + +const renderInputGroup = (options: { + title: string; + placeholder?: string; + value: string | undefined; + isEditing: boolean; + url?: string; + onChange: (value: string) => void; + labelProps?: FormLabelProps; + inputProps?: ColProps; +}) => ( + + {renderLabel(options)} + + {EditableTextUtils.renderInputGroup(options)} + + +); + +const renderDurationInput = (options: { + title: string; + placeholder?: string; + value: string | undefined; + isEditing: boolean; + asString?: boolean; + onChange: (value: string | undefined) => void; + labelProps?: FormLabelProps; + inputProps?: ColProps; +}) => { + return ( + + {renderLabel(options)} + + {EditableTextUtils.renderDurationInput(options)} + + + ); +}; + +const renderHtmlSelect = (options: { + title: string; + value?: string | number; + isEditing: boolean; + onChange: (value: string) => void; + selectOptions: Array; + labelProps?: FormLabelProps; + inputProps?: ColProps; +}) => ( + + {renderLabel(options)} + + {EditableTextUtils.renderHtmlSelect(options)} + + +); + +// TODO: isediting +const renderFilterSelect = (options: { + title: string; + type: "performers" | "studios" | "tags"; + initialId: string | undefined; + onChange: (id: string | undefined) => void; + labelProps?: FormLabelProps; + inputProps?: ColProps; +}) => ( + + {renderLabel(options)} + + {EditableTextUtils.renderFilterSelect(options)} + + +); + +// TODO: isediting +const renderMultiSelect = (options: { + title: string; + type: "performers" | "studios" | "tags"; + initialIds: string[] | undefined; + onChange: (ids: string[]) => void; + labelProps?: FormLabelProps; + inputProps?: ColProps; +}) => ( + + {renderLabel(options)} + + {EditableTextUtils.renderMultiSelect(options)} + + +); + +const FormUtils = { + renderLabel, + renderEditableText, + renderTextArea, + renderInputGroup, + renderDurationInput, + renderHtmlSelect, + renderFilterSelect, + renderMultiSelect, +}; +export default FormUtils; diff --git a/ui/v2.5/src/utils/index.ts b/ui/v2.5/src/utils/index.ts index 1f2370510..8ab4eaa9f 100644 --- a/ui/v2.5/src/utils/index.ts +++ b/ui/v2.5/src/utils/index.ts @@ -3,6 +3,7 @@ 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 FormUtils } from "./form"; export { default as DurationUtils } from "./duration"; export { default as JWUtils } from "./jwplayer"; export { default as SessionUtils } from "./session";