mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +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.
|
* Add support for parent/child studios.
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
|
* Improved the layout of the scene page.
|
||||||
* Show rating as stars in scene page.
|
* Show rating as stars in scene page.
|
||||||
* Add reload scrapers button.
|
* Add reload scrapers button.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { JWUtils } from "src/utils";
|
|||||||
import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
|
import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
|
||||||
|
|
||||||
interface IScenePlayerProps {
|
interface IScenePlayerProps {
|
||||||
|
className?: string;
|
||||||
scene: GQL.SceneDataFragment;
|
scene: GQL.SceneDataFragment;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
autoplay?: boolean;
|
autoplay?: boolean;
|
||||||
@@ -161,8 +162,8 @@ export class ScenePlayerImpl extends React.Component<
|
|||||||
kind: "chapters",
|
kind: "chapters",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
aspectratio: "16:9",
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
floating: {
|
floating: {
|
||||||
dismissible: true,
|
dismissible: true,
|
||||||
},
|
},
|
||||||
@@ -183,11 +184,20 @@ export class ScenePlayerImpl extends React.Component<
|
|||||||
}
|
}
|
||||||
|
|
||||||
public render() {
|
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 (
|
||||||
<div
|
<div id="jwplayer-container" className={className}>
|
||||||
id="jwplayer-container"
|
|
||||||
className="w-100 col-sm-9 m-sm-auto no-gutter"
|
|
||||||
>
|
|
||||||
<ReactJWPlayer
|
<ReactJWPlayer
|
||||||
playerId={JWUtils.playerID}
|
playerId={JWUtils.playerID}
|
||||||
playerScript="/jwplayer/jwplayer.js"
|
playerScript="/jwplayer/jwplayer.js"
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scrubber-wrapper d-none d-sm-block">
|
<div className="scrubber-wrapper">
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="scrubber-button"
|
className="scrubber-button"
|
||||||
|
|||||||
@@ -1,13 +1,82 @@
|
|||||||
.scene-player:focus {
|
$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;
|
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 {
|
.scrubber-wrapper {
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* hide scrubber when height is < 450px or width < 576 */
|
||||||
|
@media (max-height: 449px), (max-width: 575px) {
|
||||||
|
.scrubber-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#scrubber-back {
|
#scrubber-back {
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
@@ -24,7 +93,7 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
line-height: 120px;
|
line-height: $scrubberHeight;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 1.5%;
|
width: 1.5%;
|
||||||
@@ -33,7 +102,7 @@
|
|||||||
.scrubber-content {
|
.scrubber-content {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
height: 120px;
|
height: $scrubberHeight;
|
||||||
margin: 0 0.5%;
|
margin: 0 0.5%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ export const OCounterButton: React.FC<IOCounterButtonProps> = (
|
|||||||
if (props.loading) return <Spinner animation="border" role="status" />;
|
if (props.loading) return <Spinner animation="border" role="status" />;
|
||||||
|
|
||||||
const renderButton = () => (
|
const renderButton = () => (
|
||||||
<Button className="minimal" onClick={props.onIncrement} variant="secondary">
|
<Button
|
||||||
|
className="minimal"
|
||||||
|
onClick={props.onIncrement}
|
||||||
|
variant="secondary"
|
||||||
|
title="O-Counter"
|
||||||
|
>
|
||||||
<SweatDrops />
|
<SweatDrops />
|
||||||
<span className="ml-2">{props.value}</span>
|
<span className="ml-2">{props.value}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const PrimaryTags: React.FC<IPrimaryTags> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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>
|
<h3>{primaries[id].name}</h3>
|
||||||
<Card.Body className="primary-card-body">{markers}</Card.Body>
|
<Card.Body className="primary-card-body">{markers}</Card.Body>
|
||||||
</Card>
|
</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 queryString from "query-string";
|
||||||
import React, { useEffect, useState } from "react";
|
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 * as GQL from "src/core/generated-graphql";
|
||||||
import {
|
import {
|
||||||
useFindScene,
|
useFindScene,
|
||||||
useSceneIncrementO,
|
useSceneIncrementO,
|
||||||
useSceneDecrementO,
|
useSceneDecrementO,
|
||||||
useSceneResetO,
|
useSceneResetO,
|
||||||
|
useSceneGenerateScreenshot,
|
||||||
|
useSceneDestroy,
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import { GalleryViewer } from "src/components/Galleries/GalleryViewer";
|
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 { useToast } from "src/hooks";
|
||||||
import { ScenePlayer } from "src/components/ScenePlayer";
|
import { ScenePlayer } from "src/components/ScenePlayer";
|
||||||
import { ScenePerformerPanel } from "./ScenePerformerPanel";
|
import { TextUtils, JWUtils } from "src/utils";
|
||||||
import { SceneMarkersPanel } from "./SceneMarkersPanel";
|
import { SceneMarkersPanel } from "./SceneMarkersPanel";
|
||||||
import { SceneFileInfoPanel } from "./SceneFileInfoPanel";
|
import { SceneFileInfoPanel } from "./SceneFileInfoPanel";
|
||||||
import { SceneEditPanel } from "./SceneEditPanel";
|
import { SceneEditPanel } from "./SceneEditPanel";
|
||||||
import { SceneDetailPanel } from "./SceneDetailPanel";
|
import { SceneDetailPanel } from "./SceneDetailPanel";
|
||||||
import { OCounterButton } from "./OCounterButton";
|
import { OCounterButton } from "./OCounterButton";
|
||||||
import { SceneOperationsPanel } from "./SceneOperationsPanel";
|
|
||||||
import { SceneMoviePanel } from "./SceneMoviePanel";
|
import { SceneMoviePanel } from "./SceneMoviePanel";
|
||||||
|
|
||||||
export const Scene: React.FC = () => {
|
export const Scene: React.FC = () => {
|
||||||
@@ -27,7 +28,15 @@ export const Scene: React.FC = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
|
const [generateScreenshot] = useSceneGenerateScreenshot();
|
||||||
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
|
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 [scene, setScene] = useState<GQL.SceneDataFragment | undefined>();
|
||||||
const { data, error, loading } = useFindScene(id);
|
const { data, error, loading } = useFindScene(id);
|
||||||
const [oLoading, setOLoading] = useState(false);
|
const [oLoading, setOLoading] = useState(false);
|
||||||
@@ -97,17 +106,146 @@ export const Scene: React.FC = () => {
|
|||||||
setTimestamp(marker.seconds);
|
setTimestamp(marker.seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading || !scene || !data?.findScene) {
|
async function onGenerateScreenshot(at?: number) {
|
||||||
return <LoadingIndicator />;
|
if (!scene) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) return <div>{error.message}</div>;
|
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 (
|
return (
|
||||||
<>
|
<Tab.Container defaultActiveKey="scene-details-panel">
|
||||||
<ScenePlayer scene={scene} timestamp={timestamp} autoplay={autoplay} />
|
<div>
|
||||||
<div id="scene-details-container" className="col col-sm-9 m-sm-auto">
|
<Nav variant="tabs" className="mr-auto">
|
||||||
<div className="float-right">
|
<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
|
<OCounterButton
|
||||||
loading={oLoading}
|
loading={oLoading}
|
||||||
value={scene.o_counter || 0}
|
value={scene.o_counter || 0}
|
||||||
@@ -115,54 +253,83 @@ export const Scene: React.FC = () => {
|
|||||||
onDecrement={onDecrementClick}
|
onDecrement={onDecrementClick}
|
||||||
onReset={onResetClick}
|
onReset={onResetClick}
|
||||||
/>
|
/>
|
||||||
|
</Nav.Item>
|
||||||
|
<Nav.Item>{renderOperations()}</Nav.Item>
|
||||||
|
</Nav>
|
||||||
</div>
|
</div>
|
||||||
<Tabs id="scene-tabs" mountOnEnter>
|
|
||||||
<Tab eventKey="scene-details-panel" title="Details">
|
<Tab.Content>
|
||||||
|
<Tab.Pane eventKey="scene-details-panel" title="Details">
|
||||||
<SceneDetailPanel scene={scene} />
|
<SceneDetailPanel scene={scene} />
|
||||||
</Tab>
|
</Tab.Pane>
|
||||||
<Tab eventKey="scene-markers-panel" title="Markers">
|
<Tab.Pane eventKey="scene-markers-panel" title="Markers">
|
||||||
<SceneMarkersPanel scene={scene} onClickMarker={onClickMarker} />
|
<SceneMarkersPanel scene={scene} onClickMarker={onClickMarker} />
|
||||||
</Tab>
|
</Tab.Pane>
|
||||||
{scene.performers.length > 0 ? (
|
<Tab.Pane eventKey="scene-movie-panel" title="Movies">
|
||||||
<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} />
|
<SceneMoviePanel scene={scene} />
|
||||||
</Tab>
|
</Tab.Pane>
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
{scene.gallery ? (
|
{scene.gallery ? (
|
||||||
<Tab eventKey="scene-gallery-panel" title="Gallery">
|
<Tab.Pane eventKey="scene-gallery-panel" title="Gallery">
|
||||||
<GalleryViewer gallery={scene.gallery} />
|
<GalleryViewer gallery={scene.gallery} />
|
||||||
</Tab>
|
</Tab.Pane>
|
||||||
) : (
|
) : (
|
||||||
""
|
""
|
||||||
)}
|
)}
|
||||||
<Tab
|
<Tab.Pane
|
||||||
className="file-info-panel"
|
className="file-info-panel"
|
||||||
eventKey="scene-file-info-panel"
|
eventKey="scene-file-info-panel"
|
||||||
title="File Info"
|
title="File Info"
|
||||||
>
|
>
|
||||||
<SceneFileInfoPanel scene={scene} />
|
<SceneFileInfoPanel scene={scene} />
|
||||||
</Tab>
|
</Tab.Pane>
|
||||||
<Tab eventKey="scene-edit-panel" title="Edit">
|
<Tab.Pane eventKey="scene-edit-panel" title="Edit">
|
||||||
<SceneEditPanel
|
<SceneEditPanel
|
||||||
scene={scene}
|
scene={scene}
|
||||||
onUpdate={(newScene) => setScene(newScene)}
|
onUpdate={(newScene) => setScene(newScene)}
|
||||||
onDelete={() => history.push("/scenes")}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</Tab.Pane>
|
||||||
<Tab eventKey="scene-operations-panel" title="Operations">
|
</Tab.Content>
|
||||||
<SceneOperationsPanel scene={scene} />
|
</Tab.Container>
|
||||||
</Tab>
|
);
|
||||||
</Tabs>
|
}
|
||||||
|
|
||||||
|
if (deleteLoading || loading || !scene || !data?.findScene) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <div>{error.message}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
{renderTabs()}
|
||||||
|
</div>
|
||||||
|
<div className="scene-player-container">
|
||||||
|
<ScenePlayer
|
||||||
|
className="w-100 m-sm-auto no-gutter"
|
||||||
|
scene={scene}
|
||||||
|
timestamp={timestamp}
|
||||||
|
autoplay={autoplay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FormattedDate } from "react-intl";
|
|||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { TextUtils } from "src/utils";
|
import { TextUtils } from "src/utils";
|
||||||
import { TagLink } from "src/components/Shared";
|
import { TagLink } from "src/components/Shared";
|
||||||
|
import { PerformerCard } from "src/components/Performers/PerformerCard";
|
||||||
import { RatingStars } from "./RatingStars";
|
import { RatingStars } from "./RatingStars";
|
||||||
|
|
||||||
interface ISceneDetailProps {
|
interface ISceneDetailProps {
|
||||||
@@ -34,16 +35,43 @@ 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 (
|
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">
|
<div className="row">
|
||||||
<h3 className="col scene-header text-truncate">
|
<div className={`${sceneDetailsWidth} col-xl-12 scene-details`}>
|
||||||
{props.scene.title ?? TextUtils.fileNameFromPath(props.scene.path)}
|
<div className="scene-header d-xl-none">
|
||||||
|
<h3 className="text-truncate">
|
||||||
|
{props.scene.title ??
|
||||||
|
TextUtils.fileNameFromPath(props.scene.path)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="col-6 scene-details">
|
</div>
|
||||||
{props.scene.date ? (
|
{props.scene.date ? (
|
||||||
<h4>
|
<h5>
|
||||||
<FormattedDate value={props.scene.date} format="long" />
|
<FormattedDate value={props.scene.date} format="long" />
|
||||||
</h4>
|
</h5>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{props.scene.rating ? (
|
{props.scene.rating ? (
|
||||||
<h6>
|
<h6>
|
||||||
@@ -55,20 +83,26 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
|
|||||||
{props.scene.file.height && (
|
{props.scene.file.height && (
|
||||||
<h6>Resolution: {TextUtils.resolution(props.scene.file.height)}</h6>
|
<h6>Resolution: {TextUtils.resolution(props.scene.file.height)}</h6>
|
||||||
)}
|
)}
|
||||||
{renderDetails()}
|
|
||||||
{renderTags()}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-4 offset-2">
|
|
||||||
{props.scene.studio && (
|
{props.scene.studio && (
|
||||||
|
<div className="col-3 d-xl-none">
|
||||||
<Link to={`/studios/${props.scene.studio.id}`}>
|
<Link to={`/studios/${props.scene.studio.id}`}>
|
||||||
<img
|
<img
|
||||||
src={props.scene.studio.image_path ?? ""}
|
src={props.scene.studio.image_path ?? ""}
|
||||||
alt={`${props.scene.studio.name} logo`}
|
alt={`${props.scene.studio.name} logo`}
|
||||||
className="studio-logo"
|
className="studio-logo float-right"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12">
|
||||||
|
{renderDetails()}
|
||||||
|
{renderTags()}
|
||||||
|
{renderPerformers()}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
/* eslint-disable react/no-this-in-sfc */
|
/* eslint-disable react/no-this-in-sfc */
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
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 * as GQL from "src/core/generated-graphql";
|
||||||
import {
|
import {
|
||||||
queryScrapeScene,
|
queryScrapeScene,
|
||||||
queryScrapeSceneURL,
|
queryScrapeSceneURL,
|
||||||
useListSceneScrapers,
|
useListSceneScrapers,
|
||||||
useSceneUpdate,
|
useSceneUpdate,
|
||||||
useSceneDestroy,
|
|
||||||
mutateReloadScrapers,
|
mutateReloadScrapers,
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import {
|
import {
|
||||||
@@ -16,13 +22,12 @@ import {
|
|||||||
TagSelect,
|
TagSelect,
|
||||||
StudioSelect,
|
StudioSelect,
|
||||||
SceneGallerySelect,
|
SceneGallerySelect,
|
||||||
Modal,
|
|
||||||
Icon,
|
Icon,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
ImageInput,
|
ImageInput,
|
||||||
} from "src/components/Shared";
|
} from "src/components/Shared";
|
||||||
import { useToast } from "src/hooks";
|
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 { MovieSelect } from "src/components/Shared/Select";
|
||||||
import { SceneMovieTable, MovieSceneIndexMap } from "./SceneMovieTable";
|
import { SceneMovieTable, MovieSceneIndexMap } from "./SceneMovieTable";
|
||||||
import { RatingStars } from "./RatingStars";
|
import { RatingStars } from "./RatingStars";
|
||||||
@@ -53,17 +58,12 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||||||
const Scrapers = useListSceneScrapers();
|
const Scrapers = useListSceneScrapers();
|
||||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
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>();
|
const [coverImagePreview, setCoverImagePreview] = useState<string>();
|
||||||
|
|
||||||
// Network state
|
// Network state
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const [updateScene] = useSceneUpdate(getSceneInput());
|
const [updateScene] = useSceneUpdate(getSceneInput());
|
||||||
const [deleteScene] = useSceneDestroy(getSceneDeleteInput());
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newQueryableScrapers = (
|
const newQueryableScrapers = (
|
||||||
@@ -187,27 +187,6 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||||||
setIsLoading(false);
|
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() {
|
function renderTableMovies() {
|
||||||
return (
|
return (
|
||||||
<SceneMovieTable
|
<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) {
|
function onImageLoad(imageData: string) {
|
||||||
setCoverImagePreview(imageData);
|
setCoverImagePreview(imageData);
|
||||||
setCoverImage(imageData);
|
setCoverImage(imageData);
|
||||||
@@ -402,7 +352,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button id="scrape-url-button" onClick={onScrapeSceneURL}>
|
<Button id="scrape-url-button" onClick={onScrapeSceneURL} title="Scrape">
|
||||||
<Icon icon="file-download" />
|
<Icon icon="file-download" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -411,70 +361,99 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||||||
if (isLoading) return <LoadingIndicator />;
|
if (isLoading) return <LoadingIndicator />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-container row">
|
<>
|
||||||
<div className="col-12 col-lg-6">
|
<div className="form-container row px-3 pt-3">
|
||||||
<Table id="scene-edit-details">
|
<div className="col edit-buttons mb-3 pl-0">
|
||||||
<tbody>
|
<Button className="edit-button" variant="primary" onClick={onSave}>
|
||||||
{TableUtils.renderInputGroup({
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="edit-button"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => props.onDelete()}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{renderScraperMenu()}
|
||||||
|
</div>
|
||||||
|
<div className="form-container row px-3">
|
||||||
|
<div className="col-12 col-lg-6 col-xl-12">
|
||||||
|
{FormUtils.renderInputGroup({
|
||||||
title: "Title",
|
title: "Title",
|
||||||
value: title,
|
value: title,
|
||||||
onChange: setTitle,
|
onChange: setTitle,
|
||||||
isEditing: true,
|
isEditing: true,
|
||||||
})}
|
})}
|
||||||
<tr>
|
<Form.Group controlId="url" as={Row}>
|
||||||
<td>URL</td>
|
{FormUtils.renderLabel({
|
||||||
<td>
|
title: "URL",
|
||||||
<Form.Control
|
})}
|
||||||
onChange={(newValue: React.ChangeEvent<HTMLInputElement>) =>
|
<Col xs={9}>
|
||||||
setUrl(newValue.currentTarget.value)
|
{EditableTextUtils.renderInputGroup({
|
||||||
}
|
title: "URL",
|
||||||
value={url}
|
value: url,
|
||||||
placeholder="URL"
|
onChange: setUrl,
|
||||||
className="text-input"
|
isEditing: true,
|
||||||
/>
|
})}
|
||||||
{maybeRenderScrapeButton()}
|
{maybeRenderScrapeButton()}
|
||||||
</td>
|
</Col>
|
||||||
</tr>
|
</Form.Group>
|
||||||
{TableUtils.renderInputGroup({
|
{FormUtils.renderInputGroup({
|
||||||
title: "Date",
|
title: "Date",
|
||||||
value: date,
|
value: date,
|
||||||
isEditing: true,
|
isEditing: true,
|
||||||
onChange: setDate,
|
onChange: setDate,
|
||||||
placeholder: "YYYY-MM-DD",
|
placeholder: "YYYY-MM-DD",
|
||||||
})}
|
})}
|
||||||
<tr className="rating">
|
<Form.Group controlId="rating" as={Row}>
|
||||||
<td>Rating</td>
|
{FormUtils.renderLabel({
|
||||||
<td>
|
title: "Rating",
|
||||||
|
})}
|
||||||
|
<Col xs={9}>
|
||||||
<RatingStars
|
<RatingStars
|
||||||
value={rating}
|
value={rating}
|
||||||
onSetRating={(value) => setRating(value)}
|
onSetRating={(value) => setRating(value)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</Col>
|
||||||
</tr>
|
</Form.Group>
|
||||||
<tr>
|
<Form.Group controlId="gallery" as={Row}>
|
||||||
<td>Gallery</td>
|
{FormUtils.renderLabel({
|
||||||
<td>
|
title: "Gallery",
|
||||||
|
})}
|
||||||
|
<Col xs={9}>
|
||||||
<SceneGallerySelect
|
<SceneGallerySelect
|
||||||
sceneId={props.scene.id}
|
sceneId={props.scene.id}
|
||||||
initialId={galleryId}
|
initialId={galleryId}
|
||||||
onSelect={(item) => setGalleryId(item ? item.id : undefined)}
|
onSelect={(item) => setGalleryId(item ? item.id : undefined)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</Col>
|
||||||
</tr>
|
</Form.Group>
|
||||||
<tr>
|
|
||||||
<td>Studio</td>
|
<Form.Group controlId="studio" as={Row}>
|
||||||
<td>
|
{FormUtils.renderLabel({
|
||||||
|
title: "Studio",
|
||||||
|
})}
|
||||||
|
<Col xs={9}>
|
||||||
<StudioSelect
|
<StudioSelect
|
||||||
onSelect={(items) =>
|
onSelect={(items) =>
|
||||||
setStudioId(items.length > 0 ? items[0]?.id : undefined)
|
setStudioId(items.length > 0 ? items[0]?.id : undefined)
|
||||||
}
|
}
|
||||||
ids={studioId ? [studioId] : []}
|
ids={studioId ? [studioId] : []}
|
||||||
/>
|
/>
|
||||||
</td>
|
</Col>
|
||||||
</tr>
|
</Form.Group>
|
||||||
<tr>
|
|
||||||
<td>Performers</td>
|
<Form.Group controlId="performers" as={Row}>
|
||||||
<td>
|
{FormUtils.renderLabel({
|
||||||
|
title: "Performers",
|
||||||
|
labelProps: {
|
||||||
|
column: true,
|
||||||
|
sm: 3,
|
||||||
|
xl: 12,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
<Col sm={9} xl={12}>
|
||||||
<PerformerSelect
|
<PerformerSelect
|
||||||
isMulti
|
isMulti
|
||||||
onSelect={(items) =>
|
onSelect={(items) =>
|
||||||
@@ -482,35 +461,47 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||||||
}
|
}
|
||||||
ids={performerIds}
|
ids={performerIds}
|
||||||
/>
|
/>
|
||||||
</td>
|
</Col>
|
||||||
</tr>
|
</Form.Group>
|
||||||
<tr>
|
|
||||||
<td>Movies/Scenes</td>
|
<Form.Group controlId="moviesScenes" as={Row}>
|
||||||
<td>
|
{FormUtils.renderLabel({
|
||||||
|
title: "Movies/Scenes",
|
||||||
|
labelProps: {
|
||||||
|
column: true,
|
||||||
|
sm: 3,
|
||||||
|
xl: 12,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
<Col sm={9} xl={12}>
|
||||||
<MovieSelect
|
<MovieSelect
|
||||||
isMulti
|
isMulti
|
||||||
onSelect={(items) =>
|
onSelect={(items) => setMovieIds(items.map((item) => item.id))}
|
||||||
setMovieIds(items.map((item) => item.id))
|
|
||||||
}
|
|
||||||
ids={movieIds}
|
ids={movieIds}
|
||||||
/>
|
/>
|
||||||
{renderTableMovies()}
|
{renderTableMovies()}
|
||||||
</td>
|
</Col>
|
||||||
</tr>
|
</Form.Group>
|
||||||
<tr>
|
|
||||||
<td>Tags</td>
|
<Form.Group controlId="tags" as={Row}>
|
||||||
<td>
|
{FormUtils.renderLabel({
|
||||||
|
title: "Tags",
|
||||||
|
labelProps: {
|
||||||
|
column: true,
|
||||||
|
sm: 3,
|
||||||
|
xl: 12,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
<Col sm={9} xl={12}>
|
||||||
<TagSelect
|
<TagSelect
|
||||||
isMulti
|
isMulti
|
||||||
onSelect={(items) => setTagIds(items.map((item) => item.id))}
|
onSelect={(items) => setTagIds(items.map((item) => item.id))}
|
||||||
ids={tagIds}
|
ids={tagIds}
|
||||||
/>
|
/>
|
||||||
</td>
|
</Col>
|
||||||
</tr>
|
</Form.Group>
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-12 col-lg-6">
|
<div className="col-12 col-lg-6 col-xl-12">
|
||||||
<Form.Group controlId="details">
|
<Form.Group controlId="details">
|
||||||
<Form.Label>Details</Form.Label>
|
<Form.Label>Details</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -522,9 +513,8 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||||||
value={details}
|
value={details}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Form.Group className="test" controlId="cover">
|
<Form.Group controlId="cover">
|
||||||
<Form.Label>Cover Image</Form.Label>
|
<Form.Label>Cover Image</Form.Label>
|
||||||
{imageEncoding ? (
|
{imageEncoding ? (
|
||||||
<LoadingIndicator message="Encoding image..." />
|
<LoadingIndicator message="Encoding image..." />
|
||||||
@@ -539,20 +529,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||||||
</Form.Group>
|
</Form.Group>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
|
|||||||
.catch((err) => Toast.error(err));
|
.catch((err) => Toast.error(err));
|
||||||
};
|
};
|
||||||
const renderTitleField = (fieldProps: FieldProps<string>) => (
|
const renderTitleField = (fieldProps: FieldProps<string>) => (
|
||||||
<div className="col-10">
|
<div className="col-10 col-xl-12">
|
||||||
<MarkerTitleSuggest
|
<MarkerTitleSuggest
|
||||||
initialMarkerTitle={fieldProps.field.value}
|
initialMarkerTitle={fieldProps.field.value}
|
||||||
onChange={(query: string) =>
|
onChange={(query: string) =>
|
||||||
@@ -78,7 +78,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderSecondsField = (fieldProps: FieldProps<string>) => (
|
const renderSecondsField = (fieldProps: FieldProps<string>) => (
|
||||||
<div className="col-3">
|
<div className="col-3 col-xl-12">
|
||||||
<DurationInput
|
<DurationInput
|
||||||
onValueChange={(s) => fieldProps.form.setFieldValue("seconds", s)}
|
onValueChange={(s) => fieldProps.form.setFieldValue("seconds", s)}
|
||||||
onReset={() =>
|
onReset={() =>
|
||||||
@@ -132,33 +132,34 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
|
|||||||
<FormikForm>
|
<FormikForm>
|
||||||
<div>
|
<div>
|
||||||
<Form.Group className="row">
|
<Form.Group className="row">
|
||||||
<Form.Label htmlFor="title" className="col-2">
|
<Form.Label htmlFor="title" className="col-2 col-xl-12">
|
||||||
Scene Marker Title
|
Scene Marker Title
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
<Field name="title">{renderTitleField}</Field>
|
<Field name="title">{renderTitleField}</Field>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group className="row">
|
<Form.Group className="row">
|
||||||
<Form.Label htmlFor="primaryTagId" className="col-2">
|
<Form.Label htmlFor="primaryTagId" className="col-2 col-xl-12">
|
||||||
Primary Tag
|
Primary Tag
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
<div className="col-6">
|
<div className="col-6 col-xl-12">
|
||||||
<Field name="primaryTagId">{renderPrimaryTagField}</Field>
|
<Field name="primaryTagId">{renderPrimaryTagField}</Field>
|
||||||
</div>
|
</div>
|
||||||
<Form.Label htmlFor="seconds" className="col-1">
|
<Form.Label htmlFor="seconds" className="col-1 col-xl-12">
|
||||||
Time
|
Time
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
<Field name="seconds">{renderSecondsField}</Field>
|
<Field name="seconds">{renderSecondsField}</Field>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group className="row">
|
<Form.Group className="row">
|
||||||
<Form.Label htmlFor="tagIds" className="col-2">
|
<Form.Label htmlFor="tagIds" className="col-2 col-xl-12">
|
||||||
Tags
|
Tags
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
<div className="col-10">
|
<div className="col-10 col-xl-12">
|
||||||
<Field name="tagIds">{renderTagsField}</Field>
|
<Field name="tagIds">{renderTagsField}</Field>
|
||||||
</div>
|
</div>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
</div>
|
</div>
|
||||||
<div className="buttons-container row">
|
<div className="buttons-container row">
|
||||||
|
<div className="col">
|
||||||
<Button variant="primary" type="submit">
|
<Button variant="primary" type="submit">
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -180,6 +181,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</FormikForm>
|
</FormikForm>
|
||||||
</Formik>
|
</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 {
|
.studio-logo {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
max-height: 8rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-header {
|
.scene-header {
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
|
margin-top: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#scene-details-container {
|
#scene-details-container {
|
||||||
@@ -215,9 +217,32 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movie-table td {
|
.movie-table {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
td {
|
||||||
vertical-align: middle;
|
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 {
|
.rating-stars {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -250,11 +275,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rating td {
|
|
||||||
padding-bottom: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scene-edit-details .rating-stars {
|
#scene-edit-details .rating-stars {
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
height: calc(1.5em + 0.75rem + 2px);
|
height: calc(1.5em + 0.75rem + 2px);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const ImageInput: React.FC<IImageInput> = ({
|
|||||||
if (!isEditing) return <div />;
|
if (!isEditing) return <div />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Label className="image-input ml-2">
|
<Form.Label className="image-input">
|
||||||
<Button variant="secondary">{text ?? "Browse for image..."}</Button>
|
<Button variant="secondary">{text ?? "Browse for image..."}</Button>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
type="file"
|
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 TableUtils } from "./table";
|
||||||
export { default as TextUtils } from "./text";
|
export { default as TextUtils } from "./text";
|
||||||
export { default as EditableTextUtils } from "./editabletext";
|
export { default as EditableTextUtils } from "./editabletext";
|
||||||
|
export { default as FormUtils } from "./form";
|
||||||
export { default as DurationUtils } from "./duration";
|
export { default as DurationUtils } from "./duration";
|
||||||
export { default as JWUtils } from "./jwplayer";
|
export { default as JWUtils } from "./jwplayer";
|
||||||
export { default as SessionUtils } from "./session";
|
export { default as SessionUtils } from "./session";
|
||||||
|
|||||||
Reference in New Issue
Block a user