mirror of
https://github.com/stashapp/stash.git
synced 2025-12-09 18:04:33 +01:00
Add gallery wall view, and new lightbox (#1008)
This commit is contained in:
parent
c8bcaaf27d
commit
232a69c518
24 changed files with 979 additions and 216 deletions
|
|
@ -39,7 +39,6 @@
|
|||
"flag-icon-css": "^3.5.0",
|
||||
"flexbin": "^0.2.0",
|
||||
"formik": "^2.2.1",
|
||||
"fslightbox-react": "^1.5.0",
|
||||
"graphql": "^15.4.0",
|
||||
"graphql-tag": "^2.11.0",
|
||||
"i18n-iso-countries": "^6.0.0",
|
||||
|
|
@ -52,11 +51,9 @@
|
|||
"react": "17.0.1",
|
||||
"react-bootstrap": "1.4.0",
|
||||
"react-dom": "17.0.1",
|
||||
"react-images": "0.5.19",
|
||||
"react-intl": "^5.8.8",
|
||||
"react-jw-player": "1.19.1",
|
||||
"react-markdown": "^5.0.2",
|
||||
"react-photo-gallery": "^8.0.0",
|
||||
"react-router-bootstrap": "^0.25.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-hash-link": "^2.2.2",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import { Route, Switch } from "react-router-dom";
|
||||
import { IntlProvider } from "react-intl";
|
||||
import { ToastProvider } from "src/hooks/Toast";
|
||||
import LightboxProvider from "src/hooks/Lightbox/context";
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { fas } from "@fortawesome/free-solid-svg-icons";
|
||||
import "@formatjs/intl-numberformat/polyfill";
|
||||
|
|
@ -53,25 +54,27 @@ export const App: React.FC = () => {
|
|||
<ErrorBoundary>
|
||||
<IntlProvider locale={language} messages={messages} formats={intlFormats}>
|
||||
<ToastProvider>
|
||||
<MainNavbar />
|
||||
<div className="main container-fluid">
|
||||
<Switch>
|
||||
<Route exact path="/" component={Stats} />
|
||||
<Route path="/scenes" component={Scenes} />
|
||||
<Route path="/images" component={Images} />
|
||||
<Route path="/galleries" component={Galleries} />
|
||||
<Route path="/performers" component={Performers} />
|
||||
<Route path="/tags" component={Tags} />
|
||||
<Route path="/studios" component={Studios} />
|
||||
<Route path="/movies" component={Movies} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route
|
||||
path="/sceneFilenameParser"
|
||||
component={SceneFilenameParser}
|
||||
/>
|
||||
<Route component={PageNotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
<LightboxProvider>
|
||||
<MainNavbar />
|
||||
<div className="main container-fluid">
|
||||
<Switch>
|
||||
<Route exact path="/" component={Stats} />
|
||||
<Route path="/scenes" component={Scenes} />
|
||||
<Route path="/images" component={Images} />
|
||||
<Route path="/galleries" component={Galleries} />
|
||||
<Route path="/performers" component={Performers} />
|
||||
<Route path="/tags" component={Tags} />
|
||||
<Route path="/studios" component={Studios} />
|
||||
<Route path="/movies" component={Movies} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route
|
||||
path="/sceneFilenameParser"
|
||||
component={SceneFilenameParser}
|
||||
/>
|
||||
<Route component={PageNotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
</LightboxProvider>
|
||||
</ToastProvider>
|
||||
</IntlProvider>
|
||||
</ErrorBoundary>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
68
ui/v2.5/src/components/Galleries/GalleryWallCard.tsx
Normal file
68
ui/v2.5/src/components/Galleries/GalleryWallCard.tsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
35
ui/v2.5/src/components/Shared/RatingStars.tsx
Normal file
35
ui/v2.5/src/components/Shared/RatingStars.tsx
Normal 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>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
350
ui/v2.5/src/hooks/Lightbox/Lightbox.tsx
Normal file
350
ui/v2.5/src/hooks/Lightbox/Lightbox.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Button } from "react-bootstrap";
|
||||
import cx from "classnames";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
import { Icon, LoadingIndicator } from "src/components/Shared";
|
||||
|
||||
const CLASSNAME = "Lightbox";
|
||||
const CLASSNAME_HEADER = `${CLASSNAME}-header`;
|
||||
const CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`;
|
||||
const CLASSNAME_DISPLAY = `${CLASSNAME}-display`;
|
||||
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
|
||||
const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`;
|
||||
const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`;
|
||||
const CLASSNAME_NAVBUTTON = `${CLASSNAME}-navbutton`;
|
||||
const CLASSNAME_NAV = `${CLASSNAME}-nav`;
|
||||
const CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`;
|
||||
const CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`;
|
||||
|
||||
type Image = Pick<GQL.Image, "paths">;
|
||||
interface IProps {
|
||||
images: Image[];
|
||||
isVisible: boolean;
|
||||
isLoading: boolean;
|
||||
initialIndex?: number;
|
||||
showNavigation: boolean;
|
||||
pageHeader?: string;
|
||||
pageCallback?: (direction: number) => boolean;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
export const LightboxComponent: React.FC<IProps> = ({
|
||||
images,
|
||||
isVisible,
|
||||
isLoading,
|
||||
initialIndex = 0,
|
||||
showNavigation,
|
||||
pageHeader,
|
||||
pageCallback,
|
||||
hide,
|
||||
}) => {
|
||||
const index = useRef<number | null>(null);
|
||||
const [instantTransition, setInstantTransition] = useState(false);
|
||||
const [isSwitchingPage, setIsSwitchingPage] = useState(false);
|
||||
const [isFullscreen, setFullscreen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const carouselRef = useRef<HTMLDivElement | null>(null);
|
||||
const indicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const navRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSwitchingPage(false);
|
||||
if (index.current === -1) index.current = images.length - 1;
|
||||
}, [images]);
|
||||
|
||||
const disableInstantTransition = debounce(
|
||||
() => setInstantTransition(false),
|
||||
400
|
||||
);
|
||||
const setInstant = useCallback(() => {
|
||||
setInstantTransition(true);
|
||||
disableInstantTransition();
|
||||
}, [disableInstantTransition]);
|
||||
|
||||
const setIndex = useCallback(
|
||||
(i: number) => {
|
||||
if (images.length < 2) return;
|
||||
|
||||
index.current = i;
|
||||
if (carouselRef.current) carouselRef.current.style.left = `${i * -100}vw`;
|
||||
if (indicatorRef.current)
|
||||
indicatorRef.current.innerHTML = `${i + 1} / ${images.length}`;
|
||||
if (navRef.current) {
|
||||
const currentThumb = navRef.current.children[i + 1];
|
||||
if (currentThumb instanceof HTMLImageElement) {
|
||||
const offset =
|
||||
-1 *
|
||||
(currentThumb.offsetLeft -
|
||||
document.documentElement.clientWidth / 2);
|
||||
navRef.current.style.left = `${offset}px`;
|
||||
|
||||
const previouslySelected = navRef.current.getElementsByClassName(
|
||||
CLASSNAME_NAVSELECTED
|
||||
)?.[0];
|
||||
if (previouslySelected)
|
||||
previouslySelected.className = CLASSNAME_NAVIMAGE;
|
||||
|
||||
currentThumb.className = `${CLASSNAME_NAVIMAGE} ${CLASSNAME_NAVSELECTED}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
[images]
|
||||
);
|
||||
|
||||
const selectIndex = (e: React.MouseEvent, i: number) => {
|
||||
setIndex(i);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
if (index.current === null) setIndex(initialIndex);
|
||||
document.body.style.overflow = "hidden";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Mousetrap as any).pause();
|
||||
}
|
||||
}, [initialIndex, isVisible, setIndex]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
if (!isFullscreen) {
|
||||
hide();
|
||||
document.body.style.overflow = "auto";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Mousetrap as any).unpause();
|
||||
} else document.exitFullscreen();
|
||||
}, [isFullscreen, hide]);
|
||||
|
||||
const handleClose = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const { nodeName } = e.target as Node;
|
||||
if (nodeName === "DIV" || nodeName === "PICTURE") close();
|
||||
};
|
||||
|
||||
const handleLeft = useCallback(() => {
|
||||
if (isSwitchingPage || index.current === -1) return;
|
||||
|
||||
if (index.current === 0) {
|
||||
if (pageCallback) {
|
||||
setIsSwitchingPage(true);
|
||||
setIndex(-1);
|
||||
// Check if calling page wants to swap page
|
||||
const repage = pageCallback(-1);
|
||||
if (!repage) {
|
||||
setIsSwitchingPage(false);
|
||||
setIndex(0);
|
||||
}
|
||||
} else setIndex(images.length - 1);
|
||||
} else setIndex((index.current ?? 0) - 1);
|
||||
}, [images, setIndex, pageCallback, isSwitchingPage]);
|
||||
const handleRight = useCallback(() => {
|
||||
if (isSwitchingPage) return;
|
||||
|
||||
if (index.current === images.length - 1) {
|
||||
if (pageCallback) {
|
||||
setIsSwitchingPage(true);
|
||||
setIndex(0);
|
||||
const repage = pageCallback?.(1);
|
||||
if (!repage) {
|
||||
setIsSwitchingPage(false);
|
||||
setIndex(images.length - 1);
|
||||
}
|
||||
} else setIndex(0);
|
||||
} else setIndex((index.current ?? 0) + 1);
|
||||
}, [images, setIndex, pageCallback, isSwitchingPage]);
|
||||
|
||||
const handleKey = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.repeat && (e.key === "ArrowRight" || e.key === "ArrowLeft"))
|
||||
setInstant();
|
||||
if (e.key === "ArrowLeft") handleLeft();
|
||||
else if (e.key === "ArrowRight") handleRight();
|
||||
else if (e.key === "Escape") close();
|
||||
},
|
||||
[setInstant, handleLeft, handleRight, close]
|
||||
);
|
||||
const handleFullScreenChange = () =>
|
||||
setFullscreen(document.fullscreenElement !== null);
|
||||
|
||||
const handleTouchStart = (ev: React.TouchEvent<HTMLDivElement>) => {
|
||||
setInstantTransition(true);
|
||||
|
||||
const el = ev.currentTarget;
|
||||
if (ev.touches.length !== 1) return;
|
||||
|
||||
const startX = ev.touches[0].clientX;
|
||||
let position = 0;
|
||||
|
||||
const resetPosition = () => {
|
||||
if (carouselRef.current)
|
||||
carouselRef.current.style.left = `${(index.current ?? 0) * -100}vw`;
|
||||
};
|
||||
const handleMove = (e: TouchEvent) => {
|
||||
position = e.touches[0].clientX;
|
||||
if (carouselRef.current)
|
||||
carouselRef.current.style.left = `calc(${
|
||||
(index.current ?? 0) * -100
|
||||
}vw + ${e.touches[0].clientX - startX}px)`;
|
||||
};
|
||||
const handleEnd = () => {
|
||||
const diff = position - startX;
|
||||
if (diff <= -50) handleRight();
|
||||
else if (diff >= 50) handleLeft();
|
||||
else resetPosition();
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
cleanup();
|
||||
};
|
||||
const handleCancel = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
cleanup();
|
||||
resetPosition();
|
||||
};
|
||||
const cleanup = () => {
|
||||
el.removeEventListener("touchmove", handleMove);
|
||||
el.removeEventListener("touchend", handleEnd);
|
||||
el.removeEventListener("touchcancel", handleCancel);
|
||||
setInstantTransition(false);
|
||||
};
|
||||
|
||||
el.addEventListener("touchmove", handleMove);
|
||||
el.addEventListener("touchend", handleEnd);
|
||||
el.addEventListener("touchcancel", handleCancel);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
document.addEventListener("keydown", handleKey);
|
||||
document.addEventListener("fullscreenchange", handleFullScreenChange);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
document.removeEventListener("fullscreenchange", handleFullScreenChange);
|
||||
};
|
||||
}, [isVisible, handleKey]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!isFullscreen) containerRef.current?.requestFullscreen();
|
||||
else document.exitFullscreen();
|
||||
}, [isFullscreen]);
|
||||
|
||||
const navItems = images.map((image, i) => (
|
||||
<img
|
||||
src={image.paths.thumbnail ?? ""}
|
||||
alt=""
|
||||
className={cx(CLASSNAME_NAVIMAGE, {
|
||||
[CLASSNAME_NAVSELECTED]: i === index.current,
|
||||
})}
|
||||
onClick={(e: React.MouseEvent) => selectIndex(e, i)}
|
||||
role="presentation"
|
||||
loading="lazy"
|
||||
key={image.paths.thumbnail}
|
||||
/>
|
||||
));
|
||||
|
||||
const currentIndex = index.current === null ? initialIndex : index.current;
|
||||
|
||||
const element = isVisible ? (
|
||||
<div
|
||||
className={CLASSNAME}
|
||||
role="presentation"
|
||||
ref={containerRef}
|
||||
onClick={handleClose}
|
||||
>
|
||||
{images.length > 0 && !isLoading && !isSwitchingPage ? (
|
||||
<>
|
||||
<div className={CLASSNAME_HEADER}>
|
||||
<div className={CLASSNAME_INDICATOR}>
|
||||
<span>{pageHeader}</span>
|
||||
<b ref={indicatorRef}>
|
||||
{`${currentIndex + 1} / ${images.length}`}
|
||||
</b>
|
||||
</div>
|
||||
{document.fullscreenEnabled && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={toggleFullscreen}
|
||||
title="Toggle Fullscreen"
|
||||
>
|
||||
<Icon icon="expand" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => close()}
|
||||
title="Close Lightbox"
|
||||
>
|
||||
<Icon icon="times" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={CLASSNAME_DISPLAY} onTouchStart={handleTouchStart}>
|
||||
{images.length > 1 && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={handleLeft}
|
||||
className={`${CLASSNAME_NAVBUTTON} d-none d-lg-block`}
|
||||
>
|
||||
<Icon icon="chevron-left" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cx(CLASSNAME_CAROUSEL, {
|
||||
[CLASSNAME_INSTANT]: instantTransition,
|
||||
})}
|
||||
style={{ left: `${currentIndex * -100}vw` }}
|
||||
ref={carouselRef}
|
||||
>
|
||||
{images.map((image) => (
|
||||
<div className={CLASSNAME_IMAGE} key={image.paths.image}>
|
||||
<picture>
|
||||
<source
|
||||
srcSet={image.paths.image ?? ""}
|
||||
media="(min-width: 800px)"
|
||||
/>
|
||||
<img src={image.paths.thumbnail ?? ""} alt="" />
|
||||
</picture>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={handleRight}
|
||||
className={`${CLASSNAME_NAVBUTTON} d-none d-lg-block`}
|
||||
>
|
||||
<Icon icon="chevron-right" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showNavigation && !isFullscreen && images.length > 1 && (
|
||||
<div className={CLASSNAME_NAV} ref={navRef}>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setIndex(images.length - 1)}
|
||||
className={CLASSNAME_NAVBUTTON}
|
||||
>
|
||||
<Icon icon="arrow-left" className="mr-4" />
|
||||
</Button>
|
||||
{navItems}
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setIndex(0)}
|
||||
className={CLASSNAME_NAVBUTTON}
|
||||
>
|
||||
<Icon icon="arrow-right" className="ml-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
|
||||
return element;
|
||||
};
|
||||
54
ui/v2.5/src/hooks/Lightbox/context.tsx
Normal file
54
ui/v2.5/src/hooks/Lightbox/context.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useCallback, useState } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { LightboxComponent } from "./Lightbox";
|
||||
|
||||
type Image = Pick<GQL.Image, "paths">;
|
||||
|
||||
export interface IState {
|
||||
images: Image[];
|
||||
isVisible: boolean;
|
||||
isLoading: boolean;
|
||||
showNavigation: boolean;
|
||||
initialIndex?: number;
|
||||
pageCallback?: (direction: number) => boolean;
|
||||
pageHeader?: string;
|
||||
}
|
||||
interface IContext {
|
||||
setLightboxState: (state: Partial<IState>) => void;
|
||||
}
|
||||
|
||||
export const LightboxContext = React.createContext<IContext>({
|
||||
setLightboxState: () => {},
|
||||
});
|
||||
const Lightbox: React.FC = ({ children }) => {
|
||||
const [lightboxState, setLightboxState] = useState<IState>({
|
||||
images: [],
|
||||
isVisible: false,
|
||||
isLoading: false,
|
||||
showNavigation: true,
|
||||
});
|
||||
|
||||
const setPartialState = useCallback(
|
||||
(state: Partial<IState>) => {
|
||||
setLightboxState((currentState: IState) => ({
|
||||
...currentState,
|
||||
...state,
|
||||
}));
|
||||
},
|
||||
[setLightboxState]
|
||||
);
|
||||
|
||||
return (
|
||||
<LightboxContext.Provider value={{ setLightboxState: setPartialState }}>
|
||||
{children}
|
||||
{lightboxState.isVisible && (
|
||||
<LightboxComponent
|
||||
{...lightboxState}
|
||||
hide={() => setLightboxState({ ...lightboxState, isVisible: false })}
|
||||
/>
|
||||
)}
|
||||
</LightboxContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Lightbox;
|
||||
73
ui/v2.5/src/hooks/Lightbox/hooks.ts
Normal file
73
ui/v2.5/src/hooks/Lightbox/hooks.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { useCallback, useContext, useEffect } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { LightboxContext, IState } from "./context";
|
||||
|
||||
export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
|
||||
const { setLightboxState } = useContext(LightboxContext);
|
||||
|
||||
useEffect(() => {
|
||||
setLightboxState({
|
||||
images: state.images,
|
||||
showNavigation: state.showNavigation,
|
||||
pageCallback: state.pageCallback,
|
||||
initialIndex: state.initialIndex,
|
||||
pageHeader: state.pageHeader,
|
||||
});
|
||||
}, [
|
||||
setLightboxState,
|
||||
state.images,
|
||||
state.showNavigation,
|
||||
state.pageCallback,
|
||||
state.initialIndex,
|
||||
state.pageHeader,
|
||||
]);
|
||||
|
||||
const show = useCallback(
|
||||
(index?: number) => {
|
||||
setLightboxState({
|
||||
initialIndex: index,
|
||||
isVisible: true,
|
||||
});
|
||||
},
|
||||
[setLightboxState]
|
||||
);
|
||||
return show;
|
||||
};
|
||||
|
||||
export const useGalleryLightbox = (id: string) => {
|
||||
const { setLightboxState } = useContext(LightboxContext);
|
||||
const [fetchGallery, { data }] = GQL.useFindGalleryLazyQuery({
|
||||
variables: { id },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data)
|
||||
setLightboxState({
|
||||
images: data.findGallery?.images ?? [],
|
||||
isLoading: false,
|
||||
isVisible: true,
|
||||
});
|
||||
}, [setLightboxState, data]);
|
||||
|
||||
const show = () => {
|
||||
if (data)
|
||||
setLightboxState({
|
||||
isLoading: false,
|
||||
isVisible: true,
|
||||
images: data.findGallery?.images ?? [],
|
||||
pageCallback: undefined,
|
||||
pageHeader: undefined,
|
||||
});
|
||||
else {
|
||||
setLightboxState({
|
||||
isLoading: true,
|
||||
isVisible: true,
|
||||
pageCallback: undefined,
|
||||
pageHeader: undefined,
|
||||
});
|
||||
fetchGallery();
|
||||
}
|
||||
};
|
||||
|
||||
return show;
|
||||
};
|
||||
2
ui/v2.5/src/hooks/Lightbox/index.ts
Normal file
2
ui/v2.5/src/hooks/Lightbox/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./context";
|
||||
export * from "./hooks";
|
||||
124
ui/v2.5/src/hooks/Lightbox/lightbox.scss
Normal file
124
ui/v2.5/src/hooks/Lightbox/lightbox.scss
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
.Lightbox {
|
||||
background-color: rgba(20, 20, 20, 0.8);
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1040;
|
||||
|
||||
.fa-icon {
|
||||
path {
|
||||
fill: white;
|
||||
}
|
||||
opacity: 0.4;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 4rem;
|
||||
|
||||
&-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 49%;
|
||||
margin-right: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fa-icon {
|
||||
height: 1.5rem;
|
||||
opacity: 1;
|
||||
width: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-display {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&-carousel {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
transition: left 400ms;
|
||||
|
||||
&-instant {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
&-image {
|
||||
content-visibility: auto;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100vw;
|
||||
|
||||
picture {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
img {
|
||||
margin: auto;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-navbutton {
|
||||
z-index: 1045;
|
||||
|
||||
.fa-icon {
|
||||
height: 4rem;
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: drop-shadow(2px 2px 2px black);
|
||||
}
|
||||
}
|
||||
|
||||
&-nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
height: 10rem;
|
||||
margin: 0 auto 2rem 0;
|
||||
padding: 0 10rem;
|
||||
position: relative;
|
||||
transition: left 400ms;
|
||||
|
||||
@media (max-height: 800px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-selected {
|
||||
box-shadow: 0 0 0 6px white;
|
||||
}
|
||||
|
||||
&-image {
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,7 @@ interface IListHookData {
|
|||
filter: ListFilterModel;
|
||||
template: React.ReactElement;
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
|
||||
onChangePage: (page: number) => void;
|
||||
}
|
||||
|
||||
export interface IListHookOperation<T> {
|
||||
|
|
@ -92,7 +93,9 @@ interface IListHookOptions<T, E> {
|
|||
result: T,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>,
|
||||
zoomIndex: number
|
||||
zoomIndex: number,
|
||||
onChangePage: (page: number) => void,
|
||||
pageCount: number
|
||||
) => React.ReactNode;
|
||||
renderEditDialog?: (
|
||||
selected: E[],
|
||||
|
|
@ -350,10 +353,19 @@ const RenderList = <
|
|||
return;
|
||||
}
|
||||
|
||||
const pages = Math.ceil(totalCount / filter.itemsPerPage);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderPagination()}
|
||||
{renderContent(result, filter, selectedIds, zoomIndex)}
|
||||
{renderContent(
|
||||
result,
|
||||
filter,
|
||||
selectedIds,
|
||||
zoomIndex,
|
||||
onChangePage,
|
||||
pages
|
||||
)}
|
||||
<PaginationIndex
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
|
|
@ -525,6 +537,7 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
|
|||
filter,
|
||||
template,
|
||||
onSelectChange,
|
||||
onChangePage,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,3 +8,4 @@ export {
|
|||
useStudiosList,
|
||||
usePerformersList,
|
||||
} from "./ListHook";
|
||||
export { useLightbox, useGalleryLightbox } from "./Lightbox";
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
@import "src/components/Wall/styles.scss";
|
||||
@import "../node_modules/flag-icon-css/css/flag-icon.min.css";
|
||||
@import "src/components/Tagger/styles.scss";
|
||||
@import "src/hooks/Lightbox/lightbox.scss";
|
||||
|
||||
/* stylelint-disable */
|
||||
#root {
|
||||
|
|
|
|||
|
|
@ -248,6 +248,11 @@ export class ListFilterModel {
|
|||
new PerformersCriterionOption(),
|
||||
new StudiosCriterionOption(),
|
||||
];
|
||||
this.displayModeOptions = [
|
||||
DisplayMode.Grid,
|
||||
DisplayMode.List,
|
||||
DisplayMode.Wall,
|
||||
];
|
||||
break;
|
||||
case FilterMode.SceneMarkers:
|
||||
this.sortBy = "title";
|
||||
|
|
|
|||
|
|
@ -3281,14 +3281,6 @@ anymatch@^3.0.3, anymatch@~3.1.1:
|
|||
normalize-path "^3.0.0"
|
||||
picomatch "^2.0.4"
|
||||
|
||||
aphrodite@^0.5.0:
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/aphrodite/-/aphrodite-0.5.0.tgz#a4b9a8902662395d2702e70ac7a2b4ca66f25703"
|
||||
integrity sha1-pLmokCZiOV0nAucKx6K0ymbyVwM=
|
||||
dependencies:
|
||||
asap "^2.0.3"
|
||||
inline-style-prefixer "^2.0.0"
|
||||
|
||||
apollo-upload-client@^14.1.2:
|
||||
version "14.1.2"
|
||||
resolved "https://registry.yarnpkg.com/apollo-upload-client/-/apollo-upload-client-14.1.2.tgz#7a72b000f1cd67eaf8f12b4bda2796d0898c0dae"
|
||||
|
|
@ -3419,7 +3411,7 @@ arrify@^2.0.1:
|
|||
resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
|
||||
integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
|
||||
|
||||
asap@^2.0.3, asap@~2.0.3, asap@~2.0.6:
|
||||
asap@~2.0.3, asap@~2.0.6:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
|
||||
integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=
|
||||
|
|
@ -3948,11 +3940,6 @@ bootstrap@^4.5.3:
|
|||
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.5.3.tgz#c6a72b355aaf323920be800246a6e4ef30997fe6"
|
||||
integrity sha512-o9ppKQioXGqhw8Z7mah6KdTYpNQY//tipnkxppWhPbiSWdD+1raYsnhwEZjkTHYbGee4cVQ0Rx65EhOY/HNLcQ==
|
||||
|
||||
bowser@^1.0.0:
|
||||
version "1.9.4"
|
||||
resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a"
|
||||
integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
|
||||
|
|
@ -5544,13 +5531,6 @@ dom-converter@^0.2:
|
|||
dependencies:
|
||||
utila "~0.4"
|
||||
|
||||
dom-helpers@^3.4.0:
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8"
|
||||
integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.1.2"
|
||||
|
||||
dom-helpers@^5.0.1, dom-helpers@^5.1.2, dom-helpers@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.0.tgz#57fd054c5f8f34c52a3eeffdb7e7e93cd357d95b"
|
||||
|
|
@ -6285,11 +6265,6 @@ execall@^2.0.0:
|
|||
dependencies:
|
||||
clone-regexp "^2.1.0"
|
||||
|
||||
exenv@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
|
||||
integrity sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=
|
||||
|
||||
exif-parser@^0.1.12:
|
||||
version "0.1.12"
|
||||
resolved "https://registry.yarnpkg.com/exif-parser/-/exif-parser-0.1.12.tgz#58a9d2d72c02c1f6f02a0ef4a9166272b7760922"
|
||||
|
|
@ -6873,11 +6848,6 @@ fsevents@^2.1.2, fsevents@^2.1.3, fsevents@~2.1.2:
|
|||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
|
||||
integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
|
||||
|
||||
fslightbox-react@^1.5.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/fslightbox-react/-/fslightbox-react-1.5.0.tgz#07cf41d7ff8b02a79a0886d13519550b79dc50e5"
|
||||
integrity sha512-xBe1K06pa3opWar/xBtArsHMnxMJWsmg5EmNdDtheDL9nMCqk2AXYlNnstfYVqtJJjqNReqeL21wc52Yy4rwWg==
|
||||
|
||||
fstream@^1.0.0, fstream@^1.0.12:
|
||||
version "1.0.12"
|
||||
resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045"
|
||||
|
|
@ -7541,11 +7511,6 @@ human-signals@^1.1.1:
|
|||
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
|
||||
integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
|
||||
|
||||
hyphenate-style-name@^1.0.1:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d"
|
||||
integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==
|
||||
|
||||
i18n-iso-countries@^6.0.0:
|
||||
version "6.2.2"
|
||||
resolved "https://registry.yarnpkg.com/i18n-iso-countries/-/i18n-iso-countries-6.2.2.tgz#6b63d00e90ee4022e8c159a9e688d2a8156b0e0b"
|
||||
|
|
@ -7744,14 +7709,6 @@ ini@^1.3.5, ini@~1.3.0:
|
|||
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
|
||||
integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
|
||||
|
||||
inline-style-prefixer@^2.0.0:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/inline-style-prefixer/-/inline-style-prefixer-2.0.5.tgz#c153c7e88fd84fef5c602e95a8168b2770671fe7"
|
||||
integrity sha1-wVPH6I/YT+9cYC6VqBaLJ3BnH+c=
|
||||
dependencies:
|
||||
bowser "^1.0.0"
|
||||
hyphenate-style-name "^1.0.1"
|
||||
|
||||
inquirer@7.3.3, inquirer@^7.3.3:
|
||||
version "7.3.3"
|
||||
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003"
|
||||
|
|
@ -11903,7 +11860,7 @@ prop-types-extra@^1.1.0:
|
|||
react-is "^16.3.2"
|
||||
warning "^4.0.0"
|
||||
|
||||
prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@~15.7.2:
|
||||
prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2:
|
||||
version "15.7.2"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
|
||||
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
|
||||
|
|
@ -12176,16 +12133,6 @@ react-fast-compare@^2.0.1:
|
|||
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"
|
||||
integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==
|
||||
|
||||
react-images@0.5.19:
|
||||
version "0.5.19"
|
||||
resolved "https://registry.yarnpkg.com/react-images/-/react-images-0.5.19.tgz#9339570029e065f9f28a19f03fdb5d9d5aa109d3"
|
||||
integrity sha512-B3d4W1uFJj+m17K8S65iAyEJShKGBjPk7n7N1YsPiAydEm8mIq9a6CoeQFMY1d7N2QMs6FBCjT9vELyc5jP5JA==
|
||||
dependencies:
|
||||
aphrodite "^0.5.0"
|
||||
prop-types "^15.6.0"
|
||||
react-scrolllock "^2.0.1"
|
||||
react-transition-group "2"
|
||||
|
||||
react-input-autosize@^2.2.2:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.2.tgz#fcaa7020568ec206bc04be36f4eb68e647c4d8c2"
|
||||
|
|
@ -12263,19 +12210,6 @@ react-overlays@^4.1.0:
|
|||
uncontrollable "^7.0.0"
|
||||
warning "^4.0.3"
|
||||
|
||||
react-photo-gallery@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/react-photo-gallery/-/react-photo-gallery-8.0.0.tgz#04ff9f902a2342660e63e6817b4f010488db02b8"
|
||||
integrity sha512-Y9458yygEB9cIZAWlBWuenlR+ghin1RopmmU3Vice8BeJl0Se7hzfxGDq8W1armB/ic/kphGg+G1jq5fOEd0sw==
|
||||
dependencies:
|
||||
prop-types "~15.7.2"
|
||||
resize-observer-polyfill "^1.5.0"
|
||||
|
||||
react-prop-toggle@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-prop-toggle/-/react-prop-toggle-1.0.2.tgz#8b0b7e74653606b1427cfcf6c4eaa9198330568e"
|
||||
integrity sha512-JmerjAXs7qJ959+d0Ygt7Cb2+4fG+n3I2VXO6JO0AcAY1vkRN/JpZKAN67CMXY889xEJcfylmMPhzvf6nWO68Q==
|
||||
|
||||
react-refresh@^0.8.3:
|
||||
version "0.8.3"
|
||||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
|
||||
|
|
@ -12389,14 +12323,6 @@ react-scripts@^4.0.0:
|
|||
optionalDependencies:
|
||||
fsevents "^2.1.3"
|
||||
|
||||
react-scrolllock@^2.0.1:
|
||||
version "2.0.7"
|
||||
resolved "https://registry.yarnpkg.com/react-scrolllock/-/react-scrolllock-2.0.7.tgz#3b879e1fe308fc900ab76e226e9be594c41226fd"
|
||||
integrity sha512-Gzpu8+ulxdYcybAgJOFTXc70xs7SBZDQbZNpKzchZUgLCJKjz6lrgESx6LHHZgfELx1xYL4yHu3kYQGQPFas/g==
|
||||
dependencies:
|
||||
exenv "^1.2.2"
|
||||
react-prop-toggle "^1.0.2"
|
||||
|
||||
react-select@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/react-select/-/react-select-3.1.0.tgz#ab098720b2e9fe275047c993f0d0caf5ded17c27"
|
||||
|
|
@ -12411,16 +12337,6 @@ react-select@^3.1.0:
|
|||
react-input-autosize "^2.2.2"
|
||||
react-transition-group "^4.3.0"
|
||||
|
||||
react-transition-group@2:
|
||||
version "2.9.0"
|
||||
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
|
||||
integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==
|
||||
dependencies:
|
||||
dom-helpers "^3.4.0"
|
||||
loose-envify "^1.4.0"
|
||||
prop-types "^15.6.2"
|
||||
react-lifecycles-compat "^3.0.4"
|
||||
|
||||
react-transition-group@^4.3.0, react-transition-group@^4.4.1:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9"
|
||||
|
|
@ -12865,11 +12781,6 @@ requires-port@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
|
||||
integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
|
||||
|
||||
resize-observer-polyfill@^1.5.0:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
|
||||
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
|
||||
|
||||
resolve-cwd@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a"
|
||||
|
|
|
|||
Loading…
Reference in a new issue