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
|
||||
pkg/models/generated_*.go
|
||||
ui/v2/src/core/generated-*.tsx
|
||||
ui/v2.5/src/core/generated-*.tsx
|
||||
|
||||
# packr generated files
|
||||
*-packr.go
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
type Movie {
|
||||
id: ID!
|
||||
checksum: String!
|
||||
name: String
|
||||
name: String!
|
||||
aliases: String
|
||||
duration: String
|
||||
date: String
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import (
|
|||
"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 {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { Stats } from "./components/Stats";
|
|||
import Studios from "./components/Studios/Studios";
|
||||
import { TagList } from "./components/Tags/TagList";
|
||||
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
|
||||
import Movies from "./components/Movies/Movies";
|
||||
|
||||
// Set fontawesome/free-solid-svg as default fontawesome icons
|
||||
library.add(fas);
|
||||
|
|
@ -43,6 +44,7 @@ export const App: React.FC = () => {
|
|||
<Route path="/performers" component={Performers} />
|
||||
<Route path="/tags" component={TagList} />
|
||||
<Route path="/studios" component={Studios} />
|
||||
<Route path="/movies" component={Movies} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route
|
||||
path="/sceneFilenameParser"
|
||||
|
|
|
|||
|
|
@ -140,7 +140,8 @@ export const AddFilter: React.FC<IAddFilterProps> = (
|
|||
if (
|
||||
criterion.type !== "performers" &&
|
||||
criterion.type !== "studios" &&
|
||||
criterion.type !== "tags"
|
||||
criterion.type !== "tags" &&
|
||||
criterion.type !== "movies"
|
||||
)
|
||||
return;
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ const menuItems: IMenuItem[] = [
|
|||
messageID: "scenes",
|
||||
href: "/scenes"
|
||||
},
|
||||
{
|
||||
href: "/movies",
|
||||
icon: "film",
|
||||
messageID: "movies"
|
||||
},
|
||||
{
|
||||
href: "/scenes/markers",
|
||||
icon: "map-marker-alt",
|
||||
|
|
@ -79,6 +84,8 @@ export const MainNavbar: React.FC = () => {
|
|||
? "/performers/new"
|
||||
: location.pathname === "/studios"
|
||||
? "/studios/new"
|
||||
: location.pathname === "/movies"
|
||||
? "/movies/new"
|
||||
: null;
|
||||
const newButton =
|
||||
path === null ? (
|
||||
|
|
@ -98,7 +105,7 @@ export const MainNavbar: React.FC = () => {
|
|||
variant="dark"
|
||||
bg="dark"
|
||||
className="top-nav"
|
||||
expand="md"
|
||||
expand="lg"
|
||||
expanded={expanded}
|
||||
onToggle={setExpanded}
|
||||
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() {
|
||||
if (props.scene.scene_markers.length <= 0) return;
|
||||
|
||||
|
|
@ -161,6 +194,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||
if (
|
||||
props.scene.tags.length > 0 ||
|
||||
props.scene.performers.length > 0 ||
|
||||
props.scene.movies.length > 0 ||
|
||||
props.scene.scene_markers.length > 0 ||
|
||||
props.scene?.o_counter
|
||||
) {
|
||||
|
|
@ -170,6 +204,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||
<ButtonGroup className="scene-popovers">
|
||||
{maybeRenderTagPopoverButton()}
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{maybeRenderMoviePopoverButton()}
|
||||
{maybeRenderSceneMarkerPopoverButton()}
|
||||
{maybeRenderOCounter()}
|
||||
</ButtonGroup>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { SceneEditPanel } from "./SceneEditPanel";
|
|||
import { SceneDetailPanel } from "./SceneDetailPanel";
|
||||
import { OCounterButton } from "./OCounterButton";
|
||||
import { SceneOperationsPanel } from "./SceneOperationsPanel";
|
||||
import { SceneMoviePanel } from "./SceneMoviePanel";
|
||||
|
||||
export const Scene: React.FC = () => {
|
||||
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 ? (
|
||||
<Tab eventKey="scene-gallery-panel" title="Gallery">
|
||||
<GalleryViewer gallery={scene.gallery} />
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import {
|
|||
} from "src/components/Shared";
|
||||
import { useToast } from "src/hooks";
|
||||
import { ImageUtils, TableUtils } from "src/utils";
|
||||
import { MovieSelect } from "src/components/Shared/Select";
|
||||
import { SceneMovieTable, MovieSceneIndexMap } from "./SceneMovieTable";
|
||||
|
||||
interface IProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
|
|
@ -33,6 +35,10 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
const [galleryId, setGalleryId] = useState<string>();
|
||||
const [studioId, setStudioId] = 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 [coverImage, setCoverImage] = useState<string>();
|
||||
|
||||
|
|
@ -59,9 +65,46 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
setQueryableScrapers(newQueryableScrapers);
|
||||
}, [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>) {
|
||||
const perfIds = state.performers?.map(performer => performer.id);
|
||||
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);
|
||||
setDetails(state.details ?? undefined);
|
||||
|
|
@ -70,6 +113,8 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
setRating(state.rating === null ? NaN : state.rating);
|
||||
setGalleryId(state?.gallery?.id ?? undefined);
|
||||
setStudioId(state?.studio?.id ?? undefined);
|
||||
setMovieIds(moviIds);
|
||||
setMovieSceneIndexes(movieSceneIdx);
|
||||
setPerformerIds(perfIds);
|
||||
setTagIds(tIds);
|
||||
}
|
||||
|
|
@ -93,11 +138,31 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
gallery_id: galleryId,
|
||||
studio_id: studioId,
|
||||
performer_ids: performerIds,
|
||||
movies: makeMovieInputs(),
|
||||
tag_ids: tagIds,
|
||||
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() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
|
|
@ -133,6 +198,17 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
props.onDelete();
|
||||
}
|
||||
|
||||
function renderTableMovies() {
|
||||
return (
|
||||
<SceneMovieTable
|
||||
movieSceneIndexes={movieSceneIndexes}
|
||||
onUpdate={items => {
|
||||
setMovieSceneIndexes(items);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
return (
|
||||
<Modal
|
||||
|
|
@ -197,7 +273,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
return (
|
||||
<DropdownButton id="scene-scrape" title="Scrape with...">
|
||||
{queryableScrapers.map(s => (
|
||||
<Dropdown.Item onClick={() => onScrapeClicked(s)}>
|
||||
<Dropdown.Item key={s.name} onClick={() => onScrapeClicked(s)}>
|
||||
{s.name}
|
||||
</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) {
|
||||
const idTags = scene.tags.filter(p => {
|
||||
return p.id !== undefined && p.id !== null;
|
||||
|
|
@ -369,6 +460,17 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Movies/Scenes</td>
|
||||
<td>
|
||||
<MovieSelect
|
||||
isMulti
|
||||
onSelect={items => setMovieIds(items.map(item => item.id))}
|
||||
ids={movieIds}
|
||||
/>
|
||||
{renderTableMovies()}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tags</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>
|
||||
));
|
||||
|
||||
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) => (
|
||||
<tr key={scene.id}>
|
||||
<td>
|
||||
|
|
@ -58,6 +70,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
|
|||
</Link>
|
||||
)}
|
||||
</td>
|
||||
<td>{renderMovies(scene.movies)}</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
|
|
@ -73,6 +86,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
|
|||
<th>Tags</th>
|
||||
<th>Performers</th>
|
||||
<th>Studio</th>
|
||||
<th>Movies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{props.scenes.map(renderSceneRow)}</tbody>
|
||||
|
|
|
|||
|
|
@ -35,12 +35,12 @@
|
|||
z-index: 1;
|
||||
}
|
||||
|
||||
.performer-tag-container {
|
||||
.performer-tag-container, .movie-tag-container {
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.performer-tag.image {
|
||||
.performer-tag.image, .movie-tag.image {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
|
|
@ -212,3 +212,7 @@
|
|||
margin-top: 10px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.movie-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
import { Button, Modal } from "react-bootstrap";
|
||||
import React, { useState } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { ImageInput } from "src/components/Shared";
|
||||
|
||||
interface IProps {
|
||||
performer?: Partial<GQL.PerformerDataFragment>;
|
||||
studio?: Partial<GQL.StudioDataFragment>;
|
||||
objectName?: string;
|
||||
isNew: boolean;
|
||||
isEditing: boolean;
|
||||
onToggleEdit: () => void;
|
||||
|
|
@ -13,6 +11,7 @@ interface IProps {
|
|||
onDelete: () => void;
|
||||
onAutoTag?: () => void;
|
||||
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||
onBackImageChange?: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
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() {
|
||||
if (props.isNew || props.isEditing) return;
|
||||
|
||||
|
|
@ -74,11 +86,11 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
|
|||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
const name = props?.studio?.name ?? props?.performer?.name;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Button variant="danger" onClick={props.onDelete}>
|
||||
Delete
|
||||
|
|
@ -99,8 +111,10 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
|
|||
{renderEditButton()}
|
||||
<ImageInput
|
||||
isEditing={props.isEditing}
|
||||
text={props.onBackImageChange ? "Front image..." : undefined}
|
||||
onImageChange={props.onImageChange}
|
||||
/>
|
||||
{renderBackImageInput()}
|
||||
{renderAutoTagButton()}
|
||||
{renderSaveButton()}
|
||||
{renderDeleteButton()}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,20 @@ import { Button, Form } from "react-bootstrap";
|
|||
|
||||
interface IImageInput {
|
||||
isEditing: boolean;
|
||||
text?: string;
|
||||
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const ImageInput: React.FC<IImageInput> = ({
|
||||
isEditing,
|
||||
text,
|
||||
onImageChange
|
||||
}) => {
|
||||
if (!isEditing) return <div />;
|
||||
|
||||
return (
|
||||
<Form.Label className="image-input">
|
||||
<Button variant="secondary">Browse for image...</Button>
|
||||
<Button variant="secondary">{text ?? "Browse for image..."}</Button>
|
||||
<Form.Control
|
||||
type="file"
|
||||
onChange={onImageChange}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ type ValidTypes =
|
|||
type Option = { value: string; label: string };
|
||||
|
||||
interface ITypeProps {
|
||||
type?: "performers" | "studios" | "tags";
|
||||
type?: "performers" | "studios" | "tags" | "movies";
|
||||
}
|
||||
interface IFilterProps {
|
||||
ids?: string[];
|
||||
|
|
@ -172,6 +172,8 @@ export const FilterSelect: React.FC<IFilterProps & ITypeProps> = props =>
|
|||
<PerformerSelect {...(props as IFilterProps)} />
|
||||
) : props.type === "studios" ? (
|
||||
<StudioSelect {...(props as IFilterProps)} />
|
||||
) : props.type === "movies" ? (
|
||||
<MovieSelect {...(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 => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>(props.ids ?? []);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { Link } from "react-router-dom";
|
|||
import {
|
||||
PerformerDataFragment,
|
||||
SceneMarkerDataFragment,
|
||||
TagDataFragment
|
||||
TagDataFragment,
|
||||
MovieDataFragment
|
||||
} from "src/core/generated-graphql";
|
||||
import { NavUtils, TextUtils } from "src/utils";
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ interface IProps {
|
|||
tag?: Partial<TagDataFragment>;
|
||||
performer?: Partial<PerformerDataFragment>;
|
||||
marker?: Partial<SceneMarkerDataFragment>;
|
||||
movie?: Partial<MovieDataFragment>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -24,6 +26,9 @@ export const TagLink: React.FC<IProps> = (props: IProps) => {
|
|||
} else if (props.performer) {
|
||||
link = NavUtils.makePerformerScenesUrl(props.performer);
|
||||
title = props.performer.name || "";
|
||||
} else if (props.movie) {
|
||||
link = NavUtils.makeMovieScenesUrl(props.movie);
|
||||
title = props.movie.name || "";
|
||||
} else if (props.marker) {
|
||||
link = NavUtils.makeSceneMarkerUrl(props.marker);
|
||||
title = `${props.marker.title} - ${TextUtils.secondsToTimestamp(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ export const Stats: React.FC = () => {
|
|||
<FormattedMessage id="scenes" defaultMessage="Scenes" />
|
||||
</p>
|
||||
</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">
|
||||
<p className="title">
|
||||
<FormattedNumber value={data.stats.gallery_count} />
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export const Studio: React.FC = () => {
|
|||
</tbody>
|
||||
</Table>
|
||||
<DetailsEditNavbar
|
||||
studio={studio}
|
||||
objectName={studio.name ?? "studio"}
|
||||
isNew={isNew}
|
||||
isEditing={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) {
|
||||
let performerFilter = {};
|
||||
// if (!!filter && filter.criteriaFilterOpen) {
|
||||
|
|
@ -220,6 +228,10 @@ export class StashService {
|
|||
const skip = id === "new";
|
||||
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
|
||||
private static sceneMarkerMutationImpactedQueries = [
|
||||
|
|
@ -279,6 +291,9 @@ export class StashService {
|
|||
public static useAllStudiosForFilter() {
|
||||
return GQL.useAllStudiosForFilterQuery();
|
||||
}
|
||||
public static useAllMoviesForFilter() {
|
||||
return GQL.useAllMoviesForFilterQuery();
|
||||
}
|
||||
public static useValidGalleriesForScene(sceneId: string) {
|
||||
return GQL.useValidGalleriesForSceneQuery({
|
||||
variables: { scene_id: sceneId }
|
||||
|
|
@ -341,6 +356,7 @@ export class StashService {
|
|||
"findScenes",
|
||||
"findSceneMarkers",
|
||||
"findStudios",
|
||||
"findMovies",
|
||||
"allTags"
|
||||
// TODO - add "findTags" when it is implemented
|
||||
];
|
||||
|
|
@ -362,6 +378,7 @@ export class StashService {
|
|||
"findPerformers",
|
||||
"findSceneMarkers",
|
||||
"findStudios",
|
||||
"findMovies",
|
||||
"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 = [
|
||||
"findScenes",
|
||||
"findSceneMarkers",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -14,7 +14,9 @@ import {
|
|||
FindSceneMarkersQueryResult,
|
||||
FindGalleriesQueryResult,
|
||||
FindStudiosQueryResult,
|
||||
FindPerformersQueryResult
|
||||
FindPerformersQueryResult,
|
||||
FindMoviesQueryResult,
|
||||
MovieDataFragment
|
||||
} from "src/core/generated-graphql";
|
||||
import {
|
||||
useInterfaceLocalForage,
|
||||
|
|
@ -453,3 +455,14 @@ export const usePerformersList = (
|
|||
getCount: (result: FindPerformersQueryResult) =>
|
||||
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 "src/components/Galleries/styles.scss";
|
||||
@import "src/components/List/styles.scss";
|
||||
@import "src/components/Movies/styles.scss";
|
||||
@import "src/components/Performers/styles.scss";
|
||||
@import "src/components/Scenes/styles.scss";
|
||||
@import "src/components/SceneFilenameParser/styles.scss";
|
||||
|
|
@ -48,7 +49,8 @@ code,
|
|||
}
|
||||
|
||||
.text-input,
|
||||
.text-input:focus {
|
||||
.text-input:focus,
|
||||
.text-input[readonly] {
|
||||
background-color: $textfield-bg;
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +59,12 @@ code,
|
|||
background-color: $secondary;
|
||||
}
|
||||
|
||||
textarea.text-input {
|
||||
line-height: 2.5ex;
|
||||
min-height: 12ex;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.table-list a {
|
||||
color: $text-color;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"new": "Neu",
|
||||
"tags": "Etiketten",
|
||||
"scenes": "Szenen",
|
||||
"movies": "Filme",
|
||||
"studios": "Studios",
|
||||
"galleries": "Galerien",
|
||||
"performers": "Künstler",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"new": "New",
|
||||
"tags": "Tags",
|
||||
"scenes": "Scenes",
|
||||
"movies": "Movies",
|
||||
"studios": "Studios",
|
||||
"galleries": "Galleries",
|
||||
"performers": "Performers",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export type CriterionType =
|
|||
| "sceneTags"
|
||||
| "performers"
|
||||
| "studios"
|
||||
| "movies"
|
||||
| "birth_year"
|
||||
| "age"
|
||||
| "ethnicity"
|
||||
|
|
@ -60,6 +61,8 @@ export abstract class Criterion {
|
|||
return "Performers";
|
||||
case "studios":
|
||||
return "Studios";
|
||||
case "movies":
|
||||
return "Movies";
|
||||
case "birth_year":
|
||||
return "Birth Year";
|
||||
case "age":
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export class IsMissingCriterion extends Criterion {
|
|||
"date",
|
||||
"gallery",
|
||||
"studio",
|
||||
"movie",
|
||||
"performers"
|
||||
];
|
||||
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 { StudiosCriterion } from "./studios";
|
||||
import { TagsCriterion } from "./tags";
|
||||
import { MoviesCriterion } from "./movies";
|
||||
|
||||
export function makeCriteria(type: CriterionType = "none") {
|
||||
switch (type) {
|
||||
|
|
@ -43,6 +44,8 @@ export function makeCriteria(type: CriterionType = "none") {
|
|||
return new PerformersCriterion();
|
||||
case "studios":
|
||||
return new StudiosCriterion();
|
||||
case "movies":
|
||||
return new MoviesCriterion();
|
||||
|
||||
case "birth_year":
|
||||
return new NumberCriterion(type, type);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
} from "./criteria/tags";
|
||||
import { makeCriteria } from "./criteria/utils";
|
||||
import { DisplayMode, FilterMode } from "./types";
|
||||
import { MoviesCriterionOption, MoviesCriterion } from "./criteria/movies";
|
||||
|
||||
interface IQueryParameters {
|
||||
perPage?: string;
|
||||
|
|
@ -115,7 +116,8 @@ export class ListFilterModel {
|
|||
new IsMissingCriterionOption(),
|
||||
new TagsCriterionOption(),
|
||||
new PerformersCriterionOption(),
|
||||
new StudiosCriterionOption()
|
||||
new StudiosCriterionOption(),
|
||||
new MoviesCriterionOption()
|
||||
];
|
||||
break;
|
||||
case FilterMode.Performers: {
|
||||
|
|
@ -155,6 +157,12 @@ export class ListFilterModel {
|
|||
this.displayModeOptions = [DisplayMode.Grid];
|
||||
this.criterionOptions = [new NoneCriterionOption()];
|
||||
break;
|
||||
case FilterMode.Movies:
|
||||
this.sortBy = "name";
|
||||
this.sortByOptions = ["name", "scenes_count"];
|
||||
this.displayModeOptions = [DisplayMode.Grid];
|
||||
this.criterionOptions = [new NoneCriterionOption()];
|
||||
break;
|
||||
case FilterMode.Galleries:
|
||||
this.sortBy = "path";
|
||||
this.sortByOptions = ["path"];
|
||||
|
|
@ -236,9 +244,12 @@ export class ListFilterModel {
|
|||
jsonParameters.forEach(jsonString => {
|
||||
const encodedCriterion = JSON.parse(jsonString);
|
||||
const criterion = makeCriteria(encodedCriterion.type);
|
||||
// it's possible that we have unsupported criteria. Just skip if so.
|
||||
if (criterion) {
|
||||
criterion.value = encodedCriterion.value;
|
||||
criterion.modifier = encodedCriterion.modifier;
|
||||
this.criteria.push(criterion);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -394,6 +405,14 @@ export class ListFilterModel {
|
|||
};
|
||||
break;
|
||||
}
|
||||
case "movies": {
|
||||
const movCrit = criterion as MoviesCriterion;
|
||||
result.movies = {
|
||||
value: movCrit.value.map(movie => movie.id),
|
||||
modifier: movCrit.modifier
|
||||
};
|
||||
break;
|
||||
}
|
||||
// no default
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// NOTE: add new enum values to the end, to ensure existing data
|
||||
// is not impacted
|
||||
export enum DisplayMode {
|
||||
Grid,
|
||||
List,
|
||||
|
|
@ -9,7 +11,8 @@ export enum FilterMode {
|
|||
Performers,
|
||||
Studios,
|
||||
Galleries,
|
||||
SceneMarkers
|
||||
SceneMarkers,
|
||||
Movies
|
||||
}
|
||||
|
||||
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 { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { FilterMode } from "src/models/list-filter/types";
|
||||
import { MoviesCriterion } from "src/models/list-filter/criteria/movies";
|
||||
|
||||
const makePerformerScenesUrl = (
|
||||
performer: Partial<GQL.PerformerDataFragment>
|
||||
|
|
@ -29,6 +30,17 @@ const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
|
|||
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>) => {
|
||||
if (!tag.id) return "#";
|
||||
const filter = new ListFilterModel(FilterMode.Scenes);
|
||||
|
|
@ -59,5 +71,6 @@ export default {
|
|||
makeStudioScenesUrl,
|
||||
makeTagSceneMarkersUrl,
|
||||
makeTagScenesUrl,
|
||||
makeSceneMarkerUrl
|
||||
makeSceneMarkerUrl,
|
||||
makeMovieScenesUrl
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const renderTextArea = (options: {
|
|||
<td>{options.title}</td>
|
||||
<td>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
as="textarea"
|
||||
readOnly={!options.isEditing}
|
||||
plaintext={!options.isEditing}
|
||||
|
|
|
|||
Loading…
Reference in a new issue