From ff495361d9cd8656f33e3050e617c9685c29b0a0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sat, 21 Mar 2020 08:21:49 +1100 Subject: [PATCH] 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 --- .gitignore | 1 + graphql/schema/types/movie.graphql | 2 +- pkg/api/resolver_model_movie.go | 8 +- ui/v2.5/src/App.tsx | 2 + ui/v2.5/src/components/List/AddFilter.tsx | 3 +- ui/v2.5/src/components/MainNavbar.tsx | 9 +- ui/v2.5/src/components/Movies/MovieCard.tsx | 51 + .../components/Movies/MovieDetails/Movie.tsx | 282 + .../Movies/MovieDetails/MovieScenesPanel.tsx | 45 + ui/v2.5/src/components/Movies/MovieList.tsx | 35 + ui/v2.5/src/components/Movies/Movies.tsx | 13 + ui/v2.5/src/components/Movies/styles.scss | 23 + ui/v2.5/src/components/Scenes/SceneCard.tsx | 35 + .../components/Scenes/SceneDetails/Scene.tsx | 8 + .../Scenes/SceneDetails/SceneEditPanel.tsx | 104 +- .../Scenes/SceneDetails/SceneMoviePanel.tsx | 25 + .../Scenes/SceneDetails/SceneMovieTable.tsx | 76 + .../src/components/Scenes/SceneListTable.tsx | 14 + ui/v2.5/src/components/Scenes/styles.scss | 432 +- .../components/Shared/DetailsEditNavbar.tsx | 26 +- ui/v2.5/src/components/Shared/ImageInput.tsx | 4 +- ui/v2.5/src/components/Shared/Select.tsx | 42 +- ui/v2.5/src/components/Shared/TagLink.tsx | 7 +- ui/v2.5/src/components/Stats.tsx | 8 + .../Studios/StudioDetails/Studio.tsx | 2 +- ui/v2.5/src/core/StashService.ts | 53 + ui/v2.5/src/core/generated-graphql.tsx | 5192 ----------------- ui/v2.5/src/hooks/ListHook.tsx | 15 +- ui/v2.5/src/index.scss | 916 +-- ui/v2.5/src/locale/de.json | 27 +- ui/v2.5/src/locale/en.json | 27 +- .../models/list-filter/criteria/criterion.ts | 3 + .../models/list-filter/criteria/is-missing.ts | 1 + .../src/models/list-filter/criteria/movies.ts | 32 + .../src/models/list-filter/criteria/utils.ts | 3 + ui/v2.5/src/models/list-filter/filter.ts | 27 +- ui/v2.5/src/models/list-filter/types.ts | 5 +- ui/v2.5/src/utils/navigation.ts | 15 +- ui/v2.5/src/utils/table.tsx | 1 + 39 files changed, 1663 insertions(+), 5911 deletions(-) create mode 100644 ui/v2.5/src/components/Movies/MovieCard.tsx create mode 100644 ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx create mode 100644 ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx create mode 100644 ui/v2.5/src/components/Movies/MovieList.tsx create mode 100644 ui/v2.5/src/components/Movies/Movies.tsx create mode 100644 ui/v2.5/src/components/Movies/styles.scss create mode 100644 ui/v2.5/src/components/Scenes/SceneDetails/SceneMoviePanel.tsx create mode 100644 ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx delete mode 100644 ui/v2.5/src/core/generated-graphql.tsx create mode 100644 ui/v2.5/src/models/list-filter/criteria/movies.ts diff --git a/.gitignore b/.gitignore index a54db8fb0..4f92a344f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/graphql/schema/types/movie.graphql b/graphql/schema/types/movie.graphql index 6b8c47d79..28b828678 100644 --- a/graphql/schema/types/movie.graphql +++ b/graphql/schema/types/movie.graphql @@ -1,7 +1,7 @@ type Movie { id: ID! checksum: String! - name: String + name: String! aliases: String duration: String date: String diff --git a/pkg/api/resolver_model_movie.go b/pkg/api/resolver_model_movie.go index b30e113f6..3f1323cc8 100644 --- a/pkg/api/resolver_model_movie.go +++ b/pkg/api/resolver_model_movie.go @@ -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) { @@ -81,4 +81,4 @@ func (r *movieResolver) SceneCount(ctx context.Context, obj *models.Movie) (*int qb := models.NewSceneQueryBuilder() res, err := qb.CountByMovieID(obj.ID) return &res, err -} \ No newline at end of file +} diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index fb9b783b6..e8b1447b9 100755 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -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 = () => { + = ( if ( criterion.type !== "performers" && criterion.type !== "studios" && - criterion.type !== "tags" + criterion.type !== "tags" && + criterion.type !== "movies" ) return; diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index 556d88622..cef3716ab 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -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} diff --git a/ui/v2.5/src/components/Movies/MovieCard.tsx b/ui/v2.5/src/components/Movies/MovieCard.tsx new file mode 100644 index 000000000..1cf6bc710 --- /dev/null +++ b/ui/v2.5/src/components/Movies/MovieCard.tsx @@ -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 = (props: IProps) => { + function maybeRenderRatingBanner() { + if (!props.movie.rating) { + return; + } + return ( +
+ RATING: {props.movie.rating} +
+ ); + } + + function maybeRenderSceneNumber() { + if (!props.sceneIndex) { + return {props.movie.scene_count} scenes.; + } + + return Scene number: {props.sceneIndex}; + } + + return ( + + + {props.movie.name + {maybeRenderRatingBanner()} + +
+
{props.movie.name}
+ {maybeRenderSceneNumber()} +
+
+ ); +}; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx new file mode 100644 index 000000000..a684291ab --- /dev/null +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -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(isNew); + const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + + // Editing movie state + const [frontImage, setFrontImage] = useState(undefined); + const [backImage, setBackImage] = useState(undefined); + const [name, setName] = useState(undefined); + const [aliases, setAliases] = useState(undefined); + const [duration, setDuration] = useState(undefined); + const [date, setDate] = useState(undefined); + const [rating, setRating] = useState(undefined); + const [director, setDirector] = useState(undefined); + const [synopsis, setSynopsis] = useState(undefined); + const [url, setUrl] = useState(undefined); + + // Movie state + const [movie, setMovie] = useState>({}); + const [imagePreview, setImagePreview] = useState( + undefined + ); + const [backimagePreview, setBackImagePreview] = useState( + 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) { + 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) => { + 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 ; + if (error) { + return <>{error!.message}; + } + } + + function getMovieInput() { + const input: Partial = { + 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) { + ImageUtils.onImageChange(event, onImageLoad); + } + + function onBackImageChange(event: React.FormEvent) { + ImageUtils.onImageChange(event, onBackImageLoad); + } + + function renderDeleteAlert() { + return ( + setIsDeleteAlertOpen(false) }} + > +

Are you sure you want to delete {movie.name ?? "movie"}?

+
+ ); + } + + // TODO: CSS class + return ( +
+
+ {isNew &&

Add Movie

} +
+ {name} + {name} +
+ + + + {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"] + })} + +
+ + + URL + ) => + setUrl(newValue.currentTarget.value) + } + value={url} + /> + + + + Synopsis + ) => + setSynopsis(newValue.currentTarget.value) + } + value={synopsis} + /> + + + setIsEditing(!isEditing)} + onSave={onSave} + onImageChange={onImageChange} + onBackImageChange={onBackImageChange} + onDelete={onDelete} + /> +
+ {!isNew && ( +
+ +
+ )} + {renderDeleteAlert()} +
+ ); +}; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx new file mode 100644 index 000000000..45ce35968 --- /dev/null +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx @@ -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; +} + +export const MovieScenesPanel: React.FC = ({ 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 ; +}; diff --git a/ui/v2.5/src/components/Movies/MovieList.tsx b/ui/v2.5/src/components/Movies/MovieList.tsx new file mode 100644 index 000000000..02292d098 --- /dev/null +++ b/ui/v2.5/src/components/Movies/MovieList.tsx @@ -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 ( +
+ {result.data.findMovies.movies.map(p => ( + + ))} +
+ ); + } + if (filter.displayMode === DisplayMode.List) { + return

TODO

; + } + } + + return listData.template; +}; diff --git a/ui/v2.5/src/components/Movies/Movies.tsx b/ui/v2.5/src/components/Movies/Movies.tsx new file mode 100644 index 000000000..387f6391c --- /dev/null +++ b/ui/v2.5/src/components/Movies/Movies.tsx @@ -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 = () => ( + + + + +); + +export default Movies; diff --git a/ui/v2.5/src/components/Movies/styles.scss b/ui/v2.5/src/components/Movies/styles.scss new file mode 100644 index 000000000..19c487d47 --- /dev/null +++ b/ui/v2.5/src/components/Movies/styles.scss @@ -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%; + } + } +} + \ No newline at end of file diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index eb36bfeea..cb178e057 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -126,6 +126,39 @@ export const SceneCard: React.FC = ( ); } + function maybeRenderMoviePopoverButton() { + if (props.scene.movies.length <= 0) return; + + const popoverContent = props.scene.movies.map(sceneMovie => ( +
+ + {sceneMovie.movie.name + + +
+ )); + + return ( + + + + ); + } + function maybeRenderSceneMarkerPopoverButton() { if (props.scene.scene_markers.length <= 0) return; @@ -161,6 +194,7 @@ export const SceneCard: React.FC = ( 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 = ( {maybeRenderTagPopoverButton()} {maybeRenderPerformerPopoverButton()} + {maybeRenderMoviePopoverButton()} {maybeRenderSceneMarkerPopoverButton()} {maybeRenderOCounter()} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 29b316364..e2fdadd24 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -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 ? ( + + + + ) : ( + "" + )} {scene.gallery ? ( diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 99b68fe14..91dc954ad 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -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 = (props: IProps) => { const [galleryId, setGalleryId] = useState(); const [studioId, setStudioId] = useState(); const [performerIds, setPerformerIds] = useState(); + const [movieIds, setMovieIds] = useState(undefined); + const [movieSceneIndexes, setMovieSceneIndexes] = useState< + MovieSceneIndexMap + >(new Map()); const [tagIds, setTagIds] = useState(); const [coverImage, setCoverImage] = useState(); @@ -59,9 +65,46 @@ export const SceneEditPanel: React.FC = (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) { 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 = (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 = (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 = (props: IProps) => { props.onDelete(); } + function renderTableMovies() { + return ( + { + setMovieSceneIndexes(items); + }} + /> + ); + } + function renderDeleteAlert() { return ( = (props: IProps) => { return ( {queryableScrapers.map(s => ( - onScrapeClicked(s)}> + onScrapeClicked(s)}> {s.name} ))} @@ -247,6 +323,21 @@ export const SceneEditPanel: React.FC = (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 = (props: IProps) => { /> + + Movies/Scenes + + setMovieIds(items.map(item => item.id))} + ids={movieIds} + /> + {renderTableMovies()} + + Tags diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMoviePanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMoviePanel.tsx new file mode 100644 index 000000000..a68e8cc53 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMoviePanel.tsx @@ -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 = ( + props: ISceneMoviePanelProps +) => { + const cards = props.scene.movies.map(sceneMovie => ( + + )); + + return ( + <> +
{cards}
+ + ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx new file mode 100644 index 000000000..2c4ba41c7 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx @@ -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; + +export interface IProps { + movieSceneIndexes: MovieSceneIndexMap; + onUpdate: (value: MovieSceneIndexMap) => void; +} + +export const SceneMovieTable: React.FunctionComponent = ( + 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 ( + + {itemsFilter!.map((item, index: number) => ( + + {item.name} + + Scene number: + + ) => + updateFieldChanged(item.id, e.currentTarget.value) + } + > + {["", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"].map( + opt => ( + + ) + )} + + + + ))} + + ); + } + + return ( +
+ {renderTableData()}
+
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneListTable.tsx b/ui/v2.5/src/components/Scenes/SceneListTable.tsx index 8dc3e66cc..63bf5119a 100644 --- a/ui/v2.5/src/components/Scenes/SceneListTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneListTable.tsx @@ -26,6 +26,18 @@ export const SceneListTable: React.FC = ( )); + const renderMovies = (movies: Partial[]) => { + return movies.map(sceneMovie => + !sceneMovie.movie ? ( + undefined + ) : ( + +
{sceneMovie.movie.name}
+ + ) + ); + }; + const renderSceneRow = (scene: GQL.SlimSceneDataFragment) => ( @@ -58,6 +70,7 @@ export const SceneListTable: React.FC = ( )} + {renderMovies(scene.movies)} ); @@ -73,6 +86,7 @@ export const SceneListTable: React.FC = ( Tags Performers Studio + Movies {props.scenes.map(renderSceneRow)} diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 1a23390bd..40288ff3c 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -1,214 +1,218 @@ -.scene-popovers { - display: flex; - justify-content: center; - margin-bottom: 10px; - - .btn { - padding-bottom: 3px; - padding-top: 3px; - } - - .fa-icon { - margin-right: 7px; - } -} - -.card-section { - margin-bottom: 0; - padding: .5rem 1rem 0 1rem; - - &-title { - overflow: hidden; - overflow-wrap: normal; - text-overflow: ellipsis; - } -} - -.scene-card-check { - left: .5rem; - margin-top: -12px; - opacity: .5; - padding-left: 15px; - position: absolute; - top: .7rem; - width: 1.2rem; - z-index: 1; -} - -.performer-tag-container { - display: inline-block; - margin: 5px; -} - -.performer-tag.image { - background-position: center; - background-repeat: no-repeat; - background-size: cover; - height: 150px; - margin: 0 auto; - width: 100%; -} - -.operation-container { - .operation-item { - min-width: 240px; - } - - .rating-operation { - min-width: 20px; - } - - .apply-operation { - margin-top: 2rem; - } -} - -.marker-container { - display: "flex"; - flex-wrap: "nowrap"; - margin-bottom: "20px"; - overflow-x: "scroll"; - overflow-y: "hidden"; - white-space: "nowrap"; -} - -.studio-logo { - margin-top: 1rem; - max-width: 100%; -} - -.scene-header { - flex-basis: auto; -} - -#scene-details-container { - .tab-content { - min-height: 15rem; - } - - .scene-description { - width: 100%; - } -} - -.file-info-panel { - div { - margin-bottom: .5rem; - } -} - -#details { - min-height: 150px; -} - -.primary-card { - margin: 1rem 0; - - &-body { - max-height: 15rem; - overflow-y: auto; - } -} - -.studio-card { - padding: .5rem; - - &-header { - height: 150px; - line-height: 150px; - text-align: center; - } - - &-image { - max-height: 150px; - object-fit: contain; - vertical-align: middle; - width: 320px; - - @media (max-width: 576px) { - width: 100%; - } - } -} - -.scene-specs-overlay { - bottom: 1rem; - color: $text-color; - display: block; - font-weight: 400; - letter-spacing: -.03rem; - position: absolute; - right: .7rem; - text-shadow: 0 0 3px #000; -} - -.scene-studio-overlay { - display: block; - font-weight: 900; - height: 10%; - max-width: 40%; - opacity: .75; - position: absolute; - right: .7rem; - top: .7rem; - z-index: 9; - - .image-thumbnail { - height: auto; - max-height: 50px; - max-width: 100%; - } - - a { - color: $text-color; - display: inline-block; - letter-spacing: -.03rem; - text-align: right; - text-decoration: none; - text-shadow: 0 0 3px #000; - } -} - -.overlay-resolution { - font-weight: 900; - margin-right: .3rem; - text-transform: uppercase; -} - -.scene-card { - &.card { - overflow: hidden; - padding: 0; - } - - &-link { - position: relative; - } - - .scene-specs-overlay, - .rating-banner, - .scene-studio-overlay { - transition: opacity .5s; - } - - &:hover { - .scene-specs-overlay, - .rating-banner, - .scene-studio-overlay { - opacity: 0; - transition: opacity .5s; - } - - .scene-studio-overlay:hover { - opacity: .75; - transition: opacity .5s; - } - } -} - -.scene-cover { - display: block; - margin-bottom: 10px; - margin-top: 10px; - max-width: 100%; -} +.scene-popovers { + display: flex; + justify-content: center; + margin-bottom: 10px; + + .btn { + padding-bottom: 3px; + padding-top: 3px; + } + + .fa-icon { + margin-right: 7px; + } +} + +.card-section { + margin-bottom: 0; + padding: .5rem 1rem 0 1rem; + + &-title { + overflow: hidden; + overflow-wrap: normal; + text-overflow: ellipsis; + } +} + +.scene-card-check { + left: .5rem; + margin-top: -12px; + opacity: .5; + padding-left: 15px; + position: absolute; + top: .7rem; + width: 1.2rem; + z-index: 1; +} + +.performer-tag-container, .movie-tag-container { + display: inline-block; + margin: 5px; +} + +.performer-tag.image, .movie-tag.image { + background-position: center; + background-repeat: no-repeat; + background-size: cover; + height: 150px; + margin: 0 auto; + width: 100%; +} + +.operation-container { + .operation-item { + min-width: 240px; + } + + .rating-operation { + min-width: 20px; + } + + .apply-operation { + margin-top: 2rem; + } +} + +.marker-container { + display: "flex"; + flex-wrap: "nowrap"; + margin-bottom: "20px"; + overflow-x: "scroll"; + overflow-y: "hidden"; + white-space: "nowrap"; +} + +.studio-logo { + margin-top: 1rem; + max-width: 100%; +} + +.scene-header { + flex-basis: auto; +} + +#scene-details-container { + .tab-content { + min-height: 15rem; + } + + .scene-description { + width: 100%; + } +} + +.file-info-panel { + div { + margin-bottom: .5rem; + } +} + +#details { + min-height: 150px; +} + +.primary-card { + margin: 1rem 0; + + &-body { + max-height: 15rem; + overflow-y: auto; + } +} + +.studio-card { + padding: .5rem; + + &-header { + height: 150px; + line-height: 150px; + text-align: center; + } + + &-image { + max-height: 150px; + object-fit: contain; + vertical-align: middle; + width: 320px; + + @media (max-width: 576px) { + width: 100%; + } + } +} + +.scene-specs-overlay { + bottom: 1rem; + color: $text-color; + display: block; + font-weight: 400; + letter-spacing: -.03rem; + position: absolute; + right: .7rem; + text-shadow: 0 0 3px #000; +} + +.scene-studio-overlay { + display: block; + font-weight: 900; + height: 10%; + max-width: 40%; + opacity: .75; + position: absolute; + right: .7rem; + top: .7rem; + z-index: 9; + + .image-thumbnail { + height: auto; + max-height: 50px; + max-width: 100%; + } + + a { + color: $text-color; + display: inline-block; + letter-spacing: -.03rem; + text-align: right; + text-decoration: none; + text-shadow: 0 0 3px #000; + } +} + +.overlay-resolution { + font-weight: 900; + margin-right: .3rem; + text-transform: uppercase; +} + +.scene-card { + &.card { + overflow: hidden; + padding: 0; + } + + &-link { + position: relative; + } + + .scene-specs-overlay, + .rating-banner, + .scene-studio-overlay { + transition: opacity .5s; + } + + &:hover { + .scene-specs-overlay, + .rating-banner, + .scene-studio-overlay { + opacity: 0; + transition: opacity .5s; + } + + .scene-studio-overlay:hover { + opacity: .75; + transition: opacity .5s; + } + } +} + +.scene-cover { + display: block; + margin-bottom: 10px; + margin-top: 10px; + max-width: 100%; +} + +.movie-table td { + vertical-align: middle; +} \ No newline at end of file diff --git a/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx b/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx index a4297ef88..2a092d0b0 100644 --- a/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx +++ b/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx @@ -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; - studio?: Partial; + objectName?: string; isNew: boolean; isEditing: boolean; onToggleEdit: () => void; @@ -13,6 +11,7 @@ interface IProps { onDelete: () => void; onAutoTag?: () => void; onImageChange: (event: React.FormEvent) => void; + onBackImageChange?: (event: React.FormEvent) => void; } export const DetailsEditNavbar: React.FC = (props: IProps) => { @@ -54,6 +53,19 @@ export const DetailsEditNavbar: React.FC = (props: IProps) => { ); } + function renderBackImageInput() { + if (!props.isEditing || !props.onBackImageChange) { + return; + } + return ( + + ); + } + function renderAutoTagButton() { if (props.isNew || props.isEditing) return; @@ -74,11 +86,11 @@ export const DetailsEditNavbar: React.FC = (props: IProps) => { } function renderDeleteAlert() { - const name = props?.studio?.name ?? props?.performer?.name; - return ( - Are you sure you want to delete {name}? + + Are you sure you want to delete {props.objectName}? + + = props => ) : props.type === "studios" ? ( + ) : props.type === "movies" ? ( + ) : ( ); @@ -247,6 +249,44 @@ export const StudioSelect: React.FC = props => { ); }; +export const MovieSelect: React.FC = 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