diff --git a/src/main/ts2/.editorconfig b/src/main/ts2/.editorconfig
new file mode 100755
index 0000000..6e87a00
--- /dev/null
+++ b/src/main/ts2/.editorconfig
@@ -0,0 +1,13 @@
+# Editor configuration, see http://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false
diff --git a/src/main/ts2/.gitignore b/src/main/ts2/.gitignore
new file mode 100755
index 0000000..ee5c9d8
--- /dev/null
+++ b/src/main/ts2/.gitignore
@@ -0,0 +1,39 @@
+# See http://help.github.com/ignore-files/ for more about ignoring files.
+
+# compiled output
+/dist
+/tmp
+/out-tsc
+
+# dependencies
+/node_modules
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# IDE - VSCode
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# misc
+/.sass-cache
+/connect.lock
+/coverage
+/libpeerconnection.log
+npm-debug.log
+yarn-error.log
+testem.log
+/typings
+
+# System Files
+.DS_Store
+Thumbs.db
diff --git a/src/main/ts2/angular.json b/src/main/ts2/angular.json
new file mode 100755
index 0000000..13aaf20
--- /dev/null
+++ b/src/main/ts2/angular.json
@@ -0,0 +1,153 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "codiki": {
+ "root": "",
+ "sourceRoot": "src",
+ "projectType": "application",
+ "prefix": "app",
+ "schematics": {
+ "@schematics/angular:component": {
+ "styleext": "scss"
+ }
+ },
+ "architect": {
+ "build": {
+ "builder": "@angular-devkit/build-angular:browser",
+ "options": {
+ "outputPath": "../resources/static/",
+ "index": "src/index.html",
+ "main": "src/main.ts",
+ "polyfills": "src/polyfills.ts",
+ "tsConfig": "src/tsconfig.app.json",
+ "assets": [
+ "src/favicon.ico",
+ "src/assets"
+ ],
+ "styles": [
+ "node_modules/font-awesome/scss/font-awesome.scss",
+ "node_modules/angular-bootstrap-md/scss/bootstrap/bootstrap.scss",
+ "node_modules/angular-bootstrap-md/scss/mdb-free.scss",
+ "src/styles.scss"
+ ],
+ "scripts": [
+ "node_modules/hammerjs/hammer.min.js"
+ ]
+ },
+ "configurations": {
+ "production": {
+ "fileReplacements": [
+ {
+ "replace": "src/environments/environment.ts",
+ "with": "src/environments/environment.prod.ts"
+ }
+ ],
+ "optimization": true,
+ "outputHashing": "all",
+ "sourceMap": false,
+ "extractCss": true,
+ "namedChunks": false,
+ "aot": true,
+ "extractLicenses": true,
+ "vendorChunk": false,
+ "buildOptimizer": true
+ },
+ "integ": {
+ "fileReplacements": [
+ {
+ "replace": "src/environments/environment.ts",
+ "with": "src/environments/environment.integ.ts"
+ }
+ ],
+ "optimization": true,
+ "outputHashing": "all",
+ "sourceMap": true,
+ "extractCss": true,
+ "namedChunks": true,
+ "aot": true,
+ "extractLicenses": true,
+ "vendorChunk": false,
+ "buildOptimizer": true
+ }
+ }
+ },
+ "serve": {
+ "builder": "@angular-devkit/build-angular:dev-server",
+ "options": {
+ "browserTarget": "codiki:build"
+ },
+ "configurations": {
+ "production": {
+ "browserTarget": "codiki:build:production"
+ }
+ }
+ },
+ "extract-i18n": {
+ "builder": "@angular-devkit/build-angular:extract-i18n",
+ "options": {
+ "browserTarget": "codiki:build"
+ }
+ },
+ "test": {
+ "builder": "@angular-devkit/build-angular:karma",
+ "options": {
+ "main": "src/test.ts",
+ "polyfills": "src/polyfills.ts",
+ "tsConfig": "src/tsconfig.spec.json",
+ "karmaConfig": "src/karma.conf.js",
+ "styles": [
+ "src/styles.css"
+ ],
+ "scripts": [],
+ "assets": [
+ "src/favicon.ico",
+ "src/assets"
+ ]
+ }
+ },
+ "lint": {
+ "builder": "@angular-devkit/build-angular:tslint",
+ "options": {
+ "tsConfig": [
+ "src/tsconfig.app.json",
+ "src/tsconfig.spec.json"
+ ],
+ "exclude": [
+ "**/node_modules/**"
+ ]
+ }
+ }
+ }
+ },
+ "codiki-e2e": {
+ "root": "e2e/",
+ "projectType": "application",
+ "architect": {
+ "e2e": {
+ "builder": "@angular-devkit/build-angular:protractor",
+ "options": {
+ "protractorConfig": "e2e/protractor.conf.js",
+ "devServerTarget": "codiki:serve"
+ },
+ "configurations": {
+ "production": {
+ "devServerTarget": "codiki:serve:production"
+ }
+ }
+ },
+ "lint": {
+ "builder": "@angular-devkit/build-angular:tslint",
+ "options": {
+ "tsConfig": "e2e/tsconfig.e2e.json",
+ "exclude": [
+ "**/node_modules/**"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "defaultProject": "codiki"
+}
\ No newline at end of file
diff --git a/src/main/ts2/e2e/protractor.conf.js b/src/main/ts2/e2e/protractor.conf.js
new file mode 100755
index 0000000..86776a3
--- /dev/null
+++ b/src/main/ts2/e2e/protractor.conf.js
@@ -0,0 +1,28 @@
+// Protractor configuration file, see link for more information
+// https://github.com/angular/protractor/blob/master/lib/config.ts
+
+const { SpecReporter } = require('jasmine-spec-reporter');
+
+exports.config = {
+ allScriptsTimeout: 11000,
+ specs: [
+ './src/**/*.e2e-spec.ts'
+ ],
+ capabilities: {
+ 'browserName': 'chrome'
+ },
+ directConnect: true,
+ baseUrl: 'http://localhost:4200/',
+ framework: 'jasmine',
+ jasmineNodeOpts: {
+ showColors: true,
+ defaultTimeoutInterval: 30000,
+ print: function() {}
+ },
+ onPrepare() {
+ require('ts-node').register({
+ project: require('path').join(__dirname, './tsconfig.e2e.json')
+ });
+ jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
+ }
+};
\ No newline at end of file
diff --git a/src/main/ts2/e2e/src/app.e2e-spec.ts b/src/main/ts2/e2e/src/app.e2e-spec.ts
new file mode 100755
index 0000000..3b7cf26
--- /dev/null
+++ b/src/main/ts2/e2e/src/app.e2e-spec.ts
@@ -0,0 +1,14 @@
+import { AppPage } from './app.po';
+
+describe('workspace-project App', () => {
+ let page: AppPage;
+
+ beforeEach(() => {
+ page = new AppPage();
+ });
+
+ it('should display welcome message', () => {
+ page.navigateTo();
+ expect(page.getParagraphText()).toEqual('Welcome to Codiki!');
+ });
+});
diff --git a/src/main/ts2/e2e/src/app.po.ts b/src/main/ts2/e2e/src/app.po.ts
new file mode 100755
index 0000000..82ea75b
--- /dev/null
+++ b/src/main/ts2/e2e/src/app.po.ts
@@ -0,0 +1,11 @@
+import { browser, by, element } from 'protractor';
+
+export class AppPage {
+ navigateTo() {
+ return browser.get('/');
+ }
+
+ getParagraphText() {
+ return element(by.css('app-root h1')).getText();
+ }
+}
diff --git a/src/main/ts2/e2e/tsconfig.e2e.json b/src/main/ts2/e2e/tsconfig.e2e.json
new file mode 100755
index 0000000..a6dd622
--- /dev/null
+++ b/src/main/ts2/e2e/tsconfig.e2e.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../out-tsc/app",
+ "module": "commonjs",
+ "target": "es5",
+ "types": [
+ "jasmine",
+ "jasminewd2",
+ "node"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/src/main/ts2/package.json b/src/main/ts2/package.json
new file mode 100755
index 0000000..f1ac9e7
--- /dev/null
+++ b/src/main/ts2/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "codiki",
+ "version": "0.0.0",
+ "scripts": {
+ "ng": "ng",
+ "start": "ng serve --proxy-config proxy.conf.json",
+ "build": "ng build",
+ "test": "ng test",
+ "lint": "ng lint",
+ "e2e": "ng e2e"
+ },
+ "private": true,
+ "dependencies": {
+ "@angular/animations": "^7.2.1",
+ "@angular/common": "^7.2.1",
+ "@angular/compiler": "^7.2.1",
+ "@angular/core": "^7.2.1",
+ "@angular/forms": "^7.2.1",
+ "@angular/http": "^7.2.1",
+ "@angular/platform-browser": "^7.2.1",
+ "@angular/platform-browser-dynamic": "^7.2.1",
+ "@angular/router": "^7.2.1",
+ "angular-bootstrap-md": "^7.2.0",
+ "core-js": "^2.5.4",
+ "font-awesome": "^4.7.0",
+ "chart.js": "^2.5.0",
+ "hammerjs": "^2.0.8",
+ "rxjs": "~6.3.3",
+ "zone.js": "~0.8.28"
+ },
+ "devDependencies": {
+ "@angular-devkit/build-angular": "~0.8.0",
+ "@angular/cli": "^6.2.9",
+ "@angular/compiler-cli": "^7.2.1",
+ "@angular/language-service": "^7.2.1",
+ "@types/jasmine": "~2.8.8",
+ "@types/jasminewd2": "~2.0.3",
+ "@types/node": "~8.9.4",
+ "codelyzer": "~4.3.0",
+ "jasmine-core": "~2.99.1",
+ "jasmine-spec-reporter": "~4.2.1",
+ "karma": "~3.0.0",
+ "karma-chrome-launcher": "~2.2.0",
+ "karma-coverage-istanbul-reporter": "~2.0.1",
+ "karma-jasmine": "~1.1.2",
+ "karma-jasmine-html-reporter": "^0.2.2",
+ "protractor": "~5.4.0",
+ "ts-node": "~7.0.0",
+ "tslint": "~5.11.0",
+ "typescript": "~3.2.4"
+ }
+}
diff --git a/src/main/ts2/src/app/account-settings/account-settings.component.html b/src/main/ts2/src/app/account-settings/account-settings.component.html
new file mode 100755
index 0000000..00bc730
--- /dev/null
+++ b/src/main/ts2/src/app/account-settings/account-settings.component.html
@@ -0,0 +1,55 @@
+
Paramètres
+
+
+
+
+
+
+
+
+ Paramètres de compte
+
+
+
+
+
+
+
+ Paramètres divers
+
+
+
diff --git a/src/main/ts2/src/app/account-settings/account-settings.component.ts b/src/main/ts2/src/app/account-settings/account-settings.component.ts
new file mode 100755
index 0000000..50e20bb
--- /dev/null
+++ b/src/main/ts2/src/app/account-settings/account-settings.component.ts
@@ -0,0 +1,24 @@
+import { Component } from '@angular/core';
+
+
+@Component({
+ selector: 'app-account-settings',
+ templateUrl: './account-settings.component.html',
+ styles: [`
+ h4 i {
+ margin-right: 10px;
+ }
+ a, a:visited, a:hover {
+ color: black;
+ }
+ .card {
+ margin-right: 20px;
+ width: 30%;
+ display: inline-block;
+ vertical-align: top;
+ }
+ `]
+})
+export class AccountSettingsComponent {
+
+}
diff --git a/src/main/ts2/src/app/account-settings/change-password/change-password.component.html b/src/main/ts2/src/app/account-settings/change-password/change-password.component.html
new file mode 100755
index 0000000..5e2c09b
--- /dev/null
+++ b/src/main/ts2/src/app/account-settings/change-password/change-password.component.html
@@ -0,0 +1,61 @@
+
+
\ No newline at end of file
diff --git a/src/main/ts2/src/app/account-settings/change-password/change-password.component.ts b/src/main/ts2/src/app/account-settings/change-password/change-password.component.ts
new file mode 100755
index 0000000..488a835
--- /dev/null
+++ b/src/main/ts2/src/app/account-settings/change-password/change-password.component.ts
@@ -0,0 +1,37 @@
+import { Component } from '@angular/core';
+import { PasswordWrapper } from '../../core/entities';
+import { ChangePasswordService } from './change-password.service';
+import { FormGroup, Validators, FormBuilder } from '@angular/forms';
+
+@Component({
+ selector: 'app-change-password',
+ templateUrl: './change-password.component.html',
+ styles: [`
+ #form.card-body {
+ padding-bottom: 10px;
+ }
+
+ .submitFormArea {
+ line-height: 50px;
+ }
+ `]
+})
+export class ChangePasswordComponent {
+ model: PasswordWrapper = new PasswordWrapper('', '', '');
+
+ error: string;
+
+ constructor(
+ private changePasswordService: ChangePasswordService
+ ) {}
+
+ onSubmit(): void {
+ if (this.model.newPassword !== this.model.confirmPassword) {
+ this.error = 'Les mots de passe saisis ne correspondent pas.';
+ } else {
+ this.changePasswordService.changePassword(this.model).subscribe(null, error => {
+ this.error = 'Le mot de passe saisi ne correspond pas au votre.';
+ });
+ }
+ }
+}
diff --git a/src/main/ts2/src/app/account-settings/change-password/change-password.service.ts b/src/main/ts2/src/app/account-settings/change-password/change-password.service.ts
new file mode 100755
index 0000000..47b3a95
--- /dev/null
+++ b/src/main/ts2/src/app/account-settings/change-password/change-password.service.ts
@@ -0,0 +1,17 @@
+import { Injectable } from '@angular/core';
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { Observable } from 'rxjs';
+import { PasswordWrapper } from '../../core/entities';
+import { environment } from 'src/environments/environment';
+
+const ACCOUNT_URL = environment.apiUrl + '/api/account';
+
+@Injectable()
+export class ChangePasswordService {
+
+ constructor(private http: HttpClient) {}
+
+ changePassword(passwordWrapper: PasswordWrapper): Observable {
+ return this.http.put(ACCOUNT_URL + '/changePassword', passwordWrapper);
+ }
+}
diff --git a/src/main/ts2/src/app/account-settings/profil-edition/profil-edition.component.html b/src/main/ts2/src/app/account-settings/profil-edition/profil-edition.component.html
new file mode 100755
index 0000000..0113bcb
--- /dev/null
+++ b/src/main/ts2/src/app/account-settings/profil-edition/profil-edition.component.html
@@ -0,0 +1,60 @@
+
+
+
+
![Card image cap]()
+
+
+
diff --git a/src/main/ts2/src/app/account-settings/profil-edition/profil-edition.component.ts b/src/main/ts2/src/app/account-settings/profil-edition/profil-edition.component.ts
new file mode 100755
index 0000000..09f5a6d
--- /dev/null
+++ b/src/main/ts2/src/app/account-settings/profil-edition/profil-edition.component.ts
@@ -0,0 +1,101 @@
+import { Component, OnInit } from '@angular/core';
+import { ProfilEditionService } from './profil-edition.service';
+import { HttpEventType, HttpResponse } from '@angular/common/http';
+import { User } from '../../core/entities';
+import { AuthService } from '../../core/services/auth.service';
+import { environment } from '../../../environments/environment';
+
+
+@Component({
+ selector: 'app-profil-edition',
+ templateUrl: './profil-edition.component.html',
+ styles: [`
+ #form {
+ padding-bottom: 10px;
+ }
+
+ .submitFormArea {
+ line-height: 50px;
+ }
+
+ #profil-image {
+ cursor: pointer;
+ }
+
+ #resultMsg, #errorMsg {
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.5s ease-out;
+ margin: 0;
+ }
+ `]
+})
+export class ProfilEditionComponent implements OnInit {
+ model: User;
+ selectedFiles: FileList;
+ currentFileUpload: File;
+ progress: { percentage: number } = { percentage: 0 };
+ modelError: string;
+ result: string;
+
+ constructor(
+ private profilEditionService: ProfilEditionService,
+ private authService: AuthService
+ ) {}
+
+ ngOnInit(): void {
+ this.model = this.authService.getUser();
+ }
+
+ getAvatarUrl(): string {
+ return this.model.image
+ ? `${environment.apiUrl}/api/images/loadAvatar/${this.model.image}`
+ : './assets/images/default_user.png';
+ }
+
+ triggerProfilImageChange(): void {
+ document.getElementById('profilImageInput').click();
+ }
+
+ uploadImage(event) {
+ this.selectedFiles = event.target.files;
+ this.progress.percentage = 0;
+
+ this.currentFileUpload = this.selectedFiles.item(0);
+ // This prevents error 400 if user doesn't select any file to upload and close the input file.
+ if (this.currentFileUpload) {
+ this.profilEditionService.uploadAvatarPicture(this.currentFileUpload).subscribe(result => {
+ if (result.type === HttpEventType.UploadProgress) {
+ this.progress.percentage = Math.round(100 * result.loaded / result.total);
+ } else if (result instanceof HttpResponse) {
+ console.log('File ' + result.body + ' completely uploaded!');
+ this.model.image = result.body as string;
+ this.authService.setUser(this.model);
+ }
+ this.selectedFiles = undefined;
+ });
+ }
+ }
+
+ onSubmit(): void {
+ this.profilEditionService.updateUser(this.model).subscribe(() => {
+ this.setMessage('Modification enregistrée.', false);
+ }, error => {
+ this.setMessage('L\'adresse mail saisie n\'est pas disponible.', true);
+ });
+ }
+
+ setMessage(message: string, error: boolean): void {
+ this[error ? 'modelError' : 'result'] = message;
+
+ const resultMsgDiv = document.getElementById(error ? 'errorMsg' : 'resultMsg');
+ resultMsgDiv.style.maxHeight = '64px';
+
+ setTimeout(() => {
+ resultMsgDiv.style.maxHeight = '0px';
+ setTimeout(() => {
+ this[error ? 'modelError' : 'result'] = undefined;
+ }, 550);
+ }, 3000);
+ }
+}
diff --git a/src/main/ts2/src/app/account-settings/profil-edition/profil-edition.service.ts b/src/main/ts2/src/app/account-settings/profil-edition/profil-edition.service.ts
new file mode 100755
index 0000000..ac67ff4
--- /dev/null
+++ b/src/main/ts2/src/app/account-settings/profil-edition/profil-edition.service.ts
@@ -0,0 +1,30 @@
+import { Injectable } from '@angular/core';
+import {HttpClient, HttpRequest, HttpEvent} from '@angular/common/http';
+import {Observable} from 'rxjs';
+import { User } from '../../core/entities';
+import { environment } from 'src/environments/environment';
+
+const IMAGES_URL = environment.apiUrl + '/api/images';
+const ACCOUNT_URL = environment.apiUrl + '/api/account';
+
+@Injectable()
+export class ProfilEditionService {
+ constructor(private http: HttpClient) {}
+
+ uploadAvatarPicture(file: File): Observable> {
+ const formData: FormData = new FormData();
+
+ formData.append('file', file);
+
+ return this.http.request(new HttpRequest(
+ 'POST', IMAGES_URL + '/uploadAvatar', formData, {
+ reportProgress: true,
+ responseType: 'text'
+ }
+ ));
+ }
+
+ updateUser(user: User): Observable {
+ return this.http.put(`${ACCOUNT_URL}/`, user);
+ }
+}
diff --git a/src/main/ts2/src/app/app.component.css b/src/main/ts2/src/app/app.component.css
new file mode 100755
index 0000000..e69de29
diff --git a/src/main/ts2/src/app/app.component.html b/src/main/ts2/src/app/app.component.html
new file mode 100755
index 0000000..becb011
--- /dev/null
+++ b/src/main/ts2/src/app/app.component.html
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/ts2/src/app/app.component.spec.ts b/src/main/ts2/src/app/app.component.spec.ts
new file mode 100755
index 0000000..60ee5db
--- /dev/null
+++ b/src/main/ts2/src/app/app.component.spec.ts
@@ -0,0 +1,27 @@
+import { TestBed, async } from '@angular/core/testing';
+import { AppComponent } from './app.component';
+describe('AppComponent', () => {
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [
+ AppComponent
+ ],
+ }).compileComponents();
+ }));
+ it('should create the app', async(() => {
+ const fixture = TestBed.createComponent(AppComponent);
+ const app = fixture.debugElement.componentInstance;
+ expect(app).toBeTruthy();
+ }));
+ it(`should have as title 'Codiki'`, async(() => {
+ const fixture = TestBed.createComponent(AppComponent);
+ const app = fixture.debugElement.componentInstance;
+ expect(app.title).toEqual('Codiki');
+ }));
+ it('should render title in a h1 tag', async(() => {
+ const fixture = TestBed.createComponent(AppComponent);
+ fixture.detectChanges();
+ const compiled = fixture.debugElement.nativeElement;
+ expect(compiled.querySelector('h1').textContent).toContain('Welcome to Codiki!');
+ }));
+});
diff --git a/src/main/ts2/src/app/app.component.ts b/src/main/ts2/src/app/app.component.ts
new file mode 100755
index 0000000..3450ecc
--- /dev/null
+++ b/src/main/ts2/src/app/app.component.ts
@@ -0,0 +1,10 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'app-root',
+ templateUrl: './app.component.html',
+ styleUrls: ['./app.component.css']
+})
+export class AppComponent {
+ title = 'codiki';
+}
diff --git a/src/main/ts2/src/app/app.module.ts b/src/main/ts2/src/app/app.module.ts
new file mode 100755
index 0000000..7bd5429
--- /dev/null
+++ b/src/main/ts2/src/app/app.module.ts
@@ -0,0 +1,120 @@
+// Angular core
+import { BrowserModule } from '@angular/platform-browser';
+import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
+import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
+
+// Dependencies
+import { MDBBootstrapModule } from 'angular-bootstrap-md';
+
+// Router
+import { appRoutes } from './app.routes';
+
+// Components
+import { AppComponent } from './app.component';
+import { HeaderComponent } from './header/header.component';
+import { FooterComponent } from './footer/footer.component';
+import { NotFoundComponent } from './not-found/not-found.component';
+import { HomeComponent } from './home/home.component';
+import { LoginComponent } from './login/login.component';
+import { DisconnectionComponent } from './disconnection/disconnection.component';
+import { PostCardComponent } from './core/post-card/post-card.component';
+import { PostComponent } from './posts/post.component';
+import { ByCategoryComponent } from './posts/byCategory/by-category.component';
+import { MyPostsComponent } from './posts/myPosts/my-posts.component';
+import { AccountSettingsComponent } from './account-settings/account-settings.component';
+import { ChangePasswordComponent } from './account-settings/change-password/change-password.component';
+import { ProfilEditionComponent } from './account-settings/profil-edition/profil-edition.component';
+import { CreateUpdatePostComponent } from './posts/create-update/create-update-post.component';
+import { ForbiddenComponent } from './forbidden/forbidden.component';
+import { SearchComponent } from './search/search.component';
+import { SigninComponent } from './signin/signin.component';
+import { VersionRevisionComponent } from './version-revisions/version-revisions.component';
+
+// html components
+import { ProgressBarComponent } from './core/directives/progress-bar/progress-bar.component';
+import { SpinnerComponent } from './core/directives/spinner/spinner.component';
+import { SearchBarComponent } from './core/directives/search-bar/search-bar.component';
+
+// Services
+import { AuthService } from './core/services/auth.service';
+import { LoginService } from './login/login.service';
+import { HomeService } from './home/home.service';
+import { PostService } from './posts/post.service';
+import { ByCategoryService } from './posts/byCategory/by-category.service';
+import { MyPostsService } from './posts/myPosts/my-posts.service';
+import { ChangePasswordService } from './account-settings/change-password/change-password.service';
+import { ProfilEditionService } from './account-settings/profil-edition/profil-edition.service';
+import { HeaderService } from './header/header.service';
+import { CreateUpdatePostService } from './posts/create-update/create-update-post.service';
+import { SearchService } from './search/search.service';
+import { VersionRevisionService } from './version-revisions/version-revisions.service';
+
+// Guards
+import { AuthGuard } from './core/guards/auth.guard';
+
+// Interceptors
+import { TokenInterceptor } from './core/interceptors/token-interceptor';
+import { SigninService } from './signin/signin.service';
+
+@NgModule({
+ declarations: [
+ AppComponent,
+ HeaderComponent,
+ FooterComponent,
+ NotFoundComponent,
+ HomeComponent,
+ LoginComponent,
+ DisconnectionComponent,
+ PostCardComponent,
+ PostComponent,
+ ByCategoryComponent,
+ MyPostsComponent,
+ AccountSettingsComponent,
+ CreateUpdatePostComponent,
+ ChangePasswordComponent,
+ ProfilEditionComponent,
+ ForbiddenComponent,
+ SearchComponent,
+ SigninComponent,
+ ProgressBarComponent,
+ SpinnerComponent,
+ SearchBarComponent,
+ VersionRevisionComponent
+ ],
+ imports: [
+ BrowserModule,
+ HttpClientModule,
+ FormsModule,
+ MDBBootstrapModule.forRoot(),
+ RouterModule.forRoot(
+ appRoutes,
+ // { enableTracing: true } // Enabling tracing
+ { onSameUrlNavigation: 'reload' }
+ )
+ ],
+ providers: [
+ AuthService,
+ LoginService,
+ SigninService,
+ HomeService,
+ PostService,
+ ByCategoryService,
+ MyPostsService,
+ ChangePasswordService,
+ ProfilEditionService,
+ HeaderService,
+ CreateUpdatePostService,
+ SearchService,
+ VersionRevisionService,
+ // AuthGuard,
+ // {
+ // provide: HTTP_INTERCEPTORS,
+ // useClass: TokenInterceptor,
+ // multi: true
+ // }
+ ],
+ bootstrap: [AppComponent]
+})
+export class AppModule { }
diff --git a/src/main/ts2/src/app/app.routes.ts b/src/main/ts2/src/app/app.routes.ts
new file mode 100755
index 0000000..76efa56
--- /dev/null
+++ b/src/main/ts2/src/app/app.routes.ts
@@ -0,0 +1,59 @@
+import { Routes } from '@angular/router';
+
+import { HomeComponent } from './home/home.component';
+import { NotFoundComponent } from './not-found/not-found.component';
+import { LoginComponent } from './login/login.component';
+import { SigninComponent } from './signin/signin.component';
+import { DisconnectionComponent } from './disconnection/disconnection.component';
+import { PostComponent } from './posts/post.component';
+import { ByCategoryComponent } from './posts/byCategory/by-category.component';
+import { MyPostsComponent } from './posts/myPosts/my-posts.component';
+import { AccountSettingsComponent } from './account-settings/account-settings.component';
+import { ChangePasswordComponent } from './account-settings/change-password/change-password.component';
+import { CreateUpdatePostComponent } from './posts/create-update/create-update-post.component';
+import { ForbiddenComponent } from './forbidden/forbidden.component';
+import { ProfilEditionComponent } from './account-settings/profil-edition/profil-edition.component';
+import { SearchComponent } from './search/search.component';
+import { VersionRevisionComponent } from './version-revisions/version-revisions.component';
+
+import { AuthGuard } from './core/guards/auth.guard';
+
+// export const appRoutes: Routes = [
+// { path: 'login', component: LoginComponent },
+// { path: 'signin', component: SigninComponent },
+// { path: 'home', component: HomeComponent },
+// { path: 'disconnection', component: DisconnectionComponent },
+// { path: 'posts/new', component: CreateUpdatePostComponent, canActivate: [AuthGuard] },
+// { path: 'posts/update/:postKey', component: CreateUpdatePostComponent, canActivate: [AuthGuard] },
+// { path: 'posts/:postKey', component: PostComponent },
+// { path: 'posts/byCategory/:categoryId', component: ByCategoryComponent},
+// { path: 'posts/search/:searchCriteria', component: SearchComponent},
+// { path: 'myPosts', component: MyPostsComponent, canActivate: [AuthGuard]},
+// { path: 'accountSettings', component: AccountSettingsComponent, canActivate: [AuthGuard] },
+// { path: 'changePassword', component: ChangePasswordComponent, canActivate: [AuthGuard] },
+// { path: 'profilEdit', component: ProfilEditionComponent, canActivate: [AuthGuard] },
+// { path: 'versionrevisions', component: VersionRevisionComponent },
+// { path: 'forbidden', component: ForbiddenComponent },
+// { path: '', redirectTo: '/home', pathMatch: 'full' },
+// { path: '**', component: NotFoundComponent }
+// ];
+
+export const appRoutes: Routes = [
+ { path: 'login', component: LoginComponent },
+ { path: 'signin', component: SigninComponent },
+ { path: 'home', component: HomeComponent },
+ { path: 'disconnection', component: DisconnectionComponent },
+ { path: 'posts/new', component: CreateUpdatePostComponent },
+ { path: 'posts/update/:postKey', component: CreateUpdatePostComponent },
+ { path: 'posts/:postKey', component: PostComponent },
+ { path: 'posts/byCategory/:categoryId', component: ByCategoryComponent},
+ { path: 'posts/search/:searchCriteria', component: SearchComponent},
+ { path: 'myPosts', component: MyPostsComponent},
+ { path: 'accountSettings', component: AccountSettingsComponent },
+ { path: 'changePassword', component: ChangePasswordComponent },
+ { path: 'profilEdit', component: ProfilEditionComponent },
+ { path: 'versionrevisions', component: VersionRevisionComponent },
+ { path: 'forbidden', component: ForbiddenComponent },
+ { path: '', redirectTo: '/home', pathMatch: 'full' },
+ { path: '**', component: NotFoundComponent }
+];
diff --git a/src/main/ts2/src/app/core/directives/progress-bar/progress-bar.component.scss b/src/main/ts2/src/app/core/directives/progress-bar/progress-bar.component.scss
new file mode 100755
index 0000000..eee4681
--- /dev/null
+++ b/src/main/ts2/src/app/core/directives/progress-bar/progress-bar.component.scss
@@ -0,0 +1,112 @@
+.progress {
+ position: relative;
+ height: 4px;
+ display: block;
+ width: 100%;
+ background-color: #3F729B;
+ border-radius: 2px;
+ background-clip: padding-box;
+ margin: 0.5rem 0 1rem 0;
+ overflow: hidden;
+ -webkit-animation: random-background 5s infinite;
+ animation: random-background 5s infinite;transition: all 0.3s ease-out;
+}
+@keyframes random-background {
+ 15% { background-color: #ff444480; }
+ 30% { background-color: #ffbb3381; }
+ 45% { background-color: rgba(0, 200, 80, 0.575); }
+ 60% { background-color: #33b6e58c; }
+ 75% { background-color: #aa66cc7c; }
+}
+.progress .indeterminate {
+ background-color: #1C2331;
+ -webkit-animation: random-bar 5s infinite;
+ animation: random-bar 5s infinite;transition: all 0.3s ease-out;
+}
+@keyframes random-bar {
+ 15% { background-color: #ff4444; }
+ 30% { background-color: #ffbb33; }
+ 45% { background-color: #00C851; }
+ 60% { background-color: #33b5e5; }
+ 75% { background-color: #aa66cc; }
+}
+.progress .indeterminate:before {
+ content: '';
+ position: absolute;
+ background-color: inherit;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ will-change: left, right;
+ -webkit-animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
+ animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
+}
+.progress .indeterminate:after {
+ content: '';
+ position: absolute;
+ background-color: inherit;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ will-change: left, right;
+ -webkit-animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) infinite;
+ animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) infinite;
+ -webkit-animation-delay: 1.15s;
+ animation-delay: 1.15s;
+}
+@-webkit-keyframes indeterminate {
+ 0% {
+ left: -35%;
+ right: 100%;
+ }
+ 60% {
+ left: 100%;
+ right: -90%;
+ }
+ 100% {
+ left: 100%;
+ right: -90%;
+ }
+}
+@keyframes indeterminate {
+ 0% {
+ left: -35%;
+ right: 100%;
+ }
+ 60% {
+ left: 100%;
+ right: -90%;
+ }
+ 100% {
+ left: 100%;
+ right: -90%;
+ }
+}
+@-webkit-keyframes indeterminate-short {
+ 0% {
+ left: -200%;
+ right: 100%;
+ }
+ 60% {
+ left: 107%;
+ right: -8%;
+ }
+ 100% {
+ left: 107%;
+ right: -8%;
+ }
+}
+@keyframes indeterminate-short {
+ 0% {
+ left: -200%;
+ right: 100%;
+ }
+ 60% {
+ left: 107%;
+ right: -8%;
+ }
+ 100% {
+ left: 107%;
+ right: -8%;
+ }
+}
diff --git a/src/main/ts2/src/app/core/directives/progress-bar/progress-bar.component.ts b/src/main/ts2/src/app/core/directives/progress-bar/progress-bar.component.ts
new file mode 100755
index 0000000..4913831
--- /dev/null
+++ b/src/main/ts2/src/app/core/directives/progress-bar/progress-bar.component.ts
@@ -0,0 +1,12 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'app-progress-bar',
+ template: `
+
+ `,
+ styleUrls: ['./progress-bar.component.scss']
+})
+export class ProgressBarComponent {}
diff --git a/src/main/ts2/src/app/core/directives/search-bar/search-bar.component.ts b/src/main/ts2/src/app/core/directives/search-bar/search-bar.component.ts
new file mode 100755
index 0000000..95ac79e
--- /dev/null
+++ b/src/main/ts2/src/app/core/directives/search-bar/search-bar.component.ts
@@ -0,0 +1,66 @@
+import { Component } from '@angular/core';
+import { Router } from '@angular/router';
+
+ @Component({
+ selector: 'app-search-bar',
+ template: `
+
+
+
+
+ `,
+ styles: [`
+ div#search-bar {
+ position: relative;
+ margin-right: 5px;
+ }
+
+ input#search, input#search:focus {
+ border-bottom: none;
+ }
+
+ input#search {
+ width: 400px;
+ height: 36px;
+ color: white;
+ background-color: #5c6bc0;
+ border-radius: 2px;
+ border-style: unset;
+ padding-left: 10px;
+ padding-right: 35px;
+ }
+
+ input#search:focus {
+ background: white;
+ color: #3f51b5;
+ }
+
+ i#search-icon {
+ font-size: 20px;
+ position: absolute;
+ right: 15px;
+ top: 8px;
+ color: #9e9e9e;
+ cursor: pointer;
+ }
+ `]
+})
+export class SearchBarComponent {
+ model: string;
+
+ constructor(
+ private router: Router
+ ) {}
+
+ search(): void {
+ if (this.model) {
+ this.router.routeReuseStrategy.shouldReuseRoute = () => false;
+ this.router.navigateByUrl(`/posts/search/${this.model}`).then(() => {
+ this.router.navigated = false;
+ this.router.navigate([this.router.url]);
+ });
+ }
+ }
+}
diff --git a/src/main/ts2/src/app/core/directives/spinner/spinner.component.scss b/src/main/ts2/src/app/core/directives/spinner/spinner.component.scss
new file mode 100755
index 0000000..62c6b9d
--- /dev/null
+++ b/src/main/ts2/src/app/core/directives/spinner/spinner.component.scss
@@ -0,0 +1,84 @@
+$green: #00C851;
+$blue: #33b5e5;
+$red: #ff4444;
+$yellow: #ffbb33;
+$white: #eee;
+
+$width: 100px;
+
+body {
+ background-color: $white;
+}
+
+.showbox {
+ padding: 5%;
+}
+
+.loader {
+ position: relative;
+ margin: 0 auto;
+ width: $width;
+ &:before {
+ content: '';
+ display: block;
+ padding-top: 100%;
+ }
+}
+
+.circular {
+ animation: rotate 2s linear infinite;
+ height: 100%;
+ transform-origin: center center;
+ width: 100%;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+}
+
+.path {
+ stroke-dasharray: 1, 200;
+ stroke-dashoffset: 0;
+ animation: dash 1.5s ease-in-out infinite, color 6s ease-in-out infinite;
+ stroke-linecap: round;
+}
+
+@keyframes rotate {
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes dash {
+ 0% {
+ stroke-dasharray: 1, 200;
+ stroke-dashoffset: 0;
+ }
+ 50% {
+ stroke-dasharray: 89, 200;
+ stroke-dashoffset: -35px;
+ }
+ 100% {
+ stroke-dasharray: 89, 200;
+ stroke-dashoffset: -124px;
+ }
+}
+
+@keyframes color {
+ 100%,
+ 0% {
+ stroke: $red;
+ }
+ 40% {
+ stroke: $blue;
+ }
+ 66% {
+ stroke: $green;
+ }
+ 80%,
+ 90% {
+ stroke: $yellow;
+ }
+}
diff --git a/src/main/ts2/src/app/core/directives/spinner/spinner.component.ts b/src/main/ts2/src/app/core/directives/spinner/spinner.component.ts
new file mode 100755
index 0000000..7d5a8dd
--- /dev/null
+++ b/src/main/ts2/src/app/core/directives/spinner/spinner.component.ts
@@ -0,0 +1,18 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'app-spinner',
+ template: `
+
+ `,
+ styleUrls: ['./spinner.component.scss']
+})
+export class SpinnerComponent {
+
+}
diff --git a/src/main/ts2/src/app/core/entities.ts b/src/main/ts2/src/app/core/entities.ts
new file mode 100755
index 0000000..926c94b
--- /dev/null
+++ b/src/main/ts2/src/app/core/entities.ts
@@ -0,0 +1,75 @@
+export class Role {
+ constructor(
+ public id: number,
+ public name: string
+ ) { }
+}
+
+export class User {
+ constructor(
+ public key: string,
+ public name: string,
+ public email: string,
+ public password: string,
+ public image: string,
+ public inscriptionDate: Date,
+ public role: Role,
+ public token: string
+ ) { }
+}
+
+export class Post {
+ constructor(
+ public key: string,
+ public title: string,
+ public text: string,
+ public description: string,
+ public image: string,
+ public creationDate: Date,
+ public author: User,
+ public category: Category
+ ) { }
+}
+
+export class Category {
+ constructor(
+ public id: number,
+ public name: string,
+ public listSubCategories: Array
+ ) { }
+}
+
+export class Image {
+ constructor(
+ public id: number,
+ public link: string
+ ) { }
+}
+
+/**
+ * Class to send the new password to backoffice in order to change the user password.
+ */
+export class PasswordWrapper {
+ constructor(
+ public oldPassword: string,
+ public newPassword: string,
+ public confirmPassword: string
+ ) { }
+}
+
+export class Version {
+ constructor(
+ public id: number,
+ public number: string,
+ public active: boolean
+ ) { }
+}
+
+export class VersionRevision {
+ constructor(
+ public id: number,
+ public text: string,
+ public version: Version,
+ public bugfix: boolean
+ ) { }
+}
\ No newline at end of file
diff --git a/src/main/ts2/src/app/core/guards/auth.guard.ts b/src/main/ts2/src/app/core/guards/auth.guard.ts
new file mode 100755
index 0000000..ba0ec48
--- /dev/null
+++ b/src/main/ts2/src/app/core/guards/auth.guard.ts
@@ -0,0 +1,26 @@
+import { Injectable } from '@angular/core';
+import { Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
+
+import { AuthService } from '../services/auth.service';
+
+@Injectable()
+export class AuthGuard implements CanActivate {
+
+ constructor(
+ private router: Router,
+ private authService: AuthService
+ ) {}
+
+ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
+ let result: boolean;
+
+ if (this.authService.isAuthenticated()) {
+ result = true;
+ } else {
+ result = false;
+ this.router.navigate(['/login']);
+ }
+
+ return result;
+ }
+}
diff --git a/src/main/ts2/src/app/core/interceptors/token-interceptor.ts b/src/main/ts2/src/app/core/interceptors/token-interceptor.ts
new file mode 100755
index 0000000..50fc4d0
--- /dev/null
+++ b/src/main/ts2/src/app/core/interceptors/token-interceptor.ts
@@ -0,0 +1,43 @@
+import { Injectable } from '@angular/core';
+import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
+import { Router } from '@angular/router';
+
+import { Observable } from 'rxjs';
+
+import { User } from '../entities';
+import { AuthService } from '../services/auth.service';
+
+@Injectable()
+export class TokenInterceptor implements HttpInterceptor {
+
+ constructor(
+ private authService: AuthService,
+ private router: Router
+ ) {}
+
+ intercept(req: HttpRequest, next: HttpHandler): Observable> {
+ const token = this.authService.getToken();
+
+ let request: HttpRequest = req;
+
+ if (token) {
+ request = req.clone({
+ setHeaders: {
+ token: token
+ }
+ });
+ }
+
+ return undefined;
+ // return next.handle(request).tap((event: HttpEvent) => {
+ // // Do nothing for the interceptor
+ // }, (err: any) => {
+ // if (err instanceof HttpErrorResponse && err.status === 401) {
+ // this.authService.disconnect();
+ // this.router.navigate(['/login']);
+ // }
+ // });
+ }
+
+
+}
diff --git a/src/main/ts2/src/app/core/post-card/post-card.component.ts b/src/main/ts2/src/app/core/post-card/post-card.component.ts
new file mode 100755
index 0000000..16bdd5c
--- /dev/null
+++ b/src/main/ts2/src/app/core/post-card/post-card.component.ts
@@ -0,0 +1,60 @@
+import { Component, Input } from '@angular/core';
+import { Post } from '../entities';
+import { environment } from '../../../environments/environment';
+
+@Component({
+ selector: 'app-post-card',
+ template: `
+
+
+
+
{{post.title}}
+
{{post.description}}
+
+
+
![]()
+ Article écrit par {{post.author.name}}
+
({{post.creationDate | date:'yyyy-MM-dd HH:mm:ss'}})
+
+
`,
+ styles: [`
+ div.card {
+ margin-bottom: 50px;
+ }
+ .card .card-data {
+ padding: 15px;
+ background-color: #f5f5f5;
+ }
+ .creation-date-area {
+ color: #bdbdbd;
+ font-style: italic;
+ }
+ .author-img {
+ width: 60px;
+ height: 60px;
+ border-radius: 50%;
+ margin-right: 15px;
+ }
+ #post-image {
+ width: 100%;
+ }
+ `]
+})
+export class PostCardComponent {
+ @Input() post: Post;
+
+ getAvatarUrl(): string {
+ return this.post.author.image
+ ? `${environment.apiUrl}/api/images/loadAvatar/${this.post.author.image}`
+ : './assets/images/default_user.png';
+ }
+}
diff --git a/src/main/ts2/src/app/core/services/auth.service.ts b/src/main/ts2/src/app/core/services/auth.service.ts
new file mode 100755
index 0000000..074f1c5
--- /dev/null
+++ b/src/main/ts2/src/app/core/services/auth.service.ts
@@ -0,0 +1,44 @@
+import { Injectable } from '@angular/core';
+import { User } from '../entities';
+
+const PARAM_TOKEN = 'token';
+const PARAM_USER = 'user';
+
+@Injectable()
+export class AuthService {
+ authenticated: boolean = false;
+
+ constructor() {}
+
+ public setAuthenticated(pAuthenticated: boolean): void {
+ this.authenticated = pAuthenticated;
+ }
+
+ public getToken(): string {
+ return localStorage.getItem(PARAM_TOKEN);
+ }
+
+ public setToken(token: string): void {
+ localStorage.setItem(PARAM_TOKEN, token);
+ }
+
+ public isAuthenticated(): boolean {
+ return this.authenticated;
+ }
+
+ public disconnect(): void {
+ localStorage.clear();
+ }
+
+ public isAdmin(): boolean {
+ return false;
+ }
+
+ public setUser(user: User): void {
+ localStorage.setItem(PARAM_USER, JSON.stringify(user));
+ }
+
+ public getUser(): User {
+ return JSON.parse(localStorage.getItem(PARAM_USER));
+ }
+}
diff --git a/src/main/ts2/src/app/disconnection/disconnection.component.ts b/src/main/ts2/src/app/disconnection/disconnection.component.ts
new file mode 100755
index 0000000..d7d4a95
--- /dev/null
+++ b/src/main/ts2/src/app/disconnection/disconnection.component.ts
@@ -0,0 +1,21 @@
+import { Component, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { AuthService } from '../core/services/auth.service';
+
+@Component({
+ selector: 'app-disconnection',
+ template: 'Déconnexion...'
+})
+export class DisconnectionComponent implements OnInit {
+
+ constructor(
+ private authService: AuthService,
+ private router: Router
+ ) {}
+
+ ngOnInit(): void {
+ this.authService.setAuthenticated(false);
+ this.router.navigate(['/home']);
+ }
+}
diff --git a/src/main/ts2/src/app/footer/footer.component.html b/src/main/ts2/src/app/footer/footer.component.html
new file mode 100755
index 0000000..01a1993
--- /dev/null
+++ b/src/main/ts2/src/app/footer/footer.component.html
@@ -0,0 +1,20 @@
+
diff --git a/src/main/ts2/src/app/footer/footer.component.scss b/src/main/ts2/src/app/footer/footer.component.scss
new file mode 100755
index 0000000..e7947e6
--- /dev/null
+++ b/src/main/ts2/src/app/footer/footer.component.scss
@@ -0,0 +1,19 @@
+footer {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+}
+
+.page-footer {
+ padding-top: 0px;
+}
+
+span.anticopy {
+ display: inline-block;
+ transform: rotate(180deg);
+}
+
+#appVersion {
+ color: rgba(255, 255, 255, 0.6);
+}
\ No newline at end of file
diff --git a/src/main/ts2/src/app/footer/footer.component.ts b/src/main/ts2/src/app/footer/footer.component.ts
new file mode 100755
index 0000000..4598308
--- /dev/null
+++ b/src/main/ts2/src/app/footer/footer.component.ts
@@ -0,0 +1,12 @@
+import { Component } from '@angular/core';
+
+import { environment } from 'src/environments/environment';
+
+@Component({
+ selector: 'app-footer',
+ templateUrl: './footer.component.html',
+ styleUrls: ['./footer.component.scss']
+})
+export class FooterComponent {
+ appVersion = environment.appVersion;
+}
diff --git a/src/main/ts2/src/app/forbidden/forbidden.component.ts b/src/main/ts2/src/app/forbidden/forbidden.component.ts
new file mode 100755
index 0000000..693c5e1
--- /dev/null
+++ b/src/main/ts2/src/app/forbidden/forbidden.component.ts
@@ -0,0 +1,18 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'app-forbidden',
+ template: `
+
+

+
Vous n'êtes pas autorisé à effectuer cette action...
+
+ `,
+ styles: [`
+ img {
+ max-height: 500px;
+ margin-bottom: 30px;
+ }
+ `]
+})
+export class ForbiddenComponent {}
diff --git a/src/main/ts2/src/app/header/header.component.html b/src/main/ts2/src/app/header/header.component.html
new file mode 100755
index 0000000..e46b1e7
--- /dev/null
+++ b/src/main/ts2/src/app/header/header.component.html
@@ -0,0 +1,62 @@
+
+
+
+
+ Codiki
+ Codiki - {{title}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/ts2/src/app/header/header.component.scss b/src/main/ts2/src/app/header/header.component.scss
new file mode 100755
index 0000000..2d46cec
--- /dev/null
+++ b/src/main/ts2/src/app/header/header.component.scss
@@ -0,0 +1,87 @@
+#title {
+ color: white;
+ font-weight: 400;
+}
+
+#logo {
+ width: 30px;
+ height: 30px;
+ margin-top: -7px;
+ vertical-align: middle;
+ margin-right: 10px;
+}
+
+#sidebarButton {
+ margin-left: 10px;
+}
+
+/* The side navigation menu */
+.sidenav {
+ height: 100%; /* 100% Full-height */
+ width: 0; /* 0 width - change this with JavaScript */
+ position: fixed; /* Stay in place */
+ z-index: 1000; /* Stay on top */
+ top: 0; /* Stay at the top */
+ left: 0;
+ background-color: #3f51b5;
+ overflow-x: hidden; /* Disable horizontal scroll */
+ padding-top: 20px; /* Place content 60px from the top */
+ transition: 0.3s; /* 0.5 second transition effect to slide in the sidenav */
+}
+
+/* The navigation menu links */
+.sidenav a {
+ padding: 8px 32px 8px 32px;
+ text-decoration: none;
+ color: white;
+ display: block;
+ transition: 0.3s;
+}
+
+/* When you mouse over the navigation links, change their color */
+.sidenav a:hover {
+ color: #ccc;
+ background-color: #5c6bc0;
+}
+
+.sidenav h3 {
+ padding: 8px 8px 8px 32px;
+ color: white;
+ padding-bottom: 25px;
+ border-bottom: 1px solid #5c6bc0;
+}
+/* Position and style the close button (top right corner) */
+.sidenav .closebtn {
+ position: absolute;
+ top: 25px;
+ right: 25px;
+ margin-left: 50px;
+ padding: 8px;
+}
+
+/* On smaller screens, where height is less than 450px, change the style of the sidenav (less padding and a smaller font size) */
+@media screen and (max-height: 450px) {
+ .sidenav {padding-top: 15px;}
+ .sidenav a {font-size: 18px;}
+}
+
+#overlay {
+ position: fixed; /* Sit on top of the page content */
+ display: none; /* Hidden by default */
+ width: 100%; /* Full width (cover the whole page) */
+ height: 100%; /* Full height (cover the whole page) */
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0,0,0,0.5); /* Black background with opacity */
+ z-index: 999; /* Specify a stack order in case you're using a different order for other elements */
+ transition: 0.5s;
+}
+
+.categoriesLinks {
+ background-color: #303f9f;
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.2s ease-out;
+}
diff --git a/src/main/ts2/src/app/header/header.component.ts b/src/main/ts2/src/app/header/header.component.ts
new file mode 100755
index 0000000..438d9dd
--- /dev/null
+++ b/src/main/ts2/src/app/header/header.component.ts
@@ -0,0 +1,80 @@
+import { Component, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
+import { AuthService } from '../core/services/auth.service';
+import { Category } from '../core/entities';
+import { HeaderService } from './header.service';
+import { environment } from 'src/environments/environment';
+
+const SIDENAV_WIDTH = '300px';
+
+@Component({
+ selector: 'app-header',
+ templateUrl: './header.component.html',
+ styleUrls: ['./header.component.scss']
+})
+export class HeaderComponent implements OnInit {
+ isAdmin: boolean;
+ listCategories: Array = [];
+ title: string;
+
+ constructor(
+ private authService: AuthService,
+ private headerService: HeaderService,
+ private router: Router
+ ) {}
+
+ ngOnInit() {
+ this.title = environment.title;
+ this.isAdmin = this.authService.isAdmin();
+ this.headerService.getAllCategories().subscribe(listCategories => {
+ this.listCategories = listCategories;
+ });
+ }
+
+ isAuthenticated(): boolean {
+ // console.log('Checking if user is connected... for header');
+ return this.authService.isAuthenticated();
+ }
+
+ openSidebar(): void {
+ document.getElementById('sidenav').style.width = SIDENAV_WIDTH;
+ document.getElementById('overlay').style.display = 'block';
+ }
+
+ closeSidebar(): void {
+ document.getElementById('sidenav').style.width = '0';
+ document.getElementById('overlay').style.display = 'none';
+ }
+
+ /**
+ * Redirect the user to the page to show the posts of the category which its id is in parameters.
+ *
+ * @param categoryId The id of the category which we need to show its posts after redirection.
+ */
+ openPostsByCategory(categoryId: string): void {
+ this.closeSidebar();
+
+ this.router.routeReuseStrategy.shouldReuseRoute = () => false;
+ this.router.navigateByUrl('/posts/byCategory/' + categoryId).then(() => {
+ this.router.navigated = false;
+ this.router.navigate([this.router.url]);
+ });
+ }
+
+ /**
+ * Opens the accordion which correspond to the category in parameters.
+ *
+ * @param category The category which its accodion needs to be open.
+ */
+ openCategoriesLinks(category: Category): void {
+ const divCategoriesLinks = document.getElementById('category-' + category.id);
+
+ divCategoriesLinks.classList.toggle('active');
+ const divContent = divCategoriesLinks.nextElementSibling as HTMLElement;
+ if (divContent.style.maxHeight) {
+ divContent.style.maxHeight = null;
+ } else {
+ divContent.style.maxHeight = divContent.scrollHeight + 'px';
+ }
+ }
+}
diff --git a/src/main/ts2/src/app/header/header.service.ts b/src/main/ts2/src/app/header/header.service.ts
new file mode 100755
index 0000000..68d27f2
--- /dev/null
+++ b/src/main/ts2/src/app/header/header.service.ts
@@ -0,0 +1,16 @@
+import { Injectable } from '@angular/core';
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { Observable } from 'rxjs';
+import { Category } from '../core/entities';
+import { environment } from 'src/environments/environment';
+
+const CATEGORY_URL = environment.apiUrl + '/api/categories/';
+@Injectable()
+export class HeaderService {
+
+ constructor(private http: HttpClient) {}
+
+ getAllCategories(): Observable> {
+ return this.http.get>(CATEGORY_URL);
+ }
+}
diff --git a/src/main/ts2/src/app/home/home.component.html b/src/main/ts2/src/app/home/home.component.html
new file mode 100755
index 0000000..6f404e4
--- /dev/null
+++ b/src/main/ts2/src/app/home/home.component.html
@@ -0,0 +1,7 @@
+
+
Derniers articles
+
+
+
diff --git a/src/main/ts2/src/app/home/home.component.scss b/src/main/ts2/src/app/home/home.component.scss
new file mode 100755
index 0000000..8d84d2e
--- /dev/null
+++ b/src/main/ts2/src/app/home/home.component.scss
@@ -0,0 +1,3 @@
+div.card {
+ margin-bottom: 50px;
+}
diff --git a/src/main/ts2/src/app/home/home.component.ts b/src/main/ts2/src/app/home/home.component.ts
new file mode 100755
index 0000000..1bf46c9
--- /dev/null
+++ b/src/main/ts2/src/app/home/home.component.ts
@@ -0,0 +1,24 @@
+import { Component, OnInit } from '@angular/core';
+
+import { HomeService } from './home.service';
+import { Post } from '../core/entities';
+
+@Component({
+ selector: 'app-home',
+ templateUrl: './home.component.html',
+ styleUrls: ['./home.component.scss']
+})
+export class HomeComponent implements OnInit {
+ listArticle: Array;
+ title: string;
+
+ constructor(
+ private homeService: HomeService
+ ) {}
+
+ ngOnInit(): void {
+ this.homeService.getLastPosts().subscribe(lastPosts => {
+ this.listArticle = lastPosts;
+ });
+ }
+}
diff --git a/src/main/ts2/src/app/home/home.service.ts b/src/main/ts2/src/app/home/home.service.ts
new file mode 100755
index 0000000..75d4664
--- /dev/null
+++ b/src/main/ts2/src/app/home/home.service.ts
@@ -0,0 +1,18 @@
+import { Injectable } from '@angular/core';
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { Observable } from 'rxjs';
+import { Post } from '../core/entities';
+
+import { environment } from 'src/environments/environment';
+
+const POSTS_URL = environment.apiUrl + '/api/posts';
+
+@Injectable()
+export class HomeService {
+
+ constructor(private http: HttpClient) {}
+
+ getLastPosts(): Observable> {
+ return this.http.get>(POSTS_URL + '/last');
+ }
+}
diff --git a/src/main/ts2/src/app/login/login.component.html b/src/main/ts2/src/app/login/login.component.html
new file mode 100755
index 0000000..25e3a28
--- /dev/null
+++ b/src/main/ts2/src/app/login/login.component.html
@@ -0,0 +1,49 @@
+
diff --git a/src/main/ts2/src/app/login/login.component.ts b/src/main/ts2/src/app/login/login.component.ts
new file mode 100755
index 0000000..2ef70ab
--- /dev/null
+++ b/src/main/ts2/src/app/login/login.component.ts
@@ -0,0 +1,63 @@
+import { Component } from '@angular/core';
+import { User } from '../core/entities';
+import { AuthService } from '../core/services/auth.service';
+import { LoginService } from './login.service';
+import { Router } from '@angular/router';
+
+@Component({
+ selector: 'app-login',
+ templateUrl: './login.component.html',
+ styles: [`
+ #form {
+ padding-bottom: 10px;
+ }
+
+ .submitFormArea {
+ line-height: 50px;
+ }
+
+ #errorMsg {
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.5s ease-out;
+ margin: 0;
+ }
+ `]
+})
+export class LoginComponent {
+ model: User = new User('', '', '', '', '', null, null, '');
+ loginError: string;
+
+ constructor(
+ private loginService: LoginService,
+ private authService: AuthService,
+ private router: Router
+ ) {}
+
+ onSubmit(): void {
+ this.loginError = undefined;
+
+ this.loginService.login(this.model).subscribe(user => {
+ // this.authService.setToken(user.token);
+ this.authService.setAuthenticated(true);
+ this.authService.setUser(user);
+ this.router.navigate(['/myPosts']);
+ }, error => {
+ this.setMessage('Email ou password incorrect.');
+ });
+ }
+
+ setMessage(message: string): void {
+ this.loginError = message;
+
+ const resultMsgDiv = document.getElementById('errorMsg');
+ resultMsgDiv.style.maxHeight = '64px';
+
+ setTimeout(() => {
+ resultMsgDiv.style.maxHeight = '0px';
+ setTimeout(() => {
+ this.loginError = undefined;
+ }, 550);
+ }, 3000);
+ }
+}
diff --git a/src/main/ts2/src/app/login/login.service.ts b/src/main/ts2/src/app/login/login.service.ts
new file mode 100755
index 0000000..0437af0
--- /dev/null
+++ b/src/main/ts2/src/app/login/login.service.ts
@@ -0,0 +1,20 @@
+import { Injectable } from '@angular/core';
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { RequestOptions } from '@angular/http';
+import { Observable } from 'rxjs';
+
+import { User } from '../core/entities';
+import { environment } from 'src/environments/environment';
+
+const LOGIN_URL = environment.apiUrl + '/api/account/login';
+
+@Injectable()
+export class LoginService {
+
+ constructor(private http: HttpClient) {}
+
+ login(user: User): Observable {
+ const options = { withCredentials: true };
+ return this.http.post('/api/account/login', user, options);
+ }
+}
diff --git a/src/main/ts2/src/app/not-found/not-found.component.ts b/src/main/ts2/src/app/not-found/not-found.component.ts
new file mode 100755
index 0000000..1053f68
--- /dev/null
+++ b/src/main/ts2/src/app/not-found/not-found.component.ts
@@ -0,0 +1,12 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'app-not-found',
+ template: `
+
+

+
Page non trouvée...
+
+ `
+})
+export class NotFoundComponent {}
diff --git a/src/main/ts2/src/app/posts/byCategory/by-category.component.html b/src/main/ts2/src/app/posts/byCategory/by-category.component.html
new file mode 100755
index 0000000..1341b6d
--- /dev/null
+++ b/src/main/ts2/src/app/posts/byCategory/by-category.component.html
@@ -0,0 +1,7 @@
+
+
+
Catégorie {{category.name}}
+
+
diff --git a/src/main/ts2/src/app/posts/byCategory/by-category.component.ts b/src/main/ts2/src/app/posts/byCategory/by-category.component.ts
new file mode 100755
index 0000000..9eee26d
--- /dev/null
+++ b/src/main/ts2/src/app/posts/byCategory/by-category.component.ts
@@ -0,0 +1,31 @@
+import { Component, OnInit } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+
+import { Post, Category } from '../../core/entities';
+import { ByCategoryService } from './by-category.service';
+
+@Component({
+ selector: 'app-posts-by-category',
+ templateUrl: './by-category.component.html'
+})
+export class ByCategoryComponent implements OnInit {
+ category: Category;
+ listPosts: Array;
+
+ constructor(
+ private route: ActivatedRoute,
+ private byCategoryService: ByCategoryService
+ ) {}
+
+ ngOnInit(): void {
+ // Get the category
+ this.byCategoryService.getCategoryById(+this.route.snapshot.paramMap.get('categoryId')).subscribe(category => {
+ this.category = category;
+
+ // Get the posts by category
+ this.byCategoryService.getPostsByCategory(this.category).subscribe(listPosts => {
+ this.listPosts = listPosts;
+ });
+ });
+ }
+}
diff --git a/src/main/ts2/src/app/posts/byCategory/by-category.service.ts b/src/main/ts2/src/app/posts/byCategory/by-category.service.ts
new file mode 100755
index 0000000..10a4c81
--- /dev/null
+++ b/src/main/ts2/src/app/posts/byCategory/by-category.service.ts
@@ -0,0 +1,23 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { HttpClient } from '@angular/common/http';
+
+import { environment } from 'src/environments/environment';
+import { Post, Category } from '../../core/entities';
+
+const CATEGORIES_URL = environment.apiUrl + '/api/categories';
+const POSTS_URL = environment.apiUrl + '/api/posts';
+
+@Injectable()
+export class ByCategoryService {
+
+ constructor(private http: HttpClient) {}
+
+ getCategoryById(categoryId: number): Observable {
+ return this.http.get(`${CATEGORIES_URL}/${categoryId}`);
+ }
+
+ getPostsByCategory(category: Category): Observable> {
+ return this.http.get>(`${POSTS_URL}/byCategory/${category.id}`);
+ }
+}
diff --git a/src/main/ts2/src/app/posts/create-update/create-update-post.component.html b/src/main/ts2/src/app/posts/create-update/create-update-post.component.html
new file mode 100755
index 0000000..4349526
--- /dev/null
+++ b/src/main/ts2/src/app/posts/create-update/create-update-post.component.html
@@ -0,0 +1,240 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Post image]()
+
+
{{parsedPost.title}}
+
{{parsedPost.description}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Le langage et l'extrait de code doivent être renseignés.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
diff --git a/src/main/ts2/src/app/posts/create-update/create-update-post.component.scss b/src/main/ts2/src/app/posts/create-update/create-update-post.component.scss
new file mode 100755
index 0000000..94a5e4e
--- /dev/null
+++ b/src/main/ts2/src/app/posts/create-update/create-update-post.component.scss
@@ -0,0 +1,237 @@
+.card {
+ margin-bottom: 50px;
+}
+
+.card-header {
+ padding-top: 24px;
+ padding-left: 24px;
+ padding-right: 24px;
+ padding-bottom: 0px;
+ border-width: 0px;
+}
+.tabs {
+ width: 50%;
+ text-align: center;
+ line-height: 62px;
+}
+.tabs.active {
+ border-bottom: 4px solid white;
+}
+
+textarea {
+ height: 250px;
+ overflow-y: scroll;
+}
+
+.custom-select {
+ border: 0px;
+ border-bottom: 1px #aaa solid;
+ border-radius: 0;
+ padding-left: 0;
+ margin-bottom: 20px;
+}
+
+.custom-select:focus {
+ -webkit-box-shadow: 0;
+ box-shadow: 0;
+}
+
+.md-form {
+ margin-bottom: 35px;
+}
+
+#footer {
+ line-height: 57px;
+ padding-left: 15px;
+}
+
+#toolbox {
+ margin-left: 5px;
+ margin-bottom: 15px;
+}
+
+.btn-floating {
+ border-radius: 50%;
+ padding: 0;
+ margin: 2px;
+ width: 40px;
+ height: 40px;
+ background-color: #3f51b5;
+ text-align: center;
+ box-shadow: 0 5px 11px 0 rgba(0,0,0,.18), 0 4px 15px 0 rgba(0,0,0,.15);
+ transition: box-shadow 0.3s ease-in-out;
+}
+
+.btn-floating:hover {
+ box-shadow: 0 8px 17px 0 rgba(0,0,0,.2), 0 6px 20px 0 rgba(0,0,0,.19);
+}
+
+#text {
+ padding: 0;
+}
+
+#resultMsg, #errorMsg {
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.5s ease-out;
+ margin: 0;
+}
+
+
+.wrap {
+ top: 40%;
+ width: 100%;
+ margin: 0 auto;
+}
+
+/* select starting stylings ------------------------------*/
+.select {
+ position: relative;
+ width: 100%;
+}
+
+.select-text {
+ position: relative;
+ font-family: inherit;
+ background-color: transparent;
+ width: 100%;
+ padding: 10px 10px 10px 0;
+ font-size: 18px;
+ border-radius: 0;
+ border: none;
+ border-bottom: 1px solid #bdbdbd;
+}
+
+/* Remove focus */
+.select-text:focus {
+ outline: none;
+ border-bottom: 1px solid rgba(0,0,0, 0);
+}
+
+/* Use custom arrow */
+.select .select-text {
+ appearance: none;
+ -webkit-appearance:none
+}
+
+.select:after {
+ position: absolute;
+ top: 18px;
+ right: 10px;
+ /* Styling the down arrow */
+ width: 0;
+ height: 0;
+ padding: 0;
+ content: '';
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-top: 6px solid #bdbdbd;
+ pointer-events: none;
+}
+
+
+/* LABEL ======================================= */
+.select-label {
+ color: #757575;
+ font-size: 1rem;
+ font-weight: normal;
+ position: absolute;
+ pointer-events: none;
+ left: 0;
+ top: -5px;
+ transition: 0.2s ease all;
+}
+
+/* active state */
+.select-text:focus ~ .select-label {
+ color: #2F80ED;
+}
+.select-text:focus ~ .select-label, .select-text:valid ~ .select-label {
+ top: -20px;
+ transition: 0.2s ease all;
+ font-size: 14px;
+}
+
+/* BOTTOM BARS ================================= */
+.select-bar {
+ position: relative;
+ display: block;
+ width: 100%;
+}
+
+.select-bar:before, .select-bar:after {
+ content: '';
+ height: 2px;
+ width: 0;
+ bottom: 1px;
+ position: absolute;
+ background: #2F80ED;
+ transition: 0.2s ease all;
+}
+
+.select-bar:before {
+ left: 50%;
+}
+
+.select-bar:after {
+ right: 50%;
+}
+
+/* active state */
+.select-text:focus ~ .select-bar:before, .select-text:focus ~ .select-bar:after {
+ width: 50%;
+}
+
+/* HIGHLIGHTER ================================== */
+.select-highlight {
+ position: absolute;
+ height: 60%;
+ width: 40%;
+ top: 25%;
+ left: 0;
+ pointer-events: none;
+ opacity: 0.5;
+}
+
+
+
+
+$btnSize: 45px;
+
+.fixed-action-btn {
+ display: block;
+ position: absolute;
+ bottom: 10px;
+ right: 40px;
+ z-index: 997;
+ width: $btnSize;
+ height: $btnSize;
+ border-radius: 50%;
+ line-height: $btnSize;
+ text-align: center;
+ font-size: 25px;
+ box-shadow: 0 5px 11px 0 rgba(0,0,0,.18), 0 4px 15px 0 rgba(0,0,0,.15);
+ transition: box-shadow 0.3s ease-in-out;
+}
+.fixed-action-btn:hover {
+ box-shadow: 0 8px 17px 0 rgba(0,0,0,.2), 0 6px 20px 0 rgba(0,0,0,.19);
+}
+
+
+#image-div {
+ height: 60vh;
+ overflow-y: scroll;
+}
+
+.uploaded-image {
+ display: inline-flex;
+ position: relative;
+ width: 300px;
+ height: 300px;
+ background-color: #fff;
+ border-radius: 3px;
+ box-shadow: 0 2px 2px 0 rgba(0,0,0,0.14),0 3px 1px -2px rgba(0,0,0,0.12),0 1px 5px 0 rgba(0,0,0,0.2);
+ cursor:pointer;
+ margin-right: 15px;
+ margin-bottom: 15px;
+}
\ No newline at end of file
diff --git a/src/main/ts2/src/app/posts/create-update/create-update-post.component.ts b/src/main/ts2/src/app/posts/create-update/create-update-post.component.ts
new file mode 100755
index 0000000..2f31aa1
--- /dev/null
+++ b/src/main/ts2/src/app/posts/create-update/create-update-post.component.ts
@@ -0,0 +1,232 @@
+import { Component, OnInit, SecurityContext, ViewChild } from '@angular/core';
+import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
+import { Router, ActivatedRoute, RoutesRecognized } from '@angular/router';
+
+import { Post, Category, Image } from '../../core/entities';
+import { AuthService } from '../../core/services/auth.service';
+import { CreateUpdatePostService } from './create-update-post.service';
+
+import { filter } from 'rxjs/operators';
+import { environment } from 'src/environments/environment';
+import { HttpEventType, HttpResponse } from '@angular/common/http';
+
+enum Tabs {
+ EDITION = 'Édition',
+ PREVIEW = 'Aperçu'
+}
+
+declare let Prism: any;
+
+@Component({
+ selector: 'app-create-update-post',
+ templateUrl: './create-update-post.component.html',
+ styleUrls: ['./create-update-post.component.scss']
+})
+export class CreateUpdatePostComponent implements OnInit {
+ static INPUT_POST_TEXT = 'text';
+
+ @ViewChild('frameCode') public contentModal;
+ @ViewChild('frameImages') public imagesModal;
+
+ model: Post = new Post('', '', '', '', '', null, null, null);
+ parsedPost: Post;
+
+ listCategories: Array;
+
+ activatedTab: string;
+
+ modelError: string;
+ result: string;
+
+ // Variables for the code popup
+ codeTmp: string;
+ languageTmp: string;
+ codeError: string;
+
+ // Variables for the images popup
+ imagesLoaded: boolean;
+ listImages: Array;
+ selectedFiles: FileList;
+ currentFileUpload: File;
+ progress: { percentage: number } = { percentage: 0 };
+
+ constructor(
+ private createUpdatePostService: CreateUpdatePostService,
+ private activatedRoute: ActivatedRoute,
+ private sanitizer: DomSanitizer,
+ private router: Router,
+ private authService: AuthService
+ ) {
+ this.imagesLoaded = false;
+ }
+
+ ngOnInit(): void {
+ this.listCategories = [];
+ this.activatedTab = Tabs.EDITION;
+ this.createUpdatePostService.getCategories().subscribe(listCategories => {
+ this.listCategories = listCategories.filter(category => !category.listSubCategories.length);
+ });
+
+ const postKey = this.activatedRoute.snapshot.paramMap.get('postKey');
+ if (postKey) {
+ this.createUpdatePostService.getPost(postKey).subscribe(post => {
+ if (post.author.key === this.authService.getUser().key) {
+ this.model = post;
+ } else {
+ this.router.navigate(['/forbidden']);
+ }
+ });
+
+ // TODO
+ // this.router.events.filter(e => e instanceof RoutesRecognized).pairwise().subscribe((event: any[]) => {
+ // if (event[0].urlAfterRedirects === '/posts/new') {
+ // this.result = 'Article créé.';
+ // setTimeout(() => {
+ // this.result = undefined;
+ // }, 3500);
+ // }
+ // });
+ }
+ }
+
+ activateEdition(): void {
+ this.activatedTab = Tabs.EDITION;
+ }
+
+ activatePreview(): void {
+ this.activatedTab = Tabs.PREVIEW;
+ this.parsedPost = undefined;
+ this.createUpdatePostService.processPreview(this.model).subscribe(parsedPost => {
+ this.parsedPost = parsedPost;
+ setTimeout(() => {
+ Prism.highlightAll();
+ }, 100);
+ });
+ }
+
+ getContent(): string {
+ return this.sanitizer.sanitize(SecurityContext.HTML, this.parsedPost.text);
+ }
+
+ injectHeader(header: string): void {
+ this.injectElement('[' + header + '][/' + header + ']', 4);
+ }
+
+ injectLink(): void {
+ this.injectElement('[link href="" txt="" /]', 11);
+ }
+
+ injectCode(): void {
+ if (this.languageTmp && this.codeTmp) {
+ const codeExtract = '\n[code lg="' + this.languageTmp + '"]\n'
+ + this.codeTmp + '\n[/code]\n\n';
+
+ this.injectElement(codeExtract, codeExtract.length);
+
+ this.contentModal.hide();
+
+ this.codeTmp = undefined;
+ this.languageTmp = undefined;
+ this.resetSelect('languageTmp');
+ } else {
+ this.codeError = 'Le langage et l\'extrait de code doivent être renseignés.';
+ setTimeout(() => {
+ this.codeError = undefined;
+ }, 3500);
+ }
+ }
+
+ private injectElement(elementToInject: string, lengthForCursor: number): void {
+ const input = document.getElementById(CreateUpdatePostComponent.INPUT_POST_TEXT);
+ const contentValue = input.value;
+ const cursorPosition = input.selectionStart;
+
+ this.model.text = contentValue.slice(0, cursorPosition) + elementToInject + contentValue.slice(cursorPosition);
+ input.focus();
+ const newCursor = cursorPosition + lengthForCursor;
+ input.selectionStart = newCursor;
+ input.selectionEnd = newCursor;
+ }
+
+ private resetSelect(selectId: string): void {
+ const select = document.getElementById(selectId);
+ select.selectedIndex = 0;
+ }
+
+ save(): void {
+ if (this.model.title && this.model.image && this.model.description && this.model.category && this.model.text) {
+ this.model.author = this.authService.getUser();
+
+ if (this.model.key) {
+ this.createUpdatePostService.updatePost(this.model).subscribe(post => {
+ this.setMessage('Modification enregistrée', false);
+ });
+ } else {
+ this.createUpdatePostService.addPost(this.model).subscribe(post => {
+ this.router.navigate([`/posts/update/${post.key}`]);
+ });
+ }
+ } else {
+ this.setMessage('Veuillez saisir les champs obligatoires.', true);
+ }
+ }
+
+ setMessage(message: string, error: boolean): void {
+ this[error ? 'modelError' : 'result'] = message;
+
+ const resultMsgDiv = document.getElementById(error ? 'errorMsg' : 'resultMsg');
+ resultMsgDiv.style.maxHeight = '64px';
+
+ setTimeout(() => {
+ resultMsgDiv.style.maxHeight = '0px';
+ setTimeout(() => {
+ this[error ? 'modelError' : 'result'] = undefined;
+ }, 550);
+ }, 3000);
+ }
+
+ openImagesModal(): void {
+ this.imagesLoaded = false;
+ this.imagesModal.show();
+
+ this.createUpdatePostService.getImages().subscribe(listImages => {
+ this.listImages = listImages;
+ this.imagesLoaded = true;
+ });
+ }
+
+ getLinkSrc(pLink: string): string {
+ return `${environment.apiUrl}/api/images/${pLink}`;
+ }
+
+ openNewImageInput(): void {
+ document.getElementById('newImageInput').click();
+ }
+
+ uploadImage(event): void {
+ this.selectedFiles = event.target.files;
+ this.progress.percentage = 0;
+
+ this.currentFileUpload = this.selectedFiles.item(0);
+ // This prevents error 400 if user doesn't select any file to upload and close the input file.
+ if (this.currentFileUpload) {
+ this.createUpdatePostService.uploadPicture(this.currentFileUpload).subscribe(result => {
+ if (result.type === HttpEventType.UploadProgress) {
+ this.progress.percentage = Math.round(100 * result.loaded / result.total);
+ } else if (result instanceof HttpResponse) {
+ console.log('File ' + result.body + ' completely uploaded!');
+ this.createUpdatePostService.getImageDetails(result.body as string).subscribe(image => {
+ this.listImages.push(image);
+ });
+ }
+ this.selectedFiles = undefined;
+ });
+ }
+ }
+
+ injectImage(pImageLink: string): void {
+ const imgTag = `[img src="${this.getLinkSrc(pImageLink)}" /]`;
+ this.injectElement(imgTag, imgTag.length);
+ this.imagesModal.hide();
+ }
+}
diff --git a/src/main/ts2/src/app/posts/create-update/create-update-post.service.ts b/src/main/ts2/src/app/posts/create-update/create-update-post.service.ts
new file mode 100755
index 0000000..cd3a9c7
--- /dev/null
+++ b/src/main/ts2/src/app/posts/create-update/create-update-post.service.ts
@@ -0,0 +1,57 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { HttpClient, HttpEvent, HttpRequest } from '@angular/common/http';
+
+import { Post, Category, Image } from '../../core/entities';
+import { environment } from 'src/environments/environment';
+
+const IMAGES_URL = environment.apiUrl + '/api/images';
+const POSTS_URL = environment.apiUrl + '/api/posts';
+const CATEGORIES_URL = environment.apiUrl + '/api/categories';
+
+@Injectable()
+export class CreateUpdatePostService {
+
+ constructor(private http: HttpClient) {}
+
+ processPreview(post: Post): Observable {
+ return this.http.post(`${POSTS_URL}/preview`, post);
+ }
+
+ getCategories(): Observable> {
+ return this.http.get>(`${CATEGORIES_URL}/`);
+ }
+
+ addPost(post: Post): Observable {
+ return this.http.post(`${POSTS_URL}/`, post);
+ }
+
+ updatePost(post: Post): Observable {
+ return this.http.put(`${POSTS_URL}/`, post);
+ }
+
+ getPost(postKey: string): Observable {
+ return this.http.get(`${POSTS_URL}/${postKey}/source`);
+ }
+
+ getImages(): Observable> {
+ return this.http.get>(`${IMAGES_URL}/myImages`);
+ }
+
+ uploadPicture(file: File): Observable> {
+ const formData: FormData = new FormData();
+
+ formData.append('file', file);
+
+ return this.http.request(new HttpRequest(
+ 'POST', IMAGES_URL, formData, {
+ reportProgress: true,
+ responseType: 'text'
+ }
+ ));
+ }
+
+ getImageDetails(imageLink: string): Observable {
+ return this.http.get(`${IMAGES_URL}/${imageLink}/details`);
+ }
+}
diff --git a/src/main/ts2/src/app/posts/myPosts/my-posts.component.html b/src/main/ts2/src/app/posts/myPosts/my-posts.component.html
new file mode 100755
index 0000000..f39aa46
--- /dev/null
+++ b/src/main/ts2/src/app/posts/myPosts/my-posts.component.html
@@ -0,0 +1,8 @@
+
diff --git a/src/main/ts2/src/app/posts/myPosts/my-posts.component.scss b/src/main/ts2/src/app/posts/myPosts/my-posts.component.scss
new file mode 100755
index 0000000..07b236e
--- /dev/null
+++ b/src/main/ts2/src/app/posts/myPosts/my-posts.component.scss
@@ -0,0 +1,20 @@
+$btnSize: 55px;
+
+.fixed-action-btn {
+ display: block;
+ position: fixed;
+ bottom: 23px;
+ right: 23px;
+ z-index: 997;
+ width: $btnSize;
+ height: $btnSize;
+ border-radius: 50%;
+ line-height: $btnSize;
+ text-align: center;
+ font-size: 30px;
+ box-shadow: 0 5px 11px 0 rgba(0,0,0,.18), 0 4px 15px 0 rgba(0,0,0,.15);
+ transition: box-shadow 0.3s ease-in-out;
+}
+.fixed-action-btn:hover {
+ box-shadow: 0 8px 17px 0 rgba(0,0,0,.2), 0 6px 20px 0 rgba(0,0,0,.19);
+}
diff --git a/src/main/ts2/src/app/posts/myPosts/my-posts.component.ts b/src/main/ts2/src/app/posts/myPosts/my-posts.component.ts
new file mode 100755
index 0000000..654869f
--- /dev/null
+++ b/src/main/ts2/src/app/posts/myPosts/my-posts.component.ts
@@ -0,0 +1,23 @@
+import { Component, OnInit } from '@angular/core';
+
+import { Post } from '../../core/entities';
+import { MyPostsService } from './my-posts.service';
+
+@Component({
+ selector: 'app-my-posts',
+ templateUrl: './my-posts.component.html',
+ styleUrls: ['./my-posts.component.scss']
+})
+export class MyPostsComponent implements OnInit {
+ listPosts: Array;
+
+ constructor(
+ private myPostsService: MyPostsService
+ ) {}
+
+ ngOnInit(): void {
+ this.myPostsService.getMyPosts().subscribe(listPosts => {
+ this.listPosts = listPosts;
+ });
+ }
+}
diff --git a/src/main/ts2/src/app/posts/myPosts/my-posts.service.ts b/src/main/ts2/src/app/posts/myPosts/my-posts.service.ts
new file mode 100755
index 0000000..b08e25b
--- /dev/null
+++ b/src/main/ts2/src/app/posts/myPosts/my-posts.service.ts
@@ -0,0 +1,18 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { HttpClient } from '@angular/common/http';
+
+import { environment } from 'src/environments/environment';
+import { Post } from '../../core/entities';
+
+const POSTS_URL = environment.apiUrl + '/api/posts';
+
+@Injectable()
+export class MyPostsService {
+
+ constructor(private http: HttpClient) {}
+
+ getMyPosts(): Observable> {
+ return this.http.get>(`${POSTS_URL}/myPosts`);
+ }
+}
diff --git a/src/main/ts2/src/app/posts/post.component.html b/src/main/ts2/src/app/posts/post.component.html
new file mode 100755
index 0000000..1fc8b39
--- /dev/null
+++ b/src/main/ts2/src/app/posts/post.component.html
@@ -0,0 +1,67 @@
+
+
+
+
![Post image]()
+
+
+
+
+
+
+
{{post.title}}
+
{{post.description}}
+
+
+
+
+
+
![]()
+ Article écrit par {{post.author.name}}
+
({{post.creationDate | date:'yyyy-MM-dd HH:mm:ss'}})
+
+
+
+
+
+
+
+
+
+
+
Êtes vous sûr de vouloir supprimer cet article ?
+
+ Une erreur est survenue lors de la suppression de l'article.
+
+
+
+
+
+
+
+
+
diff --git a/src/main/ts2/src/app/posts/post.component.ts b/src/main/ts2/src/app/posts/post.component.ts
new file mode 100755
index 0000000..c1b10e5
--- /dev/null
+++ b/src/main/ts2/src/app/posts/post.component.ts
@@ -0,0 +1,107 @@
+import { Component, OnInit, SecurityContext, ViewChild } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { HttpErrorResponse } from '@angular/common/http';
+import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
+
+import { Post, User } from '../core/entities';
+import { PostService } from './post.service';
+import { AuthService } from '../core/services/auth.service';
+import { environment } from 'src/environments/environment';
+
+declare let Prism: any;
+
+@Component({
+ selector: 'app-post',
+ templateUrl: './post.component.html',
+ styles: [`
+ .author-img {
+ width: 60px;
+ height: 60px;
+ border-radius: 50%;
+ margin-right: 15px;
+ }
+ .card .card-data {
+ padding: 15px;
+ background-color: #f5f5f5;
+ }
+ #content {
+ margin-bottom: 50px;
+ }
+ .creation-date-area {
+ color: #bdbdbd;
+ font-style: italic;
+ }
+ `]
+})
+export class PostComponent implements OnInit {
+ post: Post = new Post('', '', '', '', '', null, new User('', '', '', '', '', null, null, ''), null);
+ loaded: boolean;
+ notFound: boolean;
+ owned: boolean;
+
+ @ViewChild('alertDelete') alertDelete;
+
+ postDeletionFailed: boolean;
+
+ constructor(
+ private activatedRoute: ActivatedRoute,
+ private router: Router,
+ private postService: PostService,
+ private sanitizer: DomSanitizer,
+ private authService: AuthService
+ ) {
+ this.loaded = false;
+ this.owned = false;
+ this.postDeletionFailed = false;
+ }
+
+ ngOnInit(): void {
+ this.postService.getPost(this.activatedRoute.snapshot.paramMap.get('postKey')).subscribe(post => {
+ this.post = post;
+ this.loaded = true;
+ this.owned = this.isOwned();
+ setTimeout(() => {
+ Prism.highlightAll();
+ }, 100);
+ }, error => {
+ if (error instanceof HttpErrorResponse && error.status === 404) {
+ this.notFound = true;
+ }
+ });
+ }
+
+ private isOwned(): boolean {
+ let result = false;
+
+ const connectedUser = this.authService.getUser();
+ if (connectedUser) {
+ result = this.post.author.key === connectedUser.key;
+ }
+
+ return result;
+ }
+
+ getContent(): SafeHtml {
+ return this.sanitizer.sanitize(SecurityContext.HTML, this.post.text);
+ }
+
+ getAvatarUrl(): string {
+ return this.post.author.image
+ ? `${environment.apiUrl}/api/images/loadAvatar/${this.post.author.image}`
+ : './assets/images/default_user.png';
+ }
+
+ deletePost(): void {
+ this.postDeletionFailed = false;
+
+ this.postService.deletePost(this.post).subscribe(() => {
+ this.alertDelete.hide();
+ this.router.navigate(['/myPosts']);
+ }, error => {
+ this.postDeletionFailed = true;
+ setTimeout(() => {
+ this.postDeletionFailed = false;
+ }, 3500);
+ });
+ }
+}
diff --git a/src/main/ts2/src/app/posts/post.service.ts b/src/main/ts2/src/app/posts/post.service.ts
new file mode 100755
index 0000000..216e7dc
--- /dev/null
+++ b/src/main/ts2/src/app/posts/post.service.ts
@@ -0,0 +1,22 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { HttpClient } from '@angular/common/http';
+
+import { environment } from 'src/environments/environment';
+import { Post } from '../core/entities';
+
+const POSTS_URL = environment.apiUrl + '/api/posts';
+
+@Injectable()
+export class PostService {
+
+ constructor(private http: HttpClient) {}
+
+ getPost(postKey: string): Observable {
+ return this.http.get(`${POSTS_URL}/${postKey}`);
+ }
+
+ deletePost(postToDelete: Post): Observable {
+ return this.http.delete(`${POSTS_URL}/${postToDelete.key}`);
+ }
+}
diff --git a/src/main/ts2/src/app/search/search.component.ts b/src/main/ts2/src/app/search/search.component.ts
new file mode 100755
index 0000000..e869198
--- /dev/null
+++ b/src/main/ts2/src/app/search/search.component.ts
@@ -0,0 +1,36 @@
+import { Component, OnInit } from '@angular/core';
+import { Post } from '../core/entities';
+import { SearchService } from './search.service';
+import { ActivatedRoute } from '@angular/router';
+
+@Component({
+ selector: 'app-search',
+ template: `
+
+
Résultats de la recherche
+
+
+
+ `
+})
+export class SearchComponent implements OnInit {
+ searchLoading: boolean;
+ listArticle: Array;
+
+ constructor(
+ private activatedRoute: ActivatedRoute,
+ private searchService: SearchService
+ ) {
+ this.searchLoading = true;
+ }
+
+ ngOnInit(): void {
+ this.searchService.search(this.activatedRoute.snapshot.paramMap.get('searchCriteria'))
+ .subscribe(listArticle => {
+ this.listArticle = listArticle;
+ this.searchLoading = false;
+ });
+ }
+}
diff --git a/src/main/ts2/src/app/search/search.service.ts b/src/main/ts2/src/app/search/search.service.ts
new file mode 100755
index 0000000..71cbbaf
--- /dev/null
+++ b/src/main/ts2/src/app/search/search.service.ts
@@ -0,0 +1,18 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Observable } from 'rxjs';
+
+import { environment } from 'src/environments/environment';
+import { Post } from '../core/entities';
+
+const POSTS_URL = environment.apiUrl + '/api/posts';
+
+@Injectable()
+export class SearchService {
+
+ constructor(private http: HttpClient) {}
+
+ search(searchCriteria: string): Observable> {
+ return this.http.get>(`${POSTS_URL}/search/${searchCriteria}`);
+ }
+}
diff --git a/src/main/ts2/src/app/signin/signin.component.html b/src/main/ts2/src/app/signin/signin.component.html
new file mode 100755
index 0000000..c02d484
--- /dev/null
+++ b/src/main/ts2/src/app/signin/signin.component.html
@@ -0,0 +1,76 @@
+
diff --git a/src/main/ts2/src/app/signin/signin.component.ts b/src/main/ts2/src/app/signin/signin.component.ts
new file mode 100755
index 0000000..c4b8590
--- /dev/null
+++ b/src/main/ts2/src/app/signin/signin.component.ts
@@ -0,0 +1,68 @@
+import { Component } from '@angular/core';
+import { User } from '../core/entities';
+import { SigninService } from './signin.service';
+import { Router } from '@angular/router';
+
+@Component({
+ selector: 'app-signin',
+ templateUrl: './signin.component.html',
+ styles: [`
+ #form {
+ padding-bottom: 10px;
+ }
+
+ .submitFormArea {
+ line-height: 50px;
+ }
+
+ #errorMsg {
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.5s ease-out;
+ margin: 0;
+ }
+ `]
+})
+export class SigninComponent {
+ model: User = new User('', '', '', '', '', null, null, '');
+ confirmPassword: string;
+ errorMsg: string;
+
+ constructor(
+ private signinService: SigninService,
+ private router: Router
+ ) {}
+
+ onSubmit(): void {
+ if (this.confirmPassword && this.confirmPassword === this.model.password) {
+ this.signinService.signin(this.model).subscribe(user => {
+ this.router.navigate(['/login']);
+ }, error => {
+ switch (error.status) {
+ case 409:
+ this.setMessage('L\'adresse mail saisie n\'est pas disponible');
+ break;
+ default:
+ this.setMessage('Une erreur est survenue lors de l\'inscription, veuillez réessayer plus tard');
+ break;
+ }
+ });
+ } else {
+ this.setMessage('Les mots de passe saisis ne correspondent pas');
+ }
+ }
+
+ setMessage(message: string): void {
+ this.errorMsg = message;
+
+ const resultMsgDiv = document.getElementById('errorMsg');
+ resultMsgDiv.style.maxHeight = '64px';
+
+ setTimeout(() => {
+ resultMsgDiv.style.maxHeight = '0px';
+ setTimeout(() => {
+ this.errorMsg = undefined;
+ }, 550);
+ }, 3000);
+ }
+}
diff --git a/src/main/ts2/src/app/signin/signin.service.ts b/src/main/ts2/src/app/signin/signin.service.ts
new file mode 100755
index 0000000..9ae850b
--- /dev/null
+++ b/src/main/ts2/src/app/signin/signin.service.ts
@@ -0,0 +1,18 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Observable } from 'rxjs';
+
+import { User } from '../core/entities';
+import { environment } from 'src/environments/environment';
+
+const SIGNIN_URL = environment.apiUrl + '/api/account/signin';
+
+@Injectable()
+export class SigninService {
+
+ constructor(private http: HttpClient) {}
+
+ signin(user: User): Observable {
+ return this.http.post(SIGNIN_URL, user);
+ }
+}
diff --git a/src/main/ts2/src/app/version-revisions/version-revisions.component.html b/src/main/ts2/src/app/version-revisions/version-revisions.component.html
new file mode 100755
index 0000000..dfd464f
--- /dev/null
+++ b/src/main/ts2/src/app/version-revisions/version-revisions.component.html
@@ -0,0 +1,34 @@
+
+
+
+
+
Ajouts de fonctionnalités
+
+ -
+ {{versionRevision.text}}
+
+
+
+
Aucune nouvelle fonctionnalité pour cette version.
+
+
+
Correction d'anomalies
+
+ -
+ {{versionRevision.text}}
+
+
+
+
Aucune correction d'anomalie pour cette version.
+
+
diff --git a/src/main/ts2/src/app/version-revisions/version-revisions.component.ts b/src/main/ts2/src/app/version-revisions/version-revisions.component.ts
new file mode 100755
index 0000000..682618e
--- /dev/null
+++ b/src/main/ts2/src/app/version-revisions/version-revisions.component.ts
@@ -0,0 +1,51 @@
+import { Component, OnInit } from '@angular/core';
+import { VersionRevisionService } from './version-revisions.service';
+import { VersionRevision, Version } from '../core/entities';
+
+
+
+@Component({
+ selector: 'app-version-revisions',
+ templateUrl: 'version-revisions.component.html',
+ styles: [`
+ #versionRevisionsArea {
+ padding-top: 70px;
+ }
+
+ @media screen and (max-width: 767px) {
+ #versionRevisionsArea {
+ padding-top: 20px;
+ }
+ }
+ `]
+})
+export class VersionRevisionComponent implements OnInit {
+ versionsList: Array;
+ versionRevisionsList: Array;
+ versionRevisionsBugfixList: Array;
+
+ constructor(
+ private versionRevisionService: VersionRevisionService
+ ) {
+ this.versionsList = [];
+ this.versionRevisionsList = [];
+ this.versionRevisionsBugfixList = [];
+ }
+
+ ngOnInit(): void {
+ this.versionRevisionService.getVersions().subscribe(versionsList => {
+ this.versionsList = versionsList;
+ this.showVersionRevision(this.versionsList[0]);
+ });
+ }
+
+ showVersionRevision(version: Version): void {
+ this.versionsList.forEach(versionTmp => versionTmp.active = false);
+ version.active = true;
+
+ this.versionRevisionService.findByVersionNumber(version.number).subscribe(versionRevisionsList => {
+ this.versionRevisionsList = versionRevisionsList.filter(vr => !vr.bugfix);
+ this.versionRevisionsBugfixList = versionRevisionsList.filter(vr => vr.bugfix);
+ });
+ }
+}
diff --git a/src/main/ts2/src/app/version-revisions/version-revisions.service.ts b/src/main/ts2/src/app/version-revisions/version-revisions.service.ts
new file mode 100755
index 0000000..43c925f
--- /dev/null
+++ b/src/main/ts2/src/app/version-revisions/version-revisions.service.ts
@@ -0,0 +1,23 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Version, VersionRevision } from '../core/entities';
+import { Observable } from 'rxjs';
+
+import { environment } from 'src/environments/environment';
+
+const VERSION_REVISIONS_URL = environment.apiUrl + '/api/versionrevisions';
+
+@Injectable()
+export class VersionRevisionService {
+ constructor(
+ private http: HttpClient
+ ) {}
+
+ getVersions(): Observable> {
+ return this.http.get>(`${VERSION_REVISIONS_URL}/versions`);
+ }
+
+ findByVersionNumber(versionNumber: string): Observable> {
+ return this.http.get>(`${VERSION_REVISIONS_URL}/${versionNumber}`);
+ }
+}
diff --git a/src/main/ts2/src/assets/.gitkeep b/src/main/ts2/src/assets/.gitkeep
new file mode 100755
index 0000000..e69de29
diff --git a/src/main/ts2/src/assets/css/prism.css b/src/main/ts2/src/assets/css/prism.css
new file mode 100755
index 0000000..cf33138
--- /dev/null
+++ b/src/main/ts2/src/assets/css/prism.css
@@ -0,0 +1,271 @@
+/* http://prismjs.com/download.html?themes=prism-okaidia&languages=markup+css+clike+javascript+apacheconf+c+bash+batch+cpp+csharp+ruby+css-extras+django+docker+git+http+ini+java+json+latex+less+lua+makefile+markdown+nginx+php+php-extras+powershell+properties+python+jsx+rest+rust+sass+scss+scala+sql+typescript+vim+wiki+yaml&plugins=line-numbers+autolinker+file-highlight+toolbar+unescaped-markup+command-line+show-language+copy-to-clipboard */
+/**
+ * okaidia theme for JavaScript, CSS and HTML
+ * Loosely based on Monokai textmate theme by http://www.monokai.nl/
+ * @author ocodia
+ */
+
+code[class*="language-"],
+pre[class*="language-"] {
+ color: #f8f8f2;
+ background: none;
+ text-shadow: 0 1px rgba(0, 0, 0, 0.3);
+ font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
+ text-align: left;
+ white-space: pre;
+ word-spacing: normal;
+ word-break: normal;
+ word-wrap: normal;
+ line-height: 1.5;
+
+ -moz-tab-size: 4;
+ -o-tab-size: 4;
+ tab-size: 4;
+
+ -webkit-hyphens: none;
+ -moz-hyphens: none;
+ -ms-hyphens: none;
+ hyphens: none;
+}
+
+/* Code blocks */
+pre[class*="language-"] {
+ padding: 1em;
+ margin: .5em 0;
+ overflow: auto;
+ border-radius: 0.3em;
+}
+
+:not(pre) > code[class*="language-"],
+pre[class*="language-"] {
+ background: #272822;
+}
+
+/* Inline code */
+:not(pre) > code[class*="language-"] {
+ padding: .1em;
+ border-radius: .3em;
+ white-space: normal;
+}
+
+.token.comment,
+.token.prolog,
+.token.doctype,
+.token.cdata {
+ color: slategray;
+}
+
+.token.punctuation {
+ color: #f8f8f2;
+}
+
+.namespace {
+ opacity: .7;
+}
+
+.token.property,
+.token.tag,
+.token.constant,
+.token.symbol,
+.token.deleted {
+ color: #f92672;
+}
+
+.token.boolean,
+.token.number {
+ color: #ae81ff;
+}
+
+.token.selector,
+.token.attr-name,
+.token.string,
+.token.char,
+.token.builtin,
+.token.inserted {
+ color: #a6e22e;
+}
+
+.token.operator,
+.token.entity,
+.token.url,
+.language-css .token.string,
+.style .token.string,
+.token.variable {
+ color: #f8f8f2;
+}
+
+.token.atrule,
+.token.attr-value,
+.token.function {
+ color: #e6db74;
+}
+
+.token.keyword {
+ color: #66d9ef;
+}
+
+.token.regex,
+.token.important {
+ color: #fd971f;
+}
+
+.token.important,
+.token.bold {
+ font-weight: bold;
+}
+.token.italic {
+ font-style: italic;
+}
+
+.token.entity {
+ cursor: help;
+}
+
+pre.line-numbers {
+ position: relative;
+ padding-left: 3.8em;
+ counter-reset: linenumber;
+}
+
+pre.line-numbers > code {
+ position: relative;
+ white-space: inherit;
+}
+
+.line-numbers .line-numbers-rows {
+ position: absolute;
+ pointer-events: none;
+ top: 0;
+ font-size: 100%;
+ left: -3.8em;
+ width: 3em; /* works for line-numbers below 1000 lines */
+ letter-spacing: -1px;
+ border-right: 1px solid #999;
+
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+
+}
+
+ .line-numbers-rows > span {
+ pointer-events: none;
+ display: block;
+ counter-increment: linenumber;
+ }
+
+ .line-numbers-rows > span:before {
+ content: counter(linenumber);
+ color: #999;
+ display: block;
+ padding-right: 0.8em;
+ text-align: right;
+ }
+.token a {
+ color: inherit;
+}
+pre.code-toolbar {
+ position: relative;
+}
+
+pre.code-toolbar > .toolbar {
+ position: absolute;
+ top: .3em;
+ right: .2em;
+ transition: opacity 0.3s ease-in-out;
+ opacity: 0;
+}
+
+pre.code-toolbar:hover > .toolbar {
+ opacity: 1;
+}
+
+pre.code-toolbar > .toolbar .toolbar-item {
+ display: inline-block;
+}
+
+pre.code-toolbar > .toolbar a {
+ cursor: pointer;
+}
+
+pre.code-toolbar > .toolbar button {
+ background: none;
+ border: 0;
+ color: inherit;
+ font: inherit;
+ line-height: normal;
+ overflow: visible;
+ padding: 0;
+ -webkit-user-select: none; /* for button */
+ -moz-user-select: none;
+ -ms-user-select: none;
+}
+
+pre.code-toolbar > .toolbar a,
+pre.code-toolbar > .toolbar button,
+pre.code-toolbar > .toolbar span {
+ color: #bbb;
+ font-size: .8em;
+ padding: 0 .5em;
+ background: #f5f2f0;
+ background: rgba(224, 224, 224, 0.2);
+ box-shadow: 0 2px 0 0 rgba(0,0,0,0.2);
+ border-radius: .5em;
+}
+
+pre.code-toolbar > .toolbar a:hover,
+pre.code-toolbar > .toolbar a:focus,
+pre.code-toolbar > .toolbar button:hover,
+pre.code-toolbar > .toolbar button:focus,
+pre.code-toolbar > .toolbar span:hover,
+pre.code-toolbar > .toolbar span:focus {
+ color: inherit;
+ text-decoration: none;
+}
+
+/* Fallback, in case JS does not run, to ensure the code is at least visible */
+.lang-markup script[type='text/plain'],
+.language-markup script[type='text/plain'],
+script[type='text/plain'].lang-markup,
+script[type='text/plain'].language-markup {
+ display: block;
+ font: 100% Consolas, Monaco, monospace;
+ white-space: pre;
+ overflow: auto;
+}
+
+.command-line-prompt {
+ border-right: 1px solid #999;
+ display: block;
+ float: left;
+ font-size: 100%;
+ letter-spacing: -1px;
+ margin-right: 1em;
+ pointer-events: none;
+
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+.command-line-prompt > span:before {
+ color: #999;
+ content: ' ';
+ display: block;
+ padding-right: 0.8em;
+}
+
+.command-line-prompt > span[data-user]:before {
+ content: "[" attr(data-user) "@" attr(data-host) "] $";
+}
+
+.command-line-prompt > span[data-user="root"]:before {
+ content: "[" attr(data-user) "@" attr(data-host) "] #";
+}
+
+.command-line-prompt > span[data-prompt]:before {
+ content: attr(data-prompt);
+}
+
diff --git a/src/main/ts2/src/assets/images/403.png b/src/main/ts2/src/assets/images/403.png
new file mode 100755
index 0000000..d540020
Binary files /dev/null and b/src/main/ts2/src/assets/images/403.png differ
diff --git a/src/main/ts2/src/assets/images/404.png b/src/main/ts2/src/assets/images/404.png
new file mode 100755
index 0000000..0f4480e
Binary files /dev/null and b/src/main/ts2/src/assets/images/404.png differ
diff --git a/src/main/ts2/src/assets/images/500.png b/src/main/ts2/src/assets/images/500.png
new file mode 100755
index 0000000..3664a08
Binary files /dev/null and b/src/main/ts2/src/assets/images/500.png differ
diff --git a/src/main/ts2/src/assets/images/background.png b/src/main/ts2/src/assets/images/background.png
new file mode 100755
index 0000000..5536b56
Binary files /dev/null and b/src/main/ts2/src/assets/images/background.png differ
diff --git a/src/main/ts2/src/assets/images/code.jpg b/src/main/ts2/src/assets/images/code.jpg
new file mode 100755
index 0000000..0f3f6d3
Binary files /dev/null and b/src/main/ts2/src/assets/images/code.jpg differ
diff --git a/src/main/ts2/src/assets/images/codiki.png b/src/main/ts2/src/assets/images/codiki.png
new file mode 100755
index 0000000..ab932db
Binary files /dev/null and b/src/main/ts2/src/assets/images/codiki.png differ
diff --git a/src/main/ts2/src/assets/images/default_image.png b/src/main/ts2/src/assets/images/default_image.png
new file mode 100755
index 0000000..d61f76e
Binary files /dev/null and b/src/main/ts2/src/assets/images/default_image.png differ
diff --git a/src/main/ts2/src/assets/images/default_user.png b/src/main/ts2/src/assets/images/default_user.png
new file mode 100755
index 0000000..438d20e
Binary files /dev/null and b/src/main/ts2/src/assets/images/default_user.png differ
diff --git a/src/main/ts2/src/assets/js/prism.js b/src/main/ts2/src/assets/js/prism.js
new file mode 100755
index 0000000..816ff4f
--- /dev/null
+++ b/src/main/ts2/src/assets/js/prism.js
@@ -0,0 +1,51 @@
+/* http://prismjs.com/download.html?themes=prism-okaidia&languages=markup+css+clike+javascript+apacheconf+c+bash+batch+cpp+csharp+ruby+css-extras+django+docker+git+http+ini+java+json+latex+less+lua+makefile+markdown+nginx+php+php-extras+powershell+properties+python+jsx+rest+rust+sass+scss+scala+sql+typescript+vim+wiki+yaml&plugins=line-numbers+autolinker+file-highlight+toolbar+unescaped-markup+command-line+show-language+copy-to-clipboard */
+var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(){var e=/\blang(?:uage)?-(\w+)\b/i,t=0,n=_self.Prism={manual:_self.Prism&&_self.Prism.manual,util:{encode:function(e){return e instanceof a?new a(e.type,n.util.encode(e.content),e.alias):"Array"===n.util.type(e)?e.map(n.util.encode):e.replace(/&/g,"&").replace(/e.length)return;if(!(w instanceof s)){h.lastIndex=0;var _=h.exec(w),P=1;if(!_&&m&&b!=t.length-1){if(h.lastIndex=k,_=h.exec(e),!_)break;for(var A=_.index+(d?_[1].length:0),j=_.index+_[0].length,x=b,O=k,S=t.length;S>x&&(j>O||!t[x].type&&!t[x-1].greedy);++x)O+=t[x].length,A>=O&&(++b,k=O);if(t[b]instanceof s||t[x-1].greedy)continue;P=x-b,w=e.slice(k,O),_.index-=k}if(_){d&&(p=_[1].length);var A=_.index+p,_=_[0].slice(p),j=A+_.length,N=w.slice(0,A),C=w.slice(j),E=[b,P];N&&(++b,k+=N.length,E.push(N));var I=new s(u,f?n.tokenize(_,f):_,y,_,m);if(E.push(I),C&&E.push(C),Array.prototype.splice.apply(t,E),1!=P&&n.matchGrammar(e,t,a,b,k,!0,u),l)break}else if(l)break}}}}},tokenize:function(e,t){var a=[e],r=t.rest;if(r){for(var i in r)t[i]=r[i];delete t.rest}return n.matchGrammar(e,a,t,0,0,!1),a},hooks:{all:{},add:function(e,t){var a=n.hooks.all;a[e]=a[e]||[],a[e].push(t)},run:function(e,t){var a=n.hooks.all[e];if(a&&a.length)for(var r,i=0;r=a[i++];)r(t)}}},a=n.Token=function(e,t,n,a,r){this.type=e,this.content=t,this.alias=n,this.length=0|(a||"").length,this.greedy=!!r};if(a.stringify=function(e,t,r){if("string"==typeof e)return e;if("Array"===n.util.type(e))return e.map(function(n){return a.stringify(n,t,e)}).join("");var i={type:e.type,content:a.stringify(e.content,t,r),tag:"span",classes:["token",e.type],attributes:{},language:t,parent:r};if("comment"==i.type&&(i.attributes.spellcheck="true"),e.alias){var l="Array"===n.util.type(e.alias)?e.alias:[e.alias];Array.prototype.push.apply(i.classes,l)}n.hooks.run("wrap",i);var o=Object.keys(i.attributes).map(function(e){return e+'="'+(i.attributes[e]||"").replace(/"/g,""")+'"'}).join(" ");return"<"+i.tag+' class="'+i.classes.join(" ")+'"'+(o?" "+o:"")+">"+i.content+""+i.tag+">"},!_self.document)return _self.addEventListener?(_self.addEventListener("message",function(e){var t=JSON.parse(e.data),a=t.language,r=t.code,i=t.immediateClose;_self.postMessage(n.highlight(r,n.languages[a],a)),i&&_self.close()},!1),_self.Prism):_self.Prism;var r=document.currentScript||[].slice.call(document.getElementsByTagName("script")).pop();return r&&(n.filename=r.src,n.manual||r.hasAttribute("data-manual")||("loading"!==document.readyState?window.requestAnimationFrame?window.requestAnimationFrame(n.highlightAll):window.setTimeout(n.highlightAll,16):document.addEventListener("DOMContentLoaded",n.highlightAll))),_self.Prism}();"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism);
+Prism.languages.markup={comment://,prolog:/<\?[\s\S]+?\?>/,doctype://i,cdata://i,tag:{pattern:/<\/?(?!\d)[^\s>\/=$<]+(?:\s+[^\s>\/=]+(?:=(?:("|')(?:\\\1|\\?(?!\1)[\s\S])*\1|[^\s'">=]+))?)*\s*\/?>/i,inside:{tag:{pattern:/^<\/?[^\s>\/]+/i,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"attr-value":{pattern:/=(?:('|")[\s\S]*?(\1)|[^\s>]+)/i,inside:{punctuation:/[=>"']/}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:/?[\da-z]{1,8};/i},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.hooks.add("wrap",function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))}),Prism.languages.xml=Prism.languages.markup,Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup;
+Prism.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:/@[\w-]+?.*?(;|(?=\s*\{))/i,inside:{rule:/@[\w-]+/}},url:/url\((?:(["'])(\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1|.*?)\)/i,selector:/[^\{\}\s][^\{\};]*?(?=\s*\{)/,string:{pattern:/("|')(\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},property:/(\b|\B)[\w-]+(?=\s*:)/i,important:/\B!important\b/i,"function":/[-a-z0-9]+(?=\()/i,punctuation:/[(){};:]/},Prism.languages.css.atrule.inside.rest=Prism.util.clone(Prism.languages.css),Prism.languages.markup&&(Prism.languages.insertBefore("markup","tag",{style:{pattern:/(