upload user avatar, front end avatar crop modal
This commit is contained in:
parent
d7fc152c8a
commit
88f19163c6
145
package-lock.json
generated
145
package-lock.json
generated
@ -13,13 +13,18 @@
|
||||
"@nestjs/common": "^8.0.0",
|
||||
"@nestjs/core": "^8.0.0",
|
||||
"@nestjs/platform-express": "^8.0.0",
|
||||
"@nestjs/serve-static": "^2.2.2",
|
||||
"@nestjs/throttler": "^2.0.1",
|
||||
"bcrypt": "^5.0.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"cropperjs": "^1.5.12",
|
||||
"dotenv": "^16.0.0",
|
||||
"express-session": "^1.17.2",
|
||||
"image-size": "^1.0.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"multer": "^1.4.4",
|
||||
"mysql2": "^2.3.3",
|
||||
"nodemailer": "^6.7.2",
|
||||
"otplib": "^12.0.1",
|
||||
@ -43,6 +48,8 @@
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/qrcode": "^1.4.2",
|
||||
@ -2830,6 +2837,23 @@
|
||||
"typescript": "^3.4.5 || ^4.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/serve-static": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-2.2.2.tgz",
|
||||
"integrity": "sha512-3Mr+Q/npS3N7iGoF3Wd6Lj9QcjMGxbNrSqupi5cviM0IKrZ1BHl5qekW95rWYNATAVqoTmjGROAq+nKKpuUagQ==",
|
||||
"dependencies": {
|
||||
"path-to-regexp": "0.1.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"@nestjs/core": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/serve-static/node_modules/path-to-regexp": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
|
||||
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
|
||||
},
|
||||
"node_modules/@nestjs/testing": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-8.4.0.tgz",
|
||||
@ -3239,6 +3263,21 @@
|
||||
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/mime-types": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
|
||||
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
|
||||
"integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "16.11.26",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz",
|
||||
@ -5081,6 +5120,11 @@
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cropperjs": {
|
||||
"version": "1.5.12",
|
||||
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.5.12.tgz",
|
||||
"integrity": "sha512-re7UdjE5UnwdrovyhNzZ6gathI4Rs3KGCBSc8HCIjUo5hO42CtzyblmWLj6QWVw7huHyDMfpKxhiO2II77nhDw=="
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@ -6831,6 +6875,20 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.1.tgz",
|
||||
"integrity": "sha512-VAwkvNSNGClRw9mDHhc5Efax8PLlsOGcUTh0T/LIriC8vPA3U5PdqXWqkz406MoYHMKW8Uf9gWr05T/rYB44kQ==",
|
||||
"dependencies": {
|
||||
"queue": "6.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
|
||||
@ -8666,19 +8724,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz",
|
||||
"integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==",
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.34",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz",
|
||||
"integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==",
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dependencies": {
|
||||
"mime-db": "1.51.0"
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@ -9816,6 +9874,14 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"dependencies": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@ -14296,6 +14362,21 @@
|
||||
"pluralize": "8.0.0"
|
||||
}
|
||||
},
|
||||
"@nestjs/serve-static": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-2.2.2.tgz",
|
||||
"integrity": "sha512-3Mr+Q/npS3N7iGoF3Wd6Lj9QcjMGxbNrSqupi5cviM0IKrZ1BHl5qekW95rWYNATAVqoTmjGROAq+nKKpuUagQ==",
|
||||
"requires": {
|
||||
"path-to-regexp": "0.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"path-to-regexp": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
|
||||
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@nestjs/testing": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-8.4.0.tgz",
|
||||
@ -14659,6 +14740,21 @@
|
||||
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/mime-types": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
|
||||
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/multer": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
|
||||
"integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "16.11.26",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz",
|
||||
@ -16097,6 +16193,11 @@
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true
|
||||
},
|
||||
"cropperjs": {
|
||||
"version": "1.5.12",
|
||||
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.5.12.tgz",
|
||||
"integrity": "sha512-re7UdjE5UnwdrovyhNzZ6gathI4Rs3KGCBSc8HCIjUo5hO42CtzyblmWLj6QWVw7huHyDMfpKxhiO2II77nhDw=="
|
||||
},
|
||||
"cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@ -17401,6 +17502,14 @@
|
||||
"integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
|
||||
"dev": true
|
||||
},
|
||||
"image-size": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.1.tgz",
|
||||
"integrity": "sha512-VAwkvNSNGClRw9mDHhc5Efax8PLlsOGcUTh0T/LIriC8vPA3U5PdqXWqkz406MoYHMKW8Uf9gWr05T/rYB44kQ==",
|
||||
"requires": {
|
||||
"queue": "6.0.2"
|
||||
}
|
||||
},
|
||||
"immutable": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
|
||||
@ -18829,16 +18938,16 @@
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
|
||||
},
|
||||
"mime-db": {
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz",
|
||||
"integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g=="
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
|
||||
},
|
||||
"mime-types": {
|
||||
"version": "2.1.34",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz",
|
||||
"integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==",
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"requires": {
|
||||
"mime-db": "1.51.0"
|
||||
"mime-db": "1.52.0"
|
||||
}
|
||||
},
|
||||
"mimic-fn": {
|
||||
@ -19715,6 +19824,14 @@
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz",
|
||||
"integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw=="
|
||||
},
|
||||
"queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"requires": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
@ -27,13 +27,18 @@
|
||||
"@nestjs/common": "^8.0.0",
|
||||
"@nestjs/core": "^8.0.0",
|
||||
"@nestjs/platform-express": "^8.0.0",
|
||||
"@nestjs/serve-static": "^2.2.2",
|
||||
"@nestjs/throttler": "^2.0.1",
|
||||
"bcrypt": "^5.0.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"cropperjs": "^1.5.12",
|
||||
"dotenv": "^16.0.0",
|
||||
"express-session": "^1.17.2",
|
||||
"image-size": "^1.0.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"multer": "^1.4.4",
|
||||
"mysql2": "^2.3.3",
|
||||
"nodemailer": "^6.7.2",
|
||||
"otplib": "^12.0.1",
|
||||
@ -57,6 +62,8 @@
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/qrcode": "^1.4.2",
|
||||
|
@ -1,5 +1,12 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import {
|
||||
MiddlewareConsumer,
|
||||
Module,
|
||||
NestModule,
|
||||
RequestMethod,
|
||||
} from '@nestjs/common';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { join } from 'path';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { CSRFMiddleware } from './middleware/csrf.middleware';
|
||||
@ -20,6 +27,10 @@ import { UtilityModule } from './modules/utility/utility.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '..', 'uploads'),
|
||||
serveRoot: '/uploads',
|
||||
}),
|
||||
ThrottlerModule.forRoot({
|
||||
ttl: 10,
|
||||
limit: 10,
|
||||
@ -43,7 +54,12 @@ import { UtilityModule } from './modules/utility/utility.module';
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(CSRFMiddleware).forRoutes('*');
|
||||
consumer.apply(UserMiddleware).forRoutes('*');
|
||||
consumer
|
||||
.apply(CSRFMiddleware, UserMiddleware)
|
||||
.exclude(
|
||||
{ path: 'uploads*', method: RequestMethod.ALL },
|
||||
{ path: 'public*', method: RequestMethod.ALL },
|
||||
)
|
||||
.forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
@ -19,3 +19,17 @@
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 2rem;
|
||||
|
||||
@include break-on(xs, down) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 50%;
|
||||
}
|
||||
|
58
src/fe/scss/_modal.scss
Normal file
58
src/fe/scss/_modal.scss
Normal file
@ -0,0 +1,58 @@
|
||||
.modal {
|
||||
display: block;
|
||||
position: fixed;
|
||||
overflow: auto;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
|
||||
&__content {
|
||||
max-width: 800px;
|
||||
background-color: #2e6b81;
|
||||
box-shadow: 0px 6px 62px -14px rgba(0, 0, 0, 0.45);
|
||||
margin: 4rem auto 0 auto;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 1rem;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid #005b74;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #005b74;
|
||||
|
||||
&-button .btn {
|
||||
min-width: initial;
|
||||
font-weight: bold;
|
||||
font-size: 2rem;
|
||||
padding: 0 0.65rem;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
@import 'cropperjs/dist/cropper.css';
|
||||
|
||||
.settings {
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
@ -7,6 +9,7 @@
|
||||
&__nav {
|
||||
padding: 2rem 0rem;
|
||||
background-color: #005b74;
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -43,5 +46,39 @@
|
||||
|
||||
&__content {
|
||||
padding: 2.75rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
gap: 1rem;
|
||||
|
||||
&__picture {
|
||||
height: 120px;
|
||||
width: 120px;
|
||||
flex: 0 0 120px;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
#avatar-modal {
|
||||
#cropper {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
[data-upload-step='3'] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#crop-result {
|
||||
max-width: 100%;
|
||||
max-height: 50vh;
|
||||
background-color: #005b74;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@
|
||||
@import 'alert';
|
||||
@import 'authorize';
|
||||
@import 'settings';
|
||||
@import 'modal';
|
||||
|
||||
*,
|
||||
*::before,
|
||||
@ -43,3 +44,7 @@ a {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
[data-script] {
|
||||
display: none;
|
||||
}
|
||||
|
@ -0,0 +1,21 @@
|
||||
import { AvatarModal } from './modal/avatar';
|
||||
import { ModalManager } from './modal/modals';
|
||||
|
||||
(function () {
|
||||
// This site supports disabling javascript. Some interactive elements can be hidden.
|
||||
const noscriptElements = document.querySelectorAll('[data-noscript]');
|
||||
const scriptElements = document.querySelectorAll('[data-script]');
|
||||
|
||||
noscriptElements.forEach(
|
||||
(element: HTMLElement) => (element.style.display = 'none'),
|
||||
);
|
||||
|
||||
scriptElements.forEach(
|
||||
(element: HTMLElement) =>
|
||||
(element.style.display = element.getAttribute('data-script') || 'block'),
|
||||
);
|
||||
|
||||
const modals = new ModalManager();
|
||||
const avatar = new AvatarModal();
|
||||
modals.register(avatar);
|
||||
})();
|
175
src/fe/ts/modal/avatar.ts
Normal file
175
src/fe/ts/modal/avatar.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { Modal } from './modal';
|
||||
import Cropper from 'cropperjs';
|
||||
|
||||
export class AvatarModal extends Modal {
|
||||
private cropper?: Cropper;
|
||||
private csrf!: string;
|
||||
private stages!: NodeListOf<HTMLElement>;
|
||||
private fileInput!: HTMLInputElement;
|
||||
private continueBtn!: HTMLElement;
|
||||
private resetBtn!: HTMLElement;
|
||||
private uploadBtn!: HTMLElement;
|
||||
|
||||
private cropRoot!: HTMLImageElement;
|
||||
private previewRoot!: HTMLImageElement;
|
||||
private currentAvatar!: HTMLImageElement;
|
||||
|
||||
private cropResultUrl?: string;
|
||||
private cropResultBlob?: Blob;
|
||||
|
||||
private currentStep = 1;
|
||||
|
||||
constructor() {
|
||||
super('avatar');
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
super.reset();
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy();
|
||||
}
|
||||
|
||||
this.cropResultUrl = null;
|
||||
this.cropResultBlob = null;
|
||||
this.fileInput.value = null;
|
||||
this.previewRoot.removeAttribute('src');
|
||||
this.cropRoot.removeAttribute('src');
|
||||
this.setStep(1);
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
super.initialize();
|
||||
|
||||
this.csrf = (document.querySelector('#csrf') as HTMLInputElement).value;
|
||||
this.stages = this.modal?.querySelectorAll(
|
||||
'[data-upload-step]',
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
this.fileInput = this.modal?.querySelector(
|
||||
'#image-file',
|
||||
) as HTMLInputElement;
|
||||
|
||||
this.cropRoot = this.modal?.querySelector('#cropper') as HTMLImageElement;
|
||||
this.previewRoot = this.modal?.querySelector(
|
||||
'#crop-result',
|
||||
) as HTMLImageElement;
|
||||
this.currentAvatar = document.querySelector(
|
||||
'#current-avatar',
|
||||
) as HTMLImageElement;
|
||||
|
||||
this.continueBtn = this.modal?.querySelector('#continue') as HTMLElement;
|
||||
this.resetBtn = this.modal?.querySelector('#reset') as HTMLElement;
|
||||
this.uploadBtn = this.modal?.querySelector('#upload') as HTMLElement;
|
||||
|
||||
this.setSteps();
|
||||
this.registerEvents();
|
||||
}
|
||||
|
||||
public setSteps(): void {
|
||||
this.stages?.forEach((item) => {
|
||||
const itemState = parseInt(item.getAttribute('data-upload-step'), 10);
|
||||
item.style.display = itemState === this.currentStep ? null : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
public setStep(index: number): void {
|
||||
this.currentStep = index;
|
||||
this.setSteps();
|
||||
}
|
||||
|
||||
private startCrop(): void {
|
||||
const imgf = this.fileInput.files[0];
|
||||
if (!imgf.type.includes('image/') || imgf.type.includes('svg')) {
|
||||
// TODO: error
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => {
|
||||
// TODO: error
|
||||
this.reset();
|
||||
};
|
||||
|
||||
reader.onload = () => {
|
||||
this.cropRoot.src = reader.result as string;
|
||||
this.createCropper();
|
||||
this.setStep(2);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(imgf);
|
||||
}
|
||||
|
||||
private registerEvents(): void {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fileInput.addEventListener('change', () => {
|
||||
this.startCrop();
|
||||
});
|
||||
|
||||
this.continueBtn.addEventListener('click', () => {
|
||||
this.createCropResult();
|
||||
this.setStep(3);
|
||||
});
|
||||
|
||||
this.resetBtn.addEventListener('click', () => {
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy();
|
||||
}
|
||||
this.fileInput.value = null;
|
||||
this.cropResultUrl = null;
|
||||
this.cropResultBlob = null;
|
||||
this.previewRoot.removeAttribute('src');
|
||||
this.setStep(1);
|
||||
});
|
||||
|
||||
this.uploadBtn.addEventListener('click', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.cropResultBlob);
|
||||
formData.append('csrf', this.csrf);
|
||||
|
||||
// TODO: error
|
||||
fetch('/account/avatar', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
this.reset();
|
||||
if (data.file) {
|
||||
this.currentAvatar.src = `/uploads/${data.file}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private createCropper(): void {
|
||||
this.cropper = new Cropper(this.cropRoot, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
modal: false,
|
||||
});
|
||||
}
|
||||
|
||||
private createCropResult(): void {
|
||||
const cropCanvas = this.cropper.getCroppedCanvas({
|
||||
maxHeight: 1024,
|
||||
maxWidth: 1024,
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'high',
|
||||
});
|
||||
|
||||
this.cropResultUrl = cropCanvas.toDataURL();
|
||||
this.previewRoot.src = this.cropResultUrl;
|
||||
|
||||
cropCanvas.toBlob((blob) => {
|
||||
this.cropResultBlob = blob;
|
||||
this.setStep(3);
|
||||
});
|
||||
}
|
||||
}
|
98
src/fe/ts/modal/modal.ts
Normal file
98
src/fe/ts/modal/modal.ts
Normal file
@ -0,0 +1,98 @@
|
||||
export class Modal {
|
||||
public triggers?: NodeListOf<HTMLElement>;
|
||||
public modal?: HTMLElement;
|
||||
protected focusLock: HTMLElement[] = [];
|
||||
protected trigger?: HTMLElement;
|
||||
|
||||
constructor(public name: string) {}
|
||||
|
||||
public reset(): void {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modal.style.display = 'none';
|
||||
this.removeFocusLock();
|
||||
}
|
||||
|
||||
public open(): void {
|
||||
if (!this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modal.style.display = 'block';
|
||||
this.createFocusLock();
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
this.triggers = document.querySelectorAll(
|
||||
`[data-modal-trigger="${this.name}"]`,
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
this.modal = document.querySelector(
|
||||
`[data-modal="${this.name}"]`,
|
||||
) as HTMLElement;
|
||||
|
||||
this.triggers.forEach((item) =>
|
||||
item.addEventListener('click', (evt) => {
|
||||
evt.preventDefault();
|
||||
this.trigger = item;
|
||||
this.open();
|
||||
}),
|
||||
);
|
||||
|
||||
if (this.modal) {
|
||||
const attrLabel = `modal_${this.name}_label`;
|
||||
const label = this.modal.querySelector('.modal__title');
|
||||
this.modal.setAttribute('aria-modal', 'true');
|
||||
this.modal.setAttribute('aria-labelledby', attrLabel);
|
||||
label.setAttribute('id', attrLabel);
|
||||
}
|
||||
}
|
||||
|
||||
private getFocusable(): HTMLElement[] {
|
||||
const focusable = Array.from(
|
||||
this.modal.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
) as NodeListOf<HTMLElement>,
|
||||
).filter(
|
||||
(item) => item.offsetParent !== null && !this.focusLock.includes(item),
|
||||
);
|
||||
|
||||
const firstFocusable = focusable[0];
|
||||
const lastFocusable = focusable[focusable.length - 1];
|
||||
|
||||
return [firstFocusable, lastFocusable];
|
||||
}
|
||||
|
||||
private createFocusLock(): void {
|
||||
const startFocus = document.createElement('div');
|
||||
startFocus.setAttribute('tabindex', '0');
|
||||
|
||||
const stopFocus = document.createElement('div');
|
||||
stopFocus.setAttribute('tabindex', '0');
|
||||
|
||||
this.modal.prepend(startFocus);
|
||||
this.modal.appendChild(stopFocus);
|
||||
this.focusLock = [startFocus, stopFocus];
|
||||
|
||||
stopFocus.addEventListener('focus', (event) => {
|
||||
event.preventDefault();
|
||||
this.getFocusable()[0].focus();
|
||||
});
|
||||
|
||||
startFocus.addEventListener('focus', (event) => {
|
||||
event.preventDefault();
|
||||
this.getFocusable()[1].focus();
|
||||
});
|
||||
|
||||
this.getFocusable()[0].focus();
|
||||
}
|
||||
|
||||
private removeFocusLock(): void {
|
||||
this.focusLock.forEach((item) => {
|
||||
item.parentElement.removeChild(item);
|
||||
});
|
||||
this.trigger?.focus();
|
||||
}
|
||||
}
|
34
src/fe/ts/modal/modals.ts
Normal file
34
src/fe/ts/modal/modals.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Modal } from './modal';
|
||||
|
||||
export class ModalManager {
|
||||
public modals: Record<string, Modal> = {};
|
||||
public closers = document.querySelectorAll(
|
||||
'[data-modal-closer]',
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
constructor() {
|
||||
this.closers.forEach((item) => {
|
||||
item.setAttribute('aria-label', 'Close modal');
|
||||
item.addEventListener('click', (evt) => {
|
||||
evt.preventDefault();
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public register<T extends Modal>(item: T): T {
|
||||
this.modals[item.name] = item;
|
||||
item.initialize();
|
||||
item.reset();
|
||||
return item;
|
||||
}
|
||||
|
||||
public close(name?: string): void {
|
||||
if (name) {
|
||||
this.modals[name].reset();
|
||||
return;
|
||||
}
|
||||
|
||||
Object.values(this.modals).forEach((item) => item.reset());
|
||||
}
|
||||
}
|
@ -4,9 +4,15 @@ import { NextFunction, Request, Response } from 'express';
|
||||
@Injectable()
|
||||
export class ValidateCSRFMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
// Multipart is handeled elsewhere
|
||||
if (req.header('content-type')?.startsWith('multipart/form-data')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.body.csrf !== req.session.csrf) {
|
||||
return next(new Error('Invalid session'));
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
Res,
|
||||
Session,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Request, Response } from 'express';
|
||||
import { SessionData } from 'express-session';
|
||||
import {
|
||||
@ -244,6 +245,7 @@ export class LoginController {
|
||||
}
|
||||
|
||||
@Post('password')
|
||||
@Throttle(3, 60)
|
||||
public async setNewPassword(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
|
@ -1,13 +1,21 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Redirect,
|
||||
Render,
|
||||
Req,
|
||||
Session,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Request } from 'express';
|
||||
import { SessionData } from 'express-session';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { UploadService } from 'src/modules/objects/upload/upload.service';
|
||||
import { UserService } from 'src/modules/objects/user/user.service';
|
||||
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@ -16,6 +24,8 @@ export class SettingsController {
|
||||
constructor(
|
||||
private readonly _service: SettingsService,
|
||||
private readonly _form: FormUtilityService,
|
||||
private readonly _upload: UploadService,
|
||||
private readonly _user: UserService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -27,6 +37,39 @@ export class SettingsController {
|
||||
@Get('general')
|
||||
@Render('settings/general')
|
||||
public general(@Req() req: Request, @Session() sess: SessionData) {
|
||||
return this._form.populateTemplate(req, sess);
|
||||
return this._form.populateTemplate(req, sess, { user: req.user });
|
||||
}
|
||||
|
||||
@Post('avatar')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadAvatarFile(
|
||||
@Req() req: Request,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
if (req.body.csrf !== req.session.csrf) {
|
||||
throw new BadRequestException('Invalid session. Please try again.');
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
throw new BadRequestException('Avatar upload failed');
|
||||
}
|
||||
|
||||
try {
|
||||
const matches = await this._upload.checkImageAspect(file);
|
||||
if (!matches) {
|
||||
throw new BadRequestException(
|
||||
'Avatar should be with a 1:1 aspect ratio.',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
await unlink(file.path);
|
||||
throw e;
|
||||
}
|
||||
|
||||
const upload = await this._upload.registerUploadedFile(file, req.user);
|
||||
await this._user.updateAvatar(req.user, upload);
|
||||
return {
|
||||
file: upload.file,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -4,10 +4,16 @@ import {
|
||||
NestModule,
|
||||
RequestMethod,
|
||||
} from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import * as multer from 'multer';
|
||||
import * as mime from 'mime-types';
|
||||
import { join } from 'path';
|
||||
import { AuthMiddleware } from 'src/middleware/auth.middleware';
|
||||
import { FlashMiddleware } from 'src/middleware/flash.middleware';
|
||||
import { ValidateCSRFMiddleware } from 'src/middleware/validate-csrf.middleware';
|
||||
import { ConfigurationModule } from 'src/modules/config/config.module';
|
||||
import { ConfigurationService } from 'src/modules/config/config.service';
|
||||
import { UploadModule } from 'src/modules/objects/upload/upload.module';
|
||||
import { UserModule } from 'src/modules/objects/user/user.module';
|
||||
import { OAuth2Module } from '../oauth2/oauth2.module';
|
||||
import { SettingsController } from './settings.controller';
|
||||
@ -15,7 +21,44 @@ import { SettingsService } from './settings.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SettingsController],
|
||||
imports: [ConfigurationModule, UserModule, OAuth2Module],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
UploadModule,
|
||||
UserModule,
|
||||
OAuth2Module,
|
||||
MulterModule.registerAsync({
|
||||
imports: [ConfigurationModule],
|
||||
useFactory: async (config: ConfigurationService) => {
|
||||
return {
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, join(__dirname, '..', '..', '..', '..', 'uploads'));
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const hashTruncate = req.user.uuid.split('-')[0];
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const ext = mime.extension(file.mimetype);
|
||||
cb(null, `user-${hashTruncate}-${timestamp}.${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: {
|
||||
fileSize: 1.049e7, // 10 MiB
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (
|
||||
!file.mimetype.startsWith('image/') ||
|
||||
file.mimetype.includes('svg')
|
||||
) {
|
||||
return cb(new Error('Invalid file type.'), false);
|
||||
}
|
||||
|
||||
cb(null, true);
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [ConfigurationService],
|
||||
}),
|
||||
],
|
||||
providers: [SettingsService],
|
||||
})
|
||||
export class SettingsModule implements NestModule {
|
||||
|
@ -16,6 +16,9 @@ export class Upload {
|
||||
@Column({ nullable: false })
|
||||
original_name: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
mimetype: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
file: string;
|
||||
|
||||
|
@ -1,11 +1,57 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { readFile, unlink } from 'fs/promises';
|
||||
import { imageSize } from 'image-size';
|
||||
import { join } from 'path';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../user/user.entity';
|
||||
import { Upload } from './upload.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UploadService {
|
||||
public uploadPath = join(__dirname, '..', '..', '..', '..', 'uploads');
|
||||
|
||||
constructor(
|
||||
@Inject('UPLOAD_REPOSITORY')
|
||||
private uploadRepository: Repository<Upload>,
|
||||
) {}
|
||||
|
||||
public async registerUploadedFile(
|
||||
file: Express.Multer.File,
|
||||
user: User,
|
||||
): Promise<Upload> {
|
||||
const upload = new Upload();
|
||||
upload.file = file.filename;
|
||||
upload.original_name = file.originalname;
|
||||
upload.mimetype = file.mimetype;
|
||||
upload.uploader = user;
|
||||
|
||||
await this.uploadRepository.insert(upload);
|
||||
|
||||
return upload;
|
||||
}
|
||||
|
||||
public async checkImageAspect(file: Express.Multer.File): Promise<boolean> {
|
||||
const opened = await readFile(file.path);
|
||||
return new Promise((resolve) => {
|
||||
const result = imageSize(opened);
|
||||
|
||||
if (result.height / result.width !== 1) {
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
public async delete(upload: Upload): Promise<void> {
|
||||
const path = join(this.uploadPath, upload.file);
|
||||
|
||||
try {
|
||||
await unlink(path);
|
||||
} catch (e: any) {
|
||||
console.error('Failed to unlink avatar file:', e.stack);
|
||||
}
|
||||
|
||||
await this.uploadRepository.remove(upload);
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { EmailModule } from '../email/email.module';
|
||||
import { UploadModule } from '../upload/upload.module';
|
||||
import { UserTokenModule } from '../user-token/user-token.module';
|
||||
import { userProviders } from './user.providers';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, EmailModule, UserTokenModule],
|
||||
imports: [DatabaseModule, EmailModule, UserTokenModule, UploadModule],
|
||||
providers: [...userProviders, UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
|
@ -9,6 +9,8 @@ import { RegistrationEmail } from './email/registration.email';
|
||||
import { ForgotPasswordEmail } from './email/forgot-password.email';
|
||||
import { UserTokenService } from '../user-token/user-token.service';
|
||||
import { ConfigurationService } from 'src/modules/config/config.service';
|
||||
import { Upload } from '../upload/upload.entity';
|
||||
import { UploadService } from '../upload/upload.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
@ -19,6 +21,7 @@ export class UserService {
|
||||
private token: TokenService,
|
||||
private email: EmailService,
|
||||
private config: ConfigurationService,
|
||||
private upload: UploadService,
|
||||
) {}
|
||||
|
||||
public async getById(id: number, relations?: string[]): Promise<User> {
|
||||
@ -64,6 +67,16 @@ export class UserService {
|
||||
return user;
|
||||
}
|
||||
|
||||
public async updateAvatar(user: User, upload: Upload): Promise<User> {
|
||||
if (user.picture) {
|
||||
await this.upload.delete(user.picture);
|
||||
}
|
||||
|
||||
user.picture = upload;
|
||||
await this.updateUser(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
public async comparePasswords(
|
||||
hash: string,
|
||||
password: string,
|
||||
|
@ -21,7 +21,8 @@ block body
|
||||
div.form-container
|
||||
input#csrf(type="hidden", name="csrf", value=csrf)
|
||||
label.form-label(for="password") New password
|
||||
input.form-control#password(type="password", name="password", placeholder="Password")
|
||||
input.form-control#password(type="password", name="password", autofocus, placeholder="Password")
|
||||
small.form-hint Must be at least 8 characters long, contain a capital and lowercase letter and a number.
|
||||
label.form-label(for="password_repeat") Repeat new password
|
||||
input.form-control#password_repeat(type="password", name="password_repeat", placeholder="Password")
|
||||
button.btn.btn-primary(type="submit") Set password
|
||||
@ -32,6 +33,6 @@ block body
|
||||
div.form-container
|
||||
input#csrf(type="hidden", name="csrf", value=csrf)
|
||||
label.form-label(for="email") Email address
|
||||
input.form-control#email(type="email", name="email", placeholder="Email addres")
|
||||
input.form-control#email(type="email", name="email", autofocus, placeholder="Email addres")
|
||||
button.btn.btn-primary(type="submit") Send recovery email
|
||||
a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead
|
||||
|
@ -14,7 +14,7 @@ html(lang="en")
|
||||
meta(name="twitter:description", content="Icy Network is a Single-Sign-On (SSO) provider")
|
||||
block links
|
||||
link(rel="stylesheet", type="text/css", href="/public/css/index.css")
|
||||
script(href="/public/js/app.bundle.js")
|
||||
script(src="/public/js/app.bundle.js", defer)
|
||||
title
|
||||
block title
|
||||
body
|
||||
|
@ -5,3 +5,65 @@ block title
|
||||
|
||||
block settings
|
||||
h1 General settings
|
||||
if message.text
|
||||
if message.error
|
||||
.alert.alert-danger
|
||||
span #{message.text}
|
||||
else
|
||||
.alert.alert-success
|
||||
span #{message.text}
|
||||
.row
|
||||
.col
|
||||
form(method="post")
|
||||
div.form-container
|
||||
input#csrf(type="hidden", name="csrf", value=csrf)
|
||||
label.form-label(for="username") Username
|
||||
input.form-control#username(type="text", name="username", placeholder="Username", disabled, value=user.username)
|
||||
label.form-label(for="display_name") Display Name
|
||||
input.form-control#display_name(type="text", name="display_name", placeholder="Display name", value=user.display_name)
|
||||
button.btn.btn-primary(type="submit") Change
|
||||
.col
|
||||
.user-avatar.row
|
||||
.col.user-avatar__picture
|
||||
if user.picture
|
||||
img#current-avatar(src='/uploads/' + user.picture.file,alt=user.username)
|
||||
else
|
||||
img#current-avatar(src='/public/image/avatar.png',alt='No avatar')
|
||||
.col.user-avatar__options
|
||||
.flex-column(data-script="flex")
|
||||
button.btn.btn-primary(data-modal-trigger="avatar") Change avatar
|
||||
if user.picture
|
||||
button.btn.btn-link#remove-avatar Remove avatar
|
||||
|
||||
form(method="post", data-noscript, action="/account/avatar", enctype="multipart/form-data")
|
||||
div.form-container
|
||||
input#csrf(type="hidden", name="csrf", value=csrf)
|
||||
label.form-label(for="image") Image
|
||||
input.form-control#image(type="file", name="file")
|
||||
small.form-hint Must be less than 10 MB and 1:1 aspect ratio. Enable JavaScript to custom crop your image.
|
||||
button.btn.btn-primary(type="submit") Change
|
||||
.modal#avatar-modal(data-modal="avatar", aria-live="polite", role="modal", style="display: none")
|
||||
.modal__content
|
||||
.modal__title
|
||||
|Change avatar
|
||||
.modal__title-button
|
||||
button.btn.btn-link(data-modal-closer) x
|
||||
.modal__body
|
||||
div(data-upload-step="1")
|
||||
h2 Upload file
|
||||
div.form-container
|
||||
input#image-file(type="file", name="file")
|
||||
small.form-hint Must be less than 10 MB in size.
|
||||
div(data-upload-step="2")
|
||||
h2 Crop image
|
||||
img#cropper
|
||||
div(data-upload-step="3")
|
||||
h2 Crop result
|
||||
p This is how your profile picture will look like:
|
||||
img#crop-result
|
||||
.modal__footer
|
||||
button.btn.btn-link(data-modal-closer) Cancel
|
||||
button.btn.btn-primary#continue(data-upload-step="2") Continue
|
||||
button.btn.btn-primary#reset(data-upload-step="3") Reset
|
||||
button.btn.btn-primary#upload(data-upload-step="3") Upload
|
||||
|
||||
|
Reference in New Issue
Block a user