Add account avatar
authorChocobozzz <florian.bigard@gmail.com>
Mon, 4 Dec 2017 09:34:40 +0000 (10:34 +0100)
committerChocobozzz <florian.bigard@gmail.com>
Mon, 4 Dec 2017 09:34:40 +0000 (10:34 +0100)
31 files changed:
client/src/app/account/account-settings/account-settings.component.html
client/src/app/account/account-settings/account-settings.component.scss
client/src/app/account/account-settings/account-settings.component.ts
client/src/app/core/auth/auth.service.ts
client/src/app/menu/menu.component.html
client/src/app/menu/menu.component.scss
client/src/app/menu/menu.component.ts
client/src/app/shared/users/user.model.ts
client/src/assets/default-avatar.png [new file with mode: 0644]
client/src/sass/_mixins.scss
config/default.yaml
config/production.yaml.example
config/test-1.yaml
config/test-2.yaml
config/test-3.yaml
config/test-4.yaml
config/test-5.yaml
config/test-6.yaml
server/initializers/constants.ts
server/initializers/database.ts
server/initializers/migrations/0115-account-avatar.ts [new file with mode: 0644]
server/models/account/account-interface.ts
server/models/account/account.ts
server/models/account/user.ts
server/models/avatar/avatar-interface.ts [new file with mode: 0644]
server/models/avatar/avatar.ts [new file with mode: 0644]
server/models/avatar/index.ts [new file with mode: 0644]
server/models/index.ts
shared/models/accounts/account.model.ts
shared/models/avatars/avatar.model.ts [new file with mode: 0644]
shared/models/users/user.model.ts

index 2509eb5aace2160a4e20bc309f1b241c55fa6d5f..9e9f688d222cabae3bf8bd4138bf368459d0650f 100644 (file)
@@ -1,7 +1,13 @@
-<div class="user-info">
-  {{ user.username }}
+<div class="user">
+  <img [src]="getAvatarPath()" alt="Avatar" />
+
+  <div class="user-info">
+    <div class="user-info-username">{{ user.username }}</div>
+    <div class="user-info-followers">{{ user.account.followersCount }} subscribers</div>
+  </div>
 </div>
 
+
 <div class="account-title">Account settings</div>
 <my-account-change-password></my-account-change-password>
 
index a0822631d9381ac18d8fb0599363691e6cf67bfc..f514809b01a12b490346fcf0986197a756666894 100644 (file)
@@ -1,6 +1,21 @@
-.user-info {
-  font-size: 20px;
-  font-weight: $font-bold;
+.user {
+  display: flex;
+
+  img {
+    @include avatar(50px);
+    margin-right: 15px;
+  }
+
+  .user-info {
+    .user-info-username {
+      font-size: 20px;
+      font-weight: $font-bold;
+    }
+
+    .user-info-followers {
+      font-size: 15px;
+    }
+  }
 }
 
 .account-title {
index c3b670e028213be0570fc4a9a4262a74f607527e..cba2510009b0d5a4803b7a9ec9d4a2c61c19f7e9 100644 (file)
@@ -15,4 +15,8 @@ export class AccountSettingsComponent implements OnInit {
   ngOnInit () {
     this.user = this.authService.getUser()
   }
+
+  getAvatarPath () {
+    return this.user.getAvatarPath()
+  }
 }
index 9e6c6b8887cb0055b55ddcea8ed53d11e21ef7da..fd2708c11e4d3260c69079f0bef1856a216e2237 100644 (file)
@@ -1,30 +1,25 @@
+import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { Router } from '@angular/router'
-import { Observable } from 'rxjs/Observable'
-import { Subject } from 'rxjs/Subject'
-import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
-import { ReplaySubject } from 'rxjs/ReplaySubject'
+
+import { NotificationsService } from 'angular2-notifications'
+import 'rxjs/add/observable/throw'
 import 'rxjs/add/operator/do'
 import 'rxjs/add/operator/map'
 import 'rxjs/add/operator/mergeMap'
-import 'rxjs/add/observable/throw'
-
-import { NotificationsService } from 'angular2-notifications'
-
-import { AuthStatus } from './auth-status.model'
-import { AuthUser } from './auth-user.model'
-import {
-  OAuthClientLocal,
-  UserRole,
-  UserRefreshToken,
-  VideoChannel,
-  User as UserServerModel
-} from '../../../../../shared'
+import { Observable } from 'rxjs/Observable'
+import { ReplaySubject } from 'rxjs/ReplaySubject'
+import { Subject } from 'rxjs/Subject'
+import { OAuthClientLocal, User as UserServerModel, UserRefreshToken, UserRole, VideoChannel } from '../../../../../shared'
+import { Account } from '../../../../../shared/models/accounts'
+import { UserLogin } from '../../../../../shared/models/users/user-login.model'
 // Do not use the barrel (dependency loop)
 import { RestExtractor } from '../../shared/rest'
-import { UserLogin } from '../../../../../shared/models/users/user-login.model'
 import { UserConstructorHash } from '../../shared/users/user.model'
 
+import { AuthStatus } from './auth-status.model'
+import { AuthUser } from './auth-user.model'
+
 interface UserLoginWithUsername extends UserLogin {
   access_token: string
   refresh_token: string
@@ -42,10 +37,7 @@ interface UserLoginWithUserInformation extends UserLogin {
   displayNSFW: boolean
   email: string
   videoQuota: number
-  account: {
-    id: number
-    uuid: string
-  }
+  account: Account
   videoChannels: VideoChannel[]
 }
 
index 0ed8ec518473ede42ebedcbd16993363018c4b57..7a80fa4de25d62e60ff6f88952af6c4d476bb9f3 100644 (file)
@@ -1,5 +1,7 @@
 <menu>
   <div *ngIf="isLoggedIn" class="logged-in-block">
+    <img [src]="getUserAvatarPath()" alt="Avatar" />
+
     <div class="logged-in-info">
       <a routerLink="/account/settings" class="logged-in-username">{{ user.username  }}</a>
       <div class="logged-in-email">{{ user.email }}</div>
index 9d67ca66c6217de89237fe36d11f2ffcbe175cc2..5d6fd61c640a7cf92a1778b5782cd6cfb8e10c85 100644 (file)
@@ -21,9 +21,15 @@ menu {
     justify-content: center;
     margin-bottom: 35px;
 
+    img {
+      margin-left: 20px;
+      margin-right: 10px;
+
+      @include avatar(34px);
+    }
+
     .logged-in-info {
       flex-grow: 1;
-      margin-left: 40px;
 
       .logged-in-username {
         font-size: 16px;
index 4c35bb3a51ebed4f9d9fdb7a2adc8ab2d6c5ae19..8b8b714a807139a9619429eaef445d8d382023fb 100644 (file)
@@ -51,6 +51,10 @@ export class MenuComponent implements OnInit {
     )
   }
 
+  getUserAvatarPath () {
+    return this.user.getAvatarPath()
+  }
+
   isRegistrationAllowed () {
     return this.serverService.getConfig().signup.allowed
   }
index b075ab717b60a61ad1e80b81eb72d3cd74995a6b..83990d8b84d8714df2d97e676433f217b8ae6af3 100644 (file)
@@ -1,10 +1,5 @@
-import {
-  User as UserServerModel,
-  UserRole,
-  VideoChannel,
-  UserRight,
-  hasUserRight
-} from '../../../../../shared'
+import { hasUserRight, User as UserServerModel, UserRight, UserRole, VideoChannel } from '../../../../../shared'
+import { Account } from '../../../../../shared/models/accounts'
 
 export type UserConstructorHash = {
   id: number,
@@ -14,10 +9,7 @@ export type UserConstructorHash = {
   videoQuota?: number,
   displayNSFW?: boolean,
   createdAt?: Date,
-  account?: {
-    id: number
-    uuid: string
-  },
+  account?: Account,
   videoChannels?: VideoChannel[]
 }
 export class User implements UserServerModel {
@@ -27,10 +19,7 @@ export class User implements UserServerModel {
   role: UserRole
   displayNSFW: boolean
   videoQuota: number
-  account: {
-    id: number
-    uuid: string
-  }
+  account: Account
   videoChannels: VideoChannel[]
   createdAt: Date
 
@@ -61,4 +50,10 @@ export class User implements UserServerModel {
   hasRight (right: UserRight) {
     return hasUserRight(this.role, right)
   }
+
+  getAvatarPath () {
+    if (this.account && this.account.avatar) return this.account.avatar.path
+
+    return '/assets/default-avatar.png'
+  }
 }
diff --git a/client/src/assets/default-avatar.png b/client/src/assets/default-avatar.png
new file mode 100644 (file)
index 0000000..4b7fd2c
Binary files /dev/null and b/client/src/assets/default-avatar.png differ
index 5798b8f6ead25e450c210d8fb30d363ce90b20b4..e44cf064d5259f67727d90fac9919dec084f89c0 100644 (file)
@@ -39,3 +39,8 @@
   @include peertube-button;
   @include disable-default-a-behaviour;
 }
+
+@mixin avatar ($size) {
+  width: $size;
+  height: $size;
+}
index b53fa0d5b90268140e88498e8620679d0bbd9fea..2c1043067ce432bbb8d30283516d1faae7cda3b3 100644 (file)
@@ -16,6 +16,7 @@ database:
 
 # From the project root directory
 storage:
+  avatars: 'avatars/'
   certs: 'certs/'
   videos: 'videos/'
   logs: 'logs/'
index 1af20a9e4bb68b4d7145eca46b27cee21e180a3e..404d35c16f51e6424f883c9050f1a7568542564e 100644 (file)
@@ -17,6 +17,7 @@ database:
 
 # From the project root directory
 storage:
+  avatars: 'avatars/'
   certs: 'certs/'
   videos: 'videos/'
   logs: 'logs/'
index d9b4d2b1a46a900d645fa1f112c823c37a4f90b2..49fbebf0440db460b6dda95d7e70fe54d2933f06 100644 (file)
@@ -10,6 +10,7 @@ database:
 
 # From the project root directory
 storage:
+  avatars: 'test1/avatars/'
   certs: 'test1/certs/'
   videos: 'test1/videos/'
   logs: 'test1/logs/'
index 236dcb10d73d177dd3eb7fd0cbc768d615e24e09..ff0df5962873326f26571007257cbdabc719c6e3 100644 (file)
@@ -10,6 +10,7 @@ database:
 
 # From the project root directory
 storage:
+  avatars: 'test2/avatars/'
   certs: 'test2/certs/'
   videos: 'test2/videos/'
   logs: 'test2/logs/'
index 291b43edc7926450845478082c1dd1e543df2312..4fbb000501d789a1c9a164946e8fbcc867def9bc 100644 (file)
@@ -10,6 +10,7 @@ database:
 
 # From the project root directory
 storage:
+  avatars: 'test3/avatars/'
   certs: 'test3/certs/'
   videos: 'test3/videos/'
   logs: 'test3/logs/'
index 6f80939fc1782c9667aae5b724bea0a9915643e9..e4f0f2691cd3d51f331e3fe9f98b05419c93fbd8 100644 (file)
@@ -10,6 +10,7 @@ database:
 
 # From the project root directory
 storage:
+  avatars: 'test4/avatars/'
   certs: 'test4/certs/'
   videos: 'test4/videos/'
   logs: 'test4/logs/'
index 0b5eab72e6b5b30a08d9af967f8d50e61b0267bb..610f523c8ca778d78e6a5adf8a4890bb7c4dbd10 100644 (file)
@@ -10,6 +10,7 @@ database:
 
 # From the project root directory
 storage:
+  avatars: 'test5/avatars/'
   certs: 'test5/certs/'
   videos: 'test5/videos/'
   logs: 'test5/logs/'
index 5d33e45b9ae566521159ced8a396fdca9031e63b..088b55c179a2e6816665eb44bd7f9d97f2e5660a 100644 (file)
@@ -10,6 +10,7 @@ database:
 
 # From the project root directory
 storage:
+  avatars: 'test6/avatars/'
   certs: 'test6/certs/'
   videos: 'test6/videos/'
   logs: 'test6/logs/'
index e3d779456974dfc0f0fc3c8b2c2cf748e96fea3f..144a4edbf6f3672e020101ac78e091ab2fdb6888 100644 (file)
@@ -14,7 +14,7 @@ import { FollowState } from '../../shared/models/accounts/follow.model'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 110
+const LAST_MIGRATION_VERSION = 115
 
 // ---------------------------------------------------------------------------
 
@@ -60,6 +60,7 @@ const CONFIG = {
     PASSWORD: config.get<string>('database.password')
   },
   STORAGE: {
+    AVATARS_DIR: join(root(), config.get<string>('storage.avatars')),
     LOG_DIR: join(root(), config.get<string>('storage.logs')),
     VIDEOS_DIR: join(root(), config.get<string>('storage.videos')),
     THUMBNAILS_DIR: join(root(), config.get<string>('storage.thumbnails')),
@@ -105,6 +106,9 @@ const CONFIG = {
 CONFIG.WEBSERVER.URL = CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
 CONFIG.WEBSERVER.HOST = CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
 
+const AVATARS_DIR = {
+  ACCOUNT: join(CONFIG.STORAGE.AVATARS_DIR, 'account')
+}
 // ---------------------------------------------------------------------------
 
 const CONSTRAINTS_FIELDS = {
@@ -356,6 +360,7 @@ export {
   PREVIEWS_SIZE,
   REMOTE_SCHEME,
   FOLLOW_STATES,
+  AVATARS_DIR,
   SEARCHABLE_COLUMNS,
   SERVER_ACCOUNT_NAME,
   PRIVATE_RSA_KEY_SIZE,
index 90dbba5b9f87cd89c5a1012b63731def6b5986b8..bb95992e1334ddb0f27a0019036a00790ce25d52 100644 (file)
@@ -2,6 +2,7 @@ import { join } from 'path'
 import { flattenDepth } from 'lodash'
 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 import * as Sequelize from 'sequelize'
+import { AvatarModel } from '../models/avatar'
 
 import { CONFIG } from './constants'
 // Do not use barrel, we need to load database first
@@ -36,6 +37,7 @@ export type PeerTubeDatabase = {
   init?: (silent: boolean) => Promise<void>,
 
   Application?: ApplicationModel,
+  Avatar?: AvatarModel,
   Account?: AccountModel,
   Job?: JobModel,
   OAuthClient?: OAuthClientModel,
diff --git a/server/initializers/migrations/0115-account-avatar.ts b/server/initializers/migrations/0115-account-avatar.ts
new file mode 100644 (file)
index 0000000..e3531f5
--- /dev/null
@@ -0,0 +1,31 @@
+import * as Sequelize from 'sequelize'
+import { PeerTubeDatabase } from '../database'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction,
+  queryInterface: Sequelize.QueryInterface,
+  sequelize: Sequelize.Sequelize,
+  db: PeerTubeDatabase
+}): Promise<void> {
+  await db.Avatar.sync()
+
+  const data = {
+    type: Sequelize.INTEGER,
+    allowNull: true,
+    references: {
+      model: 'Avatars',
+      key: 'id'
+    },
+    onDelete: 'CASCADE'
+  }
+  await utils.queryInterface.addColumn('Accounts', 'avatarId', data)
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}
index b369766dc4d6b6ecd399715eded4163684c972c8..46fe068e31ecbf20b510cfe893a88f8de649db1d 100644 (file)
@@ -1,6 +1,7 @@
 import * as Bluebird from 'bluebird'
 import * as Sequelize from 'sequelize'
 import { Account as FormattedAccount, ActivityPubActor } from '../../../shared'
+import { AvatarInstance } from '../avatar'
 import { ServerInstance } from '../server/server-interface'
 import { VideoChannelInstance } from '../video/video-channel-interface'
 
@@ -51,6 +52,7 @@ export interface AccountAttributes {
   serverId?: number
   userId?: number
   applicationId?: number
+  avatarId?: number
 }
 
 export interface AccountInstance extends AccountClass, AccountAttributes, Sequelize.Instance<AccountAttributes> {
@@ -68,6 +70,7 @@ export interface AccountInstance extends AccountClass, AccountAttributes, Sequel
 
   Server: ServerInstance
   VideoChannels: VideoChannelInstance[]
+  Avatar: AvatarInstance
 }
 
 export interface AccountModel extends AccountClass, Sequelize.Model<AccountInstance, AccountAttributes> {}
index 61a88524c2d39662557d9ba524450f01b0566981..15be1126be11ba379d03a96a76cfa25e59137887 100644 (file)
@@ -1,4 +1,6 @@
+import { join } from 'path'
 import * as Sequelize from 'sequelize'
+import { Avatar } from '../../../shared/models/avatars/avatar.model'
 import {
   activityPubContextify,
   isAccountFollowersCountValid,
@@ -8,8 +10,10 @@ import {
   isUserUsernameValid
 } from '../../helpers'
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
+import { AVATARS_DIR } from '../../initializers'
 import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers/constants'
 import { sendDeleteAccount } from '../../lib/activitypub/send/send-delete'
+import { AvatarModel } from '../avatar'
 import { addMethodsToModel } from '../utils'
 import { AccountAttributes, AccountInstance, AccountMethods } from './account-interface'
 
@@ -252,6 +256,14 @@ function associate (models) {
     as: 'followers',
     onDelete: 'cascade'
   })
+
+  Account.hasOne(models.Avatar, {
+    foreignKey: {
+      name: 'avatarId',
+      allowNull: true
+    },
+    onDelete: 'cascade'
+  })
 }
 
 function afterDestroy (account: AccountInstance) {
@@ -265,6 +277,15 @@ function afterDestroy (account: AccountInstance) {
 toFormattedJSON = function (this: AccountInstance) {
   let host = CONFIG.WEBSERVER.HOST
   let score: number
+  let avatar: Avatar = null
+
+  if (this.Avatar) {
+    avatar = {
+      path: join(AVATARS_DIR.ACCOUNT, this.Avatar.filename),
+      createdAt: this.Avatar.createdAt,
+      updatedAt: this.Avatar.updatedAt
+    }
+  }
 
   if (this.Server) {
     host = this.Server.host
@@ -273,11 +294,15 @@ toFormattedJSON = function (this: AccountInstance) {
 
   const json = {
     id: this.id,
+    uuid: this.uuid,
     host,
     score,
     name: this.name,
+    followingCount: this.followingCount,
+    followersCount: this.followersCount,
     createdAt: this.createdAt,
-    updatedAt: this.updatedAt
+    updatedAt: this.updatedAt,
+    avatar
   }
 
   return json
index 8f7c9b01338a1e13e354766f8ef231a5ec3b0007..3705947c067b485153ad821f7395c12d8a584bff 100644 (file)
@@ -157,10 +157,7 @@ toFormattedJSON = function (this: UserInstance) {
     roleLabel: USER_ROLE_LABELS[this.role],
     videoQuota: this.videoQuota,
     createdAt: this.createdAt,
-    account: {
-      id: this.Account.id,
-      uuid: this.Account.uuid
-    }
+    account: this.Account.toFormattedJSON()
   }
 
   if (Array.isArray(this.Account.VideoChannels) === true) {
diff --git a/server/models/avatar/avatar-interface.ts b/server/models/avatar/avatar-interface.ts
new file mode 100644 (file)
index 0000000..4af2b87
--- /dev/null
@@ -0,0 +1,16 @@
+import * as Sequelize from 'sequelize'
+
+export namespace AvatarMethods {}
+
+export interface AvatarClass {}
+
+export interface AvatarAttributes {
+  filename: string
+}
+
+export interface AvatarInstance extends AvatarClass, AvatarAttributes, Sequelize.Instance<AvatarAttributes> {
+  createdAt: Date
+  updatedAt: Date
+}
+
+export interface AvatarModel extends AvatarClass, Sequelize.Model<AvatarInstance, AvatarAttributes> {}
diff --git a/server/models/avatar/avatar.ts b/server/models/avatar/avatar.ts
new file mode 100644 (file)
index 0000000..3d329d8
--- /dev/null
@@ -0,0 +1,24 @@
+import * as Sequelize from 'sequelize'
+import { addMethodsToModel } from '../utils'
+import { AvatarAttributes, AvatarInstance, AvatarMethods } from './avatar-interface'
+
+let Avatar: Sequelize.Model<AvatarInstance, AvatarAttributes>
+
+export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
+  Avatar = sequelize.define<AvatarInstance, AvatarAttributes>('Avatar',
+    {
+      filename: {
+        type: DataTypes.STRING,
+        allowNull: false
+      }
+    },
+    {}
+  )
+
+  const classMethods = []
+  addMethodsToModel(Avatar, classMethods)
+
+  return Avatar
+}
+
+// ------------------------------ Statics ------------------------------
diff --git a/server/models/avatar/index.ts b/server/models/avatar/index.ts
new file mode 100644 (file)
index 0000000..877aed1
--- /dev/null
@@ -0,0 +1 @@
+export * from './avatar-interface'
index 65faa5294aa0f246b99115fe273c3f01e977ce1b..fedd97dd160ddbc2969581dbe2952e660d42b122 100644 (file)
@@ -1,4 +1,5 @@
 export * from './application'
+export * from './avatar'
 export * from './job'
 export * from './oauth'
 export * from './server'
index 338426dc728eee414b1cc6954095651ad07a1596..d1470131722a048ffd60fdbf9e51ee619ec9bf27 100644 (file)
@@ -1,5 +1,13 @@
+import { Avatar } from '../avatars/avatar.model'
+
 export interface Account {
   id: number
+  uuid: string
   name: string
   host: string
+  followingCount: number
+  followersCount: number
+  createdAt: Date
+  updatedAt: Date
+  avatar: Avatar
 }
diff --git a/shared/models/avatars/avatar.model.ts b/shared/models/avatars/avatar.model.ts
new file mode 100644 (file)
index 0000000..301d009
--- /dev/null
@@ -0,0 +1,5 @@
+export interface Avatar {
+  path: string
+  createdAt: Date | string
+  updatedAt: Date | string
+}
index a8012734ce33e5dd85fbbcb744a5a9e4dc2df3c9..4b17881e5cd6e382b8e8d93d52c7ee1442e3a243 100644 (file)
@@ -1,3 +1,4 @@
+import { Account } from '../accounts'
 import { VideoChannel } from '../videos/video-channel.model'
 import { UserRole } from './user-role'
 
@@ -8,10 +9,7 @@ export interface User {
   displayNSFW: boolean
   role: UserRole
   videoQuota: number
-  createdAt: Date,
-  account: {
-    id: number
-    uuid: string
-  }
+  createdAt: Date
+  account: Account
   videoChannels?: VideoChannel[]
 }