mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Port Movies UI to v2.5 (#397)
* Ignore generated-graphql.tsx in 2.5 * Make movie name mandatory * Port #395 fix to v2.5 * Differentiate front/back image browse buttons * Move URL, Synopsis to separate rows * Fix unknown query params crashing UI
This commit is contained in:
parent
5aa6dec8dc
commit
ff495361d9
39 changed files with 1663 additions and 5911 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -21,6 +21,7 @@
|
||||||
# GraphQL generated output
|
# GraphQL generated output
|
||||||
pkg/models/generated_*.go
|
pkg/models/generated_*.go
|
||||||
ui/v2/src/core/generated-*.tsx
|
ui/v2/src/core/generated-*.tsx
|
||||||
|
ui/v2.5/src/core/generated-*.tsx
|
||||||
|
|
||||||
# packr generated files
|
# packr generated files
|
||||||
*-packr.go
|
*-packr.go
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
type Movie {
|
type Movie {
|
||||||
id: ID!
|
id: ID!
|
||||||
checksum: String!
|
checksum: String!
|
||||||
name: String
|
name: String!
|
||||||
aliases: String
|
aliases: String
|
||||||
duration: String
|
duration: String
|
||||||
date: String
|
date: String
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import (
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *movieResolver) Name(ctx context.Context, obj *models.Movie) (*string, error) {
|
func (r *movieResolver) Name(ctx context.Context, obj *models.Movie) (string, error) {
|
||||||
if obj.Name.Valid {
|
if obj.Name.Valid {
|
||||||
return &obj.Name.String, nil
|
return obj.Name.String, nil
|
||||||
}
|
}
|
||||||
return nil, nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *movieResolver) URL(ctx context.Context, obj *models.Movie) (*string, error) {
|
func (r *movieResolver) URL(ctx context.Context, obj *models.Movie) (*string, error) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { Stats } from "./components/Stats";
|
||||||
import Studios from "./components/Studios/Studios";
|
import Studios from "./components/Studios/Studios";
|
||||||
import { TagList } from "./components/Tags/TagList";
|
import { TagList } from "./components/Tags/TagList";
|
||||||
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
|
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
|
||||||
|
import Movies from "./components/Movies/Movies";
|
||||||
|
|
||||||
// Set fontawesome/free-solid-svg as default fontawesome icons
|
// Set fontawesome/free-solid-svg as default fontawesome icons
|
||||||
library.add(fas);
|
library.add(fas);
|
||||||
|
|
@ -43,6 +44,7 @@ export const App: React.FC = () => {
|
||||||
<Route path="/performers" component={Performers} />
|
<Route path="/performers" component={Performers} />
|
||||||
<Route path="/tags" component={TagList} />
|
<Route path="/tags" component={TagList} />
|
||||||
<Route path="/studios" component={Studios} />
|
<Route path="/studios" component={Studios} />
|
||||||
|
<Route path="/movies" component={Movies} />
|
||||||
<Route path="/settings" component={Settings} />
|
<Route path="/settings" component={Settings} />
|
||||||
<Route
|
<Route
|
||||||
path="/sceneFilenameParser"
|
path="/sceneFilenameParser"
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,8 @@ export const AddFilter: React.FC<IAddFilterProps> = (
|
||||||
if (
|
if (
|
||||||
criterion.type !== "performers" &&
|
criterion.type !== "performers" &&
|
||||||
criterion.type !== "studios" &&
|
criterion.type !== "studios" &&
|
||||||
criterion.type !== "tags"
|
criterion.type !== "tags" &&
|
||||||
|
criterion.type !== "movies"
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ const menuItems: IMenuItem[] = [
|
||||||
messageID: "scenes",
|
messageID: "scenes",
|
||||||
href: "/scenes"
|
href: "/scenes"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/movies",
|
||||||
|
icon: "film",
|
||||||
|
messageID: "movies"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/scenes/markers",
|
href: "/scenes/markers",
|
||||||
icon: "map-marker-alt",
|
icon: "map-marker-alt",
|
||||||
|
|
@ -79,6 +84,8 @@ export const MainNavbar: React.FC = () => {
|
||||||
? "/performers/new"
|
? "/performers/new"
|
||||||
: location.pathname === "/studios"
|
: location.pathname === "/studios"
|
||||||
? "/studios/new"
|
? "/studios/new"
|
||||||
|
: location.pathname === "/movies"
|
||||||
|
? "/movies/new"
|
||||||
: null;
|
: null;
|
||||||
const newButton =
|
const newButton =
|
||||||
path === null ? (
|
path === null ? (
|
||||||
|
|
@ -98,7 +105,7 @@ export const MainNavbar: React.FC = () => {
|
||||||
variant="dark"
|
variant="dark"
|
||||||
bg="dark"
|
bg="dark"
|
||||||
className="top-nav"
|
className="top-nav"
|
||||||
expand="md"
|
expand="lg"
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onToggle={setExpanded}
|
onToggle={setExpanded}
|
||||||
ref={navbarRef}
|
ref={navbarRef}
|
||||||
|
|
|
||||||
51
ui/v2.5/src/components/Movies/MovieCard.tsx
Normal file
51
ui/v2.5/src/components/Movies/MovieCard.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { Card } from "react-bootstrap";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
movie: GQL.MovieDataFragment;
|
||||||
|
sceneIndex?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
function maybeRenderRatingBanner() {
|
||||||
|
if (!props.movie.rating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rating-banner ${
|
||||||
|
props.movie.rating ? `rating-${props.movie.rating}` : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
RATING: {props.movie.rating}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderSceneNumber() {
|
||||||
|
if (!props.sceneIndex) {
|
||||||
|
return <span>{props.movie.scene_count} scenes.</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span>Scene number: {props.sceneIndex}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="movie-card">
|
||||||
|
<Link to={`/movies/${props.movie.id}`} className="movie-card-header">
|
||||||
|
<img
|
||||||
|
className="movie-card-image"
|
||||||
|
alt={props.movie.name ?? ""}
|
||||||
|
src={props.movie.front_image_path ?? ""}
|
||||||
|
/>
|
||||||
|
{maybeRenderRatingBanner()}
|
||||||
|
</Link>
|
||||||
|
<div className="card-section">
|
||||||
|
<h5 className="text-truncate">{props.movie.name}</h5>
|
||||||
|
{maybeRenderSceneNumber()}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
282
ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx
Normal file
282
ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
/* eslint-disable react/no-this-in-sfc */
|
||||||
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { StashService } from "src/core/StashService";
|
||||||
|
import { useParams, useHistory } from "react-router-dom";
|
||||||
|
import cx from "classnames";
|
||||||
|
import {
|
||||||
|
DetailsEditNavbar,
|
||||||
|
LoadingIndicator,
|
||||||
|
Modal
|
||||||
|
} from "src/components/Shared";
|
||||||
|
import { useToast } from "src/hooks";
|
||||||
|
import { Table, Form } from "react-bootstrap";
|
||||||
|
import { TableUtils, ImageUtils } from "src/utils";
|
||||||
|
import { MovieScenesPanel } from "./MovieScenesPanel";
|
||||||
|
|
||||||
|
export const Movie: React.FC = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const Toast = useToast();
|
||||||
|
const { id = "new" } = useParams();
|
||||||
|
const isNew = id === "new";
|
||||||
|
|
||||||
|
// Editing state
|
||||||
|
const [isEditing, setIsEditing] = useState<boolean>(isNew);
|
||||||
|
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Editing movie state
|
||||||
|
const [frontImage, setFrontImage] = useState<string | undefined>(undefined);
|
||||||
|
const [backImage, setBackImage] = useState<string | undefined>(undefined);
|
||||||
|
const [name, setName] = useState<string | undefined>(undefined);
|
||||||
|
const [aliases, setAliases] = useState<string | undefined>(undefined);
|
||||||
|
const [duration, setDuration] = useState<string | undefined>(undefined);
|
||||||
|
const [date, setDate] = useState<string | undefined>(undefined);
|
||||||
|
const [rating, setRating] = useState<string | undefined>(undefined);
|
||||||
|
const [director, setDirector] = useState<string | undefined>(undefined);
|
||||||
|
const [synopsis, setSynopsis] = useState<string | undefined>(undefined);
|
||||||
|
const [url, setUrl] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// Movie state
|
||||||
|
const [movie, setMovie] = useState<Partial<GQL.MovieDataFragment>>({});
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [backimagePreview, setBackImagePreview] = useState<string | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Network state
|
||||||
|
const { data, error, loading } = StashService.useFindMovie(id);
|
||||||
|
const [updateMovie] = StashService.useMovieUpdate(
|
||||||
|
getMovieInput() as GQL.MovieUpdateInput
|
||||||
|
);
|
||||||
|
const [createMovie] = StashService.useMovieCreate(
|
||||||
|
getMovieInput() as GQL.MovieCreateInput
|
||||||
|
);
|
||||||
|
const [deleteMovie] = StashService.useMovieDestroy(
|
||||||
|
getMovieInput() as GQL.MovieDestroyInput
|
||||||
|
);
|
||||||
|
|
||||||
|
function updateMovieEditState(state: Partial<GQL.MovieDataFragment>) {
|
||||||
|
setName(state.name ?? undefined);
|
||||||
|
setAliases(state.aliases ?? undefined);
|
||||||
|
setDuration(state.duration ?? undefined);
|
||||||
|
setDate(state.date ?? undefined);
|
||||||
|
setRating(state.rating ?? undefined);
|
||||||
|
setDirector(state.director ?? undefined);
|
||||||
|
setSynopsis(state.synopsis ?? undefined);
|
||||||
|
setUrl(state.url ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMovieData = useCallback(
|
||||||
|
(movieData: Partial<GQL.MovieDataFragment>) => {
|
||||||
|
setFrontImage(undefined);
|
||||||
|
setBackImage(undefined);
|
||||||
|
updateMovieEditState(movieData);
|
||||||
|
setImagePreview(movieData.front_image_path ?? undefined);
|
||||||
|
setBackImagePreview(movieData.back_image_path ?? undefined);
|
||||||
|
setMovie(movieData);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && data.findMovie) {
|
||||||
|
updateMovieData(data.findMovie);
|
||||||
|
}
|
||||||
|
}, [data, updateMovieData]);
|
||||||
|
|
||||||
|
function onImageLoad(this: FileReader) {
|
||||||
|
setImagePreview(this.result as string);
|
||||||
|
setFrontImage(this.result as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackImageLoad(this: FileReader) {
|
||||||
|
setBackImagePreview(this.result as string);
|
||||||
|
setBackImage(this.result as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageUtils.usePasteImage(onImageLoad);
|
||||||
|
ImageUtils.usePasteImage(onBackImageLoad);
|
||||||
|
|
||||||
|
if (!isNew && !isEditing) {
|
||||||
|
if (!data || !data.findMovie || loading) return <LoadingIndicator />;
|
||||||
|
if (error) {
|
||||||
|
return <>{error!.message}</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMovieInput() {
|
||||||
|
const input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
|
||||||
|
name,
|
||||||
|
aliases,
|
||||||
|
duration,
|
||||||
|
date,
|
||||||
|
rating,
|
||||||
|
director,
|
||||||
|
synopsis,
|
||||||
|
url,
|
||||||
|
front_image: frontImage,
|
||||||
|
back_image: backImage
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isNew) {
|
||||||
|
(input as GQL.MovieUpdateInput).id = id;
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
try {
|
||||||
|
if (!isNew) {
|
||||||
|
const result = await updateMovie();
|
||||||
|
if (result.data?.movieUpdate) {
|
||||||
|
updateMovieData(result.data.movieUpdate);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await createMovie();
|
||||||
|
if (result.data?.movieCreate?.id) {
|
||||||
|
history.push(`/movies/${result.data.movieCreate.id}`);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete() {
|
||||||
|
try {
|
||||||
|
await deleteMovie();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirect to movies page
|
||||||
|
history.push(`/movies`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||||
|
ImageUtils.onImageChange(event, onImageLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||||
|
ImageUtils.onImageChange(event, onBackImageLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeleteAlert() {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
show={isDeleteAlertOpen}
|
||||||
|
icon="trash-alt"
|
||||||
|
accept={{ text: "Delete", variant: "danger", onClick: onDelete }}
|
||||||
|
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
|
||||||
|
>
|
||||||
|
<p>Are you sure you want to delete {movie.name ?? "movie"}?</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: CSS class
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div
|
||||||
|
className={cx("movie-details", {
|
||||||
|
"col ml-sm-5": !isNew,
|
||||||
|
"col-8": isNew
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isNew && <h2>Add Movie</h2>}
|
||||||
|
<div className="logo w-100">
|
||||||
|
<img alt={name} className="logo w-50" src={imagePreview} />
|
||||||
|
<img alt={name} className="logo w-50" src={backimagePreview} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<tbody>
|
||||||
|
{TableUtils.renderInputGroup({
|
||||||
|
title: "Name",
|
||||||
|
value: movie.name ?? "",
|
||||||
|
isEditing: !!isEditing,
|
||||||
|
onChange: setName
|
||||||
|
})}
|
||||||
|
{TableUtils.renderInputGroup({
|
||||||
|
title: "Aliases",
|
||||||
|
value: aliases,
|
||||||
|
isEditing,
|
||||||
|
onChange: setAliases
|
||||||
|
})}
|
||||||
|
{TableUtils.renderInputGroup({
|
||||||
|
title: "Duration",
|
||||||
|
value: duration,
|
||||||
|
isEditing,
|
||||||
|
onChange: setDuration
|
||||||
|
})}
|
||||||
|
{TableUtils.renderInputGroup({
|
||||||
|
title: "Date (YYYY-MM-DD)",
|
||||||
|
value: date,
|
||||||
|
isEditing,
|
||||||
|
onChange: setDate
|
||||||
|
})}
|
||||||
|
{TableUtils.renderInputGroup({
|
||||||
|
title: "Director",
|
||||||
|
value: director,
|
||||||
|
isEditing,
|
||||||
|
onChange: setDirector
|
||||||
|
})}
|
||||||
|
{TableUtils.renderHtmlSelect({
|
||||||
|
title: "Rating",
|
||||||
|
value: rating,
|
||||||
|
isEditing,
|
||||||
|
onChange: (value: string) => setRating(value),
|
||||||
|
selectOptions: ["", "1", "2", "3", "4", "5"]
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Form.Group controlId="url">
|
||||||
|
<Form.Label>URL</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
readOnly={!isEditing}
|
||||||
|
onChange={(newValue: React.FormEvent<HTMLTextAreaElement>) =>
|
||||||
|
setUrl(newValue.currentTarget.value)
|
||||||
|
}
|
||||||
|
value={url}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group controlId="synopsis">
|
||||||
|
<Form.Label>Synopsis</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
as="textarea"
|
||||||
|
readOnly={!isEditing}
|
||||||
|
className="movie-synopsis text-input"
|
||||||
|
onChange={(newValue: React.FormEvent<HTMLTextAreaElement>) =>
|
||||||
|
setSynopsis(newValue.currentTarget.value)
|
||||||
|
}
|
||||||
|
value={synopsis}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<DetailsEditNavbar
|
||||||
|
objectName={movie.name ?? "movie"}
|
||||||
|
isNew={isNew}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onToggleEdit={() => setIsEditing(!isEditing)}
|
||||||
|
onSave={onSave}
|
||||||
|
onImageChange={onImageChange}
|
||||||
|
onBackImageChange={onBackImageChange}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isNew && (
|
||||||
|
<div className="col-12 col-sm-8">
|
||||||
|
<MovieScenesPanel movie={movie} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{renderDeleteAlert()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import React from "react";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { MoviesCriterion } from "src/models/list-filter/criteria/movies";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { SceneList } from "src/components/Scenes/SceneList";
|
||||||
|
|
||||||
|
interface IMovieScenesPanel {
|
||||||
|
movie: Partial<GQL.MovieDataFragment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MovieScenesPanel: React.FC<IMovieScenesPanel> = ({ movie }) => {
|
||||||
|
function filterHook(filter: ListFilterModel) {
|
||||||
|
const movieValue = { id: movie.id!, label: movie.name! };
|
||||||
|
// if movie is already present, then we modify it, otherwise add
|
||||||
|
let movieCriterion = filter.criteria.find(c => {
|
||||||
|
return c.type === "movies";
|
||||||
|
}) as MoviesCriterion;
|
||||||
|
|
||||||
|
if (
|
||||||
|
movieCriterion &&
|
||||||
|
(movieCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||||
|
movieCriterion.modifier === GQL.CriterionModifier.Includes)
|
||||||
|
) {
|
||||||
|
// add the movie if not present
|
||||||
|
if (
|
||||||
|
!movieCriterion.value.find(p => {
|
||||||
|
return p.id === movie.id;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
movieCriterion.value.push(movieValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
movieCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||||
|
} else {
|
||||||
|
// overwrite
|
||||||
|
movieCriterion = new MoviesCriterion();
|
||||||
|
movieCriterion.value = [movieValue];
|
||||||
|
filter.criteria.push(movieCriterion);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SceneList subComponent filterHook={filterHook} />;
|
||||||
|
};
|
||||||
35
ui/v2.5/src/components/Movies/MovieList.tsx
Normal file
35
ui/v2.5/src/components/Movies/MovieList.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import React from "react";
|
||||||
|
import { FindMoviesQueryResult } from "src/core/generated-graphql";
|
||||||
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
|
import { useMoviesList } from "src/hooks/ListHook";
|
||||||
|
import { MovieCard } from "./MovieCard";
|
||||||
|
|
||||||
|
export const MovieList: React.FC = () => {
|
||||||
|
const listData = useMoviesList({
|
||||||
|
renderContent
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderContent(
|
||||||
|
result: FindMoviesQueryResult,
|
||||||
|
filter: ListFilterModel
|
||||||
|
) {
|
||||||
|
if (!result.data?.findMovies) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (filter.displayMode === DisplayMode.Grid) {
|
||||||
|
return (
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
{result.data.findMovies.movies.map(p => (
|
||||||
|
<MovieCard key={p.id} movie={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.displayMode === DisplayMode.List) {
|
||||||
|
return <h1>TODO</h1>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listData.template;
|
||||||
|
};
|
||||||
13
ui/v2.5/src/components/Movies/Movies.tsx
Normal file
13
ui/v2.5/src/components/Movies/Movies.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Route, Switch } from "react-router-dom";
|
||||||
|
import { Movie } from "./MovieDetails/Movie";
|
||||||
|
import { MovieList } from "./MovieList";
|
||||||
|
|
||||||
|
const Movies = () => (
|
||||||
|
<Switch>
|
||||||
|
<Route exact path="/movies" component={MovieList} />
|
||||||
|
<Route path="/movies/:id" component={Movie} />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Movies;
|
||||||
23
ui/v2.5/src/components/Movies/styles.scss
Normal file
23
ui/v2.5/src/components/Movies/styles.scss
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
.card.movie-card {
|
||||||
|
padding: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-card {
|
||||||
|
&-header {
|
||||||
|
height: 240px;
|
||||||
|
line-height: 240px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-image {
|
||||||
|
max-height: 240px;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 320px;
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +126,39 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maybeRenderMoviePopoverButton() {
|
||||||
|
if (props.scene.movies.length <= 0) return;
|
||||||
|
|
||||||
|
const popoverContent = props.scene.movies.map(sceneMovie => (
|
||||||
|
<div className="movie-tag-container row" key="movie">
|
||||||
|
<Link
|
||||||
|
to={`/movies/${sceneMovie.movie.id}`}
|
||||||
|
className="movie-tag col m-auto zoom-2"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="image-thumbnail"
|
||||||
|
alt={sceneMovie.movie.name ?? ""}
|
||||||
|
src={sceneMovie.movie.front_image_path ?? ""}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<TagLink
|
||||||
|
key={sceneMovie.movie.id}
|
||||||
|
movie={sceneMovie.movie}
|
||||||
|
className="d-block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverPopover placement="bottom" content={popoverContent}>
|
||||||
|
<Button className="minimal">
|
||||||
|
<Icon icon="film" />
|
||||||
|
<span>{props.scene.movies.length}</span>
|
||||||
|
</Button>
|
||||||
|
</HoverPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function maybeRenderSceneMarkerPopoverButton() {
|
function maybeRenderSceneMarkerPopoverButton() {
|
||||||
if (props.scene.scene_markers.length <= 0) return;
|
if (props.scene.scene_markers.length <= 0) return;
|
||||||
|
|
||||||
|
|
@ -161,6 +194,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
||||||
if (
|
if (
|
||||||
props.scene.tags.length > 0 ||
|
props.scene.tags.length > 0 ||
|
||||||
props.scene.performers.length > 0 ||
|
props.scene.performers.length > 0 ||
|
||||||
|
props.scene.movies.length > 0 ||
|
||||||
props.scene.scene_markers.length > 0 ||
|
props.scene.scene_markers.length > 0 ||
|
||||||
props.scene?.o_counter
|
props.scene?.o_counter
|
||||||
) {
|
) {
|
||||||
|
|
@ -170,6 +204,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
||||||
<ButtonGroup className="scene-popovers">
|
<ButtonGroup className="scene-popovers">
|
||||||
{maybeRenderTagPopoverButton()}
|
{maybeRenderTagPopoverButton()}
|
||||||
{maybeRenderPerformerPopoverButton()}
|
{maybeRenderPerformerPopoverButton()}
|
||||||
|
{maybeRenderMoviePopoverButton()}
|
||||||
{maybeRenderSceneMarkerPopoverButton()}
|
{maybeRenderSceneMarkerPopoverButton()}
|
||||||
{maybeRenderOCounter()}
|
{maybeRenderOCounter()}
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { SceneEditPanel } from "./SceneEditPanel";
|
||||||
import { SceneDetailPanel } from "./SceneDetailPanel";
|
import { SceneDetailPanel } from "./SceneDetailPanel";
|
||||||
import { OCounterButton } from "./OCounterButton";
|
import { OCounterButton } from "./OCounterButton";
|
||||||
import { SceneOperationsPanel } from "./SceneOperationsPanel";
|
import { SceneOperationsPanel } from "./SceneOperationsPanel";
|
||||||
|
import { SceneMoviePanel } from "./SceneMoviePanel";
|
||||||
|
|
||||||
export const Scene: React.FC = () => {
|
export const Scene: React.FC = () => {
|
||||||
const { id = "new" } = useParams();
|
const { id = "new" } = useParams();
|
||||||
|
|
@ -124,6 +125,13 @@ export const Scene: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
""
|
""
|
||||||
)}
|
)}
|
||||||
|
{scene.movies.length > 0 ? (
|
||||||
|
<Tab eventKey="scene-movie-panel" title="Movies">
|
||||||
|
<SceneMoviePanel scene={scene} />
|
||||||
|
</Tab>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
{scene.gallery ? (
|
{scene.gallery ? (
|
||||||
<Tab eventKey="scene-gallery-panel" title="Gallery">
|
<Tab eventKey="scene-gallery-panel" title="Gallery">
|
||||||
<GalleryViewer gallery={scene.gallery} />
|
<GalleryViewer gallery={scene.gallery} />
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import {
|
||||||
} from "src/components/Shared";
|
} from "src/components/Shared";
|
||||||
import { useToast } from "src/hooks";
|
import { useToast } from "src/hooks";
|
||||||
import { ImageUtils, TableUtils } from "src/utils";
|
import { ImageUtils, TableUtils } from "src/utils";
|
||||||
|
import { MovieSelect } from "src/components/Shared/Select";
|
||||||
|
import { SceneMovieTable, MovieSceneIndexMap } from "./SceneMovieTable";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
scene: GQL.SceneDataFragment;
|
scene: GQL.SceneDataFragment;
|
||||||
|
|
@ -33,6 +35,10 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
const [galleryId, setGalleryId] = useState<string>();
|
const [galleryId, setGalleryId] = useState<string>();
|
||||||
const [studioId, setStudioId] = useState<string>();
|
const [studioId, setStudioId] = useState<string>();
|
||||||
const [performerIds, setPerformerIds] = useState<string[]>();
|
const [performerIds, setPerformerIds] = useState<string[]>();
|
||||||
|
const [movieIds, setMovieIds] = useState<string[] | undefined>(undefined);
|
||||||
|
const [movieSceneIndexes, setMovieSceneIndexes] = useState<
|
||||||
|
MovieSceneIndexMap
|
||||||
|
>(new Map());
|
||||||
const [tagIds, setTagIds] = useState<string[]>();
|
const [tagIds, setTagIds] = useState<string[]>();
|
||||||
const [coverImage, setCoverImage] = useState<string>();
|
const [coverImage, setCoverImage] = useState<string>();
|
||||||
|
|
||||||
|
|
@ -59,9 +65,46 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
setQueryableScrapers(newQueryableScrapers);
|
setQueryableScrapers(newQueryableScrapers);
|
||||||
}, [Scrapers]);
|
}, [Scrapers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let changed = false;
|
||||||
|
const newMap: MovieSceneIndexMap = new Map();
|
||||||
|
if (movieIds) {
|
||||||
|
movieIds.forEach(id => {
|
||||||
|
if (!movieSceneIndexes.has(id)) {
|
||||||
|
changed = true;
|
||||||
|
newMap.set(id, undefined);
|
||||||
|
} else {
|
||||||
|
newMap.set(id, movieSceneIndexes.get(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
movieSceneIndexes.forEach((v, id) => {
|
||||||
|
if (!newMap.has(id)) {
|
||||||
|
// id was removed
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
setMovieSceneIndexes(newMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [movieIds, movieSceneIndexes]);
|
||||||
|
|
||||||
function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) {
|
function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) {
|
||||||
const perfIds = state.performers?.map(performer => performer.id);
|
const perfIds = state.performers?.map(performer => performer.id);
|
||||||
const tIds = state.tags ? state.tags.map(tag => tag.id) : undefined;
|
const tIds = state.tags ? state.tags.map(tag => tag.id) : undefined;
|
||||||
|
const moviIds = state.movies
|
||||||
|
? state.movies.map(sceneMovie => sceneMovie.movie.id)
|
||||||
|
: undefined;
|
||||||
|
const movieSceneIdx: MovieSceneIndexMap = new Map();
|
||||||
|
if (state.movies) {
|
||||||
|
state.movies.forEach(m => {
|
||||||
|
movieSceneIdx.set(m.movie.id, m.scene_index ?? undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setTitle(state.title ?? undefined);
|
setTitle(state.title ?? undefined);
|
||||||
setDetails(state.details ?? undefined);
|
setDetails(state.details ?? undefined);
|
||||||
|
|
@ -70,6 +113,8 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
setRating(state.rating === null ? NaN : state.rating);
|
setRating(state.rating === null ? NaN : state.rating);
|
||||||
setGalleryId(state?.gallery?.id ?? undefined);
|
setGalleryId(state?.gallery?.id ?? undefined);
|
||||||
setStudioId(state?.studio?.id ?? undefined);
|
setStudioId(state?.studio?.id ?? undefined);
|
||||||
|
setMovieIds(moviIds);
|
||||||
|
setMovieSceneIndexes(movieSceneIdx);
|
||||||
setPerformerIds(perfIds);
|
setPerformerIds(perfIds);
|
||||||
setTagIds(tIds);
|
setTagIds(tIds);
|
||||||
}
|
}
|
||||||
|
|
@ -93,11 +138,31 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
gallery_id: galleryId,
|
gallery_id: galleryId,
|
||||||
studio_id: studioId,
|
studio_id: studioId,
|
||||||
performer_ids: performerIds,
|
performer_ids: performerIds,
|
||||||
|
movies: makeMovieInputs(),
|
||||||
tag_ids: tagIds,
|
tag_ids: tagIds,
|
||||||
cover_image: coverImage
|
cover_image: coverImage
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeMovieInputs(): GQL.SceneMovieInput[] | undefined {
|
||||||
|
if (!movieIds) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ret = movieIds.map(id => {
|
||||||
|
const r: GQL.SceneMovieInput = {
|
||||||
|
movie_id: id
|
||||||
|
};
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
ret = ret.map(r => {
|
||||||
|
return { scene_index: movieSceneIndexes.get(r.movie_id), ...r };
|
||||||
|
});
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
async function onSave() {
|
async function onSave() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -133,6 +198,17 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
props.onDelete();
|
props.onDelete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTableMovies() {
|
||||||
|
return (
|
||||||
|
<SceneMovieTable
|
||||||
|
movieSceneIndexes={movieSceneIndexes}
|
||||||
|
onUpdate={items => {
|
||||||
|
setMovieSceneIndexes(items);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderDeleteAlert() {
|
function renderDeleteAlert() {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
|
@ -197,7 +273,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
return (
|
return (
|
||||||
<DropdownButton id="scene-scrape" title="Scrape with...">
|
<DropdownButton id="scene-scrape" title="Scrape with...">
|
||||||
{queryableScrapers.map(s => (
|
{queryableScrapers.map(s => (
|
||||||
<Dropdown.Item onClick={() => onScrapeClicked(s)}>
|
<Dropdown.Item key={s.name} onClick={() => onScrapeClicked(s)}>
|
||||||
{s.name}
|
{s.name}
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
))}
|
))}
|
||||||
|
|
@ -247,6 +323,21 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(!movieIds || movieIds.length === 0) &&
|
||||||
|
scene.movies &&
|
||||||
|
scene.movies.length > 0
|
||||||
|
) {
|
||||||
|
const idMovis = scene.movies.filter(p => {
|
||||||
|
return p.id !== undefined && p.id !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (idMovis.length > 0) {
|
||||||
|
const newIds = idMovis.map(p => p.id);
|
||||||
|
setMovieIds(newIds as string[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!tagIds?.length && scene?.tags?.length) {
|
if (!tagIds?.length && scene?.tags?.length) {
|
||||||
const idTags = scene.tags.filter(p => {
|
const idTags = scene.tags.filter(p => {
|
||||||
return p.id !== undefined && p.id !== null;
|
return p.id !== undefined && p.id !== null;
|
||||||
|
|
@ -369,6 +460,17 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Movies/Scenes</td>
|
||||||
|
<td>
|
||||||
|
<MovieSelect
|
||||||
|
isMulti
|
||||||
|
onSelect={items => setMovieIds(items.map(item => item.id))}
|
||||||
|
ids={movieIds}
|
||||||
|
/>
|
||||||
|
{renderTableMovies()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Tags</td>
|
<td>Tags</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { MovieCard } from "src/components/Movies/MovieCard";
|
||||||
|
|
||||||
|
interface ISceneMoviePanelProps {
|
||||||
|
scene: GQL.SceneDataFragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneMoviePanel: FunctionComponent<ISceneMoviePanelProps> = (
|
||||||
|
props: ISceneMoviePanelProps
|
||||||
|
) => {
|
||||||
|
const cards = props.scene.movies.map(sceneMovie => (
|
||||||
|
<MovieCard
|
||||||
|
key={sceneMovie.movie.id}
|
||||||
|
movie={sceneMovie.movie}
|
||||||
|
sceneIndex={sceneMovie.scene_index ?? undefined}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="row justify-content-center">{cards}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { StashService } from "src/core/StashService";
|
||||||
|
import { Form } from "react-bootstrap";
|
||||||
|
|
||||||
|
type ValidTypes = GQL.SlimMovieDataFragment;
|
||||||
|
|
||||||
|
export type MovieSceneIndexMap = Map<string, string | undefined>;
|
||||||
|
|
||||||
|
export interface IProps {
|
||||||
|
movieSceneIndexes: MovieSceneIndexMap;
|
||||||
|
onUpdate: (value: MovieSceneIndexMap) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneMovieTable: React.FunctionComponent<IProps> = (
|
||||||
|
props: IProps
|
||||||
|
) => {
|
||||||
|
const { data } = StashService.useAllMoviesForFilter();
|
||||||
|
|
||||||
|
const items = !!data && !!data.allMovies ? data.allMovies : [];
|
||||||
|
let itemsFilter: ValidTypes[] = [];
|
||||||
|
|
||||||
|
if (!!props.movieSceneIndexes && !!items) {
|
||||||
|
props.movieSceneIndexes.forEach((index, movieId) => {
|
||||||
|
itemsFilter = itemsFilter.concat(items.filter(x => x.id === movieId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeIdx = itemsFilter.map(movie => {
|
||||||
|
return props.movieSceneIndexes.get(movie.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateFieldChanged = (movieId: string, value: string) => {
|
||||||
|
const newMap = new Map(props.movieSceneIndexes);
|
||||||
|
newMap.set(movieId, value);
|
||||||
|
props.onUpdate(newMap);
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderTableData() {
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
{itemsFilter!.map((item, index: number) => (
|
||||||
|
<tr key={item.toString()}>
|
||||||
|
<td>{item.name} </td>
|
||||||
|
<td />
|
||||||
|
<td>Scene number:</td>
|
||||||
|
<td>
|
||||||
|
<Form.Control
|
||||||
|
as="select"
|
||||||
|
className="input-control"
|
||||||
|
value={storeIdx[index] ?? ""}
|
||||||
|
onChange={(e: React.FormEvent<HTMLInputElement>) =>
|
||||||
|
updateFieldChanged(item.id, e.currentTarget.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{["", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"].map(
|
||||||
|
opt => (
|
||||||
|
<option value={opt} key={opt}>
|
||||||
|
{opt}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Form.Control>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<table className="movie-table">{renderTableData()}</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -26,6 +26,18 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
|
||||||
</Link>
|
</Link>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
const renderMovies = (movies: Partial<GQL.SceneMovie>[]) => {
|
||||||
|
return movies.map(sceneMovie =>
|
||||||
|
!sceneMovie.movie ? (
|
||||||
|
undefined
|
||||||
|
) : (
|
||||||
|
<Link to={NavUtils.makeMovieScenesUrl(sceneMovie.movie)}>
|
||||||
|
<h6>{sceneMovie.movie.name}</h6>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderSceneRow = (scene: GQL.SlimSceneDataFragment) => (
|
const renderSceneRow = (scene: GQL.SlimSceneDataFragment) => (
|
||||||
<tr key={scene.id}>
|
<tr key={scene.id}>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -58,6 +70,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{renderMovies(scene.movies)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -73,6 +86,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
|
||||||
<th>Tags</th>
|
<th>Tags</th>
|
||||||
<th>Performers</th>
|
<th>Performers</th>
|
||||||
<th>Studio</th>
|
<th>Studio</th>
|
||||||
|
<th>Movies</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>{props.scenes.map(renderSceneRow)}</tbody>
|
<tbody>{props.scenes.map(renderSceneRow)}</tbody>
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,12 @@
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.performer-tag-container {
|
.performer-tag-container, .movie-tag-container {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.performer-tag.image {
|
.performer-tag.image, .movie-tag.image {
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
|
|
@ -212,3 +212,7 @@
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.movie-table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { Button, Modal } from "react-bootstrap";
|
import { Button, Modal } from "react-bootstrap";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
|
||||||
import { ImageInput } from "src/components/Shared";
|
import { ImageInput } from "src/components/Shared";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
performer?: Partial<GQL.PerformerDataFragment>;
|
objectName?: string;
|
||||||
studio?: Partial<GQL.StudioDataFragment>;
|
|
||||||
isNew: boolean;
|
isNew: boolean;
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
onToggleEdit: () => void;
|
onToggleEdit: () => void;
|
||||||
|
|
@ -13,6 +11,7 @@ interface IProps {
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onAutoTag?: () => void;
|
onAutoTag?: () => void;
|
||||||
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
|
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||||
|
onBackImageChange?: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
|
export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
|
||||||
|
|
@ -54,6 +53,19 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderBackImageInput() {
|
||||||
|
if (!props.isEditing || !props.onBackImageChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ImageInput
|
||||||
|
isEditing={props.isEditing}
|
||||||
|
text="Back image..."
|
||||||
|
onImageChange={props.onBackImageChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderAutoTagButton() {
|
function renderAutoTagButton() {
|
||||||
if (props.isNew || props.isEditing) return;
|
if (props.isNew || props.isEditing) return;
|
||||||
|
|
||||||
|
|
@ -74,11 +86,11 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDeleteAlert() {
|
function renderDeleteAlert() {
|
||||||
const name = props?.studio?.name ?? props?.performer?.name;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal show={isDeleteAlertOpen}>
|
<Modal show={isDeleteAlertOpen}>
|
||||||
<Modal.Body>Are you sure you want to delete {name}?</Modal.Body>
|
<Modal.Body>
|
||||||
|
Are you sure you want to delete {props.objectName}?
|
||||||
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant="danger" onClick={props.onDelete}>
|
<Button variant="danger" onClick={props.onDelete}>
|
||||||
Delete
|
Delete
|
||||||
|
|
@ -99,8 +111,10 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
|
||||||
{renderEditButton()}
|
{renderEditButton()}
|
||||||
<ImageInput
|
<ImageInput
|
||||||
isEditing={props.isEditing}
|
isEditing={props.isEditing}
|
||||||
|
text={props.onBackImageChange ? "Front image..." : undefined}
|
||||||
onImageChange={props.onImageChange}
|
onImageChange={props.onImageChange}
|
||||||
/>
|
/>
|
||||||
|
{renderBackImageInput()}
|
||||||
{renderAutoTagButton()}
|
{renderAutoTagButton()}
|
||||||
{renderSaveButton()}
|
{renderSaveButton()}
|
||||||
{renderDeleteButton()}
|
{renderDeleteButton()}
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,20 @@ import { Button, Form } from "react-bootstrap";
|
||||||
|
|
||||||
interface IImageInput {
|
interface IImageInput {
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
|
text?: string;
|
||||||
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
|
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImageInput: React.FC<IImageInput> = ({
|
export const ImageInput: React.FC<IImageInput> = ({
|
||||||
isEditing,
|
isEditing,
|
||||||
|
text,
|
||||||
onImageChange
|
onImageChange
|
||||||
}) => {
|
}) => {
|
||||||
if (!isEditing) return <div />;
|
if (!isEditing) return <div />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Label className="image-input">
|
<Form.Label className="image-input">
|
||||||
<Button variant="secondary">Browse for image...</Button>
|
<Button variant="secondary">{text ?? "Browse for image..."}</Button>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
type="file"
|
type="file"
|
||||||
onChange={onImageChange}
|
onChange={onImageChange}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ type ValidTypes =
|
||||||
type Option = { value: string; label: string };
|
type Option = { value: string; label: string };
|
||||||
|
|
||||||
interface ITypeProps {
|
interface ITypeProps {
|
||||||
type?: "performers" | "studios" | "tags";
|
type?: "performers" | "studios" | "tags" | "movies";
|
||||||
}
|
}
|
||||||
interface IFilterProps {
|
interface IFilterProps {
|
||||||
ids?: string[];
|
ids?: string[];
|
||||||
|
|
@ -172,6 +172,8 @@ export const FilterSelect: React.FC<IFilterProps & ITypeProps> = props =>
|
||||||
<PerformerSelect {...(props as IFilterProps)} />
|
<PerformerSelect {...(props as IFilterProps)} />
|
||||||
) : props.type === "studios" ? (
|
) : props.type === "studios" ? (
|
||||||
<StudioSelect {...(props as IFilterProps)} />
|
<StudioSelect {...(props as IFilterProps)} />
|
||||||
|
) : props.type === "movies" ? (
|
||||||
|
<MovieSelect {...(props as IFilterProps)} />
|
||||||
) : (
|
) : (
|
||||||
<TagSelect {...(props as IFilterProps)} />
|
<TagSelect {...(props as IFilterProps)} />
|
||||||
);
|
);
|
||||||
|
|
@ -247,6 +249,44 @@ export const StudioSelect: React.FC<IFilterProps> = props => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MovieSelect: React.FC<IFilterProps> = props => {
|
||||||
|
const { data, loading } = StashService.useAllMoviesForFilter();
|
||||||
|
|
||||||
|
const normalizedData = data?.allMovies ?? [];
|
||||||
|
|
||||||
|
const items = (normalizedData.length > 0
|
||||||
|
? [{ name: "None", id: "0" }, ...normalizedData]
|
||||||
|
: []
|
||||||
|
).map(item => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.name
|
||||||
|
}));
|
||||||
|
|
||||||
|
const placeholder = props.noSelectionString ?? "Select movie...";
|
||||||
|
const selectedOptions: Option[] = props.ids
|
||||||
|
? items.filter(item => props.ids?.indexOf(item.value) !== -1)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const onChange = (selectedItems: ValueType<Option>) => {
|
||||||
|
const selectedIds = getSelectedValues(selectedItems);
|
||||||
|
props.onSelect?.(
|
||||||
|
normalizedData.filter(item => selectedIds.indexOf(item.id) !== -1)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectComponent
|
||||||
|
{...props}
|
||||||
|
onChange={onChange}
|
||||||
|
type="studios"
|
||||||
|
isLoading={loading}
|
||||||
|
items={items}
|
||||||
|
placeholder={placeholder}
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const TagSelect: React.FC<IFilterProps> = props => {
|
export const TagSelect: React.FC<IFilterProps> = props => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>(props.ids ?? []);
|
const [selectedIds, setSelectedIds] = useState<string[]>(props.ids ?? []);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { Link } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
PerformerDataFragment,
|
PerformerDataFragment,
|
||||||
SceneMarkerDataFragment,
|
SceneMarkerDataFragment,
|
||||||
TagDataFragment
|
TagDataFragment,
|
||||||
|
MovieDataFragment
|
||||||
} from "src/core/generated-graphql";
|
} from "src/core/generated-graphql";
|
||||||
import { NavUtils, TextUtils } from "src/utils";
|
import { NavUtils, TextUtils } from "src/utils";
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ interface IProps {
|
||||||
tag?: Partial<TagDataFragment>;
|
tag?: Partial<TagDataFragment>;
|
||||||
performer?: Partial<PerformerDataFragment>;
|
performer?: Partial<PerformerDataFragment>;
|
||||||
marker?: Partial<SceneMarkerDataFragment>;
|
marker?: Partial<SceneMarkerDataFragment>;
|
||||||
|
movie?: Partial<MovieDataFragment>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,6 +26,9 @@ export const TagLink: React.FC<IProps> = (props: IProps) => {
|
||||||
} else if (props.performer) {
|
} else if (props.performer) {
|
||||||
link = NavUtils.makePerformerScenesUrl(props.performer);
|
link = NavUtils.makePerformerScenesUrl(props.performer);
|
||||||
title = props.performer.name || "";
|
title = props.performer.name || "";
|
||||||
|
} else if (props.movie) {
|
||||||
|
link = NavUtils.makeMovieScenesUrl(props.movie);
|
||||||
|
title = props.movie.name || "";
|
||||||
} else if (props.marker) {
|
} else if (props.marker) {
|
||||||
link = NavUtils.makeSceneMarkerUrl(props.marker);
|
link = NavUtils.makeSceneMarkerUrl(props.marker);
|
||||||
title = `${props.marker.title} - ${TextUtils.secondsToTimestamp(
|
title = `${props.marker.title} - ${TextUtils.secondsToTimestamp(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,14 @@ export const Stats: React.FC = () => {
|
||||||
<FormattedMessage id="scenes" defaultMessage="Scenes" />
|
<FormattedMessage id="scenes" defaultMessage="Scenes" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="stats-element">
|
||||||
|
<p className="title">
|
||||||
|
<FormattedNumber value={data.stats.movie_count} />
|
||||||
|
</p>
|
||||||
|
<p className="heading">
|
||||||
|
<FormattedMessage id="movies" defaultMessage="Movies" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="stats-element">
|
<div className="stats-element">
|
||||||
<p className="title">
|
<p className="title">
|
||||||
<FormattedNumber value={data.stats.gallery_count} />
|
<FormattedNumber value={data.stats.gallery_count} />
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,7 @@ export const Studio: React.FC = () => {
|
||||||
</tbody>
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<DetailsEditNavbar
|
<DetailsEditNavbar
|
||||||
studio={studio}
|
objectName={studio.name ?? "studio"}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
onToggleEdit={() => setIsEditing(!isEditing)}
|
onToggleEdit={() => setIsEditing(!isEditing)}
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,14 @@ export class StashService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static useFindMovies(filter: ListFilterModel) {
|
||||||
|
return GQL.useFindMoviesQuery({
|
||||||
|
variables: {
|
||||||
|
filter: filter.makeFindFilter()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public static useFindPerformers(filter: ListFilterModel) {
|
public static useFindPerformers(filter: ListFilterModel) {
|
||||||
let performerFilter = {};
|
let performerFilter = {};
|
||||||
// if (!!filter && filter.criteriaFilterOpen) {
|
// if (!!filter && filter.criteriaFilterOpen) {
|
||||||
|
|
@ -220,6 +228,10 @@ export class StashService {
|
||||||
const skip = id === "new";
|
const skip = id === "new";
|
||||||
return GQL.useFindStudioQuery({ variables: { id }, skip });
|
return GQL.useFindStudioQuery({ variables: { id }, skip });
|
||||||
}
|
}
|
||||||
|
public static useFindMovie(id: string) {
|
||||||
|
const skip = id === "new";
|
||||||
|
return GQL.useFindMovieQuery({ variables: { id }, skip });
|
||||||
|
}
|
||||||
|
|
||||||
// TODO - scene marker manipulation functions are handled differently
|
// TODO - scene marker manipulation functions are handled differently
|
||||||
private static sceneMarkerMutationImpactedQueries = [
|
private static sceneMarkerMutationImpactedQueries = [
|
||||||
|
|
@ -279,6 +291,9 @@ export class StashService {
|
||||||
public static useAllStudiosForFilter() {
|
public static useAllStudiosForFilter() {
|
||||||
return GQL.useAllStudiosForFilterQuery();
|
return GQL.useAllStudiosForFilterQuery();
|
||||||
}
|
}
|
||||||
|
public static useAllMoviesForFilter() {
|
||||||
|
return GQL.useAllMoviesForFilterQuery();
|
||||||
|
}
|
||||||
public static useValidGalleriesForScene(sceneId: string) {
|
public static useValidGalleriesForScene(sceneId: string) {
|
||||||
return GQL.useValidGalleriesForSceneQuery({
|
return GQL.useValidGalleriesForSceneQuery({
|
||||||
variables: { scene_id: sceneId }
|
variables: { scene_id: sceneId }
|
||||||
|
|
@ -341,6 +356,7 @@ export class StashService {
|
||||||
"findScenes",
|
"findScenes",
|
||||||
"findSceneMarkers",
|
"findSceneMarkers",
|
||||||
"findStudios",
|
"findStudios",
|
||||||
|
"findMovies",
|
||||||
"allTags"
|
"allTags"
|
||||||
// TODO - add "findTags" when it is implemented
|
// TODO - add "findTags" when it is implemented
|
||||||
];
|
];
|
||||||
|
|
@ -362,6 +378,7 @@ export class StashService {
|
||||||
"findPerformers",
|
"findPerformers",
|
||||||
"findSceneMarkers",
|
"findSceneMarkers",
|
||||||
"findStudios",
|
"findStudios",
|
||||||
|
"findMovies",
|
||||||
"allTags"
|
"allTags"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -449,6 +466,42 @@ export class StashService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static movieMutationImpactedQueries = [
|
||||||
|
"findMovies",
|
||||||
|
"findScenes",
|
||||||
|
"allMovies"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static useMovieCreate(input: GQL.MovieCreateInput) {
|
||||||
|
return GQL.useMovieCreateMutation({
|
||||||
|
variables: input,
|
||||||
|
update: () =>
|
||||||
|
StashService.invalidateQueries(
|
||||||
|
StashService.movieMutationImpactedQueries
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static useMovieUpdate(input: GQL.MovieUpdateInput) {
|
||||||
|
return GQL.useMovieUpdateMutation({
|
||||||
|
variables: input,
|
||||||
|
update: () =>
|
||||||
|
StashService.invalidateQueries(
|
||||||
|
StashService.movieMutationImpactedQueries
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static useMovieDestroy(input: GQL.MovieDestroyInput) {
|
||||||
|
return GQL.useMovieDestroyMutation({
|
||||||
|
variables: input,
|
||||||
|
update: () =>
|
||||||
|
StashService.invalidateQueries(
|
||||||
|
StashService.movieMutationImpactedQueries
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static tagMutationImpactedQueries = [
|
private static tagMutationImpactedQueries = [
|
||||||
"findScenes",
|
"findScenes",
|
||||||
"findSceneMarkers",
|
"findSceneMarkers",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -14,7 +14,9 @@ import {
|
||||||
FindSceneMarkersQueryResult,
|
FindSceneMarkersQueryResult,
|
||||||
FindGalleriesQueryResult,
|
FindGalleriesQueryResult,
|
||||||
FindStudiosQueryResult,
|
FindStudiosQueryResult,
|
||||||
FindPerformersQueryResult
|
FindPerformersQueryResult,
|
||||||
|
FindMoviesQueryResult,
|
||||||
|
MovieDataFragment
|
||||||
} from "src/core/generated-graphql";
|
} from "src/core/generated-graphql";
|
||||||
import {
|
import {
|
||||||
useInterfaceLocalForage,
|
useInterfaceLocalForage,
|
||||||
|
|
@ -453,3 +455,14 @@ export const usePerformersList = (
|
||||||
getCount: (result: FindPerformersQueryResult) =>
|
getCount: (result: FindPerformersQueryResult) =>
|
||||||
result?.data?.findPerformers?.count ?? 0
|
result?.data?.findPerformers?.count ?? 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const useMoviesList = (props: IListHookOptions<FindMoviesQueryResult>) =>
|
||||||
|
useList<FindMoviesQueryResult, MovieDataFragment>({
|
||||||
|
...props,
|
||||||
|
filterMode: FilterMode.Performers,
|
||||||
|
useData: StashService.useFindMovies,
|
||||||
|
getData: (result: FindMoviesQueryResult) =>
|
||||||
|
result?.data?.findMovies?.movies ?? [],
|
||||||
|
getCount: (result: FindMoviesQueryResult) =>
|
||||||
|
result?.data?.findMovies?.count ?? 0
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
@import "styles/scrollbars";
|
@import "styles/scrollbars";
|
||||||
@import "src/components/Galleries/styles.scss";
|
@import "src/components/Galleries/styles.scss";
|
||||||
@import "src/components/List/styles.scss";
|
@import "src/components/List/styles.scss";
|
||||||
|
@import "src/components/Movies/styles.scss";
|
||||||
@import "src/components/Performers/styles.scss";
|
@import "src/components/Performers/styles.scss";
|
||||||
@import "src/components/Scenes/styles.scss";
|
@import "src/components/Scenes/styles.scss";
|
||||||
@import "src/components/SceneFilenameParser/styles.scss";
|
@import "src/components/SceneFilenameParser/styles.scss";
|
||||||
|
|
@ -48,7 +49,8 @@ code,
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-input,
|
.text-input,
|
||||||
.text-input:focus {
|
.text-input:focus,
|
||||||
|
.text-input[readonly] {
|
||||||
background-color: $textfield-bg;
|
background-color: $textfield-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,6 +59,12 @@ code,
|
||||||
background-color: $secondary;
|
background-color: $secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea.text-input {
|
||||||
|
line-height: 2.5ex;
|
||||||
|
min-height: 12ex;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
.table-list a {
|
.table-list a {
|
||||||
color: $text-color;
|
color: $text-color;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"new": "Neu",
|
"new": "Neu",
|
||||||
"tags": "Etiketten",
|
"tags": "Etiketten",
|
||||||
"scenes": "Szenen",
|
"scenes": "Szenen",
|
||||||
|
"movies": "Filme",
|
||||||
"studios": "Studios",
|
"studios": "Studios",
|
||||||
"galleries": "Galerien",
|
"galleries": "Galerien",
|
||||||
"performers": "Künstler",
|
"performers": "Künstler",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"new": "New",
|
"new": "New",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"scenes": "Scenes",
|
"scenes": "Scenes",
|
||||||
|
"movies": "Movies",
|
||||||
"studios": "Studios",
|
"studios": "Studios",
|
||||||
"galleries": "Galleries",
|
"galleries": "Galleries",
|
||||||
"performers": "Performers",
|
"performers": "Performers",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export type CriterionType =
|
||||||
| "sceneTags"
|
| "sceneTags"
|
||||||
| "performers"
|
| "performers"
|
||||||
| "studios"
|
| "studios"
|
||||||
|
| "movies"
|
||||||
| "birth_year"
|
| "birth_year"
|
||||||
| "age"
|
| "age"
|
||||||
| "ethnicity"
|
| "ethnicity"
|
||||||
|
|
@ -60,6 +61,8 @@ export abstract class Criterion {
|
||||||
return "Performers";
|
return "Performers";
|
||||||
case "studios":
|
case "studios":
|
||||||
return "Studios";
|
return "Studios";
|
||||||
|
case "movies":
|
||||||
|
return "Movies";
|
||||||
case "birth_year":
|
case "birth_year":
|
||||||
return "Birth Year";
|
return "Birth Year";
|
||||||
case "age":
|
case "age":
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export class IsMissingCriterion extends Criterion {
|
||||||
"date",
|
"date",
|
||||||
"gallery",
|
"gallery",
|
||||||
"studio",
|
"studio",
|
||||||
|
"movie",
|
||||||
"performers"
|
"performers"
|
||||||
];
|
];
|
||||||
public value: string = "";
|
public value: string = "";
|
||||||
|
|
|
||||||
32
ui/v2.5/src/models/list-filter/criteria/movies.ts
Normal file
32
ui/v2.5/src/models/list-filter/criteria/movies.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { CriterionModifier } from "src/core/generated-graphql";
|
||||||
|
import { ILabeledId, encodeILabeledId } from "../types";
|
||||||
|
import { Criterion, CriterionType, ICriterionOption } from "./criterion";
|
||||||
|
|
||||||
|
interface IOptionType {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
image_path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MoviesCriterion extends Criterion {
|
||||||
|
public type: CriterionType = "movies";
|
||||||
|
public parameterName: string = "movies";
|
||||||
|
public modifier = CriterionModifier.Includes;
|
||||||
|
public modifierOptions = [
|
||||||
|
Criterion.getModifierOption(CriterionModifier.Includes),
|
||||||
|
Criterion.getModifierOption(CriterionModifier.Excludes)
|
||||||
|
];
|
||||||
|
public options: IOptionType[] = [];
|
||||||
|
public value: ILabeledId[] = [];
|
||||||
|
|
||||||
|
public encodeValue() {
|
||||||
|
return this.value.map(o => {
|
||||||
|
return encodeILabeledId(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MoviesCriterionOption implements ICriterionOption {
|
||||||
|
public label: string = Criterion.getLabel("movies");
|
||||||
|
public value: CriterionType = "movies";
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import { RatingCriterion } from "./rating";
|
||||||
import { ResolutionCriterion } from "./resolution";
|
import { ResolutionCriterion } from "./resolution";
|
||||||
import { StudiosCriterion } from "./studios";
|
import { StudiosCriterion } from "./studios";
|
||||||
import { TagsCriterion } from "./tags";
|
import { TagsCriterion } from "./tags";
|
||||||
|
import { MoviesCriterion } from "./movies";
|
||||||
|
|
||||||
export function makeCriteria(type: CriterionType = "none") {
|
export function makeCriteria(type: CriterionType = "none") {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|
@ -43,6 +44,8 @@ export function makeCriteria(type: CriterionType = "none") {
|
||||||
return new PerformersCriterion();
|
return new PerformersCriterion();
|
||||||
case "studios":
|
case "studios":
|
||||||
return new StudiosCriterion();
|
return new StudiosCriterion();
|
||||||
|
case "movies":
|
||||||
|
return new MoviesCriterion();
|
||||||
|
|
||||||
case "birth_year":
|
case "birth_year":
|
||||||
return new NumberCriterion(type, type);
|
return new NumberCriterion(type, type);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import {
|
||||||
} from "./criteria/tags";
|
} from "./criteria/tags";
|
||||||
import { makeCriteria } from "./criteria/utils";
|
import { makeCriteria } from "./criteria/utils";
|
||||||
import { DisplayMode, FilterMode } from "./types";
|
import { DisplayMode, FilterMode } from "./types";
|
||||||
|
import { MoviesCriterionOption, MoviesCriterion } from "./criteria/movies";
|
||||||
|
|
||||||
interface IQueryParameters {
|
interface IQueryParameters {
|
||||||
perPage?: string;
|
perPage?: string;
|
||||||
|
|
@ -115,7 +116,8 @@ export class ListFilterModel {
|
||||||
new IsMissingCriterionOption(),
|
new IsMissingCriterionOption(),
|
||||||
new TagsCriterionOption(),
|
new TagsCriterionOption(),
|
||||||
new PerformersCriterionOption(),
|
new PerformersCriterionOption(),
|
||||||
new StudiosCriterionOption()
|
new StudiosCriterionOption(),
|
||||||
|
new MoviesCriterionOption()
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
case FilterMode.Performers: {
|
case FilterMode.Performers: {
|
||||||
|
|
@ -155,6 +157,12 @@ export class ListFilterModel {
|
||||||
this.displayModeOptions = [DisplayMode.Grid];
|
this.displayModeOptions = [DisplayMode.Grid];
|
||||||
this.criterionOptions = [new NoneCriterionOption()];
|
this.criterionOptions = [new NoneCriterionOption()];
|
||||||
break;
|
break;
|
||||||
|
case FilterMode.Movies:
|
||||||
|
this.sortBy = "name";
|
||||||
|
this.sortByOptions = ["name", "scenes_count"];
|
||||||
|
this.displayModeOptions = [DisplayMode.Grid];
|
||||||
|
this.criterionOptions = [new NoneCriterionOption()];
|
||||||
|
break;
|
||||||
case FilterMode.Galleries:
|
case FilterMode.Galleries:
|
||||||
this.sortBy = "path";
|
this.sortBy = "path";
|
||||||
this.sortByOptions = ["path"];
|
this.sortByOptions = ["path"];
|
||||||
|
|
@ -236,9 +244,12 @@ export class ListFilterModel {
|
||||||
jsonParameters.forEach(jsonString => {
|
jsonParameters.forEach(jsonString => {
|
||||||
const encodedCriterion = JSON.parse(jsonString);
|
const encodedCriterion = JSON.parse(jsonString);
|
||||||
const criterion = makeCriteria(encodedCriterion.type);
|
const criterion = makeCriteria(encodedCriterion.type);
|
||||||
|
// it's possible that we have unsupported criteria. Just skip if so.
|
||||||
|
if (criterion) {
|
||||||
criterion.value = encodedCriterion.value;
|
criterion.value = encodedCriterion.value;
|
||||||
criterion.modifier = encodedCriterion.modifier;
|
criterion.modifier = encodedCriterion.modifier;
|
||||||
this.criteria.push(criterion);
|
this.criteria.push(criterion);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -394,6 +405,14 @@ export class ListFilterModel {
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "movies": {
|
||||||
|
const movCrit = criterion as MoviesCriterion;
|
||||||
|
result.movies = {
|
||||||
|
value: movCrit.value.map(movie => movie.id),
|
||||||
|
modifier: movCrit.modifier
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
// no default
|
// no default
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// NOTE: add new enum values to the end, to ensure existing data
|
||||||
|
// is not impacted
|
||||||
export enum DisplayMode {
|
export enum DisplayMode {
|
||||||
Grid,
|
Grid,
|
||||||
List,
|
List,
|
||||||
|
|
@ -9,7 +11,8 @@ export enum FilterMode {
|
||||||
Performers,
|
Performers,
|
||||||
Studios,
|
Studios,
|
||||||
Galleries,
|
Galleries,
|
||||||
SceneMarkers
|
SceneMarkers,
|
||||||
|
Movies
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ILabeledId {
|
export interface ILabeledId {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||||
import { TagsCriterion } from "src/models/list-filter/criteria/tags";
|
import { TagsCriterion } from "src/models/list-filter/criteria/tags";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { FilterMode } from "src/models/list-filter/types";
|
import { FilterMode } from "src/models/list-filter/types";
|
||||||
|
import { MoviesCriterion } from "src/models/list-filter/criteria/movies";
|
||||||
|
|
||||||
const makePerformerScenesUrl = (
|
const makePerformerScenesUrl = (
|
||||||
performer: Partial<GQL.PerformerDataFragment>
|
performer: Partial<GQL.PerformerDataFragment>
|
||||||
|
|
@ -29,6 +30,17 @@ const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
|
||||||
return `/scenes?${filter.makeQueryParameters()}`;
|
return `/scenes?${filter.makeQueryParameters()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const makeMovieScenesUrl = (movie: Partial<GQL.MovieDataFragment>) => {
|
||||||
|
if (!movie.id) return "#";
|
||||||
|
const filter = new ListFilterModel(FilterMode.Scenes);
|
||||||
|
const criterion = new MoviesCriterion();
|
||||||
|
criterion.value = [
|
||||||
|
{ id: movie.id, label: movie.name || `Movie ${movie.id}` }
|
||||||
|
];
|
||||||
|
filter.criteria.push(criterion);
|
||||||
|
return `/scenes?${filter.makeQueryParameters()}`;
|
||||||
|
};
|
||||||
|
|
||||||
const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||||
if (!tag.id) return "#";
|
if (!tag.id) return "#";
|
||||||
const filter = new ListFilterModel(FilterMode.Scenes);
|
const filter = new ListFilterModel(FilterMode.Scenes);
|
||||||
|
|
@ -59,5 +71,6 @@ export default {
|
||||||
makeStudioScenesUrl,
|
makeStudioScenesUrl,
|
||||||
makeTagSceneMarkersUrl,
|
makeTagSceneMarkersUrl,
|
||||||
makeTagScenesUrl,
|
makeTagScenesUrl,
|
||||||
makeSceneMarkerUrl
|
makeSceneMarkerUrl,
|
||||||
|
makeMovieScenesUrl
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ const renderTextArea = (options: {
|
||||||
<td>{options.title}</td>
|
<td>{options.title}</td>
|
||||||
<td>
|
<td>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
as="textarea"
|
as="textarea"
|
||||||
readOnly={!options.isEditing}
|
readOnly={!options.isEditing}
|
||||||
plaintext={!options.isEditing}
|
plaintext={!options.isEditing}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue