diff --git a/src/main/java/org/codiki/core/entities/persistence/Category.java b/src/main/java/org/codiki/core/entities/persistence/Category.java index da10518..a8dd173 100755 --- a/src/main/java/org/codiki/core/entities/persistence/Category.java +++ b/src/main/java/org/codiki/core/entities/persistence/Category.java @@ -17,6 +17,9 @@ import javax.persistence.OneToMany; import javax.persistence.Table; import org.codiki.core.entities.dto.CategoryDTO; +import org.codiki.core.entities.dto.View; + +import com.fasterxml.jackson.annotation.JsonView; @Entity @Table(name="category") @@ -29,8 +32,10 @@ public class Category implements Serializable { /* ******************* */ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @JsonView(View.PostDTO.class) private Long id; + @JsonView(View.PostDTO.class) private String name; /* ******************* */ diff --git a/src/main/java/org/codiki/posts/PostController.java b/src/main/java/org/codiki/posts/PostController.java index 96fc39c..d6b5497 100755 --- a/src/main/java/org/codiki/posts/PostController.java +++ b/src/main/java/org/codiki/posts/PostController.java @@ -120,36 +120,32 @@ public class PostController { @JsonView(View.PostDTO.class) @PostMapping("/preview") - public PostDTO preview(@RequestBody final PostDTO pPost) { - final PostDTO result = new PostDTO(); - - result.setTitle(pPost.getTitle()); - result.setImage(pPost.getImage() == null || "".equals(pPost.getImage()) + public Post preview(@RequestBody final Post pPost) { + pPost.setImage(pPost.getImage() == null || "".equals(pPost.getImage()) ? "https://news-cdn.softpedia.com/images/news2/this-is-the-default-wallpaper-of-the-gnome-3-20-desktop-environment-500743-2.jpg" : pPost.getImage()); - result.setDescription(pPost.getDescription()); - result.setText(parserService.parse(pPost.getText())); + pPost.setText(parserService.parse(pPost.getText())); - return result; + return pPost; } @JsonView(View.PostDTO.class) @PostMapping("/") - public PostDTO insert(@RequestBody final PostDTO pPost, final HttpServletRequest pRequest, + public Post insert(@RequestBody final PostDTO pPost, final HttpServletRequest pRequest, final HttpServletResponse pResponse, final Principal pPrincipal) { - PostDTO result = null; + Post result = null; Optional postCreated = postService.insert(pPost, pRequest, pResponse, pPrincipal); if(postCreated.isPresent()) { - result = new PostDTO(postCreated.get()); + result = postCreated.get(); } return result; } @PutMapping("/") - public void update(@RequestBody final PostDTO pPost, final HttpServletRequest pRequest, + public void update(@RequestBody final Post pPost, final HttpServletRequest pRequest, final HttpServletResponse pResponse, final Principal pPrincipal) throws IOException { postService.update(pPost, pRequest, pResponse, pPrincipal); } diff --git a/src/main/java/org/codiki/posts/PostService.java b/src/main/java/org/codiki/posts/PostService.java index c748623..e85506c 100755 --- a/src/main/java/org/codiki/posts/PostService.java +++ b/src/main/java/org/codiki/posts/PostService.java @@ -65,7 +65,7 @@ public class PostService { return result; } - public void update(final PostDTO pPost, final HttpServletRequest pRequest, + public void update(final Post pPost, final HttpServletRequest pRequest, final HttpServletResponse pResponse, final Principal pPrincipal) throws IOException { final Optional connectedUser = userService.getUserByPrincipal(pPrincipal); diff --git a/src/main/ts-v7/src/app/app.module.ts b/src/main/ts-v7/src/app/app.module.ts index 656bcbe..2742faa 100755 --- a/src/main/ts-v7/src/app/app.module.ts +++ b/src/main/ts-v7/src/app/app.module.ts @@ -30,6 +30,7 @@ import { ProfilEditionComponent } from './account-settings/profil-edition/profil import { PostComponent } from './posts/post.component'; import { NotFoundComponent } from './not-found/not-found.component'; import { ByCategoryComponent } from './posts/byCategory/by-category.component'; +import { CreateUpdatePostComponent } from './posts/create-update/create-update-post.component'; // Reusable components import { PostCardComponent } from './core/post-card/post-card.component'; @@ -45,6 +46,7 @@ import { ChangePasswordService } from './account-settings/change-password/change import { ProfilEditionService } from './account-settings/profil-edition/profil-edition.service'; import { PostService } from './posts/post.service'; import { ByCategoryService } from './posts/byCategory/by-category.service'; +import { CreateUpdatePostService } from './posts/create-update/create-update-post.service'; @NgModule({ declarations: [ @@ -61,7 +63,8 @@ import { ByCategoryService } from './posts/byCategory/by-category.service'; ProfilEditionComponent, PostComponent, NotFoundComponent, - ByCategoryComponent + ByCategoryComponent, + CreateUpdatePostComponent ], imports: [ BrowserModule, @@ -86,6 +89,7 @@ import { ByCategoryService } from './posts/byCategory/by-category.service'; ProfilEditionService, PostService, ByCategoryService, + CreateUpdatePostService, { provide: HTTP_INTERCEPTORS, useClass: UnauthorizedInterceptor, multi: true } ], bootstrap: [AppComponent] diff --git a/src/main/ts-v7/src/app/app.routing.ts b/src/main/ts-v7/src/app/app.routing.ts index bc39945..885a8e9 100755 --- a/src/main/ts-v7/src/app/app.routing.ts +++ b/src/main/ts-v7/src/app/app.routing.ts @@ -11,6 +11,7 @@ import { ChangePasswordComponent } from './account-settings/change-password/chan import { ProfilEditionComponent } from './account-settings/profil-edition/profil-edition.component'; import { PostComponent } from './posts/post.component'; import { ByCategoryComponent } from './posts/byCategory/by-category.component'; +import { CreateUpdatePostComponent } from './posts/create-update/create-update-post.component'; export const appRoutes: Routes = [ { path: '', redirectTo: '/home', pathMatch: 'full' }, @@ -21,7 +22,9 @@ export const appRoutes: Routes = [ { path: 'accountSettings', component: AccountSettingsComponent, canActivate: [AuthGuard] }, { path: 'changePassword', component: ChangePasswordComponent, canActivate: [AuthGuard] }, { path: 'profilEdit', component: ProfilEditionComponent, canActivate: [AuthGuard] }, + { path: 'posts/new', component: CreateUpdatePostComponent, canActivate: [AuthGuard] }, { path: 'posts/:postKey', component: PostComponent }, { path: 'posts/byCategory/:categoryId', component: ByCategoryComponent}, + { path: 'posts/update/:postKey', component: CreateUpdatePostComponent, canActivate: [AuthGuard] }, { path: '**', redirectTo: '/home' } ]; diff --git a/src/main/ts-v7/src/app/posts/create-update/create-update-post.component.html b/src/main/ts-v7/src/app/posts/create-update/create-update-post.component.html new file mode 100644 index 0000000..e64b12d --- /dev/null +++ b/src/main/ts-v7/src/app/posts/create-update/create-update-post.component.html @@ -0,0 +1,252 @@ +
+
+

Création d'un article

+

Modification de l'article {{model.key}}

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+
+
+ + + + + + +
+
+ + +
+ +
+
+ +
+ Post image +
+

{{parsedPost.title}}

+

{{parsedPost.description}}

+
+
+
+
+
+
+
+

{{modelError}}

+
+
+
+
+

{{result}}

+
+
+ +
+
+ + diff --git a/src/main/ts-v7/src/app/posts/create-update/create-update-post.component.scss b/src/main/ts-v7/src/app/posts/create-update/create-update-post.component.scss new file mode 100644 index 0000000..0922734 --- /dev/null +++ b/src/main/ts-v7/src/app/posts/create-update/create-update-post.component.scss @@ -0,0 +1,234 @@ +.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; +} + +.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; + height: 250px; + overflow-y: scroll; +} + +#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; +} diff --git a/src/main/ts-v7/src/app/posts/create-update/create-update-post.component.ts b/src/main/ts-v7/src/app/posts/create-update/create-update-post.component.ts new file mode 100644 index 0000000..25a900b --- /dev/null +++ b/src/main/ts-v7/src/app/posts/create-update/create-update-post.component.ts @@ -0,0 +1,233 @@ +import { Component, OnInit, SecurityContext, ViewChild } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { Router, ActivatedRoute, RoutesRecognized, NavigationEnd } 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, pairwise } from 'rxjs/operators'; + +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']); + } + }); + + // FIXME: The message isn't shown and the method ngOnInit is too much called, also during others components navigation. + this.router.events.pipe(filter(e => e instanceof RoutesRecognized), pairwise()).subscribe((events: any) => { + if (events[0].urlAfterRedirects === '/posts/new') { + this.setMessage('Article créé.', false); + } + }); + } + } + + 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 `/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(); + } + + compareCategories(cat1: Category, cat2: Category): boolean { + return cat1 && cat2 ? cat1.id === cat2.id : cat1 === cat2; + } +} diff --git a/src/main/ts-v7/src/app/posts/create-update/create-update-post.service.ts b/src/main/ts-v7/src/app/posts/create-update/create-update-post.service.ts new file mode 100644 index 0000000..f8f2f4c --- /dev/null +++ b/src/main/ts-v7/src/app/posts/create-update/create-update-post.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpEvent, HttpRequest } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Post, Category, Image } from '../../core/entities'; + +@Injectable() +export class CreateUpdatePostService { + + constructor(private http: HttpClient) {} + + processPreview(post: Post): Observable { + return this.http.post(`/api/posts/preview`, post); + } + + getCategories(): Observable> { + return this.http.get>(`/api/categories/`); + } + + addPost(post: Post): Observable { + return this.http.post(`/api/posts/`, post); + } + + updatePost(post: Post): Observable { + return this.http.put(`/api/posts/`, post); + } + + getPost(postKey: string): Observable { + return this.http.get(`/api/posts/${postKey}/source`); + } + + getImages(): Observable> { + return this.http.get>(`/api/images/myImages`); + } + + uploadPicture(file: File): Observable> { + const formData: FormData = new FormData(); + + formData.append('file', file); + + return this.http.request(new HttpRequest( + 'POST', '/api/images', formData, { + reportProgress: true, + responseType: 'text' + } + )); + } + + getImageDetails(imageLink: string): Observable { + return this.http.get(`/api/images/${imageLink}/details`); + } +} diff --git a/src/main/ts-v7/src/app/posts/post.component.html b/src/main/ts-v7/src/app/posts/post.component.html index 1f93b91..d34c1c3 100644 --- a/src/main/ts-v7/src/app/posts/post.component.html +++ b/src/main/ts-v7/src/app/posts/post.component.html @@ -5,7 +5,7 @@ - +
diff --git a/src/main/ts-v7/src/index.html b/src/main/ts-v7/src/index.html index 67d3737..0a4976c 100755 --- a/src/main/ts-v7/src/index.html +++ b/src/main/ts-v7/src/index.html @@ -12,6 +12,6 @@ - +