mirror of
https://github.com/stashapp/stash.git
synced 2025-12-15 21:03:22 +01:00
Revamp scene and marker wall views (#5816)
* Use gallery for scene wall * Move into separate file * Remove unnecessary class names * Apply configuration * Reuse styling * Add Scene Marker wall panel * Adjust target row height
This commit is contained in:
parent
5ea4c507b2
commit
8e697b50eb
7 changed files with 515 additions and 37 deletions
|
|
@ -9,7 +9,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
|
|||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { Tagger } from "../Tagger/scenes/SceneTagger";
|
||||
import { IPlaySceneOptions, SceneQueue } from "src/models/sceneQueue";
|
||||
import { SceneWallPanel } from "../Wall/WallPanel";
|
||||
import { SceneWallPanel } from "./SceneWallPanel";
|
||||
import { SceneListTable } from "./SceneListTable";
|
||||
import { EditScenesDialog } from "./EditScenesDialog";
|
||||
import { DeleteScenesDialog } from "./DeleteScenesDialog";
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import NavUtils from "src/utils/navigation";
|
|||
import { ItemList, ItemListContext } from "../List/ItemList";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { MarkerWallPanel } from "../Wall/WallPanel";
|
||||
import { MarkerWallPanel } from "./SceneMarkerWallPanel";
|
||||
import { View } from "../List/views";
|
||||
import { SceneMarkerCardsGrid } from "./SceneMarkerCardsGrid";
|
||||
import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog";
|
||||
|
|
|
|||
234
ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx
Normal file
234
ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import Gallery, {
|
||||
GalleryI,
|
||||
PhotoProps,
|
||||
RenderImageProps,
|
||||
} from "react-photo-gallery";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { TruncatedText } from "../Shared/TruncatedText";
|
||||
import TextUtils from "src/utils/text";
|
||||
import cx from "classnames";
|
||||
import NavUtils from "src/utils/navigation";
|
||||
import { markerTitle } from "src/core/markers";
|
||||
|
||||
function wallItemTitle(sceneMarker: GQL.SceneMarkerDataFragment) {
|
||||
const newTitle = markerTitle(sceneMarker);
|
||||
const seconds = TextUtils.formatTimestampRange(
|
||||
sceneMarker.seconds,
|
||||
sceneMarker.end_seconds ?? undefined
|
||||
);
|
||||
if (newTitle) {
|
||||
return `${newTitle} - ${seconds}`;
|
||||
} else {
|
||||
return seconds;
|
||||
}
|
||||
}
|
||||
|
||||
interface IMarkerPhoto {
|
||||
marker: GQL.SceneMarkerDataFragment;
|
||||
link: string;
|
||||
onError?: (photo: PhotoProps<IMarkerPhoto>) => void;
|
||||
}
|
||||
|
||||
export const MarkerWallItem: React.FC<RenderImageProps<IMarkerPhoto>> = (
|
||||
props: RenderImageProps<IMarkerPhoto>
|
||||
) => {
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
const playSound = configuration?.interface.soundOnPreview ?? false;
|
||||
const showTitle = configuration?.interface.wallShowTitle ?? false;
|
||||
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
type style = Record<string, string | number | undefined>;
|
||||
var divStyle: style = {
|
||||
margin: props.margin,
|
||||
display: "block",
|
||||
};
|
||||
|
||||
if (props.direction === "column") {
|
||||
divStyle.position = "absolute";
|
||||
divStyle.left = props.left;
|
||||
divStyle.top = props.top;
|
||||
}
|
||||
|
||||
var handleClick = function handleClick(event: React.MouseEvent) {
|
||||
if (props.onClick) {
|
||||
props.onClick(event, { index: props.index });
|
||||
}
|
||||
};
|
||||
|
||||
const video = props.photo.src.includes("stream");
|
||||
const ImagePreview = video ? "video" : "img";
|
||||
|
||||
const { marker } = props.photo;
|
||||
const title = wallItemTitle(marker);
|
||||
const tagNames = marker.tags.map((p) => p.name);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("wall-item", { "show-title": showTitle })}
|
||||
role="button"
|
||||
style={{
|
||||
...divStyle,
|
||||
width: props.photo.width,
|
||||
height: props.photo.height,
|
||||
}}
|
||||
>
|
||||
<ImagePreview
|
||||
loading="lazy"
|
||||
loop={video}
|
||||
muted={!video || !playSound || !active}
|
||||
autoPlay={video}
|
||||
key={props.photo.key}
|
||||
src={props.photo.src}
|
||||
width={props.photo.width}
|
||||
height={props.photo.height}
|
||||
alt={props.photo.alt}
|
||||
onMouseEnter={() => setActive(true)}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
onClick={handleClick}
|
||||
onError={() => {
|
||||
props.photo.onError?.(props.photo);
|
||||
}}
|
||||
/>
|
||||
<div className="lineargradient">
|
||||
<footer className="wall-item-footer">
|
||||
<Link to={props.photo.link} onClick={(e) => e.stopPropagation()}>
|
||||
{title && (
|
||||
<TruncatedText
|
||||
text={title}
|
||||
lineCount={1}
|
||||
className="wall-item-title"
|
||||
/>
|
||||
)}
|
||||
<TruncatedText text={tagNames.join(", ")} />
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IMarkerWallProps {
|
||||
markers: GQL.SceneMarkerDataFragment[];
|
||||
}
|
||||
|
||||
// HACK: typescript doesn't allow Gallery to accept a parameter for some reason
|
||||
const MarkerGallery = Gallery as unknown as GalleryI<IMarkerPhoto>;
|
||||
|
||||
function getFirstValidSrc(srcSet: string[], invalidSrcSet: string[]) {
|
||||
if (!srcSet.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
srcSet.find((src) => !invalidSrcSet.includes(src)) ??
|
||||
([...srcSet].pop() as string)
|
||||
);
|
||||
}
|
||||
|
||||
interface IFile {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function getDimensions(file?: IFile) {
|
||||
const defaults = { width: 1280, height: 720 };
|
||||
|
||||
if (!file) return defaults;
|
||||
|
||||
return {
|
||||
width: file.width || defaults.width,
|
||||
height: file.height || defaults.height,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultTargetRowHeight = 250;
|
||||
|
||||
const MarkerWall: React.FC<IMarkerWallProps> = ({ markers }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const margin = 3;
|
||||
const direction = "row";
|
||||
|
||||
const [erroredImgs, setErroredImgs] = useState<string[]>([]);
|
||||
|
||||
const handleError = useCallback((photo: PhotoProps<IMarkerPhoto>) => {
|
||||
setErroredImgs((prev) => [...prev, photo.src]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setErroredImgs([]);
|
||||
}, [markers]);
|
||||
|
||||
const photos: PhotoProps<IMarkerPhoto>[] = useMemo(() => {
|
||||
return markers.map((m, index) => {
|
||||
const { width = 1280, height = 720 } = getDimensions(m.scene.files[0]);
|
||||
|
||||
return {
|
||||
marker: m,
|
||||
src: getFirstValidSrc([m.stream, m.preview, m.screenshot], erroredImgs),
|
||||
link: NavUtils.makeSceneMarkerUrl(m),
|
||||
width,
|
||||
height,
|
||||
tabIndex: index,
|
||||
key: m.id,
|
||||
loading: "lazy",
|
||||
alt: objectTitle(m),
|
||||
onError: handleError,
|
||||
};
|
||||
});
|
||||
}, [markers, erroredImgs, handleError]);
|
||||
|
||||
const onClick = useCallback(
|
||||
(event, { index }) => {
|
||||
history.push(photos[index].link);
|
||||
},
|
||||
[history, photos]
|
||||
);
|
||||
|
||||
function columns(containerWidth: number) {
|
||||
let preferredSize = 300;
|
||||
let columnCount = containerWidth / preferredSize;
|
||||
return Math.round(columnCount);
|
||||
}
|
||||
|
||||
const renderImage = useCallback((props: RenderImageProps<IMarkerPhoto>) => {
|
||||
return <MarkerWallItem {...props} />;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="marker-wall">
|
||||
{photos.length ? (
|
||||
<MarkerGallery
|
||||
photos={photos}
|
||||
renderImage={renderImage}
|
||||
onClick={onClick}
|
||||
margin={margin}
|
||||
direction={direction}
|
||||
columns={columns}
|
||||
targetRowHeight={defaultTargetRowHeight}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IMarkerWallPanelProps {
|
||||
markers: GQL.SceneMarkerDataFragment[];
|
||||
}
|
||||
|
||||
export const MarkerWallPanel: React.FC<IMarkerWallPanelProps> = ({
|
||||
markers,
|
||||
}) => {
|
||||
return <MarkerWall markers={markers} />;
|
||||
};
|
||||
220
ui/v2.5/src/components/Scenes/SceneWallPanel.tsx
Normal file
220
ui/v2.5/src/components/Scenes/SceneWallPanel.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { SceneQueue } from "src/models/sceneQueue";
|
||||
import Gallery, {
|
||||
GalleryI,
|
||||
PhotoProps,
|
||||
RenderImageProps,
|
||||
} from "react-photo-gallery";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { TruncatedText } from "../Shared/TruncatedText";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { useIntl } from "react-intl";
|
||||
import cx from "classnames";
|
||||
|
||||
interface IScenePhoto {
|
||||
scene: GQL.SlimSceneDataFragment;
|
||||
link: string;
|
||||
onError?: (photo: PhotoProps<IScenePhoto>) => void;
|
||||
}
|
||||
|
||||
export const SceneWallItem: React.FC<RenderImageProps<IScenePhoto>> = (
|
||||
props: RenderImageProps<IScenePhoto>
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
const playSound = configuration?.interface.soundOnPreview ?? false;
|
||||
const showTitle = configuration?.interface.wallShowTitle ?? false;
|
||||
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
type style = Record<string, string | number | undefined>;
|
||||
var divStyle: style = {
|
||||
margin: props.margin,
|
||||
display: "block",
|
||||
};
|
||||
|
||||
if (props.direction === "column") {
|
||||
divStyle.position = "absolute";
|
||||
divStyle.left = props.left;
|
||||
divStyle.top = props.top;
|
||||
}
|
||||
|
||||
var handleClick = function handleClick(event: React.MouseEvent) {
|
||||
if (props.onClick) {
|
||||
props.onClick(event, { index: props.index });
|
||||
}
|
||||
};
|
||||
|
||||
const video = props.photo.src.includes("preview");
|
||||
const ImagePreview = video ? "video" : "img";
|
||||
|
||||
const { scene } = props.photo;
|
||||
const title = objectTitle(scene);
|
||||
const performerNames = scene.performers.map((p) => p.name);
|
||||
const performers =
|
||||
performerNames.length >= 2
|
||||
? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")]
|
||||
: performerNames;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("wall-item", { "show-title": showTitle })}
|
||||
role="button"
|
||||
style={{
|
||||
...divStyle,
|
||||
width: props.photo.width,
|
||||
height: props.photo.height,
|
||||
}}
|
||||
>
|
||||
<ImagePreview
|
||||
loading="lazy"
|
||||
loop={video}
|
||||
muted={!video || !playSound || !active}
|
||||
autoPlay={video}
|
||||
key={props.photo.key}
|
||||
src={props.photo.src}
|
||||
width={props.photo.width}
|
||||
height={props.photo.height}
|
||||
alt={props.photo.alt}
|
||||
onMouseEnter={() => setActive(true)}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
onClick={handleClick}
|
||||
onError={() => {
|
||||
props.photo.onError?.(props.photo);
|
||||
}}
|
||||
/>
|
||||
<div className="lineargradient">
|
||||
<footer className="wall-item-footer">
|
||||
<Link to={props.photo.link} onClick={(e) => e.stopPropagation()}>
|
||||
{title && (
|
||||
<TruncatedText
|
||||
text={title}
|
||||
lineCount={1}
|
||||
className="wall-item-title"
|
||||
/>
|
||||
)}
|
||||
<TruncatedText text={performers.join(", ")} />
|
||||
<div>{scene.date && TextUtils.formatDate(intl, scene.date)}</div>
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getDimensions(s: GQL.SlimSceneDataFragment) {
|
||||
const defaults = { width: 1280, height: 720 };
|
||||
|
||||
if (!s.files.length) return defaults;
|
||||
|
||||
return {
|
||||
width: s.files[0].width || defaults.width,
|
||||
height: s.files[0].height || defaults.height,
|
||||
};
|
||||
}
|
||||
|
||||
interface ISceneWallProps {
|
||||
scenes: GQL.SlimSceneDataFragment[];
|
||||
sceneQueue?: SceneQueue;
|
||||
}
|
||||
|
||||
// HACK: typescript doesn't allow Gallery to accept a parameter for some reason
|
||||
const SceneGallery = Gallery as unknown as GalleryI<IScenePhoto>;
|
||||
|
||||
const defaultTargetRowHeight = 250;
|
||||
|
||||
const SceneWall: React.FC<ISceneWallProps> = ({ scenes, sceneQueue }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const margin = 3;
|
||||
const direction = "row";
|
||||
|
||||
const [erroredImgs, setErroredImgs] = useState<string[]>([]);
|
||||
|
||||
const handleError = useCallback((photo: PhotoProps<IScenePhoto>) => {
|
||||
setErroredImgs((prev) => [...prev, photo.src]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setErroredImgs([]);
|
||||
}, [scenes]);
|
||||
|
||||
const photos: PhotoProps<IScenePhoto>[] = useMemo(() => {
|
||||
return scenes.map((s, index) => {
|
||||
const { width, height } = getDimensions(s);
|
||||
|
||||
return {
|
||||
scene: s,
|
||||
src:
|
||||
s.paths.preview && !erroredImgs.includes(s.paths.preview)
|
||||
? s.paths.preview!
|
||||
: s.paths.screenshot!,
|
||||
link: sceneQueue
|
||||
? sceneQueue.makeLink(s.id, { sceneIndex: index })
|
||||
: `/scenes/${s.id}`,
|
||||
width,
|
||||
height,
|
||||
tabIndex: index,
|
||||
key: s.id,
|
||||
loading: "lazy",
|
||||
alt: objectTitle(s),
|
||||
onError: handleError,
|
||||
};
|
||||
});
|
||||
}, [scenes, sceneQueue, erroredImgs, handleError]);
|
||||
|
||||
const onClick = useCallback(
|
||||
(event, { index }) => {
|
||||
history.push(photos[index].link);
|
||||
},
|
||||
[history, photos]
|
||||
);
|
||||
|
||||
function columns(containerWidth: number) {
|
||||
let preferredSize = 300;
|
||||
let columnCount = containerWidth / preferredSize;
|
||||
return Math.round(columnCount);
|
||||
}
|
||||
|
||||
const renderImage = useCallback((props: RenderImageProps<IScenePhoto>) => {
|
||||
return <SceneWallItem {...props} />;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="scene-wall">
|
||||
{photos.length ? (
|
||||
<SceneGallery
|
||||
photos={photos}
|
||||
renderImage={renderImage}
|
||||
onClick={onClick}
|
||||
margin={margin}
|
||||
direction={direction}
|
||||
columns={columns}
|
||||
targetRowHeight={defaultTargetRowHeight}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISceneWallPanelProps {
|
||||
scenes: GQL.SlimSceneDataFragment[];
|
||||
sceneQueue?: SceneQueue;
|
||||
}
|
||||
|
||||
export const SceneWallPanel: React.FC<ISceneWallPanelProps> = ({
|
||||
scenes,
|
||||
sceneQueue,
|
||||
}) => {
|
||||
return <SceneWall scenes={scenes} sceneQueue={sceneQueue} />;
|
||||
};
|
||||
|
|
@ -561,7 +561,7 @@ input[type="range"].blue-slider {
|
|||
}
|
||||
|
||||
.scene-markers-panel {
|
||||
.wall-item {
|
||||
.wall .wall-item {
|
||||
height: inherit;
|
||||
min-height: 14rem;
|
||||
width: calc(100% - 2rem);
|
||||
|
|
@ -901,3 +901,60 @@ input[type="range"].blue-slider {
|
|||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-wall,
|
||||
.marker-wall {
|
||||
.wall-item {
|
||||
position: relative;
|
||||
|
||||
.lineargradient {
|
||||
background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3));
|
||||
bottom: 0;
|
||||
height: 100px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&-footer {
|
||||
bottom: 20px;
|
||||
padding: 0 1rem;
|
||||
position: absolute;
|
||||
text-shadow: 1px 1px 3px black;
|
||||
transition: 0s opacity;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.wall-item-title {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .wall-item-footer {
|
||||
opacity: 1;
|
||||
transition: 1s opacity;
|
||||
transition-delay: 500ms;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.show-title .wall-item-footer {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,18 +62,6 @@ const WallPanel = <T extends WallItemType>({
|
|||
);
|
||||
};
|
||||
|
||||
interface IImageWallPanelProps {
|
||||
images: GQL.SlimImageDataFragment[];
|
||||
clickHandler?: (e: MouseEvent, item: GQL.SlimImageDataFragment) => void;
|
||||
}
|
||||
|
||||
export const ImageWallPanel: React.FC<IImageWallPanelProps> = ({
|
||||
images,
|
||||
clickHandler,
|
||||
}) => {
|
||||
return <WallPanel type="image" data={images} clickHandler={clickHandler} />;
|
||||
};
|
||||
|
||||
interface IMarkerWallPanelProps {
|
||||
markers: GQL.SceneMarkerDataFragment[];
|
||||
clickHandler?: (e: MouseEvent, item: GQL.SceneMarkerDataFragment) => void;
|
||||
|
|
@ -87,24 +75,3 @@ export const MarkerWallPanel: React.FC<IMarkerWallPanelProps> = ({
|
|||
<WallPanel type="sceneMarker" data={markers} clickHandler={clickHandler} />
|
||||
);
|
||||
};
|
||||
|
||||
interface ISceneWallPanelProps {
|
||||
scenes: GQL.SlimSceneDataFragment[];
|
||||
sceneQueue?: SceneQueue;
|
||||
clickHandler?: (e: MouseEvent, item: GQL.SlimSceneDataFragment) => void;
|
||||
}
|
||||
|
||||
export const SceneWallPanel: React.FC<ISceneWallPanelProps> = ({
|
||||
scenes,
|
||||
sceneQueue,
|
||||
clickHandler,
|
||||
}) => {
|
||||
return (
|
||||
<WallPanel
|
||||
type="scene"
|
||||
data={scenes}
|
||||
sceneQueue={sceneQueue}
|
||||
clickHandler={clickHandler}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
margin: 0 auto;
|
||||
max-width: 2250px;
|
||||
|
||||
&-item {
|
||||
.wall-item {
|
||||
height: 11.25vw;
|
||||
line-height: 0;
|
||||
max-height: 253px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue