mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
[Files Refactor] Object file management (#2790)
* Add Make Primary file function * Add delete file functionality
This commit is contained in:
@@ -4,6 +4,7 @@ fragment FolderData on Folder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fragment VideoFileData on VideoFile {
|
fragment VideoFileData on VideoFile {
|
||||||
|
id
|
||||||
path
|
path
|
||||||
size
|
size
|
||||||
duration
|
duration
|
||||||
@@ -20,6 +21,7 @@ fragment VideoFileData on VideoFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fragment ImageFileData on ImageFile {
|
fragment ImageFileData on ImageFile {
|
||||||
|
id
|
||||||
path
|
path
|
||||||
size
|
size
|
||||||
width
|
width
|
||||||
@@ -31,6 +33,7 @@ fragment ImageFileData on ImageFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fragment GalleryFileData on GalleryFile {
|
fragment GalleryFileData on GalleryFile {
|
||||||
|
id
|
||||||
path
|
path
|
||||||
size
|
size
|
||||||
fingerprints {
|
fingerprints {
|
||||||
|
|||||||
3
graphql/documents/mutations/file.graphql
Normal file
3
graphql/documents/mutations/file.graphql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mutation DeleteFiles($ids: [ID!]!) {
|
||||||
|
deleteFiles(ids: $ids)
|
||||||
|
}
|
||||||
@@ -227,6 +227,8 @@ type Mutation {
|
|||||||
tagsDestroy(ids: [ID!]!): Boolean!
|
tagsDestroy(ids: [ID!]!): Boolean!
|
||||||
tagsMerge(input: TagsMergeInput!): Tag
|
tagsMerge(input: TagsMergeInput!): Tag
|
||||||
|
|
||||||
|
deleteFiles(ids: [ID!]!): Boolean!
|
||||||
|
|
||||||
# Saved filters
|
# Saved filters
|
||||||
saveFilter(input: SaveFilterInput!): SavedFilter!
|
saveFilter(input: SaveFilterInput!): SavedFilter!
|
||||||
destroySavedFilter(input: DestroyFilterInput!): Boolean!
|
destroySavedFilter(input: DestroyFilterInput!): Boolean!
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ input GalleryUpdateInput {
|
|||||||
studio_id: ID
|
studio_id: ID
|
||||||
tag_ids: [ID!]
|
tag_ids: [ID!]
|
||||||
performer_ids: [ID!]
|
performer_ids: [ID!]
|
||||||
|
|
||||||
|
primary_file_id: ID
|
||||||
}
|
}
|
||||||
|
|
||||||
input BulkGalleryUpdateInput {
|
input BulkGalleryUpdateInput {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ input ImageUpdateInput {
|
|||||||
performer_ids: [ID!]
|
performer_ids: [ID!]
|
||||||
tag_ids: [ID!]
|
tag_ids: [ID!]
|
||||||
gallery_ids: [ID!]
|
gallery_ids: [ID!]
|
||||||
|
|
||||||
|
primary_file_id: ID
|
||||||
}
|
}
|
||||||
|
|
||||||
input BulkImageUpdateInput {
|
input BulkImageUpdateInput {
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ input SceneUpdateInput {
|
|||||||
"""This should be a URL or a base64 encoded data URL"""
|
"""This should be a URL or a base64 encoded data URL"""
|
||||||
cover_image: String
|
cover_image: String
|
||||||
stash_ids: [StashIDInput!]
|
stash_ids: [StashIDInput!]
|
||||||
|
|
||||||
|
primary_file_id: ID
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BulkUpdateIdMode {
|
enum BulkUpdateIdMode {
|
||||||
|
|||||||
74
internal/api/resolver_mutation_file.go
Normal file
74
internal/api/resolver_mutation_file.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/file"
|
||||||
|
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret bool, err error) {
|
||||||
|
fileIDs, err := stringslice.StringSliceToIntSlice(ids)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileDeleter := file.NewDeleter()
|
||||||
|
destroyer := &file.ZipDestroyer{
|
||||||
|
FileDestroyer: r.repository.File,
|
||||||
|
FolderDestroyer: r.repository.Folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||||
|
qb := r.repository.File
|
||||||
|
|
||||||
|
for _, fileIDInt := range fileIDs {
|
||||||
|
fileID := file.ID(fileIDInt)
|
||||||
|
f, err := qb.Find(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
path := f[0].Base().Path
|
||||||
|
|
||||||
|
// ensure not a primary file
|
||||||
|
isPrimary, err := qb.IsPrimary(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking if file %s is primary: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPrimary {
|
||||||
|
return fmt.Errorf("cannot delete primary file %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// destroy files in zip file
|
||||||
|
inZip, err := qb.FindByZipFileID(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finding zip file contents for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ff := range inZip {
|
||||||
|
const deleteFileInZip = false
|
||||||
|
if err := file.Destroy(ctx, qb, ff, fileDeleter, deleteFileInZip); err != nil {
|
||||||
|
return fmt.Errorf("destroying file %s: %w", ff.Base().Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFile = true
|
||||||
|
if err := destroyer.DestroyZip(ctx, f[0], fileDeleter, deleteFile); err != nil {
|
||||||
|
return fmt.Errorf("deleting file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
fileDeleter.Rollback()
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// perform the post-commit actions
|
||||||
|
fileDeleter.Commit()
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -194,6 +194,32 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle
|
|||||||
}
|
}
|
||||||
updatedGallery.Organized = translator.optionalBool(input.Organized, "organized")
|
updatedGallery.Organized = translator.optionalBool(input.Organized, "organized")
|
||||||
|
|
||||||
|
if input.PrimaryFileID != nil {
|
||||||
|
primaryFileID, err := strconv.Atoi(*input.PrimaryFileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("converting primary file id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
converted := file.ID(primaryFileID)
|
||||||
|
updatedGallery.PrimaryFileID = &converted
|
||||||
|
|
||||||
|
if err := originalGallery.LoadFiles(ctx, r.repository.Gallery); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure that new primary file is associated with scene
|
||||||
|
var f file.File
|
||||||
|
for _, ff := range originalGallery.Files.List() {
|
||||||
|
if ff.Base().ID == converted {
|
||||||
|
f = ff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if f == nil {
|
||||||
|
return nil, fmt.Errorf("file with id %d not associated with gallery", converted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if translator.hasField("performer_ids") {
|
if translator.hasField("performer_ids") {
|
||||||
updatedGallery.PerformerIDs, err = translateUpdateIDs(input.PerformerIds, models.RelationshipUpdateModeSet)
|
updatedGallery.PerformerIDs, err = translateUpdateIDs(input.PerformerIds, models.RelationshipUpdateModeSet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -110,6 +110,32 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp
|
|||||||
}
|
}
|
||||||
updatedImage.Organized = translator.optionalBool(input.Organized, "organized")
|
updatedImage.Organized = translator.optionalBool(input.Organized, "organized")
|
||||||
|
|
||||||
|
if input.PrimaryFileID != nil {
|
||||||
|
primaryFileID, err := strconv.Atoi(*input.PrimaryFileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("converting primary file id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
converted := file.ID(primaryFileID)
|
||||||
|
updatedImage.PrimaryFileID = &converted
|
||||||
|
|
||||||
|
if err := i.LoadFiles(ctx, r.repository.Image); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure that new primary file is associated with scene
|
||||||
|
var f *file.ImageFile
|
||||||
|
for _, ff := range i.Files.List() {
|
||||||
|
if ff.ID == converted {
|
||||||
|
f = ff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if f == nil {
|
||||||
|
return nil, fmt.Errorf("file with id %d not associated with image", converted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if translator.hasField("gallery_ids") {
|
if translator.hasField("gallery_ids") {
|
||||||
updatedImage.GalleryIDs, err = translateUpdateIDs(input.GalleryIds, models.RelationshipUpdateModeSet)
|
updatedImage.GalleryIDs, err = translateUpdateIDs(input.GalleryIds, models.RelationshipUpdateModeSet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/stashapp/stash/pkg/scene"
|
"github.com/stashapp/stash/pkg/scene"
|
||||||
"github.com/stashapp/stash/pkg/sliceutil/intslice"
|
"github.com/stashapp/stash/pkg/sliceutil/intslice"
|
||||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||||
|
"github.com/stashapp/stash/pkg/txn"
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,6 +97,17 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
qb := r.repository.Scene
|
||||||
|
|
||||||
|
s, err := qb.Find(ctx, sceneID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s == nil {
|
||||||
|
return nil, fmt.Errorf("scene with id %d not found", sceneID)
|
||||||
|
}
|
||||||
|
|
||||||
var coverImageData []byte
|
var coverImageData []byte
|
||||||
|
|
||||||
updatedScene := models.NewScenePartial()
|
updatedScene := models.NewScenePartial()
|
||||||
@@ -111,6 +123,46 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
|||||||
|
|
||||||
updatedScene.Organized = translator.optionalBool(input.Organized, "organized")
|
updatedScene.Organized = translator.optionalBool(input.Organized, "organized")
|
||||||
|
|
||||||
|
if input.PrimaryFileID != nil {
|
||||||
|
primaryFileID, err := strconv.Atoi(*input.PrimaryFileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("converting primary file id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
converted := file.ID(primaryFileID)
|
||||||
|
updatedScene.PrimaryFileID = &converted
|
||||||
|
|
||||||
|
// if file hash has changed, we should migrate generated files
|
||||||
|
// after commit
|
||||||
|
if err := s.LoadFiles(ctx, r.repository.Scene); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure that new primary file is associated with scene
|
||||||
|
var f *file.VideoFile
|
||||||
|
for _, ff := range s.Files.List() {
|
||||||
|
if ff.ID == converted {
|
||||||
|
f = ff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if f == nil {
|
||||||
|
return nil, fmt.Errorf("file with id %d not associated with scene", converted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileNamingAlgorithm := config.GetInstance().GetVideoFileNamingAlgorithm()
|
||||||
|
oldHash := scene.GetHash(s.Files.Primary(), fileNamingAlgorithm)
|
||||||
|
newHash := scene.GetHash(f, fileNamingAlgorithm)
|
||||||
|
|
||||||
|
if oldHash != "" && newHash != "" && oldHash != newHash {
|
||||||
|
// perform migration after commit
|
||||||
|
txn.AddPostCommitHook(ctx, func(ctx context.Context) error {
|
||||||
|
scene.MigrateHash(manager.GetInstance().Paths, oldHash, newHash)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if translator.hasField("performer_ids") {
|
if translator.hasField("performer_ids") {
|
||||||
updatedScene.PerformerIDs, err = translateUpdateIDs(input.PerformerIds, models.RelationshipUpdateModeSet)
|
updatedScene.PerformerIDs, err = translateUpdateIDs(input.PerformerIds, models.RelationshipUpdateModeSet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -158,8 +210,7 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
|||||||
// update the cover after updating the scene
|
// update the cover after updating the scene
|
||||||
}
|
}
|
||||||
|
|
||||||
qb := r.repository.Scene
|
s, err = qb.UpdatePartial(ctx, sceneID, updatedScene)
|
||||||
s, err := qb.UpdatePartial(ctx, sceneID, updatedScene)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type FileReaderWriter interface {
|
|||||||
file.Finder
|
file.Finder
|
||||||
Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error)
|
Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error)
|
||||||
GetCaptions(ctx context.Context, fileID file.ID) ([]*models.VideoCaption, error)
|
GetCaptions(ctx context.Context, fileID file.ID) ([]*models.VideoCaption, error)
|
||||||
|
IsPrimary(ctx context.Context, fileID file.ID) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type FolderReaderWriter interface {
|
type FolderReaderWriter interface {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type GalleryUpdateInput struct {
|
|||||||
StudioID *string `json:"studio_id"`
|
StudioID *string `json:"studio_id"`
|
||||||
TagIds []string `json:"tag_ids"`
|
TagIds []string `json:"tag_ids"`
|
||||||
PerformerIds []string `json:"performer_ids"`
|
PerformerIds []string `json:"performer_ids"`
|
||||||
|
PrimaryFileID *string `json:"primary_file_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GalleryDestroyInput struct {
|
type GalleryDestroyInput struct {
|
||||||
|
|||||||
@@ -178,8 +178,9 @@ type SceneUpdateInput struct {
|
|||||||
Movies []*SceneMovieInput `json:"movies"`
|
Movies []*SceneMovieInput `json:"movies"`
|
||||||
TagIds []string `json:"tag_ids"`
|
TagIds []string `json:"tag_ids"`
|
||||||
// This should be a URL or a base64 encoded data URL
|
// This should be a URL or a base64 encoded data URL
|
||||||
CoverImage *string `json:"cover_image"`
|
CoverImage *string `json:"cover_image"`
|
||||||
StashIds []StashID `json:"stash_ids"`
|
StashIds []StashID `json:"stash_ids"`
|
||||||
|
PrimaryFileID *string `json:"primary_file_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateInput constructs a SceneUpdateInput using the populated fields in the ScenePartial object.
|
// UpdateInput constructs a SceneUpdateInput using the populated fields in the ScenePartial object.
|
||||||
|
|||||||
@@ -686,6 +686,40 @@ func (qb *FileStore) FindByZipFileID(ctx context.Context, zipFileID file.ID) ([]
|
|||||||
return qb.getMany(ctx, q)
|
return qb.getMany(ctx, q)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (qb *FileStore) IsPrimary(ctx context.Context, fileID file.ID) (bool, error) {
|
||||||
|
joinTables := []exp.IdentifierExpression{
|
||||||
|
scenesFilesJoinTable,
|
||||||
|
galleriesFilesJoinTable,
|
||||||
|
imagesFilesJoinTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
var sq *goqu.SelectDataset
|
||||||
|
|
||||||
|
for _, t := range joinTables {
|
||||||
|
qq := dialect.From(t).Select(t.Col(fileIDColumn)).Where(
|
||||||
|
t.Col(fileIDColumn).Eq(fileID),
|
||||||
|
t.Col("primary").Eq(1),
|
||||||
|
)
|
||||||
|
|
||||||
|
if sq == nil {
|
||||||
|
sq = qq
|
||||||
|
} else {
|
||||||
|
sq = sq.Union(qq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dialect.Select(goqu.COUNT("*").As("count")).Prepared(true).From(
|
||||||
|
sq,
|
||||||
|
)
|
||||||
|
|
||||||
|
var ret int
|
||||||
|
if err := querySimple(ctx, q, &ret); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (qb *FileStore) validateFilter(fileFilter *models.FileFilterType) error {
|
func (qb *FileStore) validateFilter(fileFilter *models.FileFilterType) error {
|
||||||
const and = "AND"
|
const and = "AND"
|
||||||
const or = "OR"
|
const or = "OR"
|
||||||
|
|||||||
@@ -613,3 +613,52 @@ func TestFileStore_FindByFingerprint(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileStore_IsPrimary(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fileID file.ID
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"scene file",
|
||||||
|
sceneFileIDs[sceneIdx1WithPerformer],
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image file",
|
||||||
|
imageFileIDs[imageIdx1WithGallery],
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gallery file",
|
||||||
|
galleryFileIDs[galleryIdx1WithImage],
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"orphan file",
|
||||||
|
fileIDs[fileIdxZip],
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invalid file",
|
||||||
|
invalidFileID,
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
qb := db.File
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
got, err := qb.IsPrimary(ctx, tt.fileID)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("FileStore.IsPrimary() error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { Accordion, Card } from "react-bootstrap";
|
import { Accordion, Button, Card } from "react-bootstrap";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
import { TruncatedText } from "src/components/Shared";
|
import { TruncatedText } from "src/components/Shared";
|
||||||
|
import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { mutateGallerySetPrimaryFile } from "src/core/StashService";
|
||||||
|
import { useToast } from "src/hooks";
|
||||||
import { TextUtils } from "src/utils";
|
import { TextUtils } from "src/utils";
|
||||||
import { TextField, URLField } from "src/utils/field";
|
import { TextField, URLField } from "src/utils/field";
|
||||||
|
|
||||||
interface IFileInfoPanelProps {
|
interface IFileInfoPanelProps {
|
||||||
folder?: Pick<GQL.Folder, "id" | "path">;
|
folder?: Pick<GQL.Folder, "id" | "path">;
|
||||||
file?: GQL.GalleryFileDataFragment;
|
file?: GQL.GalleryFileDataFragment;
|
||||||
|
primary?: boolean;
|
||||||
|
ofMany?: boolean;
|
||||||
|
onSetPrimaryFile?: () => void;
|
||||||
|
onDeleteFile?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
||||||
@@ -18,15 +27,43 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
|||||||
const id = props.folder ? "folder" : "path";
|
const id = props.folder ? "folder" : "path";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dl className="container gallery-file-info details-list">
|
<div>
|
||||||
<TextField id="media_info.checksum" value={checksum?.value} truncate />
|
<dl className="container gallery-file-info details-list">
|
||||||
<URLField
|
{props.primary && (
|
||||||
id={id}
|
<>
|
||||||
url={`file://${path}`}
|
<dt></dt>
|
||||||
value={`file://${path}`}
|
<dd className="primary-file">
|
||||||
truncate
|
<FormattedMessage id="primary_file" />
|
||||||
/>
|
</dd>
|
||||||
</dl>
|
</>
|
||||||
|
)}
|
||||||
|
<TextField id="media_info.checksum" value={checksum?.value} truncate />
|
||||||
|
<URLField
|
||||||
|
id={id}
|
||||||
|
url={`file://${path}`}
|
||||||
|
value={`file://${path}`}
|
||||||
|
truncate
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
{props.ofMany && props.onSetPrimaryFile && !props.primary && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
className="edit-button"
|
||||||
|
disabled={props.loading}
|
||||||
|
onClick={props.onSetPrimaryFile}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="actions.make_primary" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={props.loading}
|
||||||
|
onClick={props.onDeleteFile}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="actions.delete_file" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
interface IGalleryFileInfoPanelProps {
|
interface IGalleryFileInfoPanelProps {
|
||||||
@@ -36,6 +73,13 @@ interface IGalleryFileInfoPanelProps {
|
|||||||
export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
|
export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
|
||||||
props: IGalleryFileInfoPanelProps
|
props: IGalleryFileInfoPanelProps
|
||||||
) => {
|
) => {
|
||||||
|
const Toast = useToast();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [deletingFile, setDeletingFile] = useState<
|
||||||
|
GQL.GalleryFileDataFragment | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
const filesPanel = useMemo(() => {
|
const filesPanel = useMemo(() => {
|
||||||
if (props.gallery.folder) {
|
if (props.gallery.folder) {
|
||||||
return <FileInfoPanel folder={props.gallery.folder} />;
|
return <FileInfoPanel folder={props.gallery.folder} />;
|
||||||
@@ -49,23 +93,47 @@ export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
|
|||||||
return <FileInfoPanel file={props.gallery.files[0]} />;
|
return <FileInfoPanel file={props.gallery.files[0]} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSetPrimaryFile(fileID: string) {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await mutateGallerySetPrimaryFile(props.gallery.id, fileID);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion defaultActiveKey="0">
|
<Accordion defaultActiveKey={props.gallery.files[0].id}>
|
||||||
|
{deletingFile && (
|
||||||
|
<DeleteFilesDialog
|
||||||
|
onClose={() => setDeletingFile(undefined)}
|
||||||
|
selected={[deletingFile]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{props.gallery.files.map((file, index) => (
|
{props.gallery.files.map((file, index) => (
|
||||||
<Card key={index} className="gallery-file-card">
|
<Card key={file.id} className="gallery-file-card">
|
||||||
<Accordion.Toggle as={Card.Header} eventKey={index.toString()}>
|
<Accordion.Toggle as={Card.Header} eventKey={file.id}>
|
||||||
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} />
|
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} />
|
||||||
</Accordion.Toggle>
|
</Accordion.Toggle>
|
||||||
<Accordion.Collapse eventKey={index.toString()}>
|
<Accordion.Collapse eventKey={file.id}>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<FileInfoPanel file={file} />
|
<FileInfoPanel
|
||||||
|
file={file}
|
||||||
|
primary={index === 0}
|
||||||
|
ofMany
|
||||||
|
onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
|
||||||
|
loading={loading}
|
||||||
|
onDeleteFile={() => setDeletingFile(file)}
|
||||||
|
/>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Accordion.Collapse>
|
</Accordion.Collapse>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
}, [props.gallery]);
|
}, [props.gallery, loading, Toast, deletingFile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Accordion, Card } from "react-bootstrap";
|
import { Accordion, Button, Card } from "react-bootstrap";
|
||||||
import { FormattedNumber } from "react-intl";
|
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||||
import { TruncatedText } from "src/components/Shared";
|
import { TruncatedText } from "src/components/Shared";
|
||||||
|
import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { mutateImageSetPrimaryFile } from "src/core/StashService";
|
||||||
|
import { useToast } from "src/hooks";
|
||||||
import { TextUtils } from "src/utils";
|
import { TextUtils } from "src/utils";
|
||||||
import { TextField, URLField } from "src/utils/field";
|
import { TextField, URLField } from "src/utils/field";
|
||||||
|
|
||||||
interface IFileInfoPanelProps {
|
interface IFileInfoPanelProps {
|
||||||
file: GQL.ImageFileDataFragment;
|
file: GQL.ImageFileDataFragment;
|
||||||
|
primary?: boolean;
|
||||||
|
ofMany?: boolean;
|
||||||
|
onSetPrimaryFile?: () => void;
|
||||||
|
onDeleteFile?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
||||||
@@ -39,21 +47,49 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
|||||||
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
|
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dl className="container image-file-info details-list">
|
<div>
|
||||||
<TextField id="media_info.checksum" value={checksum?.value} truncate />
|
<dl className="container image-file-info details-list">
|
||||||
<URLField
|
{props.primary && (
|
||||||
id="path"
|
<>
|
||||||
url={`file://${props.file.path}`}
|
<dt></dt>
|
||||||
value={`file://${props.file.path}`}
|
<dd className="primary-file">
|
||||||
truncate
|
<FormattedMessage id="primary_file" />
|
||||||
/>
|
</dd>
|
||||||
{renderFileSize()}
|
</>
|
||||||
<TextField
|
)}
|
||||||
id="dimensions"
|
<TextField id="media_info.checksum" value={checksum?.value} truncate />
|
||||||
value={`${props.file.width} x ${props.file.height}`}
|
<URLField
|
||||||
truncate
|
id="path"
|
||||||
/>
|
url={`file://${props.file.path}`}
|
||||||
</dl>
|
value={`file://${props.file.path}`}
|
||||||
|
truncate
|
||||||
|
/>
|
||||||
|
{renderFileSize()}
|
||||||
|
<TextField
|
||||||
|
id="dimensions"
|
||||||
|
value={`${props.file.width} x ${props.file.height}`}
|
||||||
|
truncate
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
{props.ofMany && props.onSetPrimaryFile && !props.primary && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
className="edit-button"
|
||||||
|
disabled={props.loading}
|
||||||
|
onClick={props.onSetPrimaryFile}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="actions.make_primary" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={props.loading}
|
||||||
|
onClick={props.onDeleteFile}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="actions.delete_file" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
interface IImageFileInfoPanelProps {
|
interface IImageFileInfoPanelProps {
|
||||||
@@ -63,6 +99,13 @@ interface IImageFileInfoPanelProps {
|
|||||||
export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
|
export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
|
||||||
props: IImageFileInfoPanelProps
|
props: IImageFileInfoPanelProps
|
||||||
) => {
|
) => {
|
||||||
|
const Toast = useToast();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [deletingFile, setDeletingFile] = useState<
|
||||||
|
GQL.ImageFileDataFragment | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
if (props.image.files.length === 0) {
|
if (props.image.files.length === 0) {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
@@ -71,16 +114,40 @@ export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
|
|||||||
return <FileInfoPanel file={props.image.files[0]} />;
|
return <FileInfoPanel file={props.image.files[0]} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSetPrimaryFile(fileID: string) {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await mutateImageSetPrimaryFile(props.image.id, fileID);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion defaultActiveKey="0">
|
<Accordion defaultActiveKey={props.image.files[0].id}>
|
||||||
|
{deletingFile && (
|
||||||
|
<DeleteFilesDialog
|
||||||
|
onClose={() => setDeletingFile(undefined)}
|
||||||
|
selected={[deletingFile]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{props.image.files.map((file, index) => (
|
{props.image.files.map((file, index) => (
|
||||||
<Card key={index} className="image-file-card">
|
<Card key={file.id} className="image-file-card">
|
||||||
<Accordion.Toggle as={Card.Header} eventKey={index.toString()}>
|
<Accordion.Toggle as={Card.Header} eventKey={file.id}>
|
||||||
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} />
|
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} />
|
||||||
</Accordion.Toggle>
|
</Accordion.Toggle>
|
||||||
<Accordion.Collapse eventKey={index.toString()}>
|
<Accordion.Collapse eventKey={file.id}>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<FileInfoPanel file={file} />
|
<FileInfoPanel
|
||||||
|
file={file}
|
||||||
|
primary={index === 0}
|
||||||
|
ofMany
|
||||||
|
onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
|
||||||
|
onDeleteFile={() => setDeletingFile(file)}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Accordion.Collapse>
|
</Accordion.Collapse>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { Accordion, Card } from "react-bootstrap";
|
import { Accordion, Button, Card } from "react-bootstrap";
|
||||||
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
|
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
|
||||||
import { TruncatedText } from "src/components/Shared";
|
import { TruncatedText } from "src/components/Shared";
|
||||||
|
import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { mutateSceneSetPrimaryFile } from "src/core/StashService";
|
||||||
|
import { useToast } from "src/hooks";
|
||||||
import { NavUtils, TextUtils, getStashboxBase } from "src/utils";
|
import { NavUtils, TextUtils, getStashboxBase } from "src/utils";
|
||||||
import { TextField, URLField } from "src/utils/field";
|
import { TextField, URLField } from "src/utils/field";
|
||||||
|
|
||||||
interface IFileInfoPanelProps {
|
interface IFileInfoPanelProps {
|
||||||
file: GQL.VideoFileDataFragment;
|
file: GQL.VideoFileDataFragment;
|
||||||
|
primary?: boolean;
|
||||||
|
ofMany?: boolean;
|
||||||
|
onSetPrimaryFile?: () => void;
|
||||||
|
onDeleteFile?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
||||||
@@ -40,62 +48,90 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
|||||||
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
|
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dl className="container scene-file-info details-list">
|
<div>
|
||||||
<TextField id="media_info.hash" value={oshash?.value} truncate />
|
<dl className="container scene-file-info details-list">
|
||||||
<TextField id="media_info.checksum" value={checksum?.value} truncate />
|
{props.primary && (
|
||||||
<URLField
|
<>
|
||||||
id="media_info.phash"
|
<dt></dt>
|
||||||
abbr="Perceptual hash"
|
<dd className="primary-file">
|
||||||
value={phash?.value}
|
<FormattedMessage id="primary_file" />
|
||||||
url={NavUtils.makeScenesPHashMatchUrl(phash?.value)}
|
</dd>
|
||||||
target="_self"
|
</>
|
||||||
truncate
|
)}
|
||||||
trusted
|
<TextField id="media_info.hash" value={oshash?.value} truncate />
|
||||||
/>
|
<TextField id="media_info.checksum" value={checksum?.value} truncate />
|
||||||
<URLField
|
<URLField
|
||||||
id="path"
|
id="media_info.phash"
|
||||||
url={`file://${props.file.path}`}
|
abbr="Perceptual hash"
|
||||||
value={`file://${props.file.path}`}
|
value={phash?.value}
|
||||||
truncate
|
url={NavUtils.makeScenesPHashMatchUrl(phash?.value)}
|
||||||
/>
|
target="_self"
|
||||||
{renderFileSize()}
|
truncate
|
||||||
<TextField
|
trusted
|
||||||
id="duration"
|
|
||||||
value={TextUtils.secondsToTimestamp(props.file.duration ?? 0)}
|
|
||||||
truncate
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
id="dimensions"
|
|
||||||
value={`${props.file.width} x ${props.file.height}`}
|
|
||||||
truncate
|
|
||||||
/>
|
|
||||||
<TextField id="framerate">
|
|
||||||
<FormattedMessage
|
|
||||||
id="frames_per_second"
|
|
||||||
values={{ value: intl.formatNumber(props.file.frame_rate ?? 0) }}
|
|
||||||
/>
|
/>
|
||||||
</TextField>
|
<URLField
|
||||||
<TextField id="bitrate">
|
id="path"
|
||||||
<FormattedMessage
|
url={`file://${props.file.path}`}
|
||||||
id="megabits_per_second"
|
value={`file://${props.file.path}`}
|
||||||
values={{
|
truncate
|
||||||
value: intl.formatNumber((props.file.bit_rate ?? 0) / 1000000, {
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</TextField>
|
{renderFileSize()}
|
||||||
<TextField
|
<TextField
|
||||||
id="media_info.video_codec"
|
id="duration"
|
||||||
value={props.file.video_codec ?? ""}
|
value={TextUtils.secondsToTimestamp(props.file.duration ?? 0)}
|
||||||
truncate
|
truncate
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
id="media_info.audio_codec"
|
id="dimensions"
|
||||||
value={props.file.audio_codec ?? ""}
|
value={`${props.file.width} x ${props.file.height}`}
|
||||||
truncate
|
truncate
|
||||||
/>
|
/>
|
||||||
</dl>
|
<TextField id="framerate">
|
||||||
|
<FormattedMessage
|
||||||
|
id="frames_per_second"
|
||||||
|
values={{ value: intl.formatNumber(props.file.frame_rate ?? 0) }}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
<TextField id="bitrate">
|
||||||
|
<FormattedMessage
|
||||||
|
id="megabits_per_second"
|
||||||
|
values={{
|
||||||
|
value: intl.formatNumber((props.file.bit_rate ?? 0) / 1000000, {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
id="media_info.video_codec"
|
||||||
|
value={props.file.video_codec ?? ""}
|
||||||
|
truncate
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="media_info.audio_codec"
|
||||||
|
value={props.file.audio_codec ?? ""}
|
||||||
|
truncate
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
{props.ofMany && props.onSetPrimaryFile && !props.primary && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
className="edit-button"
|
||||||
|
disabled={props.loading}
|
||||||
|
onClick={props.onSetPrimaryFile}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="actions.make_primary" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={props.loading}
|
||||||
|
onClick={props.onDeleteFile}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="actions.delete_file" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,6 +142,13 @@ interface ISceneFileInfoPanelProps {
|
|||||||
export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
||||||
props: ISceneFileInfoPanelProps
|
props: ISceneFileInfoPanelProps
|
||||||
) => {
|
) => {
|
||||||
|
const Toast = useToast();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [deletingFile, setDeletingFile] = useState<
|
||||||
|
GQL.VideoFileDataFragment | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
function renderStashIDs() {
|
function renderStashIDs() {
|
||||||
if (!props.scene.stash_ids.length) {
|
if (!props.scene.stash_ids.length) {
|
||||||
return;
|
return;
|
||||||
@@ -173,23 +216,47 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||||||
return <FileInfoPanel file={props.scene.files[0]} />;
|
return <FileInfoPanel file={props.scene.files[0]} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSetPrimaryFile(fileID: string) {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await mutateSceneSetPrimaryFile(props.scene.id, fileID);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion defaultActiveKey="0">
|
<Accordion defaultActiveKey={props.scene.files[0].id}>
|
||||||
|
{deletingFile && (
|
||||||
|
<DeleteFilesDialog
|
||||||
|
onClose={() => setDeletingFile(undefined)}
|
||||||
|
selected={[deletingFile]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{props.scene.files.map((file, index) => (
|
{props.scene.files.map((file, index) => (
|
||||||
<Card key={index} className="scene-file-card">
|
<Card key={file.id} className="scene-file-card">
|
||||||
<Accordion.Toggle as={Card.Header} eventKey={index.toString()}>
|
<Accordion.Toggle as={Card.Header} eventKey={file.id}>
|
||||||
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} />
|
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} />
|
||||||
</Accordion.Toggle>
|
</Accordion.Toggle>
|
||||||
<Accordion.Collapse eventKey={index.toString()}>
|
<Accordion.Collapse eventKey={file.id}>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<FileInfoPanel file={file} />
|
<FileInfoPanel
|
||||||
|
file={file}
|
||||||
|
primary={index === 0}
|
||||||
|
ofMany
|
||||||
|
onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
|
||||||
|
onDeleteFile={() => setDeletingFile(file)}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Accordion.Collapse>
|
</Accordion.Collapse>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
}, [props.scene]);
|
}, [props.scene, loading, Toast, deletingFile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
113
ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx
Normal file
113
ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { mutateDeleteFiles } from "src/core/StashService";
|
||||||
|
import { Modal } from "src/components/Shared";
|
||||||
|
import { useToast } from "src/hooks";
|
||||||
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
|
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
interface IFile {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IDeleteSceneDialogProps {
|
||||||
|
selected: IFile[];
|
||||||
|
onClose: (confirmed: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteFilesDialog: React.FC<IDeleteSceneDialogProps> = (
|
||||||
|
props: IDeleteSceneDialogProps
|
||||||
|
) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const singularEntity = intl.formatMessage({ id: "file" });
|
||||||
|
const pluralEntity = intl.formatMessage({ id: "files" });
|
||||||
|
|
||||||
|
const header = intl.formatMessage(
|
||||||
|
{ id: "dialogs.delete_entity_title" },
|
||||||
|
{ count: props.selected.length, singularEntity, pluralEntity }
|
||||||
|
);
|
||||||
|
const toastMessage = intl.formatMessage(
|
||||||
|
{ id: "toast.delete_past_tense" },
|
||||||
|
{ count: props.selected.length, singularEntity, pluralEntity }
|
||||||
|
);
|
||||||
|
const message = intl.formatMessage(
|
||||||
|
{ id: "dialogs.delete_entity_simple_desc" },
|
||||||
|
{ count: props.selected.length, singularEntity, pluralEntity }
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = useToast();
|
||||||
|
|
||||||
|
// Network state
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
async function onDelete() {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await mutateDeleteFiles(props.selected.map((f) => f.id));
|
||||||
|
Toast.success({ content: toastMessage });
|
||||||
|
props.onClose(true);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
props.onClose(false);
|
||||||
|
}
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeleteFileAlert() {
|
||||||
|
const deletedFiles = props.selected.map((f) => f.path);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="delete-dialog alert alert-danger text-break">
|
||||||
|
<p className="font-weight-bold">
|
||||||
|
<FormattedMessage
|
||||||
|
values={{
|
||||||
|
count: props.selected.length,
|
||||||
|
singularEntity: intl.formatMessage({ id: "file" }),
|
||||||
|
pluralEntity: intl.formatMessage({ id: "files" }),
|
||||||
|
}}
|
||||||
|
id="dialogs.delete_alert"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{deletedFiles.slice(0, 5).map((s) => (
|
||||||
|
<li key={s}>{s}</li>
|
||||||
|
))}
|
||||||
|
{deletedFiles.length > 5 && (
|
||||||
|
<FormattedMessage
|
||||||
|
values={{
|
||||||
|
count: deletedFiles.length - 5,
|
||||||
|
singularEntity: intl.formatMessage({ id: "file" }),
|
||||||
|
pluralEntity: intl.formatMessage({ id: "files" }),
|
||||||
|
}}
|
||||||
|
id="dialogs.delete_object_overflow"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
show
|
||||||
|
icon={faTrashAlt}
|
||||||
|
header={header}
|
||||||
|
accept={{
|
||||||
|
variant: "danger",
|
||||||
|
onClick: onDelete,
|
||||||
|
text: intl.formatMessage({ id: "actions.delete" }),
|
||||||
|
}}
|
||||||
|
cancel={{
|
||||||
|
onClick: () => props.onClose(false),
|
||||||
|
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||||
|
variant: "secondary",
|
||||||
|
}}
|
||||||
|
isRunning={isDeleting}
|
||||||
|
>
|
||||||
|
<p>{message}</p>
|
||||||
|
{renderDeleteFileAlert()}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteFilesDialog;
|
||||||
@@ -495,6 +495,18 @@ export const useSceneGenerateScreenshot = () =>
|
|||||||
update: deleteCache([GQL.FindScenesDocument]),
|
update: deleteCache([GQL.FindScenesDocument]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const mutateSceneSetPrimaryFile = (id: string, fileID: string) =>
|
||||||
|
client.mutate<GQL.SceneUpdateMutation>({
|
||||||
|
mutation: GQL.SceneUpdateDocument,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id,
|
||||||
|
primary_file_id: fileID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: deleteCache(sceneMutationImpactedQueries),
|
||||||
|
});
|
||||||
|
|
||||||
const imageMutationImpactedQueries = [
|
const imageMutationImpactedQueries = [
|
||||||
GQL.FindPerformerDocument,
|
GQL.FindPerformerDocument,
|
||||||
GQL.FindPerformersDocument,
|
GQL.FindPerformersDocument,
|
||||||
@@ -617,6 +629,18 @@ export const mutateImageResetO = (id: string) =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const mutateImageSetPrimaryFile = (id: string, fileID: string) =>
|
||||||
|
client.mutate<GQL.ImageUpdateMutation>({
|
||||||
|
mutation: GQL.ImageUpdateDocument,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id,
|
||||||
|
primary_file_id: fileID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: deleteCache(imageMutationImpactedQueries),
|
||||||
|
});
|
||||||
|
|
||||||
const galleryMutationImpactedQueries = [
|
const galleryMutationImpactedQueries = [
|
||||||
GQL.FindPerformerDocument,
|
GQL.FindPerformerDocument,
|
||||||
GQL.FindPerformersDocument,
|
GQL.FindPerformersDocument,
|
||||||
@@ -665,6 +689,18 @@ export const mutateRemoveGalleryImages = (input: GQL.GalleryRemoveInput) =>
|
|||||||
update: deleteCache(galleryMutationImpactedQueries),
|
update: deleteCache(galleryMutationImpactedQueries),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const mutateGallerySetPrimaryFile = (id: string, fileID: string) =>
|
||||||
|
client.mutate<GQL.GalleryUpdateMutation>({
|
||||||
|
mutation: GQL.GalleryUpdateDocument,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id,
|
||||||
|
primary_file_id: fileID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: deleteCache(galleryMutationImpactedQueries),
|
||||||
|
});
|
||||||
|
|
||||||
export const studioMutationImpactedQueries = [
|
export const studioMutationImpactedQueries = [
|
||||||
GQL.FindStudiosDocument,
|
GQL.FindStudiosDocument,
|
||||||
GQL.FindSceneDocument,
|
GQL.FindSceneDocument,
|
||||||
@@ -672,6 +708,24 @@ export const studioMutationImpactedQueries = [
|
|||||||
GQL.AllStudiosForFilterDocument,
|
GQL.AllStudiosForFilterDocument,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const mutateDeleteFiles = (ids: string[]) =>
|
||||||
|
client.mutate<GQL.DeleteFilesMutation>({
|
||||||
|
mutation: GQL.DeleteFilesDocument,
|
||||||
|
variables: {
|
||||||
|
ids,
|
||||||
|
},
|
||||||
|
update: deleteCache([
|
||||||
|
...sceneMutationImpactedQueries,
|
||||||
|
...imageMutationImpactedQueries,
|
||||||
|
...galleryMutationImpactedQueries,
|
||||||
|
]),
|
||||||
|
refetchQueries: getQueryNames([
|
||||||
|
GQL.FindSceneDocument,
|
||||||
|
GQL.FindImageDocument,
|
||||||
|
GQL.FindGalleryDocument,
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
export const useStudioCreate = () =>
|
export const useStudioCreate = () =>
|
||||||
GQL.useStudioCreateMutation({
|
GQL.useStudioCreateMutation({
|
||||||
refetchQueries: getQueryNames([GQL.AllStudiosForFilterDocument]),
|
refetchQueries: getQueryNames([GQL.AllStudiosForFilterDocument]),
|
||||||
|
|||||||
@@ -829,3 +829,7 @@ select {
|
|||||||
.left-spacing {
|
.left-spacing {
|
||||||
margin-left: 0.5em;
|
margin-left: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.primary-file {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"import": "Import…",
|
"import": "Import…",
|
||||||
"import_from_file": "Import from file",
|
"import_from_file": "Import from file",
|
||||||
"logout": "Log out",
|
"logout": "Log out",
|
||||||
|
"make_primary": "Make Primary",
|
||||||
"merge": "Merge",
|
"merge": "Merge",
|
||||||
"merge_from": "Merge from",
|
"merge_from": "Merge from",
|
||||||
"merge_into": "Merge into",
|
"merge_into": "Merge into",
|
||||||
@@ -632,6 +633,7 @@
|
|||||||
"delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:",
|
"delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:",
|
||||||
"delete_confirm": "Are you sure you want to delete {entityName}?",
|
"delete_confirm": "Are you sure you want to delete {entityName}?",
|
||||||
"delete_entity_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}",
|
"delete_entity_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}",
|
||||||
|
"delete_entity_simple_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}?} other {Are you sure you want to delete these {pluralEntity}?}}",
|
||||||
"delete_entity_title": "{count, plural, one {Delete {singularEntity}} other {Delete {pluralEntity}}}",
|
"delete_entity_title": "{count, plural, one {Delete {singularEntity}} other {Delete {pluralEntity}}}",
|
||||||
"delete_galleries_extra": "…plus any image files not attached to any other gallery.",
|
"delete_galleries_extra": "…plus any image files not attached to any other gallery.",
|
||||||
"delete_gallery_files": "Delete gallery folder/zip file and any images not attached to any other gallery.",
|
"delete_gallery_files": "Delete gallery folder/zip file and any images not attached to any other gallery.",
|
||||||
@@ -905,6 +907,7 @@
|
|||||||
},
|
},
|
||||||
"performers": "Performers",
|
"performers": "Performers",
|
||||||
"piercings": "Piercings",
|
"piercings": "Piercings",
|
||||||
|
"primary_file": "Primary file",
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"random": "Random",
|
"random": "Random",
|
||||||
"rating": "Rating",
|
"rating": "Rating",
|
||||||
|
|||||||
Reference in New Issue
Block a user