mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
[RFC] Revamp scene page (#562)
* Don't show scrubber on small height device * Move operations into ellipsis menu * Hide scrubber in mobile devices * Add delete scene to operations drop down * Remove redundant panels * Fix video height on smaller devices * Adjust player aspect ratio for portrait videos
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
id="jwplayer-container"
|
||||
className="w-100 col-sm-9 m-sm-auto no-gutter"
|
||||
>
|
||||
<div id="jwplayer-container" className={className}>
|
||||
<ReactJWPlayer
|
||||
playerId={JWUtils.playerID}
|
||||
playerScript="/jwplayer/jwplayer.js"
|
||||
|
||||
@@ -355,7 +355,7 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scrubber-wrapper d-none d-sm-block">
|
||||
<div className="scrubber-wrapper">
|
||||
<Button
|
||||
variant="link"
|
||||
className="scrubber-button"
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
.scene-player:focus {
|
||||
outline: 0;
|
||||
$scrubberHeight: 120px;
|
||||
|
||||
#jwplayer-container {
|
||||
/* full height minus the top navbar */
|
||||
height: calc(100vh - 4rem);
|
||||
padding-bottom: 0.25rem;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
height: inherit;
|
||||
|
||||
.jw-aspect {
|
||||
display: block;
|
||||
height: 56.25vw;
|
||||
max-height: calc(100vh - #{$scrubberHeight} - 4.25rem - 15px);
|
||||
}
|
||||
|
||||
&.portrait .jw-aspect {
|
||||
height: 177.78vw;
|
||||
}
|
||||
}
|
||||
@media (max-height: 449px), (max-width: 575px) {
|
||||
.jw-aspect {
|
||||
max-height: calc(100vh - 4.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
& video:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
> div:first-child {
|
||||
/* minus the scrubber height and margin */
|
||||
height: calc(100% - #{$scrubberHeight} - 15px);
|
||||
}
|
||||
}
|
||||
|
||||
/* scrubber is hidden when height < 450px or width < 576, so use entire height for scene player */
|
||||
@media (max-height: 449px), (max-width: 575px) {
|
||||
#jwplayer-container > div:first-child {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
$sceneTabWidth: 450px;
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.scene-tabs {
|
||||
flex: 0 0 $sceneTabWidth;
|
||||
max-width: $sceneTabWidth;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.scene-player-container {
|
||||
flex: 0 0 calc(100% - #{$sceneTabWidth});
|
||||
max-width: calc(100% - #{$sceneTabWidth});
|
||||
}
|
||||
}
|
||||
|
||||
.scene-tabs,
|
||||
.scene-player-container {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scrubber-wrapper {
|
||||
@@ -8,6 +70,13 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* hide scrubber when height is < 450px or width < 576 */
|
||||
@media (max-height: 449px), (max-width: 575px) {
|
||||
.scrubber-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
#scrubber-back {
|
||||
float: left;
|
||||
}
|
||||
@@ -24,7 +93,7 @@
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
height: 100%;
|
||||
line-height: 120px;
|
||||
line-height: $scrubberHeight;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 1.5%;
|
||||
@@ -33,7 +102,7 @@
|
||||
.scrubber-content {
|
||||
cursor: grab;
|
||||
display: inline-block;
|
||||
height: 120px;
|
||||
height: $scrubberHeight;
|
||||
margin: 0 0.5%;
|
||||
overflow: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
@@ -18,7 +18,12 @@ export const OCounterButton: React.FC<IOCounterButtonProps> = (
|
||||
if (props.loading) return <Spinner animation="border" role="status" />;
|
||||
|
||||
const renderButton = () => (
|
||||
<Button className="minimal" onClick={props.onIncrement} variant="secondary">
|
||||
<Button
|
||||
className="minimal"
|
||||
onClick={props.onIncrement}
|
||||
variant="secondary"
|
||||
title="O-Counter"
|
||||
>
|
||||
<SweatDrops />
|
||||
<span className="ml-2">{props.value}</span>
|
||||
</Button>
|
||||
|
||||
@@ -56,7 +56,7 @@ export const PrimaryTags: React.FC<IPrimaryTags> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="primary-card col-12 col-sm-3" key={id}>
|
||||
<Card className="primary-card col-12 col-sm-3 col-xl-6" key={id}>
|
||||
<h3>{primaries[id].name}</h3>
|
||||
<Card.Body className="primary-card-body">{markers}</Card.Body>
|
||||
</Card>
|
||||
|
||||
@@ -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<number>(getInitialTimestamp());
|
||||
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
const [deleteFile, setDeleteFile] = useState<boolean>(false);
|
||||
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [deleteScene] = useSceneDestroy(getSceneDeleteInput());
|
||||
|
||||
const [scene, setScene] = useState<GQL.SceneDataFragment | undefined>();
|
||||
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 (
|
||||
<Modal
|
||||
show={isDeleteAlertOpen}
|
||||
icon="trash-alt"
|
||||
header="Delete Scene?"
|
||||
accept={{ variant: "danger", onClick: onDelete, text: "Delete" }}
|
||||
cancel={{ onClick: () => setIsDeleteAlertOpen(false), text: "Cancel" }}
|
||||
>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<Form>
|
||||
<Form.Check
|
||||
checked={deleteFile}
|
||||
label="Delete file"
|
||||
onChange={() => setDeleteFile(!deleteFile)}
|
||||
/>
|
||||
<Form.Check
|
||||
checked={deleteGenerated}
|
||||
label="Delete generated supporting files"
|
||||
onChange={() => setDeleteGenerated(!deleteGenerated)}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function renderOperations() {
|
||||
return (
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
variant="secondary"
|
||||
id="operation-menu"
|
||||
className="minimal"
|
||||
title="Operations"
|
||||
>
|
||||
<Icon icon="ellipsis-v" />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
<Dropdown.Item
|
||||
key="generate-screenshot"
|
||||
className="bg-secondary text-white"
|
||||
onClick={() =>
|
||||
onGenerateScreenshot(JWUtils.getPlayer().getPosition())
|
||||
}
|
||||
>
|
||||
Generate thumbnail from current
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
key="generate-default"
|
||||
className="bg-secondary text-white"
|
||||
onClick={() => onGenerateScreenshot()}
|
||||
>
|
||||
Generate default thumbnail
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
key="delete-scene"
|
||||
className="bg-secondary text-white"
|
||||
onClick={() => setIsDeleteAlertOpen(true)}
|
||||
>
|
||||
Delete Scene
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
if (!scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tab.Container defaultActiveKey="scene-details-panel">
|
||||
<div>
|
||||
<Nav variant="tabs" className="mr-auto">
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-details-panel">Details</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-markers-panel">Markers</Nav.Link>
|
||||
</Nav.Item>
|
||||
{scene.movies.length > 0 ? (
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-movie-panel">Movies</Nav.Link>
|
||||
</Nav.Item>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{scene.gallery ? (
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-gallery-panel">Gallery</Nav.Link>
|
||||
</Nav.Item>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-file-info-panel">File Info</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-edit-panel">Edit</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item className="ml-auto">
|
||||
<OCounterButton
|
||||
loading={oLoading}
|
||||
value={scene.o_counter || 0}
|
||||
onIncrement={onIncrementClick}
|
||||
onDecrement={onDecrementClick}
|
||||
onReset={onResetClick}
|
||||
/>
|
||||
</Nav.Item>
|
||||
<Nav.Item>{renderOperations()}</Nav.Item>
|
||||
</Nav>
|
||||
</div>
|
||||
|
||||
<Tab.Content>
|
||||
<Tab.Pane eventKey="scene-details-panel" title="Details">
|
||||
<SceneDetailPanel scene={scene} />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="scene-markers-panel" title="Markers">
|
||||
<SceneMarkersPanel scene={scene} onClickMarker={onClickMarker} />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="scene-movie-panel" title="Movies">
|
||||
<SceneMoviePanel scene={scene} />
|
||||
</Tab.Pane>
|
||||
{scene.gallery ? (
|
||||
<Tab.Pane eventKey="scene-gallery-panel" title="Gallery">
|
||||
<GalleryViewer gallery={scene.gallery} />
|
||||
</Tab.Pane>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Tab.Pane
|
||||
className="file-info-panel"
|
||||
eventKey="scene-file-info-panel"
|
||||
title="File Info"
|
||||
>
|
||||
<SceneFileInfoPanel scene={scene} />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="scene-edit-panel" title="Edit">
|
||||
<SceneEditPanel
|
||||
scene={scene}
|
||||
onUpdate={(newScene) => setScene(newScene)}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</Tab.Pane>
|
||||
</Tab.Content>
|
||||
</Tab.Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (deleteLoading || loading || !scene || !data?.findScene) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (error) return <div>{error.message}</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScenePlayer scene={scene} timestamp={timestamp} autoplay={autoplay} />
|
||||
<div id="scene-details-container" className="col col-sm-9 m-sm-auto">
|
||||
<div className="float-right">
|
||||
<OCounterButton
|
||||
loading={oLoading}
|
||||
value={scene.o_counter || 0}
|
||||
onIncrement={onIncrementClick}
|
||||
onDecrement={onDecrementClick}
|
||||
onReset={onResetClick}
|
||||
/>
|
||||
<div className="row">
|
||||
{renderDeleteAlert()}
|
||||
<div className="scene-tabs order-xl-first order-last">
|
||||
<div className="d-none d-xl-block">
|
||||
{scene.studio && (
|
||||
<h1 className="text-center">
|
||||
<Link to={`/studios/${scene.studio.id}`}>
|
||||
<img
|
||||
src={scene.studio.image_path ?? ""}
|
||||
alt={`${scene.studio.name} logo`}
|
||||
className="studio-logo"
|
||||
/>
|
||||
</Link>
|
||||
</h1>
|
||||
)}
|
||||
<h3 className="scene-header">
|
||||
{scene.title ?? TextUtils.fileNameFromPath(scene.path)}
|
||||
</h3>
|
||||
</div>
|
||||
<Tabs id="scene-tabs" mountOnEnter>
|
||||
<Tab eventKey="scene-details-panel" title="Details">
|
||||
<SceneDetailPanel scene={scene} />
|
||||
</Tab>
|
||||
<Tab eventKey="scene-markers-panel" title="Markers">
|
||||
<SceneMarkersPanel scene={scene} onClickMarker={onClickMarker} />
|
||||
</Tab>
|
||||
{scene.performers.length > 0 ? (
|
||||
<Tab eventKey="scene-performer-panel" title="Performers">
|
||||
<ScenePerformerPanel scene={scene} />
|
||||
</Tab>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{scene.movies.length > 0 ? (
|
||||
<Tab eventKey="scene-movie-panel" title="Movies">
|
||||
<SceneMoviePanel scene={scene} />
|
||||
</Tab>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{scene.gallery ? (
|
||||
<Tab eventKey="scene-gallery-panel" title="Gallery">
|
||||
<GalleryViewer gallery={scene.gallery} />
|
||||
</Tab>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Tab
|
||||
className="file-info-panel"
|
||||
eventKey="scene-file-info-panel"
|
||||
title="File Info"
|
||||
>
|
||||
<SceneFileInfoPanel scene={scene} />
|
||||
</Tab>
|
||||
<Tab eventKey="scene-edit-panel" title="Edit">
|
||||
<SceneEditPanel
|
||||
scene={scene}
|
||||
onUpdate={(newScene) => setScene(newScene)}
|
||||
onDelete={() => history.push("/scenes")}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="scene-operations-panel" title="Operations">
|
||||
<SceneOperationsPanel scene={scene} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
{renderTabs()}
|
||||
</div>
|
||||
</>
|
||||
<div className="scene-player-container">
|
||||
<ScenePlayer
|
||||
className="w-100 m-sm-auto no-gutter"
|
||||
scene={scene}
|
||||
timestamp={timestamp}
|
||||
autoplay={autoplay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<ISceneDetailProps> = (props) => {
|
||||
);
|
||||
}
|
||||
|
||||
function renderPerformers() {
|
||||
if (props.scene.performers.length === 0) return;
|
||||
const cards = props.scene.performers.map((performer) => (
|
||||
<PerformerCard
|
||||
key={performer.id}
|
||||
performer={performer}
|
||||
ageFromDate={props.scene.date ?? undefined}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<h6>Performers</h6>
|
||||
<div className="row justify-content-center scene-performers">
|
||||
{cards}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// filename should use entire row if there is no studio
|
||||
const sceneDetailsWidth = props.scene.studio ? "col-9" : "col-12";
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<h3 className="col scene-header text-truncate">
|
||||
{props.scene.title ?? TextUtils.fileNameFromPath(props.scene.path)}
|
||||
</h3>
|
||||
<div className="col-6 scene-details">
|
||||
{props.scene.date ? (
|
||||
<h4>
|
||||
<FormattedDate value={props.scene.date} format="long" />
|
||||
</h4>
|
||||
) : undefined}
|
||||
{props.scene.rating ? (
|
||||
<h6>
|
||||
Rating: <RatingStars value={props.scene.rating} />
|
||||
</h6>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{props.scene.file.height && (
|
||||
<h6>Resolution: {TextUtils.resolution(props.scene.file.height)}</h6>
|
||||
)}
|
||||
{renderDetails()}
|
||||
{renderTags()}
|
||||
</div>
|
||||
<div className="col-4 offset-2">
|
||||
<>
|
||||
<div className="row">
|
||||
<div className={`${sceneDetailsWidth} col-xl-12 scene-details`}>
|
||||
<div className="scene-header d-xl-none">
|
||||
<h3 className="text-truncate">
|
||||
{props.scene.title ??
|
||||
TextUtils.fileNameFromPath(props.scene.path)}
|
||||
</h3>
|
||||
</div>
|
||||
{props.scene.date ? (
|
||||
<h5>
|
||||
<FormattedDate value={props.scene.date} format="long" />
|
||||
</h5>
|
||||
) : undefined}
|
||||
{props.scene.rating ? (
|
||||
<h6>
|
||||
Rating: <RatingStars value={props.scene.rating} />
|
||||
</h6>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{props.scene.file.height && (
|
||||
<h6>Resolution: {TextUtils.resolution(props.scene.file.height)}</h6>
|
||||
)}
|
||||
</div>
|
||||
{props.scene.studio && (
|
||||
<Link to={`/studios/${props.scene.studio.id}`}>
|
||||
<img
|
||||
src={props.scene.studio.image_path ?? ""}
|
||||
alt={`${props.scene.studio.name} logo`}
|
||||
className="studio-logo"
|
||||
/>
|
||||
</Link>
|
||||
<div className="col-3 d-xl-none">
|
||||
<Link to={`/studios/${props.scene.studio.id}`}>
|
||||
<img
|
||||
src={props.scene.studio.image_path ?? ""}
|
||||
alt={`${props.scene.studio.name} logo`}
|
||||
className="studio-logo float-right"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
{renderDetails()}
|
||||
{renderTags()}
|
||||
{renderPerformers()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<IProps> = (props: IProps) => {
|
||||
const Scrapers = useListSceneScrapers();
|
||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
||||
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
const [deleteFile, setDeleteFile] = useState<boolean>(false);
|
||||
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
|
||||
|
||||
const [coverImagePreview, setCoverImagePreview] = useState<string>();
|
||||
|
||||
// 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<IProps> = (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 (
|
||||
<SceneMovieTable
|
||||
@@ -219,35 +198,6 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
return (
|
||||
<Modal
|
||||
show={isDeleteAlertOpen}
|
||||
icon="trash-alt"
|
||||
header="Delete Scene?"
|
||||
accept={{ variant: "danger", onClick: onDelete, text: "Delete" }}
|
||||
cancel={{ onClick: () => setIsDeleteAlertOpen(false), text: "Cancel" }}
|
||||
>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<Form>
|
||||
<Form.Check
|
||||
checked={deleteFile}
|
||||
label="Delete file"
|
||||
onChange={() => setDeleteFile(!deleteFile)}
|
||||
/>
|
||||
<Form.Check
|
||||
checked={deleteGenerated}
|
||||
label="Delete generated supporting files"
|
||||
onChange={() => setDeleteGenerated(!deleteGenerated)}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function onImageLoad(imageData: string) {
|
||||
setCoverImagePreview(imageData);
|
||||
setCoverImage(imageData);
|
||||
@@ -402,7 +352,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<Button id="scrape-url-button" onClick={onScrapeSceneURL}>
|
||||
<Button id="scrape-url-button" onClick={onScrapeSceneURL} title="Scrape">
|
||||
<Icon icon="file-download" />
|
||||
</Button>
|
||||
);
|
||||
@@ -411,148 +361,175 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||
if (isLoading) return <LoadingIndicator />;
|
||||
|
||||
return (
|
||||
<div className="form-container row">
|
||||
<div className="col-12 col-lg-6">
|
||||
<Table id="scene-edit-details">
|
||||
<tbody>
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Title",
|
||||
value: title,
|
||||
onChange: setTitle,
|
||||
isEditing: true,
|
||||
})}
|
||||
<tr>
|
||||
<td>URL</td>
|
||||
<td>
|
||||
<Form.Control
|
||||
onChange={(newValue: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setUrl(newValue.currentTarget.value)
|
||||
}
|
||||
value={url}
|
||||
placeholder="URL"
|
||||
className="text-input"
|
||||
/>
|
||||
{maybeRenderScrapeButton()}
|
||||
</td>
|
||||
</tr>
|
||||
{TableUtils.renderInputGroup({
|
||||
title: "Date",
|
||||
value: date,
|
||||
isEditing: true,
|
||||
onChange: setDate,
|
||||
placeholder: "YYYY-MM-DD",
|
||||
})}
|
||||
<tr className="rating">
|
||||
<td>Rating</td>
|
||||
<td>
|
||||
<RatingStars
|
||||
value={rating}
|
||||
onSetRating={(value) => setRating(value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Gallery</td>
|
||||
<td>
|
||||
<SceneGallerySelect
|
||||
sceneId={props.scene.id}
|
||||
initialId={galleryId}
|
||||
onSelect={(item) => setGalleryId(item ? item.id : undefined)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Studio</td>
|
||||
<td>
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
setStudioId(items.length > 0 ? items[0]?.id : undefined)
|
||||
}
|
||||
ids={studioId ? [studioId] : []}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Performers</td>
|
||||
<td>
|
||||
<PerformerSelect
|
||||
isMulti
|
||||
onSelect={(items) =>
|
||||
setPerformerIds(items.map((item) => item.id))
|
||||
}
|
||||
ids={performerIds}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Movies/Scenes</td>
|
||||
<td>
|
||||
<MovieSelect
|
||||
isMulti
|
||||
onSelect={(items) =>
|
||||
setMovieIds(items.map((item) => item.id))
|
||||
}
|
||||
ids={movieIds}
|
||||
/>
|
||||
{renderTableMovies()}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tags</td>
|
||||
<td>
|
||||
<TagSelect
|
||||
isMulti
|
||||
onSelect={(items) => setTagIds(items.map((item) => item.id))}
|
||||
ids={tagIds}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
<>
|
||||
<div className="form-container row px-3 pt-3">
|
||||
<div className="col edit-buttons mb-3 pl-0">
|
||||
<Button className="edit-button" variant="primary" onClick={onSave}>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={() => props.onDelete()}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
{renderScraperMenu()}
|
||||
</div>
|
||||
<div className="col-12 col-lg-6">
|
||||
<Form.Group controlId="details">
|
||||
<Form.Label>Details</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
className="scene-description text-input"
|
||||
onChange={(newValue: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setDetails(newValue.currentTarget.value)
|
||||
}
|
||||
value={details}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<div>
|
||||
<Form.Group className="test" controlId="cover">
|
||||
<Form.Label>Cover Image</Form.Label>
|
||||
{imageEncoding ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
) : (
|
||||
<img
|
||||
className="scene-cover"
|
||||
src={coverImagePreview}
|
||||
alt="Scene cover"
|
||||
<div className="form-container row px-3">
|
||||
<div className="col-12 col-lg-6 col-xl-12">
|
||||
{FormUtils.renderInputGroup({
|
||||
title: "Title",
|
||||
value: title,
|
||||
onChange: setTitle,
|
||||
isEditing: true,
|
||||
})}
|
||||
<Form.Group controlId="url" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: "URL",
|
||||
})}
|
||||
<Col xs={9}>
|
||||
{EditableTextUtils.renderInputGroup({
|
||||
title: "URL",
|
||||
value: url,
|
||||
onChange: setUrl,
|
||||
isEditing: true,
|
||||
})}
|
||||
{maybeRenderScrapeButton()}
|
||||
</Col>
|
||||
</Form.Group>
|
||||
{FormUtils.renderInputGroup({
|
||||
title: "Date",
|
||||
value: date,
|
||||
isEditing: true,
|
||||
onChange: setDate,
|
||||
placeholder: "YYYY-MM-DD",
|
||||
})}
|
||||
<Form.Group controlId="rating" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: "Rating",
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<RatingStars
|
||||
value={rating}
|
||||
onSetRating={(value) => setRating(value)}
|
||||
/>
|
||||
)}
|
||||
<ImageInput isEditing onImageChange={onCoverImageChange} />
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="gallery" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: "Gallery",
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<SceneGallerySelect
|
||||
sceneId={props.scene.id}
|
||||
initialId={galleryId}
|
||||
onSelect={(item) => setGalleryId(item ? item.id : undefined)}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="studio" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: "Studio",
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
setStudioId(items.length > 0 ? items[0]?.id : undefined)
|
||||
}
|
||||
ids={studioId ? [studioId] : []}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="performers" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: "Performers",
|
||||
labelProps: {
|
||||
column: true,
|
||||
sm: 3,
|
||||
xl: 12,
|
||||
},
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<PerformerSelect
|
||||
isMulti
|
||||
onSelect={(items) =>
|
||||
setPerformerIds(items.map((item) => item.id))
|
||||
}
|
||||
ids={performerIds}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="moviesScenes" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: "Movies/Scenes",
|
||||
labelProps: {
|
||||
column: true,
|
||||
sm: 3,
|
||||
xl: 12,
|
||||
},
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<MovieSelect
|
||||
isMulti
|
||||
onSelect={(items) => setMovieIds(items.map((item) => item.id))}
|
||||
ids={movieIds}
|
||||
/>
|
||||
{renderTableMovies()}
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="tags" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: "Tags",
|
||||
labelProps: {
|
||||
column: true,
|
||||
sm: 3,
|
||||
xl: 12,
|
||||
},
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<TagSelect
|
||||
isMulti
|
||||
onSelect={(items) => setTagIds(items.map((item) => item.id))}
|
||||
ids={tagIds}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
</div>
|
||||
<div className="col-12 col-lg-6 col-xl-12">
|
||||
<Form.Group controlId="details">
|
||||
<Form.Label>Details</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
className="scene-description text-input"
|
||||
onChange={(newValue: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setDetails(newValue.currentTarget.value)
|
||||
}
|
||||
value={details}
|
||||
/>
|
||||
</Form.Group>
|
||||
<div>
|
||||
<Form.Group controlId="cover">
|
||||
<Form.Label>Cover Image</Form.Label>
|
||||
{imageEncoding ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
) : (
|
||||
<img
|
||||
className="scene-cover"
|
||||
src={coverImagePreview}
|
||||
alt="Scene cover"
|
||||
/>
|
||||
)}
|
||||
<ImageInput isEditing onImageChange={onCoverImageChange} />
|
||||
</Form.Group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col edit-buttons">
|
||||
<Button className="edit-button" variant="primary" onClick={onSave}>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={() => setIsDeleteAlertOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
{renderScraperMenu()}
|
||||
{renderDeleteAlert()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
|
||||
.catch((err) => Toast.error(err));
|
||||
};
|
||||
const renderTitleField = (fieldProps: FieldProps<string>) => (
|
||||
<div className="col-10">
|
||||
<div className="col-10 col-xl-12">
|
||||
<MarkerTitleSuggest
|
||||
initialMarkerTitle={fieldProps.field.value}
|
||||
onChange={(query: string) =>
|
||||
@@ -78,7 +78,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
|
||||
);
|
||||
|
||||
const renderSecondsField = (fieldProps: FieldProps<string>) => (
|
||||
<div className="col-3">
|
||||
<div className="col-3 col-xl-12">
|
||||
<DurationInput
|
||||
onValueChange={(s) => fieldProps.form.setFieldValue("seconds", s)}
|
||||
onReset={() =>
|
||||
@@ -132,53 +132,55 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
|
||||
<FormikForm>
|
||||
<div>
|
||||
<Form.Group className="row">
|
||||
<Form.Label htmlFor="title" className="col-2">
|
||||
<Form.Label htmlFor="title" className="col-2 col-xl-12">
|
||||
Scene Marker Title
|
||||
</Form.Label>
|
||||
<Field name="title">{renderTitleField}</Field>
|
||||
</Form.Group>
|
||||
<Form.Group className="row">
|
||||
<Form.Label htmlFor="primaryTagId" className="col-2">
|
||||
<Form.Label htmlFor="primaryTagId" className="col-2 col-xl-12">
|
||||
Primary Tag
|
||||
</Form.Label>
|
||||
<div className="col-6">
|
||||
<div className="col-6 col-xl-12">
|
||||
<Field name="primaryTagId">{renderPrimaryTagField}</Field>
|
||||
</div>
|
||||
<Form.Label htmlFor="seconds" className="col-1">
|
||||
<Form.Label htmlFor="seconds" className="col-1 col-xl-12">
|
||||
Time
|
||||
</Form.Label>
|
||||
<Field name="seconds">{renderSecondsField}</Field>
|
||||
</Form.Group>
|
||||
<Form.Group className="row">
|
||||
<Form.Label htmlFor="tagIds" className="col-2">
|
||||
<Form.Label htmlFor="tagIds" className="col-2 col-xl-12">
|
||||
Tags
|
||||
</Form.Label>
|
||||
<div className="col-10">
|
||||
<div className="col-10 col-xl-12">
|
||||
<Field name="tagIds">{renderTagsField}</Field>
|
||||
</div>
|
||||
</Form.Group>
|
||||
</div>
|
||||
<div className="buttons-container row">
|
||||
<Button variant="primary" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-2"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{editingMarker && (
|
||||
<Button
|
||||
variant="danger"
|
||||
className="ml-auto"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
Delete
|
||||
<div className="col">
|
||||
<Button variant="primary" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-2"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{editingMarker && (
|
||||
<Button
|
||||
variant="danger"
|
||||
className="ml-auto"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormikForm>
|
||||
</Formik>
|
||||
|
||||
@@ -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<IOperationsPanelProps> = (
|
||||
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 (
|
||||
<>
|
||||
<Button
|
||||
className="edit-button"
|
||||
onClick={() => onGenerateScreenshot(JWUtils.getPlayer().getPosition())}
|
||||
>
|
||||
Generate thumbnail from current
|
||||
</Button>
|
||||
<Button className="edit-button" onClick={() => onGenerateScreenshot()}>
|
||||
Generate default thumbnail
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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<IScenePerformerPanelProps> = (
|
||||
props: IScenePerformerPanelProps
|
||||
) => {
|
||||
const cards = props.scene.performers.map((performer) => (
|
||||
<PerformerCard
|
||||
key={performer.id}
|
||||
performer={performer}
|
||||
ageFromDate={props.scene.date ?? undefined}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="row justify-content-center">{cards}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ImageInput: React.FC<IImageInput> = ({
|
||||
if (!isEditing) return <div />;
|
||||
|
||||
return (
|
||||
<Form.Label className="image-input ml-2">
|
||||
<Form.Label className="image-input">
|
||||
<Button variant="secondary">{text ?? "Browse for image..."}</Button>
|
||||
<Form.Control
|
||||
type="file"
|
||||
|
||||
168
ui/v2.5/src/utils/form.tsx
Normal file
168
ui/v2.5/src/utils/form.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React from "react";
|
||||
import { Form, Col, Row, ColProps, FormLabelProps } from "react-bootstrap";
|
||||
import EditableTextUtils from "./editabletext";
|
||||
|
||||
function getLabelProps(labelProps?: FormLabelProps) {
|
||||
let ret = labelProps;
|
||||
if (!ret) {
|
||||
ret = {
|
||||
column: true,
|
||||
xs: 3,
|
||||
};
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function getInputProps(inputProps?: ColProps) {
|
||||
let ret = inputProps;
|
||||
if (!ret) {
|
||||
ret = {
|
||||
xs: 9,
|
||||
};
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
const renderLabel = (options: {
|
||||
title: string;
|
||||
labelProps?: FormLabelProps;
|
||||
}) => (
|
||||
<Form.Label column {...getLabelProps(options.labelProps)}>
|
||||
{options.title}
|
||||
</Form.Label>
|
||||
);
|
||||
|
||||
const renderEditableText = (options: {
|
||||
title: string;
|
||||
value?: string | number;
|
||||
isEditing: boolean;
|
||||
onChange: (value: string) => void;
|
||||
labelProps?: FormLabelProps;
|
||||
inputProps?: ColProps;
|
||||
}) => (
|
||||
<Form.Group controlId={options.title} as={Row}>
|
||||
{renderLabel(options)}
|
||||
<Col {...getInputProps(options.inputProps)}>
|
||||
{EditableTextUtils.renderEditableText(options)}
|
||||
</Col>
|
||||
</Form.Group>
|
||||
);
|
||||
|
||||
const renderTextArea = (options: {
|
||||
title: string;
|
||||
value: string | undefined;
|
||||
isEditing: boolean;
|
||||
onChange: (value: string) => void;
|
||||
labelProps?: FormLabelProps;
|
||||
inputProps?: ColProps;
|
||||
}) => (
|
||||
<Form.Group controlId={options.title} as={Row}>
|
||||
{renderLabel(options)}
|
||||
<Col {...getInputProps(options.inputProps)}>
|
||||
{EditableTextUtils.renderTextArea(options)}
|
||||
</Col>
|
||||
</Form.Group>
|
||||
);
|
||||
|
||||
const renderInputGroup = (options: {
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
value: string | undefined;
|
||||
isEditing: boolean;
|
||||
url?: string;
|
||||
onChange: (value: string) => void;
|
||||
labelProps?: FormLabelProps;
|
||||
inputProps?: ColProps;
|
||||
}) => (
|
||||
<Form.Group controlId={options.title} as={Row}>
|
||||
{renderLabel(options)}
|
||||
<Col {...getInputProps(options.inputProps)}>
|
||||
{EditableTextUtils.renderInputGroup(options)}
|
||||
</Col>
|
||||
</Form.Group>
|
||||
);
|
||||
|
||||
const renderDurationInput = (options: {
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
value: string | undefined;
|
||||
isEditing: boolean;
|
||||
asString?: boolean;
|
||||
onChange: (value: string | undefined) => void;
|
||||
labelProps?: FormLabelProps;
|
||||
inputProps?: ColProps;
|
||||
}) => {
|
||||
return (
|
||||
<Form.Group controlId={options.title} as={Row}>
|
||||
{renderLabel(options)}
|
||||
<Col {...getInputProps(options.inputProps)}>
|
||||
{EditableTextUtils.renderDurationInput(options)}
|
||||
</Col>
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHtmlSelect = (options: {
|
||||
title: string;
|
||||
value?: string | number;
|
||||
isEditing: boolean;
|
||||
onChange: (value: string) => void;
|
||||
selectOptions: Array<string | number>;
|
||||
labelProps?: FormLabelProps;
|
||||
inputProps?: ColProps;
|
||||
}) => (
|
||||
<Form.Group controlId={options.title} as={Row}>
|
||||
{renderLabel(options)}
|
||||
<Col {...getInputProps(options.inputProps)}>
|
||||
{EditableTextUtils.renderHtmlSelect(options)}
|
||||
</Col>
|
||||
</Form.Group>
|
||||
);
|
||||
|
||||
// TODO: isediting
|
||||
const renderFilterSelect = (options: {
|
||||
title: string;
|
||||
type: "performers" | "studios" | "tags";
|
||||
initialId: string | undefined;
|
||||
onChange: (id: string | undefined) => void;
|
||||
labelProps?: FormLabelProps;
|
||||
inputProps?: ColProps;
|
||||
}) => (
|
||||
<Form.Group controlId={options.title} as={Row}>
|
||||
{renderLabel(options)}
|
||||
<Col {...getInputProps(options.inputProps)}>
|
||||
{EditableTextUtils.renderFilterSelect(options)}
|
||||
</Col>
|
||||
</Form.Group>
|
||||
);
|
||||
|
||||
// TODO: isediting
|
||||
const renderMultiSelect = (options: {
|
||||
title: string;
|
||||
type: "performers" | "studios" | "tags";
|
||||
initialIds: string[] | undefined;
|
||||
onChange: (ids: string[]) => void;
|
||||
labelProps?: FormLabelProps;
|
||||
inputProps?: ColProps;
|
||||
}) => (
|
||||
<Form.Group controlId={options.title} as={Row}>
|
||||
{renderLabel(options)}
|
||||
<Col {...getInputProps(options.inputProps)}>
|
||||
{EditableTextUtils.renderMultiSelect(options)}
|
||||
</Col>
|
||||
</Form.Group>
|
||||
);
|
||||
|
||||
const FormUtils = {
|
||||
renderLabel,
|
||||
renderEditableText,
|
||||
renderTextArea,
|
||||
renderInputGroup,
|
||||
renderDurationInput,
|
||||
renderHtmlSelect,
|
||||
renderFilterSelect,
|
||||
renderMultiSelect,
|
||||
};
|
||||
export default FormUtils;
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user