mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Allow uploading of custom scene covers (#262)
* Refactor common code * Further refactoring * Add UI support for changing scene cover image * Add backend support for changing scene screenshot
This commit is contained in:
@@ -8,7 +8,8 @@ mutation SceneUpdate(
|
|||||||
$studio_id: ID,
|
$studio_id: ID,
|
||||||
$gallery_id: ID,
|
$gallery_id: ID,
|
||||||
$performer_ids: [ID!] = [],
|
$performer_ids: [ID!] = [],
|
||||||
$tag_ids: [ID!] = []) {
|
$tag_ids: [ID!] = [],
|
||||||
|
$cover_image: String) {
|
||||||
|
|
||||||
sceneUpdate(input: {
|
sceneUpdate(input: {
|
||||||
id: $id,
|
id: $id,
|
||||||
@@ -20,7 +21,8 @@ mutation SceneUpdate(
|
|||||||
studio_id: $studio_id,
|
studio_id: $studio_id,
|
||||||
gallery_id: $gallery_id,
|
gallery_id: $gallery_id,
|
||||||
performer_ids: $performer_ids,
|
performer_ids: $performer_ids,
|
||||||
tag_ids: $tag_ids
|
tag_ids: $tag_ids,
|
||||||
|
cover_image: $cover_image
|
||||||
}) {
|
}) {
|
||||||
...SceneData
|
...SceneData
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ input SceneUpdateInput {
|
|||||||
gallery_id: ID
|
gallery_id: ID
|
||||||
performer_ids: [ID!]
|
performer_ids: [ID!]
|
||||||
tag_ids: [ID!]
|
tag_ids: [ID!]
|
||||||
|
"""This should be base64 encoded"""
|
||||||
|
cover_image: String
|
||||||
}
|
}
|
||||||
|
|
||||||
input BulkSceneUpdateInput {
|
input BulkSceneUpdateInput {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/stashapp/stash/pkg/database"
|
"github.com/stashapp/stash/pkg/database"
|
||||||
"github.com/stashapp/stash/pkg/manager"
|
"github.com/stashapp/stash/pkg/manager"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUpdateInput) (*models.Scene, error) {
|
func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUpdateInput) (*models.Scene, error) {
|
||||||
@@ -149,6 +150,24 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, tx *sqlx.T
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// only update the cover image if provided and everything else was successful
|
||||||
|
if input.CoverImage != nil && *input.CoverImage != "" {
|
||||||
|
_, imageData, err := utils.ProcessBase64Image(*input.CoverImage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scene, err := qb.Find(sceneID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = manager.SetSceneScreenshot(scene.Checksum, imageData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return scene, nil
|
return scene, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +302,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
|
|||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// if delete generated is true, then delete the generated files
|
// if delete generated is true, then delete the generated files
|
||||||
// for the scene
|
// for the scene
|
||||||
if input.DeleteGenerated != nil && *input.DeleteGenerated {
|
if input.DeleteGenerated != nil && *input.DeleteGenerated {
|
||||||
|
|||||||
61
pkg/manager/scene_screenshot.go
Normal file
61
pkg/manager/scene_screenshot.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
|
||||||
|
// needed to decode other image formats
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeImage(path string, imageData []byte) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
_, err = f.Write(imageData)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeThumbnail(path string, thumbnail image.Image) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
return jpeg.Encode(f, thumbnail, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetSceneScreenshot(checksum string, imageData []byte) error {
|
||||||
|
thumbPath := instance.Paths.Scene.GetThumbnailScreenshotPath(checksum)
|
||||||
|
normalPath := instance.Paths.Scene.GetScreenshotPath(checksum)
|
||||||
|
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(imageData))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// resize to 320 width maintaining aspect ratio, for the thumbnail
|
||||||
|
const width = 320
|
||||||
|
origWidth := img.Bounds().Max.X
|
||||||
|
origHeight := img.Bounds().Max.Y
|
||||||
|
height := width / origWidth * origHeight
|
||||||
|
|
||||||
|
thumbnail := imaging.Resize(img, width, height, imaging.Lanczos)
|
||||||
|
err = writeThumbnail(thumbPath, thumbnail)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeImage(normalPath, imageData)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { ErrorUtils } from "../../../utils/errors";
|
|||||||
import { TableUtils } from "../../../utils/table";
|
import { TableUtils } from "../../../utils/table";
|
||||||
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
|
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
|
||||||
import { ToastUtils } from "../../../utils/toasts";
|
import { ToastUtils } from "../../../utils/toasts";
|
||||||
|
import { ImageUtils } from "../../../utils/image";
|
||||||
|
|
||||||
interface IProps extends IBaseProps {}
|
interface IProps extends IBaseProps {}
|
||||||
|
|
||||||
@@ -62,26 +63,12 @@ export const Studio: FunctionComponent<IProps> = (props: IProps) => {
|
|||||||
}
|
}
|
||||||
}, [studio]);
|
}, [studio]);
|
||||||
|
|
||||||
function pasteImage(e : any) {
|
function onImageLoad(this: FileReader) {
|
||||||
if (e.clipboardData.files.length == 0) {
|
setImagePreview(this.result as string);
|
||||||
return;
|
setImage(this.result as string);
|
||||||
}
|
|
||||||
|
|
||||||
const file: File = e.clipboardData.files[0];
|
|
||||||
const reader: FileReader = new FileReader();
|
|
||||||
|
|
||||||
reader.onloadend = (e) => {
|
|
||||||
setImagePreview(reader.result as string);
|
|
||||||
setImage(reader.result as string);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
ImageUtils.addPasteImageHook(onImageLoad);
|
||||||
window.addEventListener("paste", pasteImage);
|
|
||||||
|
|
||||||
return () => window.removeEventListener("paste", pasteImage);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isNew && !isEditing) {
|
if (!isNew && !isEditing) {
|
||||||
if (!data || !data.findStudio || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
if (!data || !data.findStudio || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
||||||
@@ -144,14 +131,7 @@ export const Studio: FunctionComponent<IProps> = (props: IProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
|
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||||
const file: File = (event.target as any).files[0];
|
ImageUtils.onImageChange(event, onImageLoad);
|
||||||
const reader: FileReader = new FileReader();
|
|
||||||
|
|
||||||
reader.onloadend = (e) => {
|
|
||||||
setImagePreview(reader.result as string);
|
|
||||||
setImage(reader.result as string);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: CSS class
|
// TODO: CSS class
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ScrapePerformerSuggest } from "../../select/ScrapePerformerSuggest";
|
|||||||
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
|
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
|
||||||
import { ToastUtils } from "../../../utils/toasts";
|
import { ToastUtils } from "../../../utils/toasts";
|
||||||
import { EditableTextUtils } from "../../../utils/editabletext";
|
import { EditableTextUtils } from "../../../utils/editabletext";
|
||||||
|
import { ImageUtils } from "../../../utils/image";
|
||||||
|
|
||||||
interface IPerformerProps extends IBaseProps {}
|
interface IPerformerProps extends IBaseProps {}
|
||||||
|
|
||||||
@@ -99,27 +100,13 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
|
|||||||
}
|
}
|
||||||
}, [performer]);
|
}, [performer]);
|
||||||
|
|
||||||
function pasteImage(e : any) {
|
function onImageLoad(this: FileReader) {
|
||||||
if (e.clipboardData.files.length == 0) {
|
setImagePreview(this.result as string);
|
||||||
return;
|
setImage(this.result as string);
|
||||||
}
|
|
||||||
|
|
||||||
const file: File = e.clipboardData.files[0];
|
|
||||||
const reader: FileReader = new FileReader();
|
|
||||||
|
|
||||||
reader.onloadend = (e) => {
|
|
||||||
setImagePreview(reader.result as string);
|
|
||||||
setImage(reader.result as string);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
ImageUtils.addPasteImageHook(onImageLoad);
|
||||||
window.addEventListener("paste", pasteImage);
|
|
||||||
|
|
||||||
return () => window.removeEventListener("paste", pasteImage);
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
var newQueryableScrapers : GQL.ListPerformerScrapersListPerformerScrapers[] = [];
|
var newQueryableScrapers : GQL.ListPerformerScrapersListPerformerScrapers[] = [];
|
||||||
|
|
||||||
@@ -208,14 +195,7 @@ export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerP
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
|
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||||
const file: File = (event.target as any).files[0];
|
ImageUtils.onImageChange(event, onImageLoad);
|
||||||
const reader: FileReader = new FileReader();
|
|
||||||
|
|
||||||
reader.onloadend = (e) => {
|
|
||||||
setImagePreview(reader.result as string);
|
|
||||||
setImage(reader.result as string);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDisplayFreeOnesDialog(scraper: GQL.ListPerformerScrapersListPerformerScrapers) {
|
function onDisplayFreeOnesDialog(scraper: GQL.ListPerformerScrapersListPerformerScrapers) {
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
InputGroup,
|
InputGroup,
|
||||||
Spinner,
|
Spinner,
|
||||||
TextArea,
|
TextArea,
|
||||||
|
Collapse,
|
||||||
|
Icon,
|
||||||
|
FileInput,
|
||||||
} from "@blueprintjs/core";
|
} from "@blueprintjs/core";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||||
@@ -18,6 +21,7 @@ import { ToastUtils } from "../../../utils/toasts";
|
|||||||
import { FilterMultiSelect } from "../../select/FilterMultiSelect";
|
import { FilterMultiSelect } from "../../select/FilterMultiSelect";
|
||||||
import { FilterSelect } from "../../select/FilterSelect";
|
import { FilterSelect } from "../../select/FilterSelect";
|
||||||
import { ValidGalleriesSelect } from "../../select/ValidGalleriesSelect";
|
import { ValidGalleriesSelect } from "../../select/ValidGalleriesSelect";
|
||||||
|
import { ImageUtils } from "../../../utils/image";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
scene: GQL.SceneDataFragment;
|
scene: GQL.SceneDataFragment;
|
||||||
@@ -36,11 +40,15 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
|
|||||||
const [studioId, setStudioId] = useState<string | undefined>(undefined);
|
const [studioId, setStudioId] = useState<string | undefined>(undefined);
|
||||||
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined);
|
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined);
|
||||||
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
|
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
|
||||||
|
const [coverImage, setCoverImage] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||||
const [deleteFile, setDeleteFile] = useState<boolean>(false);
|
const [deleteFile, setDeleteFile] = useState<boolean>(false);
|
||||||
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
|
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const [isCoverImageOpen, setIsCoverImageOpen] = useState<boolean>(false);
|
||||||
|
const [coverImagePreview, setCoverImagePreview] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
// Network state
|
// Network state
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
@@ -64,8 +72,11 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateSceneEditState(props.scene);
|
updateSceneEditState(props.scene);
|
||||||
|
setCoverImagePreview(props.scene.paths.screenshot);
|
||||||
}, [props.scene]);
|
}, [props.scene]);
|
||||||
|
|
||||||
|
ImageUtils.addPasteImageHook(onImageLoad);
|
||||||
|
|
||||||
// if (!isNew && !isEditing) {
|
// if (!isNew && !isEditing) {
|
||||||
// if (!data || !data.findPerformer || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
// if (!data || !data.findPerformer || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
||||||
// if (!!error) { return <>error...</>; }
|
// if (!!error) { return <>error...</>; }
|
||||||
@@ -83,6 +94,7 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
|
|||||||
studio_id: studioId,
|
studio_id: studioId,
|
||||||
performer_ids: performerIds,
|
performer_ids: performerIds,
|
||||||
tag_ids: tagIds,
|
tag_ids: tagIds,
|
||||||
|
cover_image: coverImage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +178,15 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onImageLoad(this: FileReader) {
|
||||||
|
setCoverImagePreview(this.result as string);
|
||||||
|
setCoverImage(this.result as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCoverImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||||
|
ImageUtils.onImageChange(event, onImageLoad);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderDeleteAlert()}
|
{renderDeleteAlert()}
|
||||||
@@ -231,6 +252,18 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
|
|||||||
<FormGroup label="Tags">
|
<FormGroup label="Tags">
|
||||||
{renderMultiSelect("tags", tagIds)}
|
{renderMultiSelect("tags", tagIds)}
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
|
<div className="bp3-form-group">
|
||||||
|
<label className="bp3-label collapsible-label" onClick={() => setIsCoverImageOpen(!isCoverImageOpen)}>
|
||||||
|
<Icon className="label-icon" icon={isCoverImageOpen ? "chevron-down" : "chevron-right"}/>
|
||||||
|
<span>Cover Image</span>
|
||||||
|
</label>
|
||||||
|
<Collapse isOpen={isCoverImageOpen}>
|
||||||
|
<img className="scene-cover" src={coverImagePreview} />
|
||||||
|
<FileInput text="Choose image..." onInputChange={onCoverImageChange} inputProps={{accept: ".jpg,.jpeg,.png"}} />
|
||||||
|
</Collapse>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<Button className="edit-button" text="Save" intent="primary" onClick={() => onSave()}/>
|
<Button className="edit-button" text="Save" intent="primary" onClick={() => onSave()}/>
|
||||||
<Button className="edit-button" text="Delete" intent="danger" onClick={() => setIsDeleteAlertOpen(true)}/>
|
<Button className="edit-button" text="Delete" intent="danger" onClick={() => setIsDeleteAlertOpen(true)}/>
|
||||||
|
|||||||
@@ -478,6 +478,26 @@ span.block {
|
|||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scene-cover {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-icon {
|
||||||
|
margin-right: 0.3em;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
& +span {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
@media only screen and (max-width: 768px) {
|
||||||
// collapse menu items into sidemenu
|
// collapse menu items into sidemenu
|
||||||
.collapsible-navlink {
|
.collapsible-navlink {
|
||||||
|
|||||||
34
ui/v2/src/utils/image.tsx
Normal file
34
ui/v2/src/utils/image.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
|
export class ImageUtils {
|
||||||
|
|
||||||
|
private static readImage(file: File, onLoadEnd: (this: FileReader) => any) {
|
||||||
|
const reader: FileReader = new FileReader();
|
||||||
|
|
||||||
|
reader.onloadend = onLoadEnd;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static onImageChange(event: React.FormEvent<HTMLInputElement>, onLoadEnd: (this: FileReader) => any) {
|
||||||
|
const file: File = (event.target as any).files[0];
|
||||||
|
ImageUtils.readImage(file, onLoadEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static pasteImage(e : any, onLoadEnd: (this: FileReader) => any) {
|
||||||
|
if (e.clipboardData.files.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file: File = e.clipboardData.files[0];
|
||||||
|
ImageUtils.readImage(file, onLoadEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static addPasteImageHook(onLoadEnd: (this: FileReader) => any) {
|
||||||
|
useEffect(() => {
|
||||||
|
const pasteImage = (e: any) => { ImageUtils.pasteImage(e, onLoadEnd) }
|
||||||
|
window.addEventListener("paste", pasteImage);
|
||||||
|
|
||||||
|
return () => window.removeEventListener("paste", pasteImage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user