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
39 changed files with 1663 additions and 5911 deletions

1
.gitignore vendored
View File

@@ -21,6 +21,7 @@
# GraphQL generated output # GraphQL generated output
pkg/models/generated_*.go pkg/models/generated_*.go
ui/v2/src/core/generated-*.tsx ui/v2/src/core/generated-*.tsx
ui/v2.5/src/core/generated-*.tsx
# packr generated files # packr generated files
*-packr.go *-packr.go

View File

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

View File

@@ -8,11 +8,11 @@ import (
"github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/utils"
) )
func (r *movieResolver) Name(ctx context.Context, obj *models.Movie) (*string, error) { func (r *movieResolver) Name(ctx context.Context, obj *models.Movie) (string, error) {
if obj.Name.Valid { if obj.Name.Valid {
return &obj.Name.String, nil return obj.Name.String, nil
} }
return nil, nil return "", nil
} }
func (r *movieResolver) URL(ctx context.Context, obj *models.Movie) (*string, error) { func (r *movieResolver) URL(ctx context.Context, obj *models.Movie) (*string, error) {
@@ -81,4 +81,4 @@ func (r *movieResolver) SceneCount(ctx context.Context, obj *models.Movie) (*int
qb := models.NewSceneQueryBuilder() qb := models.NewSceneQueryBuilder()
res, err := qb.CountByMovieID(obj.ID) res, err := qb.CountByMovieID(obj.ID)
return &res, err return &res, err
} }

View File

@@ -19,6 +19,7 @@ import { Stats } from "./components/Stats";
import Studios from "./components/Studios/Studios"; import Studios from "./components/Studios/Studios";
import { TagList } from "./components/Tags/TagList"; import { TagList } from "./components/Tags/TagList";
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser"; import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
import Movies from "./components/Movies/Movies";
// Set fontawesome/free-solid-svg as default fontawesome icons // Set fontawesome/free-solid-svg as default fontawesome icons
library.add(fas); library.add(fas);
@@ -43,6 +44,7 @@ export const App: React.FC = () => {
<Route path="/performers" component={Performers} /> <Route path="/performers" component={Performers} />
<Route path="/tags" component={TagList} /> <Route path="/tags" component={TagList} />
<Route path="/studios" component={Studios} /> <Route path="/studios" component={Studios} />
<Route path="/movies" component={Movies} />
<Route path="/settings" component={Settings} /> <Route path="/settings" component={Settings} />
<Route <Route
path="/sceneFilenameParser" path="/sceneFilenameParser"

View File

@@ -140,7 +140,8 @@ export const AddFilter: React.FC<IAddFilterProps> = (
if ( if (
criterion.type !== "performers" && criterion.type !== "performers" &&
criterion.type !== "studios" && criterion.type !== "studios" &&
criterion.type !== "tags" criterion.type !== "tags" &&
criterion.type !== "movies"
) )
return; return;

View File

@@ -19,6 +19,11 @@ const menuItems: IMenuItem[] = [
messageID: "scenes", messageID: "scenes",
href: "/scenes" href: "/scenes"
}, },
{
href: "/movies",
icon: "film",
messageID: "movies"
},
{ {
href: "/scenes/markers", href: "/scenes/markers",
icon: "map-marker-alt", icon: "map-marker-alt",
@@ -79,6 +84,8 @@ export const MainNavbar: React.FC = () => {
? "/performers/new" ? "/performers/new"
: location.pathname === "/studios" : location.pathname === "/studios"
? "/studios/new" ? "/studios/new"
: location.pathname === "/movies"
? "/movies/new"
: null; : null;
const newButton = const newButton =
path === null ? ( path === null ? (
@@ -98,7 +105,7 @@ export const MainNavbar: React.FC = () => {
variant="dark" variant="dark"
bg="dark" bg="dark"
className="top-nav" className="top-nav"
expand="md" expand="lg"
expanded={expanded} expanded={expanded}
onToggle={setExpanded} onToggle={setExpanded}
ref={navbarRef} ref={navbarRef}

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() { function maybeRenderSceneMarkerPopoverButton() {
if (props.scene.scene_markers.length <= 0) return; if (props.scene.scene_markers.length <= 0) return;
@@ -161,6 +194,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
if ( if (
props.scene.tags.length > 0 || props.scene.tags.length > 0 ||
props.scene.performers.length > 0 || props.scene.performers.length > 0 ||
props.scene.movies.length > 0 ||
props.scene.scene_markers.length > 0 || props.scene.scene_markers.length > 0 ||
props.scene?.o_counter props.scene?.o_counter
) { ) {
@@ -170,6 +204,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
<ButtonGroup className="scene-popovers"> <ButtonGroup className="scene-popovers">
{maybeRenderTagPopoverButton()} {maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()} {maybeRenderPerformerPopoverButton()}
{maybeRenderMoviePopoverButton()}
{maybeRenderSceneMarkerPopoverButton()} {maybeRenderSceneMarkerPopoverButton()}
{maybeRenderOCounter()} {maybeRenderOCounter()}
</ButtonGroup> </ButtonGroup>

View File

@@ -15,6 +15,7 @@ import { SceneEditPanel } from "./SceneEditPanel";
import { SceneDetailPanel } from "./SceneDetailPanel"; import { SceneDetailPanel } from "./SceneDetailPanel";
import { OCounterButton } from "./OCounterButton"; import { OCounterButton } from "./OCounterButton";
import { SceneOperationsPanel } from "./SceneOperationsPanel"; import { SceneOperationsPanel } from "./SceneOperationsPanel";
import { SceneMoviePanel } from "./SceneMoviePanel";
export const Scene: React.FC = () => { export const Scene: React.FC = () => {
const { id = "new" } = useParams(); const { id = "new" } = useParams();
@@ -124,6 +125,13 @@ export const Scene: React.FC = () => {
) : ( ) : (
"" ""
)} )}
{scene.movies.length > 0 ? (
<Tab eventKey="scene-movie-panel" title="Movies">
<SceneMoviePanel scene={scene} />
</Tab>
) : (
""
)}
{scene.gallery ? ( {scene.gallery ? (
<Tab eventKey="scene-gallery-panel" title="Gallery"> <Tab eventKey="scene-gallery-panel" title="Gallery">
<GalleryViewer gallery={scene.gallery} /> <GalleryViewer gallery={scene.gallery} />

View File

@@ -16,6 +16,8 @@ import {
} from "src/components/Shared"; } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { ImageUtils, TableUtils } from "src/utils"; import { ImageUtils, TableUtils } from "src/utils";
import { MovieSelect } from "src/components/Shared/Select";
import { SceneMovieTable, MovieSceneIndexMap } from "./SceneMovieTable";
interface IProps { interface IProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
@@ -33,6 +35,10 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
const [galleryId, setGalleryId] = useState<string>(); const [galleryId, setGalleryId] = useState<string>();
const [studioId, setStudioId] = useState<string>(); const [studioId, setStudioId] = useState<string>();
const [performerIds, setPerformerIds] = useState<string[]>(); const [performerIds, setPerformerIds] = useState<string[]>();
const [movieIds, setMovieIds] = useState<string[] | undefined>(undefined);
const [movieSceneIndexes, setMovieSceneIndexes] = useState<
MovieSceneIndexMap
>(new Map());
const [tagIds, setTagIds] = useState<string[]>(); const [tagIds, setTagIds] = useState<string[]>();
const [coverImage, setCoverImage] = useState<string>(); const [coverImage, setCoverImage] = useState<string>();
@@ -59,9 +65,46 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
setQueryableScrapers(newQueryableScrapers); setQueryableScrapers(newQueryableScrapers);
}, [Scrapers]); }, [Scrapers]);
useEffect(() => {
let changed = false;
const newMap: MovieSceneIndexMap = new Map();
if (movieIds) {
movieIds.forEach(id => {
if (!movieSceneIndexes.has(id)) {
changed = true;
newMap.set(id, undefined);
} else {
newMap.set(id, movieSceneIndexes.get(id));
}
});
if (!changed) {
movieSceneIndexes.forEach((v, id) => {
if (!newMap.has(id)) {
// id was removed
changed = true;
}
});
}
if (changed) {
setMovieSceneIndexes(newMap);
}
}
}, [movieIds, movieSceneIndexes]);
function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) { function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) {
const perfIds = state.performers?.map(performer => performer.id); const perfIds = state.performers?.map(performer => performer.id);
const tIds = state.tags ? state.tags.map(tag => tag.id) : undefined; const tIds = state.tags ? state.tags.map(tag => tag.id) : undefined;
const moviIds = state.movies
? state.movies.map(sceneMovie => sceneMovie.movie.id)
: undefined;
const movieSceneIdx: MovieSceneIndexMap = new Map();
if (state.movies) {
state.movies.forEach(m => {
movieSceneIdx.set(m.movie.id, m.scene_index ?? undefined);
});
}
setTitle(state.title ?? undefined); setTitle(state.title ?? undefined);
setDetails(state.details ?? undefined); setDetails(state.details ?? undefined);
@@ -70,6 +113,8 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
setRating(state.rating === null ? NaN : state.rating); setRating(state.rating === null ? NaN : state.rating);
setGalleryId(state?.gallery?.id ?? undefined); setGalleryId(state?.gallery?.id ?? undefined);
setStudioId(state?.studio?.id ?? undefined); setStudioId(state?.studio?.id ?? undefined);
setMovieIds(moviIds);
setMovieSceneIndexes(movieSceneIdx);
setPerformerIds(perfIds); setPerformerIds(perfIds);
setTagIds(tIds); setTagIds(tIds);
} }
@@ -93,11 +138,31 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
gallery_id: galleryId, gallery_id: galleryId,
studio_id: studioId, studio_id: studioId,
performer_ids: performerIds, performer_ids: performerIds,
movies: makeMovieInputs(),
tag_ids: tagIds, tag_ids: tagIds,
cover_image: coverImage cover_image: coverImage
}; };
} }
function makeMovieInputs(): GQL.SceneMovieInput[] | undefined {
if (!movieIds) {
return undefined;
}
let ret = movieIds.map(id => {
const r: GQL.SceneMovieInput = {
movie_id: id
};
return r;
});
ret = ret.map(r => {
return { scene_index: movieSceneIndexes.get(r.movie_id), ...r };
});
return ret;
}
async function onSave() { async function onSave() {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -133,6 +198,17 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
props.onDelete(); props.onDelete();
} }
function renderTableMovies() {
return (
<SceneMovieTable
movieSceneIndexes={movieSceneIndexes}
onUpdate={items => {
setMovieSceneIndexes(items);
}}
/>
);
}
function renderDeleteAlert() { function renderDeleteAlert() {
return ( return (
<Modal <Modal
@@ -197,7 +273,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
return ( return (
<DropdownButton id="scene-scrape" title="Scrape with..."> <DropdownButton id="scene-scrape" title="Scrape with...">
{queryableScrapers.map(s => ( {queryableScrapers.map(s => (
<Dropdown.Item onClick={() => onScrapeClicked(s)}> <Dropdown.Item key={s.name} onClick={() => onScrapeClicked(s)}>
{s.name} {s.name}
</Dropdown.Item> </Dropdown.Item>
))} ))}
@@ -247,6 +323,21 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
} }
} }
if (
(!movieIds || movieIds.length === 0) &&
scene.movies &&
scene.movies.length > 0
) {
const idMovis = scene.movies.filter(p => {
return p.id !== undefined && p.id !== null;
});
if (idMovis.length > 0) {
const newIds = idMovis.map(p => p.id);
setMovieIds(newIds as string[]);
}
}
if (!tagIds?.length && scene?.tags?.length) { if (!tagIds?.length && scene?.tags?.length) {
const idTags = scene.tags.filter(p => { const idTags = scene.tags.filter(p => {
return p.id !== undefined && p.id !== null; return p.id !== undefined && p.id !== null;
@@ -369,6 +460,17 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
/> />
</td> </td>
</tr> </tr>
<tr>
<td>Movies/Scenes</td>
<td>
<MovieSelect
isMulti
onSelect={items => setMovieIds(items.map(item => item.id))}
ids={movieIds}
/>
{renderTableMovies()}
</td>
</tr>
<tr> <tr>
<td>Tags</td> <td>Tags</td>
<td> <td>

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> </Link>
)); ));
const renderMovies = (movies: Partial<GQL.SceneMovie>[]) => {
return movies.map(sceneMovie =>
!sceneMovie.movie ? (
undefined
) : (
<Link to={NavUtils.makeMovieScenesUrl(sceneMovie.movie)}>
<h6>{sceneMovie.movie.name}</h6>
</Link>
)
);
};
const renderSceneRow = (scene: GQL.SlimSceneDataFragment) => ( const renderSceneRow = (scene: GQL.SlimSceneDataFragment) => (
<tr key={scene.id}> <tr key={scene.id}>
<td> <td>
@@ -58,6 +70,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
</Link> </Link>
)} )}
</td> </td>
<td>{renderMovies(scene.movies)}</td>
</tr> </tr>
); );
@@ -73,6 +86,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
<th>Tags</th> <th>Tags</th>
<th>Performers</th> <th>Performers</th>
<th>Studio</th> <th>Studio</th>
<th>Movies</th>
</tr> </tr>
</thead> </thead>
<tbody>{props.scenes.map(renderSceneRow)}</tbody> <tbody>{props.scenes.map(renderSceneRow)}</tbody>

View File

@@ -1,214 +1,218 @@
.scene-popovers { .scene-popovers {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-bottom: 10px; margin-bottom: 10px;
.btn { .btn {
padding-bottom: 3px; padding-bottom: 3px;
padding-top: 3px; padding-top: 3px;
} }
.fa-icon { .fa-icon {
margin-right: 7px; margin-right: 7px;
} }
} }
.card-section { .card-section {
margin-bottom: 0; margin-bottom: 0;
padding: .5rem 1rem 0 1rem; padding: .5rem 1rem 0 1rem;
&-title { &-title {
overflow: hidden; overflow: hidden;
overflow-wrap: normal; overflow-wrap: normal;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
} }
.scene-card-check { .scene-card-check {
left: .5rem; left: .5rem;
margin-top: -12px; margin-top: -12px;
opacity: .5; opacity: .5;
padding-left: 15px; padding-left: 15px;
position: absolute; position: absolute;
top: .7rem; top: .7rem;
width: 1.2rem; width: 1.2rem;
z-index: 1; z-index: 1;
} }
.performer-tag-container { .performer-tag-container, .movie-tag-container {
display: inline-block; display: inline-block;
margin: 5px; margin: 5px;
} }
.performer-tag.image { .performer-tag.image, .movie-tag.image {
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
height: 150px; height: 150px;
margin: 0 auto; margin: 0 auto;
width: 100%; width: 100%;
} }
.operation-container { .operation-container {
.operation-item { .operation-item {
min-width: 240px; min-width: 240px;
} }
.rating-operation { .rating-operation {
min-width: 20px; min-width: 20px;
} }
.apply-operation { .apply-operation {
margin-top: 2rem; margin-top: 2rem;
} }
} }
.marker-container { .marker-container {
display: "flex"; display: "flex";
flex-wrap: "nowrap"; flex-wrap: "nowrap";
margin-bottom: "20px"; margin-bottom: "20px";
overflow-x: "scroll"; overflow-x: "scroll";
overflow-y: "hidden"; overflow-y: "hidden";
white-space: "nowrap"; white-space: "nowrap";
} }
.studio-logo { .studio-logo {
margin-top: 1rem; margin-top: 1rem;
max-width: 100%; max-width: 100%;
} }
.scene-header { .scene-header {
flex-basis: auto; flex-basis: auto;
} }
#scene-details-container { #scene-details-container {
.tab-content { .tab-content {
min-height: 15rem; min-height: 15rem;
} }
.scene-description { .scene-description {
width: 100%; width: 100%;
} }
} }
.file-info-panel { .file-info-panel {
div { div {
margin-bottom: .5rem; margin-bottom: .5rem;
} }
} }
#details { #details {
min-height: 150px; min-height: 150px;
} }
.primary-card { .primary-card {
margin: 1rem 0; margin: 1rem 0;
&-body { &-body {
max-height: 15rem; max-height: 15rem;
overflow-y: auto; overflow-y: auto;
} }
} }
.studio-card { .studio-card {
padding: .5rem; padding: .5rem;
&-header { &-header {
height: 150px; height: 150px;
line-height: 150px; line-height: 150px;
text-align: center; text-align: center;
} }
&-image { &-image {
max-height: 150px; max-height: 150px;
object-fit: contain; object-fit: contain;
vertical-align: middle; vertical-align: middle;
width: 320px; width: 320px;
@media (max-width: 576px) { @media (max-width: 576px) {
width: 100%; width: 100%;
} }
} }
} }
.scene-specs-overlay { .scene-specs-overlay {
bottom: 1rem; bottom: 1rem;
color: $text-color; color: $text-color;
display: block; display: block;
font-weight: 400; font-weight: 400;
letter-spacing: -.03rem; letter-spacing: -.03rem;
position: absolute; position: absolute;
right: .7rem; right: .7rem;
text-shadow: 0 0 3px #000; text-shadow: 0 0 3px #000;
} }
.scene-studio-overlay { .scene-studio-overlay {
display: block; display: block;
font-weight: 900; font-weight: 900;
height: 10%; height: 10%;
max-width: 40%; max-width: 40%;
opacity: .75; opacity: .75;
position: absolute; position: absolute;
right: .7rem; right: .7rem;
top: .7rem; top: .7rem;
z-index: 9; z-index: 9;
.image-thumbnail { .image-thumbnail {
height: auto; height: auto;
max-height: 50px; max-height: 50px;
max-width: 100%; max-width: 100%;
} }
a { a {
color: $text-color; color: $text-color;
display: inline-block; display: inline-block;
letter-spacing: -.03rem; letter-spacing: -.03rem;
text-align: right; text-align: right;
text-decoration: none; text-decoration: none;
text-shadow: 0 0 3px #000; text-shadow: 0 0 3px #000;
} }
} }
.overlay-resolution { .overlay-resolution {
font-weight: 900; font-weight: 900;
margin-right: .3rem; margin-right: .3rem;
text-transform: uppercase; text-transform: uppercase;
} }
.scene-card { .scene-card {
&.card { &.card {
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
} }
&-link { &-link {
position: relative; position: relative;
} }
.scene-specs-overlay, .scene-specs-overlay,
.rating-banner, .rating-banner,
.scene-studio-overlay { .scene-studio-overlay {
transition: opacity .5s; transition: opacity .5s;
} }
&:hover { &:hover {
.scene-specs-overlay, .scene-specs-overlay,
.rating-banner, .rating-banner,
.scene-studio-overlay { .scene-studio-overlay {
opacity: 0; opacity: 0;
transition: opacity .5s; transition: opacity .5s;
} }
.scene-studio-overlay:hover { .scene-studio-overlay:hover {
opacity: .75; opacity: .75;
transition: opacity .5s; transition: opacity .5s;
} }
} }
} }
.scene-cover { .scene-cover {
display: block; display: block;
margin-bottom: 10px; margin-bottom: 10px;
margin-top: 10px; margin-top: 10px;
max-width: 100%; max-width: 100%;
} }
.movie-table td {
vertical-align: middle;
}

View File

@@ -1,11 +1,9 @@
import { Button, Modal } from "react-bootstrap"; import { Button, Modal } from "react-bootstrap";
import React, { useState } from "react"; import React, { useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { ImageInput } from "src/components/Shared"; import { ImageInput } from "src/components/Shared";
interface IProps { interface IProps {
performer?: Partial<GQL.PerformerDataFragment>; objectName?: string;
studio?: Partial<GQL.StudioDataFragment>;
isNew: boolean; isNew: boolean;
isEditing: boolean; isEditing: boolean;
onToggleEdit: () => void; onToggleEdit: () => void;
@@ -13,6 +11,7 @@ interface IProps {
onDelete: () => void; onDelete: () => void;
onAutoTag?: () => void; onAutoTag?: () => void;
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void; onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
onBackImageChange?: (event: React.FormEvent<HTMLInputElement>) => void;
} }
export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => { export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
@@ -54,6 +53,19 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
); );
} }
function renderBackImageInput() {
if (!props.isEditing || !props.onBackImageChange) {
return;
}
return (
<ImageInput
isEditing={props.isEditing}
text="Back image..."
onImageChange={props.onBackImageChange}
/>
);
}
function renderAutoTagButton() { function renderAutoTagButton() {
if (props.isNew || props.isEditing) return; if (props.isNew || props.isEditing) return;
@@ -74,11 +86,11 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
} }
function renderDeleteAlert() { function renderDeleteAlert() {
const name = props?.studio?.name ?? props?.performer?.name;
return ( return (
<Modal show={isDeleteAlertOpen}> <Modal show={isDeleteAlertOpen}>
<Modal.Body>Are you sure you want to delete {name}?</Modal.Body> <Modal.Body>
Are you sure you want to delete {props.objectName}?
</Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant="danger" onClick={props.onDelete}> <Button variant="danger" onClick={props.onDelete}>
Delete Delete
@@ -99,8 +111,10 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
{renderEditButton()} {renderEditButton()}
<ImageInput <ImageInput
isEditing={props.isEditing} isEditing={props.isEditing}
text={props.onBackImageChange ? "Front image..." : undefined}
onImageChange={props.onImageChange} onImageChange={props.onImageChange}
/> />
{renderBackImageInput()}
{renderAutoTagButton()} {renderAutoTagButton()}
{renderSaveButton()} {renderSaveButton()}
{renderDeleteButton()} {renderDeleteButton()}

View File

@@ -3,18 +3,20 @@ import { Button, Form } from "react-bootstrap";
interface IImageInput { interface IImageInput {
isEditing: boolean; isEditing: boolean;
text?: string;
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void; onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
} }
export const ImageInput: React.FC<IImageInput> = ({ export const ImageInput: React.FC<IImageInput> = ({
isEditing, isEditing,
text,
onImageChange onImageChange
}) => { }) => {
if (!isEditing) return <div />; if (!isEditing) return <div />;
return ( return (
<Form.Label className="image-input"> <Form.Label className="image-input">
<Button variant="secondary">Browse for image...</Button> <Button variant="secondary">{text ?? "Browse for image..."}</Button>
<Form.Control <Form.Control
type="file" type="file"
onChange={onImageChange} onChange={onImageChange}

View File

@@ -14,7 +14,7 @@ type ValidTypes =
type Option = { value: string; label: string }; type Option = { value: string; label: string };
interface ITypeProps { interface ITypeProps {
type?: "performers" | "studios" | "tags"; type?: "performers" | "studios" | "tags" | "movies";
} }
interface IFilterProps { interface IFilterProps {
ids?: string[]; ids?: string[];
@@ -172,6 +172,8 @@ export const FilterSelect: React.FC<IFilterProps & ITypeProps> = props =>
<PerformerSelect {...(props as IFilterProps)} /> <PerformerSelect {...(props as IFilterProps)} />
) : props.type === "studios" ? ( ) : props.type === "studios" ? (
<StudioSelect {...(props as IFilterProps)} /> <StudioSelect {...(props as IFilterProps)} />
) : props.type === "movies" ? (
<MovieSelect {...(props as IFilterProps)} />
) : ( ) : (
<TagSelect {...(props as IFilterProps)} /> <TagSelect {...(props as IFilterProps)} />
); );
@@ -247,6 +249,44 @@ export const StudioSelect: React.FC<IFilterProps> = props => {
); );
}; };
export const MovieSelect: React.FC<IFilterProps> = props => {
const { data, loading } = StashService.useAllMoviesForFilter();
const normalizedData = data?.allMovies ?? [];
const items = (normalizedData.length > 0
? [{ name: "None", id: "0" }, ...normalizedData]
: []
).map(item => ({
value: item.id,
label: item.name
}));
const placeholder = props.noSelectionString ?? "Select movie...";
const selectedOptions: Option[] = props.ids
? items.filter(item => props.ids?.indexOf(item.value) !== -1)
: [];
const onChange = (selectedItems: ValueType<Option>) => {
const selectedIds = getSelectedValues(selectedItems);
props.onSelect?.(
normalizedData.filter(item => selectedIds.indexOf(item.id) !== -1)
);
};
return (
<SelectComponent
{...props}
onChange={onChange}
type="studios"
isLoading={loading}
items={items}
placeholder={placeholder}
selectedOptions={selectedOptions}
/>
);
};
export const TagSelect: React.FC<IFilterProps> = props => { export const TagSelect: React.FC<IFilterProps> = props => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>(props.ids ?? []); const [selectedIds, setSelectedIds] = useState<string[]>(props.ids ?? []);

View File

@@ -4,7 +4,8 @@ import { Link } from "react-router-dom";
import { import {
PerformerDataFragment, PerformerDataFragment,
SceneMarkerDataFragment, SceneMarkerDataFragment,
TagDataFragment TagDataFragment,
MovieDataFragment
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils"; import { NavUtils, TextUtils } from "src/utils";
@@ -12,6 +13,7 @@ interface IProps {
tag?: Partial<TagDataFragment>; tag?: Partial<TagDataFragment>;
performer?: Partial<PerformerDataFragment>; performer?: Partial<PerformerDataFragment>;
marker?: Partial<SceneMarkerDataFragment>; marker?: Partial<SceneMarkerDataFragment>;
movie?: Partial<MovieDataFragment>;
className?: string; className?: string;
} }
@@ -24,6 +26,9 @@ export const TagLink: React.FC<IProps> = (props: IProps) => {
} else if (props.performer) { } else if (props.performer) {
link = NavUtils.makePerformerScenesUrl(props.performer); link = NavUtils.makePerformerScenesUrl(props.performer);
title = props.performer.name || ""; title = props.performer.name || "";
} else if (props.movie) {
link = NavUtils.makeMovieScenesUrl(props.movie);
title = props.movie.name || "";
} else if (props.marker) { } else if (props.marker) {
link = NavUtils.makeSceneMarkerUrl(props.marker); link = NavUtils.makeSceneMarkerUrl(props.marker);
title = `${props.marker.title} - ${TextUtils.secondsToTimestamp( title = `${props.marker.title} - ${TextUtils.secondsToTimestamp(

View File

@@ -21,6 +21,14 @@ export const Stats: React.FC = () => {
<FormattedMessage id="scenes" defaultMessage="Scenes" /> <FormattedMessage id="scenes" defaultMessage="Scenes" />
</p> </p>
</div> </div>
<div className="stats-element">
<p className="title">
<FormattedNumber value={data.stats.movie_count} />
</p>
<p className="heading">
<FormattedMessage id="movies" defaultMessage="Movies" />
</p>
</div>
<div className="stats-element"> <div className="stats-element">
<p className="title"> <p className="title">
<FormattedNumber value={data.stats.gallery_count} /> <FormattedNumber value={data.stats.gallery_count} />

View File

@@ -177,7 +177,7 @@ export const Studio: React.FC = () => {
</tbody> </tbody>
</Table> </Table>
<DetailsEditNavbar <DetailsEditNavbar
studio={studio} objectName={studio.name ?? "studio"}
isNew={isNew} isNew={isNew}
isEditing={isEditing} isEditing={isEditing}
onToggleEdit={() => setIsEditing(!isEditing)} onToggleEdit={() => setIsEditing(!isEditing)}

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) { public static useFindPerformers(filter: ListFilterModel) {
let performerFilter = {}; let performerFilter = {};
// if (!!filter && filter.criteriaFilterOpen) { // if (!!filter && filter.criteriaFilterOpen) {
@@ -220,6 +228,10 @@ export class StashService {
const skip = id === "new"; const skip = id === "new";
return GQL.useFindStudioQuery({ variables: { id }, skip }); return GQL.useFindStudioQuery({ variables: { id }, skip });
} }
public static useFindMovie(id: string) {
const skip = id === "new";
return GQL.useFindMovieQuery({ variables: { id }, skip });
}
// TODO - scene marker manipulation functions are handled differently // TODO - scene marker manipulation functions are handled differently
private static sceneMarkerMutationImpactedQueries = [ private static sceneMarkerMutationImpactedQueries = [
@@ -279,6 +291,9 @@ export class StashService {
public static useAllStudiosForFilter() { public static useAllStudiosForFilter() {
return GQL.useAllStudiosForFilterQuery(); return GQL.useAllStudiosForFilterQuery();
} }
public static useAllMoviesForFilter() {
return GQL.useAllMoviesForFilterQuery();
}
public static useValidGalleriesForScene(sceneId: string) { public static useValidGalleriesForScene(sceneId: string) {
return GQL.useValidGalleriesForSceneQuery({ return GQL.useValidGalleriesForSceneQuery({
variables: { scene_id: sceneId } variables: { scene_id: sceneId }
@@ -341,6 +356,7 @@ export class StashService {
"findScenes", "findScenes",
"findSceneMarkers", "findSceneMarkers",
"findStudios", "findStudios",
"findMovies",
"allTags" "allTags"
// TODO - add "findTags" when it is implemented // TODO - add "findTags" when it is implemented
]; ];
@@ -362,6 +378,7 @@ export class StashService {
"findPerformers", "findPerformers",
"findSceneMarkers", "findSceneMarkers",
"findStudios", "findStudios",
"findMovies",
"allTags" "allTags"
]; ];
@@ -449,6 +466,42 @@ export class StashService {
}); });
} }
private static movieMutationImpactedQueries = [
"findMovies",
"findScenes",
"allMovies"
];
public static useMovieCreate(input: GQL.MovieCreateInput) {
return GQL.useMovieCreateMutation({
variables: input,
update: () =>
StashService.invalidateQueries(
StashService.movieMutationImpactedQueries
)
});
}
public static useMovieUpdate(input: GQL.MovieUpdateInput) {
return GQL.useMovieUpdateMutation({
variables: input,
update: () =>
StashService.invalidateQueries(
StashService.movieMutationImpactedQueries
)
});
}
public static useMovieDestroy(input: GQL.MovieDestroyInput) {
return GQL.useMovieDestroyMutation({
variables: input,
update: () =>
StashService.invalidateQueries(
StashService.movieMutationImpactedQueries
)
});
}
private static tagMutationImpactedQueries = [ private static tagMutationImpactedQueries = [
"findScenes", "findScenes",
"findSceneMarkers", "findSceneMarkers",

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,9 @@ import {
FindSceneMarkersQueryResult, FindSceneMarkersQueryResult,
FindGalleriesQueryResult, FindGalleriesQueryResult,
FindStudiosQueryResult, FindStudiosQueryResult,
FindPerformersQueryResult FindPerformersQueryResult,
FindMoviesQueryResult,
MovieDataFragment
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import { import {
useInterfaceLocalForage, useInterfaceLocalForage,
@@ -453,3 +455,14 @@ export const usePerformersList = (
getCount: (result: FindPerformersQueryResult) => getCount: (result: FindPerformersQueryResult) =>
result?.data?.findPerformers?.count ?? 0 result?.data?.findPerformers?.count ?? 0
}); });
export const useMoviesList = (props: IListHookOptions<FindMoviesQueryResult>) =>
useList<FindMoviesQueryResult, MovieDataFragment>({
...props,
filterMode: FilterMode.Performers,
useData: StashService.useFindMovies,
getData: (result: FindMoviesQueryResult) =>
result?.data?.findMovies?.movies ?? [],
getCount: (result: FindMoviesQueryResult) =>
result?.data?.findMovies?.count ?? 0
});

View File

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

View File

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

View File

@@ -1,13 +1,14 @@
{ {
"new": "New", "new": "New",
"tags": "Tags", "tags": "Tags",
"scenes": "Scenes", "scenes": "Scenes",
"studios": "Studios", "movies": "Movies",
"galleries": "Galleries", "studios": "Studios",
"performers": "Performers", "galleries": "Galleries",
"markers": "Markers", "performers": "Performers",
"stats": { "markers": "Markers",
"notes": "Notes", "stats": {
"warning": "This is still an early version, some things are still a work in progress." "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" | "sceneTags"
| "performers" | "performers"
| "studios" | "studios"
| "movies"
| "birth_year" | "birth_year"
| "age" | "age"
| "ethnicity" | "ethnicity"
@@ -60,6 +61,8 @@ export abstract class Criterion {
return "Performers"; return "Performers";
case "studios": case "studios":
return "Studios"; return "Studios";
case "movies":
return "Movies";
case "birth_year": case "birth_year":
return "Birth Year"; return "Birth Year";
case "age": case "age":

View File

@@ -12,6 +12,7 @@ export class IsMissingCriterion extends Criterion {
"date", "date",
"gallery", "gallery",
"studio", "studio",
"movie",
"performers" "performers"
]; ];
public value: string = ""; 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 { ResolutionCriterion } from "./resolution";
import { StudiosCriterion } from "./studios"; import { StudiosCriterion } from "./studios";
import { TagsCriterion } from "./tags"; import { TagsCriterion } from "./tags";
import { MoviesCriterion } from "./movies";
export function makeCriteria(type: CriterionType = "none") { export function makeCriteria(type: CriterionType = "none") {
switch (type) { switch (type) {
@@ -43,6 +44,8 @@ export function makeCriteria(type: CriterionType = "none") {
return new PerformersCriterion(); return new PerformersCriterion();
case "studios": case "studios":
return new StudiosCriterion(); return new StudiosCriterion();
case "movies":
return new MoviesCriterion();
case "birth_year": case "birth_year":
return new NumberCriterion(type, type); return new NumberCriterion(type, type);

View File

@@ -46,6 +46,7 @@ import {
} from "./criteria/tags"; } from "./criteria/tags";
import { makeCriteria } from "./criteria/utils"; import { makeCriteria } from "./criteria/utils";
import { DisplayMode, FilterMode } from "./types"; import { DisplayMode, FilterMode } from "./types";
import { MoviesCriterionOption, MoviesCriterion } from "./criteria/movies";
interface IQueryParameters { interface IQueryParameters {
perPage?: string; perPage?: string;
@@ -115,7 +116,8 @@ export class ListFilterModel {
new IsMissingCriterionOption(), new IsMissingCriterionOption(),
new TagsCriterionOption(), new TagsCriterionOption(),
new PerformersCriterionOption(), new PerformersCriterionOption(),
new StudiosCriterionOption() new StudiosCriterionOption(),
new MoviesCriterionOption()
]; ];
break; break;
case FilterMode.Performers: { case FilterMode.Performers: {
@@ -155,6 +157,12 @@ export class ListFilterModel {
this.displayModeOptions = [DisplayMode.Grid]; this.displayModeOptions = [DisplayMode.Grid];
this.criterionOptions = [new NoneCriterionOption()]; this.criterionOptions = [new NoneCriterionOption()];
break; break;
case FilterMode.Movies:
this.sortBy = "name";
this.sortByOptions = ["name", "scenes_count"];
this.displayModeOptions = [DisplayMode.Grid];
this.criterionOptions = [new NoneCriterionOption()];
break;
case FilterMode.Galleries: case FilterMode.Galleries:
this.sortBy = "path"; this.sortBy = "path";
this.sortByOptions = ["path"]; this.sortByOptions = ["path"];
@@ -236,9 +244,12 @@ export class ListFilterModel {
jsonParameters.forEach(jsonString => { jsonParameters.forEach(jsonString => {
const encodedCriterion = JSON.parse(jsonString); const encodedCriterion = JSON.parse(jsonString);
const criterion = makeCriteria(encodedCriterion.type); const criterion = makeCriteria(encodedCriterion.type);
criterion.value = encodedCriterion.value; // it's possible that we have unsupported criteria. Just skip if so.
criterion.modifier = encodedCriterion.modifier; if (criterion) {
this.criteria.push(criterion); criterion.value = encodedCriterion.value;
criterion.modifier = encodedCriterion.modifier;
this.criteria.push(criterion);
}
}); });
} }
} }
@@ -394,6 +405,14 @@ export class ListFilterModel {
}; };
break; break;
} }
case "movies": {
const movCrit = criterion as MoviesCriterion;
result.movies = {
value: movCrit.value.map(movie => movie.id),
modifier: movCrit.modifier
};
break;
}
// no default // no default
} }
}); });

View File

@@ -1,3 +1,5 @@
// NOTE: add new enum values to the end, to ensure existing data
// is not impacted
export enum DisplayMode { export enum DisplayMode {
Grid, Grid,
List, List,
@@ -9,7 +11,8 @@ export enum FilterMode {
Performers, Performers,
Studios, Studios,
Galleries, Galleries,
SceneMarkers SceneMarkers,
Movies
} }
export interface ILabeledId { export interface ILabeledId {

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 { TagsCriterion } from "src/models/list-filter/criteria/tags";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { FilterMode } from "src/models/list-filter/types"; import { FilterMode } from "src/models/list-filter/types";
import { MoviesCriterion } from "src/models/list-filter/criteria/movies";
const makePerformerScenesUrl = ( const makePerformerScenesUrl = (
performer: Partial<GQL.PerformerDataFragment> performer: Partial<GQL.PerformerDataFragment>
@@ -29,6 +30,17 @@ const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
return `/scenes?${filter.makeQueryParameters()}`; return `/scenes?${filter.makeQueryParameters()}`;
}; };
const makeMovieScenesUrl = (movie: Partial<GQL.MovieDataFragment>) => {
if (!movie.id) return "#";
const filter = new ListFilterModel(FilterMode.Scenes);
const criterion = new MoviesCriterion();
criterion.value = [
{ id: movie.id, label: movie.name || `Movie ${movie.id}` }
];
filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`;
};
const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => { const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id) return "#"; if (!tag.id) return "#";
const filter = new ListFilterModel(FilterMode.Scenes); const filter = new ListFilterModel(FilterMode.Scenes);
@@ -59,5 +71,6 @@ export default {
makeStudioScenesUrl, makeStudioScenesUrl,
makeTagSceneMarkersUrl, makeTagSceneMarkersUrl,
makeTagScenesUrl, makeTagScenesUrl,
makeSceneMarkerUrl makeSceneMarkerUrl,
makeMovieScenesUrl
}; };

View File

@@ -38,6 +38,7 @@ const renderTextArea = (options: {
<td>{options.title}</td> <td>{options.title}</td>
<td> <td>
<Form.Control <Form.Control
className="text-input"
as="textarea" as="textarea"
readOnly={!options.isEditing} readOnly={!options.isEditing}
plaintext={!options.isEditing} plaintext={!options.isEditing}