Add gallery wall view, and new lightbox (#1008)

This commit is contained in:
InfiniteTF
2020-12-24 01:17:15 +01:00
committed by GitHub
parent c8bcaaf27d
commit 232a69c518
24 changed files with 979 additions and 216 deletions

View File

@@ -1,10 +1,12 @@
#### 💥 **Note: After upgrading, all scene file sizes will be 0B until a new [scan](/settings?tab=tasks) is run.
#### 💥 Note: After upgrading, all scene file sizes will be 0B until a new [scan](/settings?tab=tasks) is run.
### ✨ New Features
* Add gallery wall view.
* Add organized flag for scenes, galleries and images.
* Allow configuration of visible navbar items.
### 🎨 Improvements
* Pagination support and general improvements for image lightbox.
* Add mouse click support for CDP scrapers.
* Add gallery tabs to performer and studio pages.
* Add gallery scrapers to scraper page.

View File

@@ -186,7 +186,6 @@ export const Gallery: React.FC = () => {
<Tab.Content>
<Tab.Pane eventKey="images">
{/* <GalleryViewer gallery={gallery} /> */}
<GalleryImagesPanel gallery={gallery} />
</Tab.Pane>
<Tab.Pane eventKey="add">

View File

@@ -13,6 +13,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { queryFindGalleries } from "src/core/StashService";
import { GalleryCard } from "./GalleryCard";
import GalleryWallCard from "./GalleryWallCard";
import { EditGalleriesDialog } from "./EditGalleriesDialog";
import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog";
import { ExportDialog } from "../Shared/ExportDialog";
@@ -212,7 +213,15 @@ export const GalleryList: React.FC<IGalleryList> = ({
);
}
if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>;
return (
<div className="row">
<div className="GalleryWall">
{result.data.findGalleries.galleries.map((gallery) => (
<GalleryWallCard key={gallery.id} gallery={gallery} />
))}
</div>
</div>
);
}
}

View File

@@ -1,52 +1,36 @@
import React, { useState } from "react";
import React from "react";
import * as GQL from "src/core/generated-graphql";
import FsLightbox from "fslightbox-react";
import { useLightbox } from "src/hooks";
import "flexbin/flexbin.css";
interface IProps {
gallery: Partial<GQL.GalleryDataFragment>;
gallery: GQL.GalleryDataFragment;
}
export const GalleryViewer: React.FC<IProps> = ({ gallery }) => {
const [lightboxToggle, setLightboxToggle] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const images = gallery?.images ?? [];
const showLightbox = useLightbox({ images, showNavigation: false });
const openImage = (index: number) => {
setCurrentIndex(index);
setLightboxToggle(!lightboxToggle);
};
const photos = !gallery.images
? []
: gallery.images.map((file) => file.paths.image ?? "");
const thumbs = !gallery.images
? []
: gallery.images.map((file, index) => (
<div
role="link"
tabIndex={index}
key={file.checksum ?? index}
onClick={() => openImage(index)}
onKeyPress={() => openImage(index)}
>
<img
src={file.paths.thumbnail ?? ""}
loading="lazy"
className="gallery-image"
alt={file.title ?? index.toString()}
/>
</div>
));
const thumbs = images.map((file, index) => (
<div
role="link"
tabIndex={index}
key={file.checksum ?? index}
onClick={() => showLightbox(index)}
onKeyPress={() => showLightbox(index)}
>
<img
src={file.paths.thumbnail ?? ""}
loading="lazy"
className="gallery-image"
alt={file.title ?? index.toString()}
/>
</div>
));
return (
<div className="gallery">
<div className="flexbin">{thumbs}</div>
<FsLightbox
sourceIndex={currentIndex}
toggler={lightboxToggle}
sources={photos}
key={gallery.id!}
/>
</div>
);
};

View File

@@ -0,0 +1,68 @@
import React from "react";
import { useIntl } from "react-intl";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { RatingStars, TruncatedText } from "src/components/Shared";
import { TextUtils } from "src/utils";
import { useGalleryLightbox } from "src/hooks";
const CLASSNAME = "GalleryWallCard";
const CLASSNAME_FOOTER = `${CLASSNAME}-footer`;
const CLASSNAME_IMG = `${CLASSNAME}-img`;
const CLASSNAME_TITLE = `${CLASSNAME}-title`;
interface IProps {
gallery: GQL.GallerySlimDataFragment;
}
const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
const intl = useIntl();
const showLightbox = useGalleryLightbox(gallery.id);
const orientation =
(gallery?.cover?.file.width ?? 0) > (gallery.cover?.file.height ?? 0)
? "landscape"
: "portrait";
const cover = gallery?.cover?.paths.thumbnail ?? "";
const title = gallery.title ?? gallery.path;
const performerNames = gallery.performers.map((p) => p.name);
const performers =
performerNames.length >= 2
? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")]
: performerNames;
return (
<>
<section
className={`${CLASSNAME} ${CLASSNAME}-${orientation}`}
onClick={showLightbox}
onKeyPress={showLightbox}
role="button"
tabIndex={0}
>
<RatingStars rating={gallery.rating} />
<img src={cover} alt="" className={CLASSNAME_IMG} />
<footer className={CLASSNAME_FOOTER}>
<Link
to={`/galleries/${gallery.id}`}
onClick={(e) => e.stopPropagation()}
>
{title && (
<TruncatedText
text={title}
lineCount={1}
className={CLASSNAME_TITLE}
/>
)}
<TruncatedText text={performers.join(", ")} />
<div>
{gallery.date && TextUtils.formatDate(intl, gallery.date)}
</div>
</Link>
</footer>
</section>
</>
);
};
export default GalleryWallCard;

View File

@@ -96,3 +96,116 @@ $galleryTabWidth: 450px;
height: calc(1.5em + 0.75rem + 2px);
}
}
.GalleryWall {
display: flex;
flex-wrap: wrap;
margin: 0 auto;
width: 96vw;
/* Prevents last row from consuming all space and stretching images to oblivion */
&::after {
content: "";
flex: auto;
flex-grow: 9999;
}
}
.GalleryWallCard {
height: auto;
padding: 2px;
position: relative;
$width: 96vw;
&-landscape {
flex-grow: 2;
width: 96vw;
}
&-portrait {
flex-grow: 1;
width: 96vw;
}
@mixin galleryWidth($width) {
height: ($width / 3) * 2;
&-landscape {
width: $width;
}
&-portrait {
width: $width / 2;
}
}
@media (min-width: 576px) {
@include galleryWidth(96vw);
}
@media (min-width: 768px) {
@include galleryWidth(48vw);
}
@media (min-width: 1200px) {
@include galleryWidth(32vw);
}
&-img {
height: 100%;
object-fit: cover;
object-position: center 20%;
width: 100%;
}
&-title {
font-weight: bold;
}
&-footer {
background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3));
bottom: 0;
padding: 1rem;
position: absolute;
text-shadow: 1px 1px 3px black;
transition: 0s opacity;
width: 100%;
@media (min-width: 768px) {
opacity: 0;
}
&:hover {
.GalleryWallCard-title {
text-decoration: underline;
}
}
a {
color: white;
}
}
&:hover &-footer {
opacity: 1;
transition: 1s opacity;
transition-delay: 500ms;
a {
text-decoration: none;
}
}
.RatingStars {
position: absolute;
right: 1rem;
top: 1rem;
&-unfilled {
display: none;
}
&-filled {
filter: drop-shadow(1px 1px 1px #222);
}
}
}

View File

@@ -1,7 +1,6 @@
import React, { useState } from "react";
import React, { useCallback, useState } from "react";
import _ from "lodash";
import { useHistory } from "react-router-dom";
import FsLightbox from "fslightbox-react";
import Mousetrap from "mousetrap";
import {
FindImagesQueryResult,
@@ -9,7 +8,7 @@ import {
} from "src/core/generated-graphql";
import * as GQL from "src/core/generated-graphql";
import { queryFindImages } from "src/core/StashService";
import { useImagesList } from "src/hooks";
import { useImagesList, useLightbox } from "src/hooks";
import { TextUtils } from "src/utils";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
@@ -22,25 +21,45 @@ import { ExportDialog } from "../Shared/ExportDialog";
interface IImageWallProps {
images: GQL.SlimImageDataFragment[];
onChangePage: (page: number) => void;
currentPage: number;
pageCount: number;
}
const ImageWall: React.FC<IImageWallProps> = ({ images }) => {
const [lightboxToggle, setLightboxToggle] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const ImageWall: React.FC<IImageWallProps> = ({
images,
onChangePage,
currentPage,
pageCount,
}) => {
const handleLightBoxPage = useCallback(
(direction: number) => {
if (direction === -1) {
if (currentPage === 1) return false;
onChangePage(currentPage - 1);
} else {
if (currentPage === pageCount) return false;
onChangePage(currentPage + 1);
}
return direction === -1 || direction === 1;
},
[onChangePage, currentPage, pageCount]
);
const openImage = (index: number) => {
setCurrentIndex(index);
setLightboxToggle(!lightboxToggle);
};
const showLightbox = useLightbox({
images,
showNavigation: false,
pageCallback: handleLightBoxPage,
pageHeader: `Page ${currentPage} / ${pageCount}`,
});
const photos = images.map((image) => image.paths.image ?? "");
const thumbs = images.map((image, index) => (
<div
role="link"
tabIndex={index}
key={image.id}
onClick={() => openImage(index)}
onKeyPress={() => openImage(index)}
onClick={() => showLightbox(index)}
onKeyPress={() => showLightbox(index)}
>
<img
src={image.paths.thumbnail ?? ""}
@@ -51,32 +70,9 @@ const ImageWall: React.FC<IImageWallProps> = ({ images }) => {
</div>
));
// FsLightbox doesn't update unless the key updates
const key = images.map((i) => i.id).join(",");
function onLightboxOpen() {
// disable mousetrap
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Mousetrap as any).pause();
}
function onLightboxClose() {
// re-enable mousetrap
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Mousetrap as any).unpause();
}
return (
<div className="gallery">
<div className="flexbin">{thumbs}</div>
<FsLightbox
sourceIndex={currentIndex}
toggler={lightboxToggle}
sources={photos}
key={key}
onOpen={onLightboxOpen}
onClose={onLightboxClose}
/>
</div>
);
};
@@ -125,7 +121,7 @@ export const ImageList: React.FC<IImageList> = ({
};
};
const listData = useImagesList({
const { template, onSelectChange } = useImagesList({
zoomable: true,
selectable: true,
otherOperations,
@@ -150,12 +146,7 @@ export const ImageList: React.FC<IImageList> = ({
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindImages(filterCopy);
if (
singleResult &&
singleResult.data &&
singleResult.data.findImages &&
singleResult.data.findImages.images.length === 1
) {
if (singleResult.data.findImages.images.length === 1) {
const { id } = singleResult!.data!.findImages!.images[0];
// navigate to the image player page
history.push(`/images/${id}`);
@@ -228,7 +219,7 @@ export const ImageList: React.FC<IImageList> = ({
selecting={selectedIds.size > 0}
selected={selectedIds.has(image.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
listData.onSelectChange(image.id, selected, shiftKey)
onSelectChange(image.id, selected, shiftKey)
}
/>
);
@@ -238,7 +229,9 @@ export const ImageList: React.FC<IImageList> = ({
result: FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
zoomIndex: number
zoomIndex: number,
onChangePage: (page: number) => void,
pageCount: number
) {
if (!result.data || !result.data.findImages) {
return;
@@ -252,11 +245,15 @@ export const ImageList: React.FC<IImageList> = ({
</div>
);
}
// if (filter.displayMode === DisplayMode.List) {
// return <ImageListTable images={result.data.findImages.images} />;
// }
if (filter.displayMode === DisplayMode.Wall) {
return <ImageWall images={result.data.findImages.images} />;
return (
<ImageWall
images={result.data.findImages.images}
onChangePage={onChangePage}
currentPage={filter.currentPage}
pageCount={pageCount}
/>
);
}
}
@@ -264,15 +261,24 @@ export const ImageList: React.FC<IImageList> = ({
result: FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
zoomIndex: number
zoomIndex: number,
onChangePage: (page: number) => void,
pageCount: number
) {
return (
<>
{maybeRenderImageExportDialog(selectedIds)}
{renderImages(result, filter, selectedIds, zoomIndex)}
{renderImages(
result,
filter,
selectedIds,
zoomIndex,
onChangePage,
pageCount
)}
</>
);
}
return listData.template;
return template;
};

View File

@@ -16,9 +16,8 @@ import {
Icon,
LoadingIndicator,
} from "src/components/Shared";
import { useToast } from "src/hooks";
import { useLightbox, useToast } from "src/hooks";
import { TextUtils } from "src/utils";
import FsLightbox from "fslightbox-react";
import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
import { PerformerScenesPanel } from "./PerformerScenesPanel";
@@ -39,7 +38,6 @@ export const Performer: React.FC = () => {
// Performer state
const [imagePreview, setImagePreview] = useState<string | null>();
const [imageEncoding, setImageEncoding] = useState<boolean>(false);
const [lightboxToggle, setLightboxToggle] = useState(false);
const { data, loading: performerLoading, error } = useFindPerformer(id);
const performer = data?.findPerformer || ({} as Partial<GQL.Performer>);
@@ -51,6 +49,10 @@ export const Performer: React.FC = () => {
? performer.image_path ?? ""
: imagePreview ?? `${performer.image_path}?default=true`;
const showLightbox = useLightbox({
images: [{ paths: { thumbnail: activeImage, image: activeImage } }],
});
// Network state
const [loading, setIsLoading] = useState(false);
const isLoading = performerLoading || loading;
@@ -318,10 +320,7 @@ export const Performer: React.FC = () => {
{imageEncoding ? (
<LoadingIndicator message="Encoding image..." />
) : (
<Button
variant="link"
onClick={() => setLightboxToggle(!lightboxToggle)}
>
<Button variant="link" onClick={() => showLightbox()}>
<img className="performer" src={activeImage} alt="Performer" />
</Button>
)}
@@ -342,7 +341,6 @@ export const Performer: React.FC = () => {
<div className="performer-tabs">{renderTabs()}</div>
</div>
</div>
<FsLightbox toggler={lightboxToggle} sources={[activeImage]} />
</div>
);
};

View File

@@ -17,7 +17,7 @@ import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared";
import { useToast } from "src/hooks";
import { ScenePlayer } from "src/components/ScenePlayer";
import { TextUtils, JWUtils } from "src/utils";
import * as Mousetrap from "mousetrap";
import Mousetrap from "mousetrap";
import { SceneMarkersPanel } from "./SceneMarkersPanel";
import { SceneFileInfoPanel } from "./SceneFileInfoPanel";
import { SceneEditPanel } from "./SceneEditPanel";

View File

@@ -0,0 +1,35 @@
import React from "react";
import Icon from "./Icon";
const CLASSNAME = "RatingStars";
const CLASSNAME_FILLED = `${CLASSNAME}-filled`;
const CLASSNAME_UNFILLED = `${CLASSNAME}-unfilled`;
interface IProps {
rating?: number | null;
}
export const RatingStars: React.FC<IProps> = ({ rating }) =>
rating ? (
<div className={CLASSNAME}>
<Icon icon={["fas", "star"]} className={CLASSNAME_FILLED} />
<Icon
icon={[rating >= 2 ? "fas" : "far", "star"]}
className={rating >= 2 ? CLASSNAME_FILLED : CLASSNAME_UNFILLED}
/>
<Icon
icon={[rating >= 3 ? "fas" : "far", "star"]}
className={rating >= 3 ? CLASSNAME_FILLED : CLASSNAME_UNFILLED}
/>
<Icon
icon={[rating >= 4 ? "fas" : "far", "star"]}
className={rating >= 4 ? CLASSNAME_FILLED : CLASSNAME_UNFILLED}
/>
<Icon
icon={[rating === 5 ? "fas" : "far", "star"]}
className={rating === 5 ? CLASSNAME_FILLED : CLASSNAME_UNFILLED}
/>
</div>
) : (
<></>
);

View File

@@ -23,3 +23,4 @@ export { default as SuccessIcon } from "./SuccessIcon";
export { default as ErrorMessage } from "./ErrorMessage";
export { default as TruncatedText } from "./TruncatedText";
export { BasicCard } from "./BasicCard";
export { RatingStars } from "./RatingStars";

View File

@@ -182,3 +182,17 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active {
max-width: 300px;
}
}
.RatingStars {
&-unfilled {
path {
fill: white;
}
}
&-filled {
path {
fill: gold;
}
}
}