mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Scene ui improvements (#232)
* Move duration and resolution to overlay * Improve display of portrait videos * Condense filter controls * Add performer images to scene tags * Add studio overlay to scene cards * Fade out scene overlays on hover * CSS grid tweaks * Align overlay to bottom of video preview * Fix opacity value * Fix performer thumbnails * Show studio overlay on mouseover * Correct display colour for display mode buttons * Add scene zoom slider * Add show studio as text option * Move select all/none to more button
This commit is contained in:
@@ -17,6 +17,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
|
|||||||
wallShowTitle
|
wallShowTitle
|
||||||
maximumLoopDuration
|
maximumLoopDuration
|
||||||
autostartVideo
|
autostartVideo
|
||||||
|
showStudioAsText
|
||||||
css
|
css
|
||||||
cssEnabled
|
cssEnabled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ input ConfigInterfaceInput {
|
|||||||
maximumLoopDuration: Int
|
maximumLoopDuration: Int
|
||||||
"""If true, video will autostart on load in the scene player"""
|
"""If true, video will autostart on load in the scene player"""
|
||||||
autostartVideo: Boolean
|
autostartVideo: Boolean
|
||||||
|
"""If true, studio overlays will be shown as text instead of logo images"""
|
||||||
|
showStudioAsText: Boolean
|
||||||
"""Custom CSS"""
|
"""Custom CSS"""
|
||||||
css: String
|
css: String
|
||||||
cssEnabled: Boolean
|
cssEnabled: Boolean
|
||||||
@@ -80,6 +82,8 @@ type ConfigInterfaceResult {
|
|||||||
maximumLoopDuration: Int
|
maximumLoopDuration: Int
|
||||||
"""If true, video will autostart on load in the scene player"""
|
"""If true, video will autostart on load in the scene player"""
|
||||||
autostartVideo: Boolean
|
autostartVideo: Boolean
|
||||||
|
"""If true, studio overlays will be shown as text instead of logo images"""
|
||||||
|
showStudioAsText: Boolean
|
||||||
"""Custom CSS"""
|
"""Custom CSS"""
|
||||||
css: String
|
css: String
|
||||||
cssEnabled: Boolean
|
cssEnabled: Boolean
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
|
|||||||
config.Set(config.AutostartVideo, *input.AutostartVideo)
|
config.Set(config.AutostartVideo, *input.AutostartVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.ShowStudioAsText != nil {
|
||||||
|
config.Set(config.ShowStudioAsText, *input.ShowStudioAsText)
|
||||||
|
}
|
||||||
|
|
||||||
css := ""
|
css := ""
|
||||||
|
|
||||||
if input.CSS != nil {
|
if input.CSS != nil {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||||||
wallShowTitle := config.GetWallShowTitle()
|
wallShowTitle := config.GetWallShowTitle()
|
||||||
maximumLoopDuration := config.GetMaximumLoopDuration()
|
maximumLoopDuration := config.GetMaximumLoopDuration()
|
||||||
autostartVideo := config.GetAutostartVideo()
|
autostartVideo := config.GetAutostartVideo()
|
||||||
|
showStudioAsText := config.GetShowStudioAsText()
|
||||||
css := config.GetCSS()
|
css := config.GetCSS()
|
||||||
cssEnabled := config.GetCSSEnabled()
|
cssEnabled := config.GetCSSEnabled()
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||||||
WallShowTitle: &wallShowTitle,
|
WallShowTitle: &wallShowTitle,
|
||||||
MaximumLoopDuration: &maximumLoopDuration,
|
MaximumLoopDuration: &maximumLoopDuration,
|
||||||
AutostartVideo: &autostartVideo,
|
AutostartVideo: &autostartVideo,
|
||||||
|
ShowStudioAsText: &showStudioAsText,
|
||||||
CSS: &css,
|
CSS: &css,
|
||||||
CSSEnabled: &cssEnabled,
|
CSSEnabled: &cssEnabled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const SoundOnPreview = "sound_on_preview"
|
|||||||
const WallShowTitle = "wall_show_title"
|
const WallShowTitle = "wall_show_title"
|
||||||
const MaximumLoopDuration = "maximum_loop_duration"
|
const MaximumLoopDuration = "maximum_loop_duration"
|
||||||
const AutostartVideo = "autostart_video"
|
const AutostartVideo = "autostart_video"
|
||||||
|
const ShowStudioAsText = "show_studio_as_text"
|
||||||
const CSSEnabled = "cssEnabled"
|
const CSSEnabled = "cssEnabled"
|
||||||
|
|
||||||
// Logging options
|
// Logging options
|
||||||
@@ -191,6 +192,11 @@ func GetAutostartVideo() bool {
|
|||||||
return viper.GetBool(AutostartVideo)
|
return viper.GetBool(AutostartVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetShowStudioAsText() bool {
|
||||||
|
viper.SetDefault(ShowStudioAsText, false)
|
||||||
|
return viper.GetBool(ShowStudioAsText)
|
||||||
|
}
|
||||||
|
|
||||||
func GetCSSPath() string {
|
func GetCSSPath() string {
|
||||||
// use custom.css in the same directory as the config file
|
// use custom.css in the same directory as the config file
|
||||||
configFileUsed := viper.ConfigFileUsed()
|
configFileUsed := viper.ConfigFileUsed()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
|
|||||||
const [wallShowTitle, setWallShowTitle] = useState<boolean>();
|
const [wallShowTitle, setWallShowTitle] = useState<boolean>();
|
||||||
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
|
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
|
||||||
const [autostartVideo, setAutostartVideo] = useState<boolean>();
|
const [autostartVideo, setAutostartVideo] = useState<boolean>();
|
||||||
|
const [showStudioAsText, setShowStudioAsText] = useState<boolean>();
|
||||||
const [css, setCSS] = useState<string>();
|
const [css, setCSS] = useState<string>();
|
||||||
const [cssEnabled, setCSSEnabled] = useState<boolean>();
|
const [cssEnabled, setCSSEnabled] = useState<boolean>();
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
|
|||||||
wallShowTitle,
|
wallShowTitle,
|
||||||
maximumLoopDuration,
|
maximumLoopDuration,
|
||||||
autostartVideo,
|
autostartVideo,
|
||||||
|
showStudioAsText,
|
||||||
css,
|
css,
|
||||||
cssEnabled
|
cssEnabled
|
||||||
});
|
});
|
||||||
@@ -42,6 +44,7 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
|
|||||||
setWallShowTitle(iCfg.wallShowTitle !== undefined ? iCfg.wallShowTitle : true);
|
setWallShowTitle(iCfg.wallShowTitle !== undefined ? iCfg.wallShowTitle : true);
|
||||||
setMaximumLoopDuration(iCfg.maximumLoopDuration || 0);
|
setMaximumLoopDuration(iCfg.maximumLoopDuration || 0);
|
||||||
setAutostartVideo(iCfg.autostartVideo !== undefined ? iCfg.autostartVideo : false);
|
setAutostartVideo(iCfg.autostartVideo !== undefined ? iCfg.autostartVideo : false);
|
||||||
|
setShowStudioAsText(iCfg.showStudioAsText !== undefined ? iCfg.showStudioAsText : false);
|
||||||
setCSS(config.data.configuration.interface.css || "");
|
setCSS(config.data.configuration.interface.css || "");
|
||||||
setCSSEnabled(config.data.configuration.interface.cssEnabled || false);
|
setCSSEnabled(config.data.configuration.interface.cssEnabled || false);
|
||||||
}
|
}
|
||||||
@@ -78,6 +81,18 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
|
|||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup
|
||||||
|
label="Scene List"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={showStudioAsText}
|
||||||
|
label="Show Studios as text"
|
||||||
|
onChange={() => {
|
||||||
|
setShowStudioAsText(!showStudioAsText)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
<FormGroup
|
<FormGroup
|
||||||
label="Scene Player"
|
label="Scene Player"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
FormGroup,
|
FormGroup,
|
||||||
HTMLSelect,
|
HTMLSelect,
|
||||||
InputGroup,
|
InputGroup,
|
||||||
|
Tooltip,
|
||||||
} from "@blueprintjs/core";
|
} from "@blueprintjs/core";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import React, { FunctionComponent, useEffect, useRef, useState } from "react";
|
import React, { FunctionComponent, useEffect, useRef, useState } from "react";
|
||||||
@@ -188,7 +189,19 @@ export const AddFilter: FunctionComponent<IAddFilterProps> = (props: IAddFilterP
|
|||||||
const title = !props.editingCriterion ? "Add Filter" : "Update Filter";
|
const title = !props.editingCriterion ? "Add Filter" : "Update Filter";
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => onToggle()} active={isOpen} large={true}>Filter</Button>
|
<Tooltip
|
||||||
|
hoverOpenDelay={200}
|
||||||
|
content="Filter"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon="filter"
|
||||||
|
onClick={() => onToggle()}
|
||||||
|
active={isOpen}
|
||||||
|
large={true}
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Dialog isOpen={isOpen} onClose={() => onToggle()} title={title}>
|
<Dialog isOpen={isOpen} onClose={() => onToggle()} title={title}>
|
||||||
<div className="dialog-content">
|
<div className="dialog-content">
|
||||||
{maybeRenderFilterSelect()}
|
{maybeRenderFilterSelect()}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
Popover,
|
Popover,
|
||||||
Tag,
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Slider,
|
||||||
} from "@blueprintjs/core";
|
} from "@blueprintjs/core";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import React, { FunctionComponent, SyntheticEvent, useEffect, useRef, useState } from "react";
|
import React, { FunctionComponent, SyntheticEvent, useEffect, useRef, useState } from "react";
|
||||||
@@ -25,6 +27,8 @@ interface IListFilterProps {
|
|||||||
onChangeDisplayMode: (displayMode: DisplayMode) => void;
|
onChangeDisplayMode: (displayMode: DisplayMode) => void;
|
||||||
onAddCriterion: (criterion: Criterion, oldId?: string) => void;
|
onAddCriterion: (criterion: Criterion, oldId?: string) => void;
|
||||||
onRemoveCriterion: (criterion: Criterion) => void;
|
onRemoveCriterion: (criterion: Criterion) => void;
|
||||||
|
zoomIndex?: number;
|
||||||
|
onChangeZoom?: (zoomIndex: number) => void;
|
||||||
onSelectAll?: () => void;
|
onSelectAll?: () => void;
|
||||||
onSelectNone?: () => void;
|
onSelectNone?: () => void;
|
||||||
filter: ListFilterModel;
|
filter: ListFilterModel;
|
||||||
@@ -111,13 +115,14 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return props.filter.displayModeOptions.map((option) => (
|
return props.filter.displayModeOptions.map((option) => (
|
||||||
<Button
|
<Tooltip content={getLabel(option)} hoverOpenDelay={200}>
|
||||||
key={option}
|
<Button
|
||||||
active={props.filter.displayMode === option}
|
key={option}
|
||||||
onClick={() => onChangeDisplayMode(option)}
|
active={props.filter.displayMode === option}
|
||||||
icon={getIcon(option)}
|
onClick={() => onChangeDisplayMode(option)}
|
||||||
text={getLabel(option)}
|
icon={getIcon(option)}
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,23 +155,63 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
|
|||||||
|
|
||||||
function renderSelectAll() {
|
function renderSelectAll() {
|
||||||
if (props.onSelectAll) {
|
if (props.onSelectAll) {
|
||||||
return <Button onClick={() => onSelectAll()} text="Select All"/>;
|
return <MenuItem onClick={() => onSelectAll()} text="Select All" />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSelectNone() {
|
function renderSelectNone() {
|
||||||
if (props.onSelectNone) {
|
if (props.onSelectNone) {
|
||||||
return <Button onClick={() => onSelectNone()} text="Select None"/>;
|
return <MenuItem onClick={() => onSelectNone()} text="Select None" />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSelectAllNone() {
|
function renderMore() {
|
||||||
return (
|
let options = [];
|
||||||
<>
|
options.push(renderSelectAll());
|
||||||
{renderSelectAll()}
|
options.push(renderSelectNone());
|
||||||
{renderSelectNone()}
|
options = options.filter((o) => !!o);
|
||||||
</>
|
|
||||||
);
|
let menuItems = options as JSX.Element[];
|
||||||
|
|
||||||
|
function renderMoreOptions() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{menuItems}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuItems.length > 0) {
|
||||||
|
return (
|
||||||
|
<Popover position="bottom">
|
||||||
|
<Button icon="more"/>
|
||||||
|
<Menu>{renderMoreOptions()}</Menu>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeZoom(v : number) {
|
||||||
|
if (props.onChangeZoom) {
|
||||||
|
props.onChangeZoom(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderZoom() {
|
||||||
|
if (props.onChangeZoom) {
|
||||||
|
return (
|
||||||
|
<span className="zoom-slider">
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
value={props.zoomIndex}
|
||||||
|
initialValue={props.zoomIndex}
|
||||||
|
max={3}
|
||||||
|
labelRenderer={false}
|
||||||
|
onChange={(v) => onChangeZoom(v)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -188,18 +233,23 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
|
|||||||
value={props.filter.itemsPerPage}
|
value={props.filter.itemsPerPage}
|
||||||
className="filter-item"
|
className="filter-item"
|
||||||
/>
|
/>
|
||||||
<ControlGroup className="filter-item">
|
<ButtonGroup className="filter-item">
|
||||||
<AnchorButton
|
|
||||||
rightIcon={props.filter.sortDirection === "asc" ? "caret-up" : "caret-down"}
|
|
||||||
onClick={onChangeSortDirection}
|
|
||||||
>
|
|
||||||
{props.filter.sortDirection === "asc" ? "Ascending" : "Descending"}
|
|
||||||
</AnchorButton>
|
|
||||||
<Popover position="bottom">
|
<Popover position="bottom">
|
||||||
<Button large={true}>{props.filter.sortBy}</Button>
|
<Button large={true}>{props.filter.sortBy}</Button>
|
||||||
<Menu>{renderSortByOptions()}</Menu>
|
<Menu>{renderSortByOptions()}</Menu>
|
||||||
</Popover>
|
</Popover>
|
||||||
</ControlGroup>
|
|
||||||
|
<Tooltip
|
||||||
|
content={props.filter.sortDirection === "asc" ? "Ascending" : "Descending"}
|
||||||
|
hoverOpenDelay={200}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
rightIcon={props.filter.sortDirection === "asc" ? "caret-up" : "caret-down"}
|
||||||
|
onClick={onChangeSortDirection}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
</ButtonGroup>
|
||||||
|
|
||||||
<AddFilter
|
<AddFilter
|
||||||
filter={props.filter}
|
filter={props.filter}
|
||||||
@@ -212,8 +262,10 @@ export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilt
|
|||||||
{renderDisplayModeOptions()}
|
{renderDisplayModeOptions()}
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|
||||||
|
{maybeRenderZoom()}
|
||||||
|
|
||||||
<ButtonGroup className="filter-item">
|
<ButtonGroup className="filter-item">
|
||||||
{renderSelectAllNone()}
|
{renderMore()}
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
<div style={{display: "flex", justifyContent: "center", margin: "10px auto"}}>
|
<div style={{display: "flex", justifyContent: "center", margin: "10px auto"}}>
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ import { ColorUtils } from "../../utils/color";
|
|||||||
import { TextUtils } from "../../utils/text";
|
import { TextUtils } from "../../utils/text";
|
||||||
import { TagLink } from "../Shared/TagLink";
|
import { TagLink } from "../Shared/TagLink";
|
||||||
import { SceneHelpers } from "./helpers";
|
import { SceneHelpers } from "./helpers";
|
||||||
|
import { ZoomUtils } from "../../utils/zoom";
|
||||||
|
import { StashService } from "../../core/StashService";
|
||||||
|
|
||||||
interface ISceneCardProps {
|
interface ISceneCardProps {
|
||||||
scene: GQL.SlimSceneDataFragment;
|
scene: GQL.SlimSceneDataFragment;
|
||||||
selected: boolean | undefined;
|
selected: boolean | undefined;
|
||||||
|
zoomIndex: number;
|
||||||
onSelectedChanged: (selected : boolean, shiftKey : boolean) => void;
|
onSelectedChanged: (selected : boolean, shiftKey : boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +31,8 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
|
|||||||
const [previewPath, setPreviewPath] = useState<string | undefined>(undefined);
|
const [previewPath, setPreviewPath] = useState<string | undefined>(undefined);
|
||||||
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: false});
|
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: false});
|
||||||
|
|
||||||
|
const config = StashService.useConfiguration();
|
||||||
|
const showStudioAsText = !!config.data && !!config.data.configuration ? config.data.configuration.interface.showStudioAsText : false;
|
||||||
|
|
||||||
function maybeRenderRatingBanner() {
|
function maybeRenderRatingBanner() {
|
||||||
if (!props.scene.rating) { return; }
|
if (!props.scene.rating) { return; }
|
||||||
@@ -38,6 +43,43 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maybeRenderSceneSpecsOverlay() {
|
||||||
|
return (
|
||||||
|
<div className={`scene-specs-overlay`}>
|
||||||
|
{!!props.scene.file.height ? <span className={`overlay-resolution`}> {TextUtils.resolution(props.scene.file.height)}</span> : undefined}
|
||||||
|
{props.scene.file.duration !== undefined && props.scene.file.duration >= 1 ? TextUtils.secondsToTimestamp(props.scene.file.duration) : ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderSceneStudioOverlay() {
|
||||||
|
if (!props.scene.studio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let style: React.CSSProperties = {
|
||||||
|
backgroundImage: `url('${props.scene.studio.image_path}')`,
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
if (showStudioAsText) {
|
||||||
|
style = {};
|
||||||
|
text = props.scene.studio.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`scene-studio-overlay`}>
|
||||||
|
<Link
|
||||||
|
to={`/studios/${props.scene.studio.id}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function maybeRenderTagPopoverButton() {
|
function maybeRenderTagPopoverButton() {
|
||||||
if (props.scene.tags.length <= 0) { return; }
|
if (props.scene.tags.length <= 0) { return; }
|
||||||
|
|
||||||
@@ -58,9 +100,20 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
|
|||||||
function maybeRenderPerformerPopoverButton() {
|
function maybeRenderPerformerPopoverButton() {
|
||||||
if (props.scene.performers.length <= 0) { return; }
|
if (props.scene.performers.length <= 0) { return; }
|
||||||
|
|
||||||
const performers = props.scene.performers.map((performer) => (
|
const performers = props.scene.performers.map((performer) => {
|
||||||
<TagLink key={performer.id} performer={performer} />
|
return (
|
||||||
));
|
<>
|
||||||
|
<div className="performer-tag-container">
|
||||||
|
<Link
|
||||||
|
to={`/performers/${performer.id}`}
|
||||||
|
className="performer-tag previewable image"
|
||||||
|
style={{backgroundImage: `url(${performer.image_path})`}}
|
||||||
|
></Link>
|
||||||
|
<TagLink key={performer.id} performer={performer} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<Popover interactionKind={"hover"} position="bottom">
|
<Popover interactionKind={"hover"} position="bottom">
|
||||||
<Button
|
<Button
|
||||||
@@ -119,11 +172,38 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
|
|||||||
setPreviewPath("");
|
setPreviewPath("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPortrait() {
|
||||||
|
let file = props.scene.file;
|
||||||
|
let width = file.width ? file.width : 0;
|
||||||
|
let height = file.height ? file.height : 0;
|
||||||
|
return height > width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkClassName() {
|
||||||
|
let ret = "image previewable";
|
||||||
|
|
||||||
|
if (isPortrait()) {
|
||||||
|
ret += " portrait";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoClassName() {
|
||||||
|
let ret = "preview";
|
||||||
|
|
||||||
|
if (isPortrait()) {
|
||||||
|
ret += " portrait";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
var shiftKey = false;
|
var shiftKey = false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="grid-item"
|
className={"grid-item scene-card " + ZoomUtils.classForZoom(props.zoomIndex)}
|
||||||
elevation={Elevation.ONE}
|
elevation={Elevation.ONE}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
@@ -134,11 +214,15 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
|
|||||||
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
|
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
|
||||||
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => { shiftKey = event.shiftKey; event.stopPropagation(); } }
|
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => { shiftKey = event.shiftKey; event.stopPropagation(); } }
|
||||||
/>
|
/>
|
||||||
<Link to={`/scenes/${props.scene.id}`} className="image previewable">
|
<Link to={`/scenes/${props.scene.id}`} className={getLinkClassName()}>
|
||||||
{maybeRenderRatingBanner()}
|
<div className="video-container">
|
||||||
<video className="preview" loop={true} poster={props.scene.paths.screenshot || ""} ref={videoHoverHook.videoEl}>
|
{maybeRenderRatingBanner()}
|
||||||
{!!previewPath ? <source src={previewPath} /> : ""}
|
{maybeRenderSceneSpecsOverlay()}
|
||||||
</video>
|
{maybeRenderSceneStudioOverlay()}
|
||||||
|
<video className={getVideoClassName()} loop={true} poster={props.scene.paths.screenshot || ""} ref={videoHoverHook.videoEl}>
|
||||||
|
{!!previewPath ? <source src={previewPath} /> : ""}
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="card-section">
|
<div className="card-section">
|
||||||
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
|
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
|
||||||
@@ -149,16 +233,6 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{maybeRenderPopoverButtonGroup()}
|
{maybeRenderPopoverButtonGroup()}
|
||||||
|
|
||||||
<Divider />
|
|
||||||
<span className="card-section centered">
|
|
||||||
{props.scene.file.size !== undefined ? TextUtils.fileSize(parseInt(props.scene.file.size, 10)) : ""}
|
|
||||||
|
|
|
||||||
{props.scene.file.duration !== undefined ? TextUtils.secondsToTimestamp(props.scene.file.duration) : ""}
|
|
||||||
|
|
|
||||||
{props.scene.file.width} x {props.scene.file.height}
|
|
||||||
</span>
|
|
||||||
{SceneHelpers.maybeRenderStudio(props.scene, 50, true)}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListP
|
|||||||
const listData = ListHook.useList({
|
const listData = ListHook.useList({
|
||||||
filterMode: FilterMode.Scenes,
|
filterMode: FilterMode.Scenes,
|
||||||
props,
|
props,
|
||||||
|
zoomable: true,
|
||||||
renderContent,
|
renderContent,
|
||||||
renderSelectedOptions
|
renderSelectedOptions
|
||||||
});
|
});
|
||||||
@@ -45,23 +46,24 @@ export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSceneCard(scene : SlimSceneDataFragment, selectedIds: Set<string>) {
|
function renderSceneCard(scene : SlimSceneDataFragment, selectedIds: Set<string>, zoomIndex: number) {
|
||||||
return (
|
return (
|
||||||
<SceneCard
|
<SceneCard
|
||||||
key={scene.id}
|
key={scene.id}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
|
zoomIndex={zoomIndex}
|
||||||
selected={selectedIds.has(scene.id)}
|
selected={selectedIds.has(scene.id)}
|
||||||
onSelectedChanged={(selected: boolean, shiftKey: boolean) => listData.onSelectChange(scene.id, selected, shiftKey)}
|
onSelectedChanged={(selected: boolean, shiftKey: boolean) => listData.onSelectChange(scene.id, selected, shiftKey)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel, selectedIds: Set<string>) {
|
function renderContent(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel, selectedIds: Set<string>, zoomIndex: number) {
|
||||||
if (!result.data || !result.data.findScenes) { return; }
|
if (!result.data || !result.data.findScenes) { return; }
|
||||||
if (filter.displayMode === DisplayMode.Grid) {
|
if (filter.displayMode === DisplayMode.Grid) {
|
||||||
return (
|
return (
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
{result.data.findScenes.scenes.map((scene) => renderSceneCard(scene, selectedIds))}
|
{result.data.findScenes.scenes.map((scene) => renderSceneCard(scene, selectedIds, zoomIndex))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (filter.displayMode === DisplayMode.List) {
|
} else if (filter.displayMode === DisplayMode.List) {
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export interface IListHookData {
|
|||||||
export interface IListHookOptions {
|
export interface IListHookOptions {
|
||||||
filterMode: FilterMode;
|
filterMode: FilterMode;
|
||||||
props: IBaseProps;
|
props: IBaseProps;
|
||||||
renderContent: (result: QueryHookResult<any, any>, filter: ListFilterModel, selectedIds: Set<string>) => JSX.Element | undefined;
|
zoomable?: boolean
|
||||||
|
renderContent: (result: QueryHookResult<any, any>, filter: ListFilterModel, selectedIds: Set<string>, zoomIndex: number) => JSX.Element | undefined;
|
||||||
renderSelectedOptions?: (result: QueryHookResult<any, any>, selectedIds: Set<string>) => JSX.Element | undefined;
|
renderSelectedOptions?: (result: QueryHookResult<any, any>, selectedIds: Set<string>) => JSX.Element | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ export class ListHook {
|
|||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [lastClickedId, setLastClickedId] = useState<string | undefined>(undefined);
|
const [lastClickedId, setLastClickedId] = useState<string | undefined>(undefined);
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
const [totalCount, setTotalCount] = useState<number>(0);
|
||||||
|
const [zoomIndex, setZoomIndex] = useState<number>(1);
|
||||||
|
|
||||||
// Update the filter when the query parameters change
|
// Update the filter when the query parameters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -254,6 +256,10 @@ export class ListHook {
|
|||||||
setLastClickedId(undefined);
|
setLastClickedId(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onChangeZoom(newZoomIndex : number) {
|
||||||
|
setZoomIndex(newZoomIndex);
|
||||||
|
}
|
||||||
|
|
||||||
const template = (
|
const template = (
|
||||||
<div>
|
<div>
|
||||||
<ListFilter
|
<ListFilter
|
||||||
@@ -266,12 +272,14 @@ export class ListHook {
|
|||||||
onRemoveCriterion={onRemoveCriterion}
|
onRemoveCriterion={onRemoveCriterion}
|
||||||
onSelectAll={onSelectAll}
|
onSelectAll={onSelectAll}
|
||||||
onSelectNone={onSelectNone}
|
onSelectNone={onSelectNone}
|
||||||
|
zoomIndex={options.zoomable ? zoomIndex : undefined}
|
||||||
|
onChangeZoom={options.zoomable ? onChangeZoom : undefined}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
/>
|
/>
|
||||||
{options.renderSelectedOptions && selectedIds.size > 0 ? options.renderSelectedOptions(result, selectedIds) : undefined}
|
{options.renderSelectedOptions && selectedIds.size > 0 ? options.renderSelectedOptions(result, selectedIds) : undefined}
|
||||||
{result.loading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
{result.loading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||||
{result.error ? <h1>{result.error.message}</h1> : undefined}
|
{result.error ? <h1>{result.error.message}</h1> : undefined}
|
||||||
{options.renderContent(result, filter, selectedIds)}
|
{options.renderContent(result, filter, selectedIds, zoomIndex)}
|
||||||
<Pagination
|
<Pagination
|
||||||
itemsPerPage={filter.itemsPerPage}
|
itemsPerPage={filter.itemsPerPage}
|
||||||
currentPage={filter.currentPage}
|
currentPage={filter.currentPage}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ code {
|
|||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: $pt-grid-size $pt-grid-size 0 0;
|
margin: $pt-grid-size $pt-grid-size 0 0;
|
||||||
padding: 0 calc(10%);
|
padding: 0 100px;
|
||||||
|
|
||||||
&.wall {
|
&.wall {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -67,7 +67,7 @@ code {
|
|||||||
|
|
||||||
.grid-item {
|
.grid-item {
|
||||||
// flex: auto;
|
// flex: auto;
|
||||||
width: calc(25% - 1.5em);
|
width: 320px;
|
||||||
min-width: 185px;
|
min-width: 185px;
|
||||||
margin: 0px 0 $pt-grid-size $pt-grid-size;
|
margin: 0px 0 $pt-grid-size $pt-grid-size;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -76,6 +76,47 @@ code {
|
|||||||
width: calc(20%);
|
width: calc(20%);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.zoom-0 {
|
||||||
|
width: 240px;
|
||||||
|
|
||||||
|
& .previewable {
|
||||||
|
max-height: 180px;
|
||||||
|
}
|
||||||
|
& .previewable.portrait {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.zoom-1 {
|
||||||
|
width: 320px;
|
||||||
|
|
||||||
|
& .previewable {
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
& .previewable.portrait {
|
||||||
|
height: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.zoom-2 {
|
||||||
|
width: 480px;
|
||||||
|
|
||||||
|
& .previewable {
|
||||||
|
max-height: 360px;
|
||||||
|
}
|
||||||
|
& .previewable.portrait {
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.zoom-3 {
|
||||||
|
width: 640px;
|
||||||
|
|
||||||
|
& .previewable {
|
||||||
|
max-height: 480px;
|
||||||
|
}
|
||||||
|
& .previewable.portrait {
|
||||||
|
height: 480px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewable {
|
.previewable {
|
||||||
@@ -85,6 +126,11 @@ code {
|
|||||||
width: calc(100% + 40px);
|
width: calc(100% + 40px);
|
||||||
margin: -20px 0 0 -20px;
|
margin: -20px 0 0 -20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewable.portrait {
|
||||||
|
height: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-item label.card-select {
|
.grid-item label.card-select {
|
||||||
@@ -95,12 +141,24 @@ code {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
video.preview {
|
video.preview {
|
||||||
// height: 225px; // slows down the page
|
// height: 225px; // slows down the page
|
||||||
width: 100%;
|
width: 100%;
|
||||||
// width: calc(100% + 40px);
|
// width: calc(100% + 40px);
|
||||||
// margin: -20px 0 0 -20px;
|
// margin: -20px 0 0 -20px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.preview.portrait {
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-item, .operation-item {
|
.filter-item, .operation-item {
|
||||||
@@ -161,6 +219,67 @@ video.preview {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scene-specs-overlay {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1em;
|
||||||
|
right: .7em;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #f5f8fa;
|
||||||
|
letter-spacing: -.03em;
|
||||||
|
text-shadow: 0 0 3px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-studio-overlay {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: .7em;
|
||||||
|
right: .7em;
|
||||||
|
font-weight: 900;
|
||||||
|
width: 40%;
|
||||||
|
height: 20%;
|
||||||
|
opacity: 0.75;
|
||||||
|
z-index: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-studio-overlay a {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-size: contain;
|
||||||
|
display: inline-block;
|
||||||
|
background-position: right top;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
letter-spacing: -.03em;
|
||||||
|
text-shadow: 0 0 3px #000;
|
||||||
|
text-align: right;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #f5f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-resolution {
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-right:.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-card {
|
||||||
|
& .scene-specs-overlay, .rating-banner, .scene-studio-overlay {
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-card:hover {
|
||||||
|
& .scene-specs-overlay, .rating-banner, .scene-studio-overlay {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-studio-overlay:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#jwplayer-container {
|
#jwplayer-container {
|
||||||
margin: 10px auto;
|
margin: 10px auto;
|
||||||
width: 75%;
|
width: 75%;
|
||||||
@@ -223,6 +342,19 @@ span.block {
|
|||||||
background-repeat: no-repeat !important;
|
background-repeat: no-repeat !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.performer-tag-container {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performer-tag.image {
|
||||||
|
height: 150px;
|
||||||
|
width: 100%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.studio.image {
|
.studio.image {
|
||||||
height: 100px;
|
height: 100px;
|
||||||
background-size: contain !important;
|
background-size: contain !important;
|
||||||
@@ -331,4 +463,13 @@ span.block {
|
|||||||
float: right;
|
float: right;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-slider {
|
||||||
|
margin: auto 5px;
|
||||||
|
width: 100px;
|
||||||
|
|
||||||
|
& .bp3-slider {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,17 @@ export class TextUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static secondsToTimestamp(seconds: number): string {
|
public static secondsToTimestamp(seconds: number): string {
|
||||||
return new Date(seconds * 1000).toISOString().substr(11, 8);
|
let ret = new Date(seconds * 1000).toISOString().substr(11, 8);
|
||||||
|
|
||||||
|
if (ret.startsWith("00")) {
|
||||||
|
// strip hours if under one hour
|
||||||
|
ret = ret.substr(3);
|
||||||
|
}
|
||||||
|
if (ret.startsWith("0")) {
|
||||||
|
// for duration under a minute, leave one leading zero
|
||||||
|
ret = ret.substr(1);
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static fileNameFromPath(path: string): string {
|
public static fileNameFromPath(path: string): string {
|
||||||
|
|||||||
6
ui/v2/src/utils/zoom.ts
Normal file
6
ui/v2/src/utils/zoom.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export class ZoomUtils {
|
||||||
|
public static classForZoom(zoomIndex: number): string {
|
||||||
|
return "zoom-" + zoomIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user