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:
WithoutPants 2020-03-21 08:21:49 +11:00 committed by GitHub
parent 5aa6dec8dc
commit ff495361d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1663 additions and 5911 deletions

1
.gitignore vendored
View file

@ -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

View file

@ -1,7 +1,7 @@
type Movie {
id: ID!
checksum: String!
name: String
name: String!
aliases: String
duration: String
date: String

View file

@ -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
}
}

View file

@ -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"

View file

@ -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;

View file

@ -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}

View 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>
);
};

View 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>
);
};

View file

@ -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} />;
};

View 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;
};

View 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;

View 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%;
}
}
}

View file

@ -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>

View file

@ -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} />

View file

@ -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>

View file

@ -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>
</>
);
};

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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;
}

View file

@ -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()}

View file

@ -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}

View file

@ -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 ?? []);

View file

@ -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(

View file

@ -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} />

View file

@ -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)}

View file

@ -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

View file

@ -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
});

View file

@ -1,454 +1,462 @@
@import "styles/theme";
@import "styles/range";
@import "styles/scrollbars";
@import "src/components/Galleries/styles.scss";
@import "src/components/List/styles.scss";
@import "src/components/Performers/styles.scss";
@import "src/components/Scenes/styles.scss";
@import "src/components/SceneFilenameParser/styles.scss";
@import "src/components/ScenePlayer/styles.scss";
@import "src/components/Settings/styles.scss";
@import "src/components/Studios/styles.scss";
@import "src/components/Shared/styles.scss";
@import "src/components/Tags/styles.scss";
@import "src/components/Wall/styles.scss";
html {
font-size: 14px;
}
body {
color: $text-color;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0;
padding: 4rem 0 0 0;
}
a {
color: $primary;
}
code,
.code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
}
.input-control,
.text-input {
border: 0;
box-shadow: 0 0 0 0 rgba(19, 124, 189, 0), 0 0 0 0 rgba(19, 124, 189, 0), 0 0 0 0 rgba(19, 124, 189, 0), inset 0 0 0 1px rgba(16, 22, 26, .3), inset 0 1px 1px rgba(16, 22, 26, .4);
color: $text-color;
&:focus {
border: 0;
box-shadow: 0 0 0 1px $primary, 0 0 0 1px $primary, 0 0 0 3px rgba(19, 124, 189, .3), inset 0 0 0 1px rgba(16, 22, 26, .3), inset 0 1px 1px rgba(16, 22, 26, .4);
color: $text-color;
}
}
.text-input,
.text-input:focus {
background-color: $textfield-bg;
}
.input-control,
.input-control:focus {
background-color: $secondary;
}
.table-list a {
color: $text-color;
}
.table-list table {
width: inherit;
}
.table-list td,
.table-list th {
border-left: 1px solid #414c53;
font-size: 1rem;
text-align: center;
vertical-align: middle;
h5,
h6 {
font-size: 1rem;
}
&:first-child {
border-left: none;
}
}
@media (min-width: 576px) {
.zoom-0 {
width: 240px;
.scene-card-video {
max-height: 180px;
}
.previewable.portrait {
height: 180px;
}
}
.zoom-1 {
width: 320px;
.scene-card-video {
max-height: 240px;
}
.previewable.portrait {
height: 240px;
}
}
.zoom-2 {
width: 480px;
.scene-card-video {
max-height: 360px;
}
.previewable.portrait {
height: 360px;
}
}
.zoom-3 {
width: 640px;
.scene-card-video {
max-height: 480px;
}
.portrait {
height: 480px;
}
}
}
.scene-card-video {
height: auto;
width: 100%;
}
/* this is a bit of a hack, because we can't supply direct class names
to the react-select controls */
/* stylelint-disable selector-class-pattern */
div.react-select__control {
background-color: $secondary;
border-color: $secondary;
color: $text-color;
cursor: pointer;
.react-select__single-value,
.react-select__input {
color: $text-color;
}
.react-select__multi-value {
background-color: $muted-gray;
color: $text-color;
}
}
div.react-select__menu {
background-color: $secondary;
color: $text-color;
.react-select__option {
color: $text-color;
}
.react-select__option--is-focused {
background-color: #8a9ba826;
cursor: pointer;
}
}
/* we don't want to override this for dialogs, which are light colored */
.modal {
div.react-select__control {
background-color: #fff;
border-color: inherit;
color: $dark-text;
.react-select__single-value,
.react-select__input {
color: $dark-text;
}
.react-select__multi-value {
background-color: #fff;
color: $dark-text;
}
}
div.react-select__menu {
background-color: #fff;
color: $text-color;
.react-select__option {
color: $dark-text;
}
.react-select__option--is-focused {
background-color: rgba(167,182,194,.3);
}
}
}
/* stylelint-enable selector-class-pattern */
.image-thumbnail {
height: 100px;
min-width: 50px;
object-fit: cover;
object-position: top;
}
.card-image {
height: 30rem;
min-width: 11.25rem;
width: 20rem;
}
.edit-button {
margin-right: 10px;
}
.tag-item {
background-color: $muted-gray;
color: $dark-text;
font-size: 12px;
font-weight: 400;
line-height: 16px;
margin: 5px;
padding: 2px 6px;
&:hover {
cursor: pointer;
}
.btn {
background: none;
border: none;
bottom: 2px;
color: $dark-text;
font-size: 12px;
line-height: 1rem;
margin-left: .5rem;
opacity: .5;
padding: 0;
position: relative;
&:active,
&:hover {
opacity: 1;
}
}
a {
color: unset;
&:hover {
color: unset;
text-decoration: none;
}
}
}
.filter-container,
.operation-container {
align-items: center;
display: flex;
justify-content: center;
margin: 0 auto 10px;
}
.filter-item,
.operation-item {
margin: 0 10px;
}
.rating-5 {
background: #ff2f39;
}
.rating-4 {
background: $red1;
}
.rating-3 {
background: $orange1;
}
.rating-2 {
background: $sepia1;
}
.rating-1 {
background: $dark-gray5;
}
.rating-banner {
color: #fff;
display: block;
font-size: .86rem;
font-weight: 400;
left: -46px;
letter-spacing: 1px;
line-height: 1.6rem;
padding: 6px 45px;
position: absolute;
text-align: center;
text-size-adjust: none;
top: 14px;
transform: rotate(-36deg);
}
.card {
background-color: #30404d;
border-radius: 3px;
box-shadow: 0 0 0 1px rgba(16, 22, 26, .4), 0 0 0 rgba(16, 22, 26, 0), 0 0 0 rgba(16, 22, 26, 0);
padding: 20px;
}
.toast-container {
left: 45%;
max-width: 350px;
position: fixed;
top: 2rem;
z-index: 1031;
.success {
background-color: $success;
}
.danger {
background-color: $danger;
}
.warning {
background-color: $warning;
}
.toast {
width: 350px;
}
.toast-header {
background-color: transparent;
border: none;
color: $text-color;
.close {
color: $text-color;
text-shadow: none;
}
}
}
.image-input {
margin-bottom: 0;
overflow: hidden;
position: relative;
&:hover {
cursor: pointer;
}
[type=file] {
display: block;
filter: alpha(opacity=0);
font-size: 999px;
min-height: 100%;
min-width: 100%;
opacity: 0;
position: absolute;
right: 0;
text-align: right;
top: 0;
&:hover {
cursor: pointer;
}
}
}
.fa-icon {
margin: 0 .4rem;
}
.btn .fa-icon {
&:last-child:first-child {
margin: 0;
}
}
.brand-icon {
padding: 3px 6px;
img {
height: 1.5rem;
}
}
.top-nav {
padding: .25rem 1rem;
.nav-link {
padding: 0;
}
.fa-icon {
margin-left: 0;
}
.btn {
white-space: nowrap;
}
@media (max-width: 576px) {
.btn {
padding: 6px;
}
.settings-button {
padding-left: 1rem;
padding-right: 1rem;
}
}
}
.error-message {
white-space: "pre-wrap";
}
.stats {
&-element {
flex-grow: 1;
margin: auto .5rem;
}
.title {
font-size: 3vw;
text-align: center;
@media (max-width: 576px) {
font-size: 16px;
}
}
.heading {
text-align: center;
text-transform: uppercase;
}
}
@import "styles/theme";
@import "styles/range";
@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";
@import "src/components/ScenePlayer/styles.scss";
@import "src/components/Settings/styles.scss";
@import "src/components/Studios/styles.scss";
@import "src/components/Shared/styles.scss";
@import "src/components/Tags/styles.scss";
@import "src/components/Wall/styles.scss";
html {
font-size: 14px;
}
body {
color: $text-color;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0;
padding: 4rem 0 0 0;
}
a {
color: $primary;
}
code,
.code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
}
.input-control,
.text-input {
border: 0;
box-shadow: 0 0 0 0 rgba(19, 124, 189, 0), 0 0 0 0 rgba(19, 124, 189, 0), 0 0 0 0 rgba(19, 124, 189, 0), inset 0 0 0 1px rgba(16, 22, 26, .3), inset 0 1px 1px rgba(16, 22, 26, .4);
color: $text-color;
&:focus {
border: 0;
box-shadow: 0 0 0 1px $primary, 0 0 0 1px $primary, 0 0 0 3px rgba(19, 124, 189, .3), inset 0 0 0 1px rgba(16, 22, 26, .3), inset 0 1px 1px rgba(16, 22, 26, .4);
color: $text-color;
}
}
.text-input,
.text-input:focus,
.text-input[readonly] {
background-color: $textfield-bg;
}
.input-control,
.input-control:focus {
background-color: $secondary;
}
textarea.text-input {
line-height: 2.5ex;
min-height: 12ex;
overflow-y: scroll;
}
.table-list a {
color: $text-color;
}
.table-list table {
width: inherit;
}
.table-list td,
.table-list th {
border-left: 1px solid #414c53;
font-size: 1rem;
text-align: center;
vertical-align: middle;
h5,
h6 {
font-size: 1rem;
}
&:first-child {
border-left: none;
}
}
@media (min-width: 576px) {
.zoom-0 {
width: 240px;
.scene-card-video {
max-height: 180px;
}
.previewable.portrait {
height: 180px;
}
}
.zoom-1 {
width: 320px;
.scene-card-video {
max-height: 240px;
}
.previewable.portrait {
height: 240px;
}
}
.zoom-2 {
width: 480px;
.scene-card-video {
max-height: 360px;
}
.previewable.portrait {
height: 360px;
}
}
.zoom-3 {
width: 640px;
.scene-card-video {
max-height: 480px;
}
.portrait {
height: 480px;
}
}
}
.scene-card-video {
height: auto;
width: 100%;
}
/* this is a bit of a hack, because we can't supply direct class names
to the react-select controls */
/* stylelint-disable selector-class-pattern */
div.react-select__control {
background-color: $secondary;
border-color: $secondary;
color: $text-color;
cursor: pointer;
.react-select__single-value,
.react-select__input {
color: $text-color;
}
.react-select__multi-value {
background-color: $muted-gray;
color: $text-color;
}
}
div.react-select__menu {
background-color: $secondary;
color: $text-color;
.react-select__option {
color: $text-color;
}
.react-select__option--is-focused {
background-color: #8a9ba826;
cursor: pointer;
}
}
/* we don't want to override this for dialogs, which are light colored */
.modal {
div.react-select__control {
background-color: #fff;
border-color: inherit;
color: $dark-text;
.react-select__single-value,
.react-select__input {
color: $dark-text;
}
.react-select__multi-value {
background-color: #fff;
color: $dark-text;
}
}
div.react-select__menu {
background-color: #fff;
color: $text-color;
.react-select__option {
color: $dark-text;
}
.react-select__option--is-focused {
background-color: rgba(167,182,194,.3);
}
}
}
/* stylelint-enable selector-class-pattern */
.image-thumbnail {
height: 100px;
min-width: 50px;
object-fit: cover;
object-position: top;
}
.card-image {
height: 30rem;
min-width: 11.25rem;
width: 20rem;
}
.edit-button {
margin-right: 10px;
}
.tag-item {
background-color: $muted-gray;
color: $dark-text;
font-size: 12px;
font-weight: 400;
line-height: 16px;
margin: 5px;
padding: 2px 6px;
&:hover {
cursor: pointer;
}
.btn {
background: none;
border: none;
bottom: 2px;
color: $dark-text;
font-size: 12px;
line-height: 1rem;
margin-left: .5rem;
opacity: .5;
padding: 0;
position: relative;
&:active,
&:hover {
opacity: 1;
}
}
a {
color: unset;
&:hover {
color: unset;
text-decoration: none;
}
}
}
.filter-container,
.operation-container {
align-items: center;
display: flex;
justify-content: center;
margin: 0 auto 10px;
}
.filter-item,
.operation-item {
margin: 0 10px;
}
.rating-5 {
background: #ff2f39;
}
.rating-4 {
background: $red1;
}
.rating-3 {
background: $orange1;
}
.rating-2 {
background: $sepia1;
}
.rating-1 {
background: $dark-gray5;
}
.rating-banner {
color: #fff;
display: block;
font-size: .86rem;
font-weight: 400;
left: -46px;
letter-spacing: 1px;
line-height: 1.6rem;
padding: 6px 45px;
position: absolute;
text-align: center;
text-size-adjust: none;
top: 14px;
transform: rotate(-36deg);
}
.card {
background-color: #30404d;
border-radius: 3px;
box-shadow: 0 0 0 1px rgba(16, 22, 26, .4), 0 0 0 rgba(16, 22, 26, 0), 0 0 0 rgba(16, 22, 26, 0);
padding: 20px;
}
.toast-container {
left: 45%;
max-width: 350px;
position: fixed;
top: 2rem;
z-index: 1031;
.success {
background-color: $success;
}
.danger {
background-color: $danger;
}
.warning {
background-color: $warning;
}
.toast {
width: 350px;
}
.toast-header {
background-color: transparent;
border: none;
color: $text-color;
.close {
color: $text-color;
text-shadow: none;
}
}
}
.image-input {
margin-bottom: 0;
overflow: hidden;
position: relative;
&:hover {
cursor: pointer;
}
[type=file] {
display: block;
filter: alpha(opacity=0);
font-size: 999px;
min-height: 100%;
min-width: 100%;
opacity: 0;
position: absolute;
right: 0;
text-align: right;
top: 0;
&:hover {
cursor: pointer;
}
}
}
.fa-icon {
margin: 0 .4rem;
}
.btn .fa-icon {
&:last-child:first-child {
margin: 0;
}
}
.brand-icon {
padding: 3px 6px;
img {
height: 1.5rem;
}
}
.top-nav {
padding: .25rem 1rem;
.nav-link {
padding: 0;
}
.fa-icon {
margin-left: 0;
}
.btn {
white-space: nowrap;
}
@media (max-width: 576px) {
.btn {
padding: 6px;
}
.settings-button {
padding-left: 1rem;
padding-right: 1rem;
}
}
}
.error-message {
white-space: "pre-wrap";
}
.stats {
&-element {
flex-grow: 1;
margin: auto .5rem;
}
.title {
font-size: 3vw;
text-align: center;
@media (max-width: 576px) {
font-size: 16px;
}
}
.heading {
text-align: center;
text-transform: uppercase;
}
}

View file

@ -1,13 +1,14 @@
{
"new": "Neu",
"tags": "Etiketten",
"scenes": "Szenen",
"studios": "Studios",
"galleries": "Galerien",
"performers": "Künstler",
"markers": "Marken",
"stats": {
"notes": "Anmerkungen",
"warning": "Dies ist noch eine frühe Version, einige Dinge sind noch in Arbeit."
}
}
{
"new": "Neu",
"tags": "Etiketten",
"scenes": "Szenen",
"movies": "Filme",
"studios": "Studios",
"galleries": "Galerien",
"performers": "Künstler",
"markers": "Marken",
"stats": {
"notes": "Anmerkungen",
"warning": "Dies ist noch eine frühe Version, einige Dinge sind noch in Arbeit."
}
}

View file

@ -1,13 +1,14 @@
{
"new": "New",
"tags": "Tags",
"scenes": "Scenes",
"studios": "Studios",
"galleries": "Galleries",
"performers": "Performers",
"markers": "Markers",
"stats": {
"notes": "Notes",
"warning": "This is still an early version, some things are still a work in progress."
}
}
{
"new": "New",
"tags": "Tags",
"scenes": "Scenes",
"movies": "Movies",
"studios": "Studios",
"galleries": "Galleries",
"performers": "Performers",
"markers": "Markers",
"stats": {
"notes": "Notes",
"warning": "This is still an early version, some things are still a work in progress."
}
}

View file

@ -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":

View file

@ -12,6 +12,7 @@ export class IsMissingCriterion extends Criterion {
"date",
"gallery",
"studio",
"movie",
"performers"
];
public value: string = "";

View 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";
}

View file

@ -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);

View file

@ -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);
criterion.value = encodedCriterion.value;
criterion.modifier = encodedCriterion.modifier;
this.criteria.push(criterion);
// 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
}
});

View file

@ -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 {

View file

@ -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
};

View file

@ -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}