[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:
WithoutPants
2020-06-18 10:26:05 +10:00
committed by GitHub
parent 1ca5f357e9
commit 3fbb4cdc32
16 changed files with 795 additions and 407 deletions

View File

@@ -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.

View File

@@ -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"

View File

@@ -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"

View File

@@ -1,5 +1,67 @@
.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 {
@@ -8,6 +70,13 @@
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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
</>
); );
}; };

View File

@@ -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>
</>
); );
}; };

View File

@@ -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>
</>
); );
}; };

View File

@@ -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>
); );

View File

@@ -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>
</>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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,8 +217,31 @@
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 {
@@ -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);

View File

@@ -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
View 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;

View File

@@ -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";