From b289b25e34948ca670168191fa54616733a72782 Mon Sep 17 00:00:00 2001
From: viktorstrate <viktorstrate@gmail.com>
Date: Wed, 17 Jun 2020 18:00:58 +0200
Subject: [PATCH] Start on photo favorite

---
 .../migrations/006_favorite_photos.down.sql   |   2 +
 .../migrations/006_favorite_photos.up.sql     |   2 +
 api/graphql/generated.go                      | 128 ++++++++++++++++++
 api/graphql/models/photo.go                   |  15 +-
 api/graphql/resolvers/photo.go                |  16 +++
 api/graphql/schema.graphql                    |   4 +
 ui/src/Pages/PhotosPage/PhotosPage.js         |   1 +
 ui/src/components/photoGallery/Photo.js       |  72 ++++++----
 8 files changed, 210 insertions(+), 30 deletions(-)
 create mode 100644 api/database/migrations/006_favorite_photos.down.sql
 create mode 100644 api/database/migrations/006_favorite_photos.up.sql

diff --git a/api/database/migrations/006_favorite_photos.down.sql b/api/database/migrations/006_favorite_photos.down.sql
new file mode 100644
index 0000000..9e556e1
--- /dev/null
+++ b/api/database/migrations/006_favorite_photos.down.sql
@@ -0,0 +1,2 @@
+-- Add favorite attribute to photos
+ALTER TABLE photo DROP favorite
diff --git a/api/database/migrations/006_favorite_photos.up.sql b/api/database/migrations/006_favorite_photos.up.sql
new file mode 100644
index 0000000..c110606
--- /dev/null
+++ b/api/database/migrations/006_favorite_photos.up.sql
@@ -0,0 +1,2 @@
+-- Add favorite attribute to photos
+ALTER TABLE photo ADD favorite BOOL DEFAULT false
diff --git a/api/graphql/generated.go b/api/graphql/generated.go
index bb1481f..eac92df 100644
--- a/api/graphql/generated.go
+++ b/api/graphql/generated.go
@@ -75,6 +75,7 @@ type ComplexityRoot struct {
 		CreateUser         func(childComplexity int, username string, rootPath string, password *string, admin bool) int
 		DeleteShareToken   func(childComplexity int, token string) int
 		DeleteUser         func(childComplexity int, id int) int
+		FavoritePhoto      func(childComplexity int, photoID int, favorite bool) int
 		InitialSetupWizard func(childComplexity int, username string, password string, rootPath string) int
 		ProtectShareToken  func(childComplexity int, token string, password *string) int
 		RegisterUser       func(childComplexity int, username string, password string, rootPath string) int
@@ -100,6 +101,7 @@ type ComplexityRoot struct {
 		Album     func(childComplexity int) int
 		Downloads func(childComplexity int) int
 		Exif      func(childComplexity int) int
+		Favorite  func(childComplexity int) int
 		HighRes   func(childComplexity int) int
 		ID        func(childComplexity int) int
 		Path      func(childComplexity int) int
@@ -208,6 +210,7 @@ type MutationResolver interface {
 	SharePhoto(ctx context.Context, photoID int, expire *time.Time, password *string) (*models.ShareToken, error)
 	DeleteShareToken(ctx context.Context, token string) (*models.ShareToken, error)
 	ProtectShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error)
+	FavoritePhoto(ctx context.Context, photoID int, favorite bool) (*models.Photo, error)
 	UpdateUser(ctx context.Context, id int, username *string, rootPath *string, password *string, admin *bool) (*models.User, error)
 	CreateUser(ctx context.Context, username string, rootPath string, password *string, admin bool) (*models.User, error)
 	DeleteUser(ctx context.Context, id int) (*models.User, error)
@@ -217,6 +220,7 @@ type PhotoResolver interface {
 	HighRes(ctx context.Context, obj *models.Photo) (*models.PhotoURL, error)
 	Album(ctx context.Context, obj *models.Photo) (*models.Album, error)
 	Exif(ctx context.Context, obj *models.Photo) (*models.PhotoEXIF, error)
+
 	Shares(ctx context.Context, obj *models.Photo) ([]*models.ShareToken, error)
 	Downloads(ctx context.Context, obj *models.Photo) ([]*models.PhotoDownload, error)
 }
@@ -407,6 +411,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Mutation.DeleteUser(childComplexity, args["id"].(int)), true
 
+	case "Mutation.favoritePhoto":
+		if e.complexity.Mutation.FavoritePhoto == nil {
+			break
+		}
+
+		args, err := ec.field_Mutation_favoritePhoto_args(context.TODO(), rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Mutation.FavoritePhoto(childComplexity, args["photoId"].(int), args["favorite"].(bool)), true
+
 	case "Mutation.initialSetupWizard":
 		if e.complexity.Mutation.InitialSetupWizard == nil {
 			break
@@ -575,6 +591,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Photo.Exif(childComplexity), true
 
+	case "Photo.favorite":
+		if e.complexity.Photo.Favorite == nil {
+			break
+		}
+
+		return e.complexity.Photo.Favorite(childComplexity), true
+
 	case "Photo.highRes":
 		if e.complexity.Photo.HighRes == nil {
 			break
@@ -1158,6 +1181,9 @@ type Mutation {
   "Set a password for a token, if null is passed for the password argument, the password will be cleared"
   protectShareToken(token: String!, password: String): ShareToken
 
+  "Mark or unmark a photo as being a favorite"
+  favoritePhoto(photoId: Int!, favorite: Boolean!): Photo
+
   updateUser(
     id: Int!
     username: String
@@ -1290,6 +1316,7 @@ type Photo {
   "The album that holds the photo"
   album: Album!
   exif: PhotoEXIF
+  favorite: Boolean!
 
   shares: [ShareToken!]!
   downloads: [PhotoDownload!]!
@@ -1449,6 +1476,28 @@ func (ec *executionContext) field_Mutation_deleteUser_args(ctx context.Context,
 	return args, nil
 }
 
+func (ec *executionContext) field_Mutation_favoritePhoto_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["photoId"]; ok {
+		arg0, err = ec.unmarshalNInt2int(ctx, tmp)
+		if err != nil {
+			return nil, err
+		}
+	}
+	args["photoId"] = arg0
+	var arg1 bool
+	if tmp, ok := rawArgs["favorite"]; ok {
+		arg1, err = ec.unmarshalNBoolean2bool(ctx, tmp)
+		if err != nil {
+			return nil, err
+		}
+	}
+	args["favorite"] = arg1
+	return args, nil
+}
+
 func (ec *executionContext) field_Mutation_initialSetupWizard_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
 	var err error
 	args := map[string]interface{}{}
@@ -2672,6 +2721,44 @@ func (ec *executionContext) _Mutation_protectShareToken(ctx context.Context, fie
 	return ec.marshalOShareToken2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _Mutation_favoritePhoto(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,
+	}
+
+	ctx = graphql.WithFieldContext(ctx, fc)
+	rawArgs := field.ArgumentMap(ec.Variables)
+	args, err := ec.field_Mutation_favoritePhoto_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) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Mutation().FavoritePhoto(rctx, args["photoId"].(int), args["favorite"].(bool))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.(*models.Photo)
+	fc.Result = res
+	return ec.marshalOPhoto2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐPhoto(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _Mutation_updateUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
 	defer func() {
 		if r := recover(); r != nil {
@@ -3347,6 +3434,40 @@ func (ec *executionContext) _Photo_exif(ctx context.Context, field graphql.Colle
 	return ec.marshalOPhotoEXIF2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐPhotoEXIF(ctx, field.Selections, res)
 }
 
+func (ec *executionContext) _Photo_favorite(ctx context.Context, field graphql.CollectedField, obj *models.Photo) (ret graphql.Marshaler) {
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	fc := &graphql.FieldContext{
+		Object:   "Photo",
+		Field:    field,
+		Args:     nil,
+		IsMethod: false,
+	}
+
+	ctx = graphql.WithFieldContext(ctx, fc)
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Favorite, nil
+	})
+	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.(bool)
+	fc.Result = res
+	return ec.marshalNBoolean2bool(ctx, field.Selections, res)
+}
+
 func (ec *executionContext) _Photo_shares(ctx context.Context, field graphql.CollectedField, obj *models.Photo) (ret graphql.Marshaler) {
 	defer func() {
 		if r := recover(); r != nil {
@@ -6512,6 +6633,8 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
 			out.Values[i] = ec._Mutation_deleteShareToken(ctx, field)
 		case "protectShareToken":
 			out.Values[i] = ec._Mutation_protectShareToken(ctx, field)
+		case "favoritePhoto":
+			out.Values[i] = ec._Mutation_favoritePhoto(ctx, field)
 		case "updateUser":
 			out.Values[i] = ec._Mutation_updateUser(ctx, field)
 		case "createUser":
@@ -6664,6 +6787,11 @@ func (ec *executionContext) _Photo(ctx context.Context, sel ast.SelectionSet, ob
 				res = ec._Photo_exif(ctx, field, obj)
 				return res
 			})
+		case "favorite":
+			out.Values[i] = ec._Photo_favorite(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				atomic.AddUint32(&invalids, 1)
+			}
 		case "shares":
 			field := field
 			out.Concurrently(i, func() (res graphql.Marshaler) {
diff --git a/api/graphql/models/photo.go b/api/graphql/models/photo.go
index dc6eb24..e82115d 100644
--- a/api/graphql/models/photo.go
+++ b/api/graphql/models/photo.go
@@ -8,11 +8,12 @@ import (
 )
 
 type Photo struct {
-	PhotoID int
-	Title   string
-	Path    string
-	AlbumId int
-	ExifId  *int
+	PhotoID  int
+	Title    string
+	Path     string
+	AlbumId  int
+	ExifId   *int
+	Favorite bool
 }
 
 func (p *Photo) ID() int {
@@ -40,7 +41,7 @@ type PhotoURL struct {
 func NewPhotoFromRow(row *sql.Row) (*Photo, error) {
 	photo := Photo{}
 
-	if err := row.Scan(&photo.PhotoID, &photo.Title, &photo.Path, &photo.AlbumId, &photo.ExifId); err != nil {
+	if err := row.Scan(&photo.PhotoID, &photo.Title, &photo.Path, &photo.AlbumId, &photo.ExifId, &photo.Favorite); err != nil {
 		return nil, err
 	}
 
@@ -52,7 +53,7 @@ func NewPhotosFromRows(rows *sql.Rows) ([]*Photo, error) {
 
 	for rows.Next() {
 		var photo Photo
-		if err := rows.Scan(&photo.PhotoID, &photo.Title, &photo.Path, &photo.AlbumId, &photo.ExifId); err != nil {
+		if err := rows.Scan(&photo.PhotoID, &photo.Title, &photo.Path, &photo.AlbumId, &photo.ExifId, &photo.Favorite); err != nil {
 			return nil, err
 		}
 		photos = append(photos, &photo)
diff --git a/api/graphql/resolvers/photo.go b/api/graphql/resolvers/photo.go
index b6bc05c..1a4d758 100644
--- a/api/graphql/resolvers/photo.go
+++ b/api/graphql/resolvers/photo.go
@@ -171,3 +171,19 @@ func (r *photoResolver) Exif(ctx context.Context, obj *models.Photo) (*models.Ph
 
 	return exif, nil
 }
+
+func (r *mutationResolver) FavoritePhoto(ctx context.Context, photoID int, favourite bool) (*models.Photo, error) {
+
+	user := auth.UserFromContext(ctx)
+
+	row := r.Database.QueryRow("SELECT photo.* FROM photo JOIN album ON photo.album_id = album.album_id WHERE photo.photo_id = ? AND album.owner_id = ?", photoID, user.UserID)
+
+	photo, err := models.NewPhotoFromRow(row)
+	if err != nil {
+		return nil, err
+	}
+
+	// TODO: Update photo row
+
+	return photo, nil
+}
diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql
index 7187a80..5b3a33b 100644
--- a/api/graphql/schema.graphql
+++ b/api/graphql/schema.graphql
@@ -75,6 +75,9 @@ type Mutation {
   "Set a password for a token, if null is passed for the password argument, the password will be cleared"
   protectShareToken(token: String!, password: String): ShareToken
 
+  "Mark or unmark a photo as being a favorite"
+  favoritePhoto(photoId: Int!, favorite: Boolean!): Photo
+
   updateUser(
     id: Int!
     username: String
@@ -207,6 +210,7 @@ type Photo {
   "The album that holds the photo"
   album: Album!
   exif: PhotoEXIF
+  favorite: Boolean!
 
   shares: [ShareToken!]!
   downloads: [PhotoDownload!]!
diff --git a/ui/src/Pages/PhotosPage/PhotosPage.js b/ui/src/Pages/PhotosPage/PhotosPage.js
index c538a4c..508b07e 100644
--- a/ui/src/Pages/PhotosPage/PhotosPage.js
+++ b/ui/src/Pages/PhotosPage/PhotosPage.js
@@ -23,6 +23,7 @@ const photoQuery = gql`
         highRes {
           url
         }
+        favorite
       }
     }
   }
diff --git a/ui/src/components/photoGallery/Photo.js b/ui/src/components/photoGallery/Photo.js
index 0dcb54d..7e4e3d6 100644
--- a/ui/src/components/photoGallery/Photo.js
+++ b/ui/src/components/photoGallery/Photo.js
@@ -1,10 +1,21 @@
 import React, { useState } from 'react'
+import gql from 'graphql-tag'
+import { useMutation } from 'react-apollo'
 import PropTypes from 'prop-types'
 import styled from 'styled-components'
 import LazyLoad from 'react-lazyload'
 import { Icon } from 'semantic-ui-react'
 import ProtectedImage from './ProtectedImage'
 
+const markFavoriteMutation = gql`
+  mutation markPhotoFavorite($photoId: Int!, $favorite: Boolean!) {
+    favoritePhoto(photoId: $photoId, favorite: $favorite) {
+      id
+      favorite
+    }
+  }
+`
+
 const PhotoContainer = styled.div`
   flex-grow: 1;
   flex-basis: 0;
@@ -107,29 +118,44 @@ export const Photo = ({
   index,
   active,
   setPresenting,
-}) => (
-  <PhotoContainer
-    key={photo.id}
-    style={{
-      cursor: onSelectImage ? 'pointer' : null,
-      minWidth: `min(${minWidth}px, 100% - 8px)`,
-    }}
-    onClick={() => {
-      onSelectImage && onSelectImage(index)
-    }}
-  >
-    <LazyPhoto src={photo.thumbnail && photo.thumbnail.url} />
-    <PhotoOverlay active={active}>
-      <HoverIcon
-        name="expand"
-        onClick={() => {
-          setPresenting(true)
-        }}
-      />
-      <HoverIcon name="heart outline" />
-    </PhotoOverlay>
-  </PhotoContainer>
-)
+}) => {
+  const [markFavorite] = useMutation(markFavoriteMutation)
+
+  return (
+    <PhotoContainer
+      key={photo.id}
+      style={{
+        cursor: onSelectImage ? 'pointer' : null,
+        minWidth: `min(${minWidth}px, 100% - 8px)`,
+      }}
+      onClick={() => {
+        onSelectImage && onSelectImage(index)
+      }}
+    >
+      <LazyPhoto src={photo.thumbnail && photo.thumbnail.url} />
+      <PhotoOverlay active={active}>
+        <HoverIcon
+          name="expand"
+          onClick={() => {
+            setPresenting(true)
+          }}
+        />
+        <HoverIcon
+          name={photo.favorite ? 'heart' : 'heart outline'}
+          onClick={event => {
+            event.stopPropagation()
+            markFavorite({
+              variables: {
+                photoId: photo.id,
+                favorite: !photo.favorite,
+              },
+            })
+          }}
+        />
+      </PhotoOverlay>
+    </PhotoContainer>
+  )
+}
 
 Photo.propTypes = {
   photo: PropTypes.object.isRequired,
