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:
WithoutPants 2025-06-02 17:18:36 +10:00 committed by GitHub
parent 5ea4c507b2
commit 8e697b50eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 515 additions and 37 deletions

View file

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

View file

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

View 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} />;
};

View 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} />;
};

View file

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

View file

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

View file

@ -2,7 +2,7 @@
margin: 0 auto;
max-width: 2250px;
&-item {
.wall-item {
height: 11.25vw;
line-height: 0;
max-height: 253px;