[from now] 2013/03/08 22:52:20

diff --git a/app/controllers/Application.scala b/app/controllers/Application.scala
index e9f413a..a081bbd 100644
--- a/app/controllers/Application.scala
+++ b/app/controllers/Application.scala
@@ -23,6 +23,17 @@ object Application extends Controller with Composable {
     Ok(views.html.app("title"))
   }

+  def test = Action {
+    Ok(views.html.test())
+  }
+
+  def oauth2callback = Action { implicit request =>
+    request.
+
+
+    Ok("")
+  }
+
   case class Shelf(name: String)

   def makeShelf = Action { implicit request =>
diff --git a/app/controllers/Connection.scala b/app/controllers/Connection.scala
index 60f410a..8831ca3 100644
--- a/app/controllers/Connection.scala
+++ b/app/controllers/Connection.scala
@@ -31,8 +31,8 @@ sealed abstract class ConnectResult {
  */
 object Connection {

-  private val CLIENT_ID: String = "your client id";
-  private val SECRET_ID: String = "your secret id";
+  private val CLIENT_ID: String = "881447546058.apps.googleusercontent.com";
+  private val SECRET_ID: String = "Pa2UhdEQxC_o0HCu8ad-PzzS";

   private val JSON_FACTORY: JsonFactory = new JacksonFactory();
   private val TRANSPORT: HttpTransport = new NetHttpTransport();
diff --git a/conf/routes b/conf/routes
index 054de42..a7a27d1 100644
--- a/conf/routes
+++ b/conf/routes
@@ -11,6 +11,8 @@ GET     /books               controllers.Application.getBooksInShelf
 GET     /books/*id           controllers.Application.getBookDetail(id: Long)
 POST    /shelf               controllers.Application.makeShelf
 POST    /book                controllers.Application.makeBookInShelf
+POST    /oauth2callback      controllers.Application.oauth2callback
+GET     /test   controllers.Application.test

 # Map static resources from the /public folder to the /assets URL path
 GET     /*file               controllers.Assets.at(path="/public", file)
[from now] 2013/03/08 22:52:40

diff --git a/app/controllers/Application.scala b/app/controllers/Application.scala
index a081bbd..bcb3013 100644
--- a/app/controllers/Application.scala
+++ b/app/controllers/Application.scala
@@ -28,7 +28,7 @@ object Application extends Controller with Composable {
   }

   def oauth2callback = Action { implicit request =>
-    request.
+    request.body.as

     Ok("")
diff --git a/app/views/test.scala.html b/app/views/test.scala.html
new file mode 100644
index 0000000..947ba49
--- /dev/null
+++ b/app/views/test.scala.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html lang="ja">
+  <head>
+    <meta charset="utf-8">
+    <title>Social BookShelf</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <link href="@routes.Assets.at("stylesheets/base.css")" rel="stylesheet">
+    <link href="@routes.Assets.at("stylesheets/main.css")" rel="stylesheet">
+    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.8.3/jquery.min.js"></script>
+    <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.0.3/angular.min.js"></script>
+    <script src="//ajax.googleapis.com/ajax/libs/jqueryui/1.9.2/jquery-ui.min.js"></script>
+    <script src="@routes.Assets.at("javascripts/gplus/app.js")"></script>
+    <script src="@routes.Assets.at("javascripts/gplus/services.js")"></script>
+    <script src="@routes.Assets.at("javascripts/gplus/controllers.js")"></script>
+    <script type='text/javascript'>
+      function startApp() {
+      angular.bootstrap(document, ['photoHunt']);
+      }
+    </script>
+  </head>
+  <body ng-controller="PhotoHuntCtrl" ng-cloak>
+
+    <span id="signin" ng-show="immediateFailed">
+      <span id="myGsignin"></span>
+</span>
+      <script type="text/javascript">
+  window.___gcfg = {
+    parsetags: 'explicit'
+  };
+  (function() {
+    var po = document.createElement('script'); po.type = 'text/javascript'; po.async = true;
+    po.src = 'https://apis.google.com/js/auth:plusone.js?onload=startApp';
+    var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(po, s);
+  })();
+  $(function() {
+    $("#sort").buttonset();
+  });
+  </script>
+
+</body>
+</html>
diff --git a/public/javascripts/gplus/app.js b/public/javascripts/gplus/app.js
new file mode 100644
index 0000000..34229d3
--- /dev/null
+++ b/public/javascripts/gplus/app.js
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2013 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+
+'use strict';
+
+angular.module('photoHunt',
+    ['photoHunt.services'],
+    function($locationProvider) {
+      $locationProvider.html5Mode(true);
+    }
+);
diff --git a/public/javascripts/gplus/controllers.js b/public/javascripts/gplus/controllers.js
new file mode 100644
index 0000000..0ec09ef
--- /dev/null
+++ b/public/javascripts/gplus/controllers.js
@@ -0,0 +1,273 @@
+/*
+ * Copyright (c) 2013 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+'use strict';
+
+function PhotoHuntCtrl($scope, $location, Conf, PhotoHuntApi) {
+  // signIn
+  $scope.userProfile = undefined;
+  $scope.hasUserProfile = false;
+  $scope.isSignedIn = false;
+  $scope.immediateFailed = false;
+  // themes
+  $scope.selectedTheme;
+  $scope.themes = [];
+  // photos
+  $scope.ordering;
+  $scope.recentButtonClasses;
+  $scope.popularButtonClasses;
+  $scope.highlightedPhoto;
+  $scope.userPhotos = [];
+  $scope.friendsPhotos = [];
+  $scope.allPhotos = [];
+  // friends
+  $scope.friends = [];
+  // uploads
+  $scope.uploadUrl;
+
+  $scope.disconnect = function() {
+    PhotoHuntApi.disconnect().then(function() {
+      $scope.userProfile = undefined;
+      $scope.hasUserProfile = false;
+      $scope.isSignedIn = false;
+      $scope.immediateFailed = true;
+      $scope.userPhotos = [];
+      $scope.friendsPhotos = [];
+      //$scope.renderSignIn();
+    });
+  }
+
+  // methods
+  $scope.orderBy = function (criteria) {
+    var activeItemClasses = ['active','primary'];
+    if (criteria == 'recent') {
+      $scope.ordering = '-dateCreated';
+      $scope.recentButtonClasses = activeItemClasses;
+      $scope.popularButtonClasses = [];
+    } else if (criteria == 'popular') {
+      $scope.ordering = '-votes';
+      $scope.popularButtonClasses = activeItemClasses;
+      $scope.recentButtonClasses = [];
+    } else {
+      return 0;
+    }
+  };
+
+  $scope.adaptPhotos = function(photos) {
+    angular.forEach(photos, function(value, key) {
+      value['canDelete'] = false;
+      value['canVote'] = false;
+      value['voteClass'] = [];
+      if ($scope.hasUserProfile) {
+        if (value.ownerUserId == $scope.userProfile.id) {
+          value['canDelete'] = true;
+        } else {
+          if ($scope.userProfile.role == 'admin') {
+            value['canDelete'] = true;
+          }
+          value['canVote'] = true;
+          value['voteClass'] = ['button', 'icon', 'arrowup'];
+          if (value.voted) {
+            value['voteClass'].push('disable');
+          } else {
+            value.voted = false;
+          }
+        }
+      }
+    });
+    return photos;
+  }
+
+  $scope.deletePhoto = function(photoId) {
+    PhotoHuntApi.deletePhoto(photoId);
+    $scope.userPhotos = $scope.removePhotoFromArray($scope.userPhotos, photoId);
+    $scope.friendsPhotos = $scope.removePhotoFromArray($scope.friendsPhotos, photoId);
+    $scope.allPhotos = $scope.removePhotoFromArray($scope.allPhotos, photoId);
+  }
+
+  $scope.removePhotoFromArray = function (array, photoId) {
+    var newArray = [];
+    angular.forEach(array, function(value, key) {
+      if (value.id != photoId) {
+        newArray.push(value);
+      }
+    });
+    return newArray;
+  }
+
+  $scope.getUserPhotos = function() {
+    if ($scope.hasUserProfile && ($scope.themes.length > 0)) {
+      PhotoHuntApi.getUserPhotosByTheme($scope.selectedTheme.id)
+      	  .then(function(response) {
+        $scope.userPhotos = $scope.adaptPhotos(response.data);
+      });
+    }
+  }
+
+  $scope.getAllPhotos = function() {
+    PhotoHuntApi.getAllPhotosByTheme($scope.selectedTheme.id)
+    	.then(function(response) {
+      $scope.allPhotos = $scope.adaptPhotos(response.data);
+    });
+  }
+
+  $scope.getFriendsPhotos = function() {
+    PhotoHuntApi.getFriendsPhotosByTheme($scope.selectedTheme.id)
+        .then(function(response) {
+      $scope.friendsPhotos = $scope.adaptPhotos(response.data);
+    });
+  }
+
+  $scope.getUploadUrl = function(params) {
+    PhotoHuntApi.getUploadUrl().then(function(response) {
+      $scope.uploadUrl = response.data;
+    });
+  }
+
+  $scope.checkIfVoteActionRequested = function() {
+    if($location.search()['action'] == 'VOTE') {
+      PhotoHuntApi.votePhoto($location.search()['photoId'])
+          .then(function(response) {
+        var photo = response.data;
+        $scope.highlightedPhoto = photo;
+        $scope.notification = 'Thanks for voting!';
+      });
+    }
+  }
+
+  $scope.getFriends = function() {
+    PhotoHuntApi.getFriends().then(function(response) {
+      $scope.friends = response.data;
+      $scope.getFriendsPhotos();
+    })
+  }
+
+  $scope.selectTheme = function(themeIndex) {
+    $scope.selectedTheme = $scope.themes[themeIndex];
+    if ($scope.selectedTheme.id != $scope.themes[0].id) {
+      $scope.orderBy('popular');
+    }
+    $scope.getAllPhotos();
+    if($scope.isSignedIn) {
+      $scope.getUserPhotos();
+    }
+    if ($scope.friends.length) {
+      $scope.getFriendsPhotos();
+    }
+  }
+
+  $scope.canUpload = function() {
+    if ($scope.uploadUrl)
+      return true;
+    else
+      return false;
+  }
+
+  $scope.uploadedPhoto = function(uploadedPhoto) {
+    uploadedPhoto['canDelete'] = true;
+    $scope.userPhotos.unshift(uploadedPhoto);
+    $scope.allPhotos.unshift(uploadedPhoto);
+    $scope.getUploadUrl();
+  }
+
+  $scope.signedIn = function(profile) {
+    $scope.isSignedIn = true;
+    $scope.userProfile = profile;
+    $scope.hasUserProfile = true;
+    $scope.getUserPhotos();
+    // refresh the state of operations that depend on the local user
+    $scope.allPhotos = $scope.adaptPhotos($scope.allPhotos);
+    // now we can perform other actions that need the user to be signed in
+    $scope.getUploadUrl();
+    $scope.checkIfVoteActionRequested();
+    $scope.getFriends();
+  };
+
+  $scope.checkForHighlightedPhoto = function() {
+    if($location.search()['photoId']) {
+      PhotoHuntApi.getPhoto($location.search()['photoId'])
+          .then(function(response) {
+        $scope.highlightedPhoto = response.data;
+      })
+    }
+  }
+
+  $scope.signIn = function(authResult) {
+    $scope.$apply(function() {
+      $scope.processAuth(authResult);
+    });
+  }
+
+  $scope.processAuth = function(authResult) {
+    $scope.immediateFailed = true;
+    if ($scope.isSignedIn) {
+      return 0;
+    }
+    if (authResult['access_token']) {
+      $scope.immediateFailed = false;
+      // Successfully authorized, create session
+      PhotoHuntApi.signIn(authResult).then(function(response) {
+        $scope.signedIn(response.data);
+      });
+    } else if (authResult['error']) {
+      if (authResult['error'] == 'immediate_failed') {
+        $scope.immediateFailed = true;
+      } else {
+        console.log('Error:' + authResult['error']);
+      }
+    }
+  }
+
+  $scope.renderSignIn = function() {
+    gapi.signin.render('myGsignin', {
+      'callback': $scope.signIn,
+      'clientid': Conf.clientId,
+      'requestvisibleactions': Conf.requestvisibleactions,
+      'scope': Conf.scopes,
+      'apppackagename': Conf.apppackagename,
+      'theme': 'dark',
+      'cookiepolicy': Conf.cookiepolicy,
+      'accesstype': 'offline'
+    });
+  }
+
+  $scope.start = function() {
+    $scope.renderSignIn();
+    $scope.checkForHighlightedPhoto();
+    PhotoHuntApi.getThemes().then(function(response) {
+      $scope.themes = response.data;
+      $scope.selectedTheme = $scope.themes[0];
+      $scope.orderBy('recent');
+      $scope.getUserPhotos();
+      var options = {
+        'clientid': Conf.clientId,
+        'contenturl': Conf.rootUrl + '/invite.html',
+        'contentdeeplinkid': '/',
+        'prefilltext': 'Join the hunt, upload and vote for photos of ' +
+            $scope.selectedTheme.displayName + '. #photohunt',
+        'calltoactionlabel': 'Join',
+        'calltoactionurl': Conf.rootUrl,
+        'calltoactiondeeplinkid': '/',
+        'requestvisibleactions': Conf.requestvisibleactions,
+        'scope': Conf.scopes,
+        'cookiepolicy': Conf.cookiepolicy
+      };
+      gapi.interactivepost.render('invite', options);
+      $scope.getAllPhotos();
+    });
+  }
+
+  $scope.start();
+
+}
diff --git a/public/javascripts/gplus/services.js b/public/javascripts/gplus/services.js
new file mode 100644
index 0000000..104d0bf
--- /dev/null
+++ b/public/javascripts/gplus/services.js
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2013 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+'use strict';
+
+angular.module('photoHunt.services', [])
+    .factory('Conf', function($location) {
+      function getRootUrl() {
+        var rootUrl = $location.protocol() + '://' + $location.host();
+        if ($location.port())
+          rootUrl += ':' + $location.port();
+        return rootUrl;
+      };
+      return {
+        'clientId': '881447546058.apps.googleusercontent.com',
+        'apiBase': '/',
+        'rootUrl': getRootUrl(),
+        'scopes': 'https://www.googleapis.com/auth/plus.login ',
+        'requestvisibleactions': 'http://schemas.google.com/AddActivity ' +
+                'http://schemas.google.com/ReviewActivity',
+        'cookiepolicy': 'single_host_origin',
+        // If you have an android application and you want to enable
+        // Over-The-Air install, remove the comment below and use the package
+        // name associated to the project's Client Id for installed applications
+        // in the Google API Console.
+        //'apppackagename': 'YOUR_APP_PACKAGE'
+      };
+    })
+    .factory('PhotoHuntApi', function($http, Conf) {
+      return {
+        signIn: function(authResult) {
+          return $http.post(Conf.apiBase + 'oauth2callback', authResult);
+        },
+        votePhoto: function(photoId) {
+          return $http.put(Conf.apiBase + 'votes',
+              {'photoId': photoId});
+        },
+        getThemes: function() {
+          return $http.get(Conf.apiBase + 'themes');
+        },
+        getUploadUrl: function() {
+          return $http.post(Conf.apiBase + 'images');
+        },
+        getAllPhotosByTheme: function(themeId) {
+          return $http.get(Conf.apiBase + 'photos',
+              {params: {'themeId': themeId}});
+        },
+        getPhoto: function(photoId) {
+          return $http.get(Conf.apiBase + 'photos', {params:
+              {'photoId': photoId}});
+        },
+        getUserPhotosByTheme: function(themeId) {
+          return $http.get(Conf.apiBase + 'photos', {params:
+              {'themeId': themeId, 'userId': 'me'}});
+        },
+        getFriends: function () {
+          return $http.get(Conf.apiBase + 'friends');
+        },
+        getFriendsPhotosByTheme: function(themeId) {
+          return $http.get(Conf.apiBase + 'photos', {params:
+              {'themeId': themeId, 'userId': 'me', 'friends': 'true'}});
+        },
+        deletePhoto: function(photoId) {
+          return $http.delete(Conf.apiBase + 'photos', {params:
+              {'photoId': photoId}});
+        },
+        disconnect: function() {
+          return $http.post(Conf.apiBase + 'disconnect');
+        }
+      };
+    })
+;
[from now] 2013/03/08 23:12:50

diff --git a/app/controllers/Application.scala b/app/controllers/Application.scala
index bcb3013..1c71504 100644
--- a/app/controllers/Application.scala
+++ b/app/controllers/Application.scala
@@ -28,8 +28,11 @@ object Application extends Controller with Composable {
   }

   def oauth2callback = Action { implicit request =>
-    request.body.as
-
+    request.body.asJson.map { json =>
+      Token.fromJson(json) map { token =>
+        Connection.auth(token)
+      }
+    }

     Ok("")
   }
diff --git a/app/controllers/Connection.scala b/app/controllers/Connection.scala
index 8831ca3..090573b 100644
--- a/app/controllers/Connection.scala
+++ b/app/controllers/Connection.scala
@@ -40,7 +40,7 @@ object Connection {
   /**
    * 画面側から渡されたTokenDataを使って、Google+ との認証を行う
    */
-  def authentic(token: Token.TokenData): Either[ConnectResult, Unit] = {
+  def auth(token: Token.TokenData): Either[ConnectResult, Unit] = {

     // Credential オブジェクトを作成する
     val credential: GoogleCredential =
@@ -158,7 +158,7 @@ object Token {
   /**
    * 渡されたJson文字列から、TokenDataを取得する。取得できなければNone
    */
-  def fromJson(data: String): Option[TokenData] = {
+  def fromJson(json: JsValue): Option[TokenData] = {
     implicit val reader: Reads[TokenData] = (
       (__ \ "access_token").readNullable[String] and
       (__ \ "refresh_token").read[String] and
@@ -166,7 +166,7 @@ object Token {
       (__ \ "id_token").read[String] and
       (__ \ "expires_at").read[Long] and
       (__ \ "expires_in").readNullable[Long])(TokenData)
-    Json.fromJson(Json.parse(data)) match {
+    Json.fromJson(json) match {
       case JsError(_) => None
       case JsSuccess(v, _) => Some(v)
     }
diff --git a/conf/evolutions/default/3.sql b/conf/evolutions/default/3.sql
index 9929fef..e51a461 100644
--- a/conf/evolutions/default/3.sql
+++ b/conf/evolutions/default/3.sql
@@ -2,7 +2,7 @@

 CREATE TABLE comment
 (
-	COMMENT_ID BIGINT AUTO_INCREMENT,
+    COMMENT_ID BIGINT AUTO_INCREMENT,
     TARGET_ID_TYPE INT NOT NULL,
     TARGET_ID BIGINT NOT NULL,
     COMMENT VARCHAR(1000) NOT NULL,
@@ -10,7 +10,7 @@ CREATE TABLE comment
     CREATED_USER VARCHAR(100) NOT NULL,
     UPDATED_DATE DATETIME NOT NULL,
     UPDATED_USER VARCHAR(100) NOT NULL,
-	PRIMARY KEY(COMMENT_ID),
+    PRIMARY KEY(COMMENT_ID),
     INDEX(TARGET_ID_TYPE, TARGET_ID)
 ) DEFAULT CHARSET=UTF8 ENGINE=INNODB;
[from now] 2013/03/08 23:13:49

diff --git a/app/controllers/Application.scala b/app/controllers/Application.scala
index 1c71504..4347813 100644
--- a/app/controllers/Application.scala
+++ b/app/controllers/Application.scala
@@ -30,7 +30,11 @@ object Application extends Controller with Composable {
   def oauth2callback = Action { implicit request =>
     request.body.asJson.map { json =>
       Token.fromJson(json) map { token =>
-        Connection.auth(token)
+        Connection.auth(token) match {
+          case Left(result) => resultToResponse(result)
+          case Right(id) =>
+        }
+
       }
     }

diff --git a/conf/evolutions/default/4.sql b/conf/evolutions/default/4.sql
new file mode 100644
index 0000000..d1b27ec
--- /dev/null
+++ b/conf/evolutions/default/4.sql
@@ -0,0 +1,18 @@
+# --- !Ups
+
+CREATE TABLE user
+(
+    USER_ID BIGINT AUTO_INCREMENT,
+    USER_GOOGLE_ID VARCHAR(200) UNIQUE,
+    USER_GOOGLE_DISPLAY_NAME VARCHAR(
+    COMMENT VARCHAR(1000) NOT NULL,
+    CREATED_DATE DATETIME NOT NULL,
+    CREATED_USER VARCHAR(100) NOT NULL,
+    UPDATED_DATE DATETIME NOT NULL,
+    UPDATED_USER VARCHAR(100) NOT NULL,
+    PRIMARY KEY(COMMENT_ID),
+    INDEX(TARGET_ID_TYPE, TARGET_ID)
+) DEFAULT CHARSET=UTF8 ENGINE=INNODB;
+
+# --- !Downs
+DROP TABLE comment;