diff --git a/.husky/lint-api.sh b/.husky/lint-api.sh index 4a9a8a81..a6812155 100644 --- a/.husky/lint-api.sh +++ b/.husky/lint-api.sh @@ -1,6 +1,6 @@ #!/bin/sh -gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$') +gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep --silent '.go$') [ -z "$gofiles" ] && exit 0 # Automatically format go code, exit on error diff --git a/README.md b/README.md index d76e2657..7e0e04b5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Password: **demo** - [Demo site](#demo-site) - [Main features](#main-features) +- [Supported Platforms](#supported-platforms) - [Why yet another self-hosted photo gallery](#why-yet-another-self-hosted-photo-gallery) - [Getting started - Setup with Docker](#getting-started---setup-with-docker) - [Set up development environment](#setup-development-environment) @@ -44,6 +45,14 @@ Password: **demo** - **Performant**. Thumbnails are automatically generated and photos first load when they are visible on the screen. In full screen, thumbnails are displayed until the high resolution image has been fully loaded. - **Secure**. All media resources are protected with a cookie-token, all passwords are properly hashed, and the API uses a strict [CORS policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). +## Supported platforms + +- [Docker](https://hub.docker.com/r/viktorstrate/photoview/) +- [Arch Linux Aur](https://aur.archlinux.org/packages/photoview) +- [Unraid](https://forums.unraid.net/topic/103028-support-photoview-corneliousjd-repo/) +- EmbassyOS: [announcement](https://start9labs.medium.com/new-service-photoview-72ee681b2ff0), [repo](https://github.com/Start9Labs/embassyos-photoview-wrapper) +- [YunoHost](https://github.com/YunoHost-Apps/photoview_ynh) + ## Why yet another self-hosted photo gallery There exists a lot of open-source self-hosted photo galleries already. Here are some, just to mention a few. diff --git a/api/graphql/generated.go b/api/graphql/generated.go index d827c4ae..910e6488 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -154,8 +154,10 @@ type ComplexityRoot struct { MoveImageFaces func(childComplexity int, imageFaceIDs []int, destinationFaceGroupID int) int ProtectShareToken func(childComplexity int, token string, password *string) int RecognizeUnlabeledFaces func(childComplexity int) int + ResetAlbumCover func(childComplexity int, albumID int) int ScanAll func(childComplexity int) int ScanUser func(childComplexity int, userID int) int + SetAlbumCover func(childComplexity int, coverID int) int SetFaceGroupLabel func(childComplexity int, faceGroupID int, label *string) int SetPeriodicScanInterval func(childComplexity int, interval int) int SetScannerConcurrentWorkers func(childComplexity int, workers int) int @@ -314,6 +316,8 @@ type MutationResolver interface { SetPeriodicScanInterval(ctx context.Context, interval int) (int, error) SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error) ChangeUserPreferences(ctx context.Context, language *string) (*models.UserPreferences, error) + ResetAlbumCover(ctx context.Context, albumID int) (*models.Album, error) + SetAlbumCover(ctx context.Context, coverID int) (*models.Album, error) SetFaceGroupLabel(ctx context.Context, faceGroupID int, label *string) (*models.FaceGroup, error) CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupID int) (*models.FaceGroup, error) MoveImageFaces(ctx context.Context, imageFaceIDs []int, destinationFaceGroupID int) (*models.FaceGroup, error) @@ -928,6 +932,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.RecognizeUnlabeledFaces(childComplexity), true + case "Mutation.resetAlbumCover": + if e.complexity.Mutation.ResetAlbumCover == nil { + break + } + + args, err := ec.field_Mutation_resetAlbumCover_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.ResetAlbumCover(childComplexity, args["albumID"].(int)), true + case "Mutation.scanAll": if e.complexity.Mutation.ScanAll == nil { break @@ -947,6 +963,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.ScanUser(childComplexity, args["userId"].(int)), true + case "Mutation.setAlbumCover": + if e.complexity.Mutation.SetAlbumCover == nil { + break + } + + args, err := ec.field_Mutation_setAlbumCover_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.SetAlbumCover(childComplexity, args["coverID"].(int)), true + case "Mutation.setFaceGroupLabel": if e.complexity.Mutation.SetFaceGroupLabel == nil { break @@ -1783,6 +1811,11 @@ type Mutation { changeUserPreferences(language: String): UserPreferences! @isAuthorized + "Reset the assigned cover photo for an album" + resetAlbumCover(albumID: ID!): Album! @isAuthorized + "Assign a cover photo to an album" + setAlbumCover(coverID: ID!): Album! @isAuthorized + "Assign a label to a face group, set label to null to remove the current one" setFaceGroupLabel(faceGroupID: ID!, label: String): FaceGroup! @isAuthorized "Merge two face groups into a single one, all ImageFaces from source will be moved to destination" @@ -1911,7 +1944,7 @@ type Album { paginate: Pagination ): [Album!]! - "The album witch contains this album" + "The album which contains this album" parentAlbum: Album "The user who owns this album" owner: User! @@ -1922,6 +1955,8 @@ type Album { path: [Album!]! shares: [ShareToken!]! + + #coverID: Int } type MediaURL { @@ -2368,6 +2403,21 @@ func (ec *executionContext) field_Mutation_protectShareToken_args(ctx context.Co return args, nil } +func (ec *executionContext) field_Mutation_resetAlbumCover_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 int + if tmp, ok := rawArgs["albumID"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("albumID")) + arg0, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["albumID"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_scanUser_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -2383,6 +2433,21 @@ func (ec *executionContext) field_Mutation_scanUser_args(ctx context.Context, ra return args, nil } +func (ec *executionContext) field_Mutation_setAlbumCover_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 int + if tmp, ok := rawArgs["coverID"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("coverID")) + arg0, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["coverID"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_setFaceGroupLabel_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -5926,6 +5991,130 @@ func (ec *executionContext) _Mutation_changeUserPreferences(ctx context.Context, return ec.marshalNUserPreferences2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐUserPreferences(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_resetAlbumCover(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_resetAlbumCover_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().ResetAlbumCover(rctx, args["albumID"].(int)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + if ec.directives.IsAuthorized == nil { + return nil, errors.New("directive isAuthorized is not implemented") + } + return ec.directives.IsAuthorized(ctx, nil, directive0) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(*models.Album); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be *github.com/photoview/photoview/api/graphql/models.Album`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*models.Album) + fc.Result = res + return ec.marshalNAlbum2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbum(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_setAlbumCover(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_setAlbumCover_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().SetAlbumCover(rctx, args["coverID"].(int)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + if ec.directives.IsAuthorized == nil { + return nil, errors.New("directive isAuthorized is not implemented") + } + return ec.directives.IsAuthorized(ctx, nil, directive0) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(*models.Album); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be *github.com/photoview/photoview/api/graphql/models.Album`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*models.Album) + fc.Result = res + return ec.marshalNAlbum2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbum(ctx, field.Selections, res) +} + func (ec *executionContext) _Mutation_setFaceGroupLabel(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -10779,6 +10968,16 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "resetAlbumCover": + out.Values[i] = ec._Mutation_resetAlbumCover(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } + case "setAlbumCover": + out.Values[i] = ec._Mutation_setAlbumCover(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } case "setFaceGroupLabel": out.Values[i] = ec._Mutation_setFaceGroupLabel(ctx, field) if out.Values[i] == graphql.Null { diff --git a/api/graphql/models/actions/album_actions.go b/api/graphql/models/actions/album_actions.go new file mode 100644 index 00000000..6787249e --- /dev/null +++ b/api/graphql/models/actions/album_actions.go @@ -0,0 +1,124 @@ +package actions + +import ( + "github.com/photoview/photoview/api/graphql/models" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +func MyAlbums(db *gorm.DB, user *models.User, order *models.Ordering, paginate *models.Pagination, onlyRoot *bool, showEmpty *bool, onlyWithFavorites *bool) ([]*models.Album, error) { + if err := user.FillAlbums(db); err != nil { + return nil, err + } + + if len(user.Albums) == 0 { + return nil, nil + } + + userAlbumIDs := make([]int, len(user.Albums)) + for i, album := range user.Albums { + userAlbumIDs[i] = album.ID + } + + query := db.Model(models.Album{}).Where("id IN (?)", userAlbumIDs) + + if onlyRoot != nil && *onlyRoot { + query = query.Where("parent_album_id IS NULL") + } + + if showEmpty == nil || !*showEmpty { + subQuery := db.Model(&models.Media{}).Where("album_id = albums.id") + + if onlyWithFavorites != nil && *onlyWithFavorites { + favoritesSubquery := db. + Model(&models.UserMediaData{UserID: user.ID}). + Where("user_media_data.media_id = media.id"). + Where("user_media_data.favorite = true") + + subQuery = subQuery.Where("EXISTS (?)", favoritesSubquery) + } + + query = query.Where("EXISTS (?)", subQuery) + } + + query = models.FormatSQL(query, order, paginate) + + var albums []*models.Album + if err := query.Find(&albums).Error; err != nil { + return nil, err + } + + return albums, nil +} + +func Album(db *gorm.DB, user *models.User, id int) (*models.Album, error) { + var album models.Album + if err := db.First(&album, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("album not found") + } + return nil, err + } + + ownsAlbum, err := user.OwnsAlbum(db, &album) + if err != nil { + return nil, err + } + + if !ownsAlbum { + return nil, errors.New("forbidden") + } + + return &album, nil +} + +func SetAlbumCover(db *gorm.DB, user *models.User, mediaID int) (*models.Album, error) { + var media models.Media + + if err := db.Find(&media, mediaID).Error; err != nil { + return nil, err + } + + var album models.Album + + if err := db.Find(&album, &media.AlbumID).Error; err != nil { + return nil, err + } + + ownsAlbum, err := user.OwnsAlbum(db, &album) + if err != nil { + return nil, err + } + + if !ownsAlbum { + return nil, errors.New("forbidden") + } + + if err := db.Model(&album).Update("cover_id", mediaID).Error; err != nil { + return nil, err + } + + return &album, nil +} + +func ResetAlbumCover(db *gorm.DB, user *models.User, albumID int) (*models.Album, error) { + var album models.Album + if err := db.Find(&album, albumID).Error; err != nil { + return nil, err + } + + ownsAlbum, err := user.OwnsAlbum(db, &album) + if err != nil { + return nil, err + } + + if !ownsAlbum { + return nil, errors.New("forbidden") + } + + if err := db.Model(&album).Update("cover_id", nil).Error; err != nil { + return nil, err + } + + return &album, nil +} diff --git a/api/graphql/models/actions/album_actions_test.go b/api/graphql/models/actions/album_actions_test.go new file mode 100644 index 00000000..3cdc4e8d --- /dev/null +++ b/api/graphql/models/actions/album_actions_test.go @@ -0,0 +1,178 @@ +package actions_test + +import ( + "testing" + + "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/graphql/models/actions" + "github.com/photoview/photoview/api/test_utils" + "github.com/stretchr/testify/assert" +) + +func TestAlbumCover(t *testing.T) { + db := test_utils.DatabaseTest(t) + + rootAlbum := models.Album{ + Title: "root", + Path: "/photos", + } + + if !assert.NoError(t, db.Save(&rootAlbum).Error) { + return + } + + children := []models.Album{ + { + Title: "child1", + Path: "/photos/child1", + ParentAlbumID: &rootAlbum.ID, + }, + { + Title: "child2", + Path: "/photos/child2", + ParentAlbumID: &rootAlbum.ID, + }, + } + + if !assert.NoError(t, db.Save(&children).Error) { + return + } + + photos := []models.Media{ + { + Title: "pic1", + Path: "/photos/pic1", + AlbumID: rootAlbum.ID, + }, + { + Title: "pic2", + Path: "/photos/pic2", + AlbumID: rootAlbum.ID, + }, + { + Title: "pic3", + Path: "/photos/child1/pic3", + AlbumID: children[0].ID, + }, + { + Title: "pic4", + Path: "/photos/child1/pic4", + AlbumID: children[0].ID, + }, + { + Title: "pic5", + Path: "/photos/child2/pic5", + AlbumID: children[1].ID, + }, + { + Title: "pic6", + Path: "/photos/child2/pic6", + AlbumID: children[1].ID, + }, + } + + if !assert.NoError(t, db.Save(&photos).Error) { + return + } + + if !assert.NoError(t, db.Model(&children[0]).Update("cover_id", &photos[3].ID).Error) { + return + } + + photoUrls := []models.MediaURL{ + { + MediaID: photos[0].ID, + Media: &photos[0], + }, + { + MediaID: photos[1].ID, + Media: &photos[1], + }, + { + MediaID: photos[2].ID, + Media: &photos[2], + }, + { + MediaID: photos[3].ID, + Media: &photos[3], + }, + { + MediaID: photos[4].ID, + Media: &photos[4], + }, + { + MediaID: photos[5].ID, + Media: &photos[5], + }, + } + + if !assert.NoError(t, db.Save(&photoUrls).Error) { + return + } + + user_pass := "password" + regularUser, err := models.RegisterUser(db, "user1", &user_pass, false) + if !assert.NoError(t, err) { + return + } + + if !assert.NoError(t, db.Model(®ularUser).Association("Albums").Append(&rootAlbum)) { + return + } + + if !assert.NoError(t, db.Model(®ularUser).Association("Albums").Append(&children)) { + return + } + + // Single test since we cannot rely on the tests being performed sequentially + t.Run("Album get and reset cover photos", func(t *testing.T) { + { + album, err := actions.Album(db, regularUser, rootAlbum.ID) + assert.NoError(t, err) + + albumThumb, err := album.Thumbnail(db) + assert.NoError(t, err) + + // Should return pic1 since no coverID has been set + assert.EqualValues(t, "pic1", albumThumb.Title) + } + + { + album, err := actions.Album(db, regularUser, children[0].ID) + assert.NoError(t, err) + + albumThumb, err := album.Thumbnail(db) + assert.NoError(t, err) + + // coverID has already been set + assert.EqualValues(t, "pic4", albumThumb.Title) + } + + resetAlbum, err := actions.ResetAlbumCover(db, regularUser, children[0].ID) + assert.NoError(t, err) + + assert.Nil(t, resetAlbum.CoverID) + + resetThumb, err := resetAlbum.Thumbnail(db) + assert.NoError(t, err) + + assert.Equal(t, "pic3", resetThumb.Title) + }) + + t.Run("Album change cover photos", func(t *testing.T) { + assert.Nil(t, children[1].CoverID) + + album, err := actions.SetAlbumCover(db, regularUser, photos[4].ID) + assert.NoError(t, err) + + assert.Equal(t, children[1].ID, album.ID) + assert.NotNil(t, album.CoverID) + assert.Equal(t, photos[4].ID, *album.CoverID) + + albumThumb, err := album.Thumbnail(db) + assert.NoError(t, err) + + assert.Equal(t, photos[4].ID, albumThumb.ID) + }) + +} diff --git a/api/graphql/models/album.go b/api/graphql/models/album.go index d09f4b79..eaa759f8 100644 --- a/api/graphql/models/album.go +++ b/api/graphql/models/album.go @@ -17,6 +17,7 @@ type Album struct { Owners []User `gorm:"many2many:user_albums;constraint:OnDelete:CASCADE;"` Path string `gorm:"not null"` PathHash string `gorm:"unique"` + CoverID *int } func (a *Album) FilePath() string { @@ -78,3 +79,31 @@ func GetParentsFromAlbums(db *gorm.DB, filter func(*gorm.DB) *gorm.DB, albumID i return parents, err } + +func (a *Album) Thumbnail(db *gorm.DB) (*Media, error) { + var media Media + + if a.CoverID == nil { + if err := db.Raw(` + WITH recursive sub_albums AS ( + SELECT * FROM albums AS root WHERE id = ? + UNION ALL + SELECT child.* FROM albums AS child JOIN sub_albums ON child.parent_album_id = sub_albums.id + ) + + SELECT * FROM media WHERE media.album_id IN ( + SELECT id FROM sub_albums + ) AND media.id IN ( + SELECT media_id FROM media_urls WHERE media_urls.media_id = media.id + ) ORDER BY id LIMIT 1 + `, a.ID).Find(&media).Error; err != nil { + return nil, err + } + } else { + if err := db.Where("id = ?", a.CoverID).Find(&media).Error; err != nil { + return nil, err + } + } + + return &media, nil +} diff --git a/api/graphql/resolvers/album.go b/api/graphql/resolvers/album.go index 8592f915..b89388e9 100644 --- a/api/graphql/resolvers/album.go +++ b/api/graphql/resolvers/album.go @@ -6,6 +6,7 @@ import ( api "github.com/photoview/photoview/api/graphql" "github.com/photoview/photoview/api/graphql/auth" "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/graphql/models/actions" "github.com/pkg/errors" "gorm.io/gorm" ) @@ -16,48 +17,7 @@ func (r *queryResolver) MyAlbums(ctx context.Context, order *models.Ordering, pa return nil, auth.ErrUnauthorized } - if err := user.FillAlbums(r.Database); err != nil { - return nil, err - } - - if len(user.Albums) == 0 { - return nil, nil - } - - userAlbumIDs := make([]int, len(user.Albums)) - for i, album := range user.Albums { - userAlbumIDs[i] = album.ID - } - - query := r.Database.Model(models.Album{}).Where("id IN (?)", userAlbumIDs) - - if onlyRoot != nil && *onlyRoot == true { - query = query.Where("parent_album_id IS NULL") - } - - if showEmpty == nil || *showEmpty == false { - subQuery := r.Database.Model(&models.Media{}).Where("album_id = albums.id") - - if onlyWithFavorites != nil && *onlyWithFavorites == true { - favoritesSubquery := r.Database. - Model(&models.UserMediaData{UserID: user.ID}). - Where("user_media_data.media_id = media.id"). - Where("user_media_data.favorite = true") - - subQuery = subQuery.Where("EXISTS (?)", favoritesSubquery) - } - - query = query.Where("EXISTS (?)", subQuery) - } - - query = models.FormatSQL(query, order, paginate) - - var albums []*models.Album - if err := query.Find(&albums).Error; err != nil { - return nil, err - } - - return albums, nil + return actions.MyAlbums(r.Database, user, order, paginate, onlyRoot, showEmpty, onlyWithFavorites) } func (r *queryResolver) Album(ctx context.Context, id int, tokenCredentials *models.ShareTokenCredentials) (*models.Album, error) { @@ -89,24 +49,7 @@ func (r *queryResolver) Album(ctx context.Context, id int, tokenCredentials *mod return nil, auth.ErrUnauthorized } - var album models.Album - if err := r.Database.First(&album, id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("album not found") - } - return nil, err - } - - ownsAlbum, err := user.OwnsAlbum(r.Database, &album) - if err != nil { - return nil, err - } - - if !ownsAlbum { - return nil, errors.New("forbidden") - } - - return &album, nil + return actions.Album(r.Database, user, id) } func (r *Resolver) Album() api.AlbumResolver { @@ -144,29 +87,8 @@ func (r *albumResolver) Media(ctx context.Context, album *models.Album, order *m return media, nil } -func (r *albumResolver) Thumbnail(ctx context.Context, obj *models.Album) (*models.Media, error) { - - var media models.Media - - err := r.Database.Raw(` - WITH recursive sub_albums AS ( - SELECT * FROM albums AS root WHERE id = ? - UNION ALL - SELECT child.* FROM albums AS child JOIN sub_albums ON child.parent_album_id = sub_albums.id - ) - - SELECT * FROM media WHERE media.album_id IN ( - SELECT id FROM sub_albums - ) AND media.id IN ( - SELECT media_id FROM media_urls WHERE media_urls.media_id = media.id - ) LIMIT 1 - `, obj.ID).Find(&media).Error - - if err != nil { - return nil, err - } - - return &media, nil +func (r *albumResolver) Thumbnail(ctx context.Context, album *models.Album) (*models.Media, error) { + return album.Thumbnail(r.Database) } func (r *albumResolver) SubAlbums(ctx context.Context, parent *models.Album, order *models.Ordering, paginate *models.Pagination) ([]*models.Album, error) { @@ -242,3 +164,22 @@ func (r *albumResolver) Path(ctx context.Context, obj *models.Album) ([]*models. return album_path, nil } + +// Takes album_id, resets album.cover_id to 0 (null) +func (r *mutationResolver) ResetAlbumCover(ctx context.Context, albumID int) (*models.Album, error) { + user := auth.UserFromContext(ctx) + if user == nil { + return nil, errors.New("unauthorized") + } + + return actions.ResetAlbumCover(r.Database, user, albumID) +} + +func (r *mutationResolver) SetAlbumCover(ctx context.Context, mediaID int) (*models.Album, error) { + user := auth.UserFromContext(ctx) + if user == nil { + return nil, errors.New("unauthorized") + } + + return actions.SetAlbumCover(r.Database, user, mediaID) +} diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 6abeac21..166b435a 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -142,6 +142,11 @@ type Mutation { changeUserPreferences(language: String): UserPreferences! @isAuthorized + "Reset the assigned cover photo for an album" + resetAlbumCover(albumID: ID!): Album! @isAuthorized + "Assign a cover photo to an album" + setAlbumCover(coverID: ID!): Album! @isAuthorized + "Assign a label to a face group, set label to null to remove the current one" setFaceGroupLabel(faceGroupID: ID!, label: String): FaceGroup! @isAuthorized "Merge two face groups into a single one, all ImageFaces from source will be moved to destination" @@ -270,7 +275,7 @@ type Album { paginate: Pagination ): [Album!]! - "The album witch contains this album" + "The album which contains this album" parentAlbum: Album "The user who owns this album" owner: User! diff --git a/systemd/photoview.service b/systemd/photoview.service new file mode 100644 index 00000000..cda96224 --- /dev/null +++ b/systemd/photoview.service @@ -0,0 +1,18 @@ +[Unit] +Description=Photoview - Photo gallery for self-hosted personal servers +Documentation=https://photoview.github.io/docs +After=network.target + +[Service] +ExecStart=/usr/lib/photoview/photoview +WorkingDirectory=/usr/lib/photoview +User=photoview +Group=photoview + +ReadWritePaths=/var/lib/photoview + +EnvironmentFile=/etc/photoview.env +CacheDirectory=photoview/media_cache + +[Install] +WantedBy=multi-user.target diff --git a/systemd/photoview.sysusers.conf b/systemd/photoview.sysusers.conf new file mode 100644 index 00000000..ff2e9a5c --- /dev/null +++ b/systemd/photoview.sysusers.conf @@ -0,0 +1 @@ +u photoview - "photoview user" diff --git a/systemd/photoview.tmpfiles b/systemd/photoview.tmpfiles new file mode 100644 index 00000000..28017c62 --- /dev/null +++ b/systemd/photoview.tmpfiles @@ -0,0 +1,2 @@ +e /var/cache/photoview 0750 photoview photoview +e /var/lib/photoview 0750 photoview photoview diff --git a/ui/package-lock.json b/ui/package-lock.json index c9d28bf0..dd55fed4 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -840,6 +840,9 @@ "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/plugin-syntax-bigint": { @@ -945,6 +948,9 @@ "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/plugin-syntax-jsx": { @@ -967,6 +973,9 @@ "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { @@ -975,6 +984,9 @@ "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/plugin-syntax-numeric-separator": { @@ -983,6 +995,9 @@ "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/plugin-syntax-object-rest-spread": { @@ -991,6 +1006,9 @@ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/plugin-syntax-optional-catch-binding": { @@ -999,6 +1017,9 @@ "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/plugin-syntax-optional-chaining": { @@ -1007,6 +1028,9 @@ "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/plugin-syntax-private-property-in-object": { @@ -1750,6 +1774,9 @@ "@babel/plugin-transform-dotall-regex": "^7.4.4", "@babel/types": "^7.4.4", "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/preset-react": { @@ -2030,7 +2057,10 @@ "node_modules/@graphql-typed-document-node/core": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.1.0.tgz", - "integrity": "sha512-wYn6r8zVZyQJ6rQaALBEln5B1pzxb9shV5Ef97kTvn6yVGrqyXVnDqnU24MXnFubR+rZjBY9NWuxX3FB2sTsjg==" + "integrity": "sha512-wYn6r8zVZyQJ6rQaALBEln5B1pzxb9shV5Ef97kTvn6yVGrqyXVnDqnU24MXnFubR+rZjBY9NWuxX3FB2sTsjg==", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" + } }, "node_modules/@hapi/address": { "version": "2.1.4", @@ -2156,6 +2186,9 @@ }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/@jest/types/node_modules/chalk": { @@ -2168,6 +2201,9 @@ }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/@jest/types/node_modules/color-convert": { @@ -3874,6 +3910,9 @@ }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/@testing-library/dom/node_modules/chalk": { @@ -3886,6 +3925,9 @@ }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/@testing-library/dom/node_modules/color-convert": { @@ -3955,6 +3997,9 @@ }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/@testing-library/jest-dom/node_modules/chalk": { @@ -5131,6 +5176,10 @@ "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, "node_modules/ajv-errors": { @@ -5171,6 +5220,9 @@ }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/ansi-escapes/node_modules/type-fest": { @@ -5179,6 +5231,9 @@ "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/ansi-html": { @@ -6245,6 +6300,7 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", "integrity": "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==", + "deprecated": "babel-eslint is now @babel/eslint-parser. This package will no longer receive updates.", "dependencies": { "@babel/code-frame": "^7.0.0", "@babel/parser": "^7.7.0", @@ -6255,6 +6311,9 @@ }, "engines": { "node": ">=6" + }, + "peerDependencies": { + "eslint": ">= 4.12.1" } }, "node_modules/babel-extract-comments": { @@ -10390,6 +10449,9 @@ "integrity": "sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ==", "engines": { "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/eslint-plugin-react/node_modules/doctrine": { @@ -10541,6 +10603,9 @@ }, "engines": { "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" } }, "node_modules/eslint-visitor-keys": { @@ -10633,6 +10698,9 @@ }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/eslint/node_modules/chalk": { @@ -10645,6 +10713,9 @@ }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/eslint/node_modules/color-convert": { @@ -11517,7 +11588,8 @@ "node_modules/flatten": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/flatten/-/flatten-1.0.3.tgz", - "integrity": "sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==" + "integrity": "sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==", + "deprecated": "flatten is deprecated in favor of utility frameworks such as lodash." }, "node_modules/flush-write-stream": { "version": "1.1.1", @@ -11869,6 +11941,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, "optional": true, "os": [ "darwin" @@ -12046,6 +12119,9 @@ }, "engines": { "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/glob-parent": { @@ -12989,7 +13065,6 @@ "resolved": "https://registry.npmjs.org/husky/-/husky-6.0.0.tgz", "integrity": "sha512-SQS2gDTB7tBN486QSoKPKQItZw97BMOd+Kdb6ghfpBc0yXyzrddI0oDV5MkDAbuB4X2mO3/nj60TRMcYxwzZeQ==", "dev": true, - "license": "MIT", "bin": { "husky": "lib/bin.js" }, @@ -14005,6 +14080,9 @@ }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/jest-diff/node_modules/chalk": { @@ -14017,6 +14095,9 @@ }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/jest-diff/node_modules/color-convert": { @@ -16046,6 +16127,10 @@ "dependencies": { "@babel/runtime": "^7.5.5", "tiny-warning": "^1.0.3" + }, + "peerDependencies": { + "prop-types": "^15.0.0", + "react": "^0.14.0 || ^15.0.0 || ^16.0.0" } }, "node_modules/mini-css-extract-plugin": { @@ -17028,6 +17113,9 @@ }, "engines": { "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate": { @@ -19029,6 +19117,9 @@ }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/pretty-format/node_modules/color-convert": { @@ -19610,6 +19701,9 @@ "prop-types": "^15.7.2", "react-fast-compare": "^3.1.1", "react-side-effect": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.3.0" } }, "node_modules/react-hook-form": { @@ -19657,6 +19751,9 @@ "react-is": "^16.6.0", "tiny-invariant": "^1.0.2", "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" } }, "node_modules/react-router-dom": { @@ -19671,6 +19768,9 @@ "react-router": "5.2.0", "tiny-invariant": "^1.0.2", "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" } }, "node_modules/react-router-dom/node_modules/history": { @@ -21167,12 +21267,18 @@ "dependencies": { "object-assign": "^4.1.1", "react-is": "^16.12.0 || ^17.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0" } }, "node_modules/react-side-effect": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.0.tgz", - "integrity": "sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg==" + "integrity": "sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg==", + "peerDependencies": { + "react": "^16.3.0" + } }, "node_modules/react-spring": { "version": "8.0.27", @@ -21492,6 +21598,9 @@ "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" } }, "node_modules/regexpu-core": { @@ -27150,6 +27259,9 @@ }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/wrap-ansi/node_modules/color-convert": { @@ -28646,7 +28758,8 @@ "@graphql-typed-document-node/core": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.1.0.tgz", - "integrity": "sha512-wYn6r8zVZyQJ6rQaALBEln5B1pzxb9shV5Ef97kTvn6yVGrqyXVnDqnU24MXnFubR+rZjBY9NWuxX3FB2sTsjg==" + "integrity": "sha512-wYn6r8zVZyQJ6rQaALBEln5B1pzxb9shV5Ef97kTvn6yVGrqyXVnDqnU24MXnFubR+rZjBY9NWuxX3FB2sTsjg==", + "requires": {} }, "@hapi/address": { "version": "2.1.4", @@ -35376,7 +35489,8 @@ "eslint-plugin-react-hooks": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.2.0.tgz", - "integrity": "sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ==" + "integrity": "sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ==", + "requires": {} }, "eslint-plugin-testing-library": { "version": "3.10.2", @@ -43696,7 +43810,8 @@ "react-side-effect": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.0.tgz", - "integrity": "sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg==" + "integrity": "sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg==", + "requires": {} }, "react-spring": { "version": "8.0.27", diff --git a/ui/package.json b/ui/package.json index ab0dbc6b..d4dca0c3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -71,6 +71,7 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.0.0", "@testing-library/user-event": "^13.1.9", + "apollo": "^2.33.4", "husky": "^6.0.0", "i18next-parser": "^4.2.0", "lint-staged": "^11.0.1", diff --git a/ui/src/Pages/AlbumPage/AlbumPage.tsx b/ui/src/Pages/AlbumPage/AlbumPage.tsx index 5877e437..869b809a 100644 --- a/ui/src/Pages/AlbumPage/AlbumPage.tsx +++ b/ui/src/Pages/AlbumPage/AlbumPage.tsx @@ -25,6 +25,7 @@ const ALBUM_QUERY = gql` id title thumbnail { + id thumbnail { url } diff --git a/ui/src/Pages/AlbumPage/__generated__/albumQuery.ts b/ui/src/Pages/AlbumPage/__generated__/albumQuery.ts index eb6605f3..f7138d2e 100644 --- a/ui/src/Pages/AlbumPage/__generated__/albumQuery.ts +++ b/ui/src/Pages/AlbumPage/__generated__/albumQuery.ts @@ -19,6 +19,7 @@ export interface albumQuery_album_subAlbums_thumbnail_thumbnail { export interface albumQuery_album_subAlbums_thumbnail { __typename: "Media"; + id: string; /** * URL to display the media in a smaller resolution */ diff --git a/ui/src/Pages/AllAlbumsPage/AlbumsPage.tsx b/ui/src/Pages/AllAlbumsPage/AlbumsPage.tsx index 01978924..b0fe58a3 100644 --- a/ui/src/Pages/AllAlbumsPage/AlbumsPage.tsx +++ b/ui/src/Pages/AllAlbumsPage/AlbumsPage.tsx @@ -10,6 +10,7 @@ const getAlbumsQuery = gql` id title thumbnail { + id thumbnail { url } diff --git a/ui/src/Pages/AllAlbumsPage/__generated__/getMyAlbums.ts b/ui/src/Pages/AllAlbumsPage/__generated__/getMyAlbums.ts index 33fb85e1..600309f7 100644 --- a/ui/src/Pages/AllAlbumsPage/__generated__/getMyAlbums.ts +++ b/ui/src/Pages/AllAlbumsPage/__generated__/getMyAlbums.ts @@ -17,6 +17,7 @@ export interface getMyAlbums_myAlbums_thumbnail_thumbnail { export interface getMyAlbums_myAlbums_thumbnail { __typename: "Media"; + id: string; /** * URL to display the media in a smaller resolution */ diff --git a/ui/src/Pages/SharePage/AlbumSharePage.tsx b/ui/src/Pages/SharePage/AlbumSharePage.tsx index 084c1fa0..1549376e 100644 --- a/ui/src/Pages/SharePage/AlbumSharePage.tsx +++ b/ui/src/Pages/SharePage/AlbumSharePage.tsx @@ -27,6 +27,7 @@ export const SHARE_ALBUM_QUERY = gql` id title thumbnail { + id thumbnail { url } diff --git a/ui/src/Pages/SharePage/__generated__/shareAlbumQuery.ts b/ui/src/Pages/SharePage/__generated__/shareAlbumQuery.ts index 9309ceb2..f65bdaa4 100644 --- a/ui/src/Pages/SharePage/__generated__/shareAlbumQuery.ts +++ b/ui/src/Pages/SharePage/__generated__/shareAlbumQuery.ts @@ -19,6 +19,7 @@ export interface shareAlbumQuery_album_subAlbums_thumbnail_thumbnail { export interface shareAlbumQuery_album_subAlbums_thumbnail { __typename: "Media"; + id: string; /** * URL to display the media in a smaller resolution */ diff --git a/ui/src/components/sidebar/AlbumCovers.tsx b/ui/src/components/sidebar/AlbumCovers.tsx new file mode 100644 index 00000000..5db328fd --- /dev/null +++ b/ui/src/components/sidebar/AlbumCovers.tsx @@ -0,0 +1,155 @@ +import React, { useState, useEffect } from 'react' +import { useMutation, gql } from '@apollo/client' +import { useTranslation } from 'react-i18next' + +import { SidebarSection, SidebarSectionTitle } from './SidebarComponents' + +import { + setAlbumCover, + setAlbumCoverVariables, +} from './__generated__/setAlbumCover' +import { + resetAlbumCover, + resetAlbumCoverVariables, +} from './__generated__/resetAlbumCover' + +const RESET_ALBUM_COVER_MUTATION = gql` + mutation resetAlbumCover($albumID: ID!) { + resetAlbumCover(albumID: $albumID) { + id + thumbnail { + id + thumbnail { + url + } + } + } + } +` +const SET_ALBUM_COVER_MUTATION = gql` + mutation setAlbumCover($coverID: ID!) { + setAlbumCover(coverID: $coverID) { + id + thumbnail { + id + thumbnail { + url + } + } + } + } +` + +type SidebarPhotoCoverProps = { + cover_id: string +} + +export const SidebarPhotoCover = ({ cover_id }: SidebarPhotoCoverProps) => { + const { t } = useTranslation() + + const [setAlbumCover] = useMutation( + SET_ALBUM_COVER_MUTATION, + { + variables: { + coverID: cover_id, + }, + } + ) + + const [buttonDisabled, setButtonDisabled] = useState(false) + + useEffect(() => { + setButtonDisabled(false) + }, [cover_id]) + + return ( + + + {t('sidebar.album.cover_photo', 'Album cover')} + +
+ + + + + + +
+ +
+
+
+ ) +} + +type SidebarAlbumCoverProps = { + id: string +} + +export const SidebarAlbumCover = ({ id }: SidebarAlbumCoverProps) => { + const { t } = useTranslation() + + const [resetAlbumCover] = useMutation< + resetAlbumCover, + resetAlbumCoverVariables + >(RESET_ALBUM_COVER_MUTATION, { + variables: { + albumID: id, + }, + }) + + const [buttonDisabled, setButtonDisabled] = useState(false) + + useEffect(() => { + setButtonDisabled(false) + }, [id]) + + return ( + + + {t('sidebar.album.album_cover', 'Album cover')} + +
+ + + + + + +
+ +
+
+
+ ) +} diff --git a/ui/src/components/sidebar/AlbumSidebar.tsx b/ui/src/components/sidebar/AlbumSidebar.tsx index 47ea5c50..17b74af5 100644 --- a/ui/src/components/sidebar/AlbumSidebar.tsx +++ b/ui/src/components/sidebar/AlbumSidebar.tsx @@ -7,6 +7,7 @@ import { getAlbumSidebar, getAlbumSidebarVariables, } from './__generated__/getAlbumSidebar' +import { SidebarAlbumCover } from './AlbumCovers' const albumQuery = gql` query getAlbumSidebar($id: ID!) { @@ -46,6 +47,9 @@ const AlbumSidebar = ({ albumId }: AlbumSidebarProps) => { {/*

{data.album.title}

*/} +
+ +
) } diff --git a/ui/src/components/sidebar/MediaSidebar.tsx b/ui/src/components/sidebar/MediaSidebar.tsx index 051f0979..a4ad814c 100644 --- a/ui/src/components/sidebar/MediaSidebar.tsx +++ b/ui/src/components/sidebar/MediaSidebar.tsx @@ -26,6 +26,7 @@ import { import { sidebarDownloadQuery_media_downloads } from './__generated__/sidebarDownloadQuery' import SidebarHeader from './SidebarHeader' +import { SidebarPhotoCover } from './AlbumCovers' const SIDEBAR_MEDIA_QUERY = gql` query sidebarPhoto($id: ID!) { @@ -365,6 +366,9 @@ const SidebarContent = ({ media, hidePreview }: SidebarContentProps) => { +
+ +
) } diff --git a/ui/src/components/sidebar/Sharing.tsx b/ui/src/components/sidebar/Sharing.tsx index c75d9143..c2ccb800 100644 --- a/ui/src/components/sidebar/Sharing.tsx +++ b/ui/src/components/sidebar/Sharing.tsx @@ -312,10 +312,9 @@ export const SidebarPhotoShare = ({ id }: SidebarSharePhotoProps) => { const [ loadShares, { loading: queryLoading, error: sharesError, data: sharesData }, - ] = - useLazyQuery( - SHARE_PHOTO_QUERY - ) + ] = useLazyQuery( + SHARE_PHOTO_QUERY + ) const [sharePhoto, { loading: mutationLoading }] = useMutation< sidebarPhotoAddShare, diff --git a/ui/src/components/sidebar/__generated__/resetAlbumCover.ts b/ui/src/components/sidebar/__generated__/resetAlbumCover.ts new file mode 100644 index 00000000..1b71c76d --- /dev/null +++ b/ui/src/components/sidebar/__generated__/resetAlbumCover.ts @@ -0,0 +1,45 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: resetAlbumCover +// ==================================================== + +export interface resetAlbumCover_resetAlbumCover_thumbnail_thumbnail { + __typename: "MediaURL"; + /** + * URL for previewing the image + */ + url: string; +} + +export interface resetAlbumCover_resetAlbumCover_thumbnail { + __typename: "Media"; + id: string; + /** + * URL to display the media in a smaller resolution + */ + thumbnail: resetAlbumCover_resetAlbumCover_thumbnail_thumbnail | null; +} + +export interface resetAlbumCover_resetAlbumCover { + __typename: "Album"; + id: string; + /** + * An image in this album used for previewing this album + */ + thumbnail: resetAlbumCover_resetAlbumCover_thumbnail | null; +} + +export interface resetAlbumCover { + /** + * Reset the assigned cover photo for an album + */ + resetAlbumCover: resetAlbumCover_resetAlbumCover; +} + +export interface resetAlbumCoverVariables { + albumID: string; +} diff --git a/ui/src/components/sidebar/__generated__/setAlbumCover.ts b/ui/src/components/sidebar/__generated__/setAlbumCover.ts new file mode 100644 index 00000000..b0b70a05 --- /dev/null +++ b/ui/src/components/sidebar/__generated__/setAlbumCover.ts @@ -0,0 +1,45 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: setAlbumCover +// ==================================================== + +export interface setAlbumCover_setAlbumCover_thumbnail_thumbnail { + __typename: "MediaURL"; + /** + * URL for previewing the image + */ + url: string; +} + +export interface setAlbumCover_setAlbumCover_thumbnail { + __typename: "Media"; + id: string; + /** + * URL to display the media in a smaller resolution + */ + thumbnail: setAlbumCover_setAlbumCover_thumbnail_thumbnail | null; +} + +export interface setAlbumCover_setAlbumCover { + __typename: "Album"; + id: string; + /** + * An image in this album used for previewing this album + */ + thumbnail: setAlbumCover_setAlbumCover_thumbnail | null; +} + +export interface setAlbumCover { + /** + * Assign a cover photo to an album + */ + setAlbumCover: setAlbumCover_setAlbumCover; +} + +export interface setAlbumCoverVariables { + coverID: string; +} diff --git a/ui/src/components/sidebar/__generated__/setAlbumCoverID.ts b/ui/src/components/sidebar/__generated__/setAlbumCoverID.ts new file mode 100644 index 00000000..f623c123 --- /dev/null +++ b/ui/src/components/sidebar/__generated__/setAlbumCoverID.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: setAlbumCoverID +// ==================================================== + +export interface setAlbumCoverID_setAlbumCoverID { + __typename: 'Album' + id: string + coverID: string +} + +export interface setAlbumCoverID { + /** + * Assign a cover image to an album, set coverID to -1 to remove the current one + */ + setAlbumCoverID: setAlbumCoverID_setAlbumCoverID +} + +export interface setAlbumCoverIDVariables { + albumID: string + coverID: string +}