diff --git a/README.md b/README.md index 22d98c2..39f6c5e 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,14 @@ ## Liste des améliorations -- [ ] Faire du sessionStorage à la place d'un stockage avec cookie -- [ ] Bouton "Voir les posts de l'utilisateur" à enlever (un seul écrivain donc inutile...) -- [ ] Bug CSS concernant le footer -- [ ] Mauvaise actualisation du pseudo quand on se renomme -- [ ] Support d'un dark thème (à réflechir...) -- [ ] Erreur 403 quand on modifie le pseudo mais pas l'image d'un utilisateur -- [ ] Garder l'avatar de l'utilisateur quand il met à jour uniquement son pseudo -- [ ] Ne pas avoir à confirmer son mot de passe lors de la connexion -- [ ] Pouvoir modifier son commentaire -- [ ] L'avatar s'affiche pas quand on upload un commentaire (il faut recharger la page) -- [ ] Faire des meilleurs modal +- [x] Bug CSS concernant le footer +- [x] Problème d'actualisation des commentaires quand on écrit au moins 2 commentaires à la suite +- [x] Responsive des commentaires +- [x] Garder l'avatar de l'utilisateur quand il met à jour uniquement son pseudo +- [x] Ne pas avoir à confirmer son mot de passe lors de la connexion +- [x] L'avatar s'affiche pas quand on upload un commentaire (il faut recharger la page) - [ ] Terminer l'interface admin -- [ ] Bug (de temps en temps) pour stocker les cookies utilisateur +- [x] Bug (de temps en temps) pour stocker les données utilisateur pour run le docker : ``` diff --git a/angular.json b/angular.json index faaad1f..061f7b1 100644 --- a/angular.json +++ b/angular.json @@ -28,14 +28,10 @@ } ], "styles": [ + "@angular/material/prebuilt-themes/azure-blue.css", "src/styles.css" ], - "scripts": [], - "server": "src/main.server.ts", - "prerender": true, - "ssr": { - "entry": "server.ts" - } + "scripts": [] }, "configurations": { "production": { @@ -97,6 +93,7 @@ } ], "styles": [ + "@angular/material/prebuilt-themes/magenta-violet.css", "src/styles.css" ], "scripts": [] diff --git a/package.json b/package.json index 59d6047..38566fc 100644 --- a/package.json +++ b/package.json @@ -12,20 +12,23 @@ "private": true, "dependencies": { "@angular/animations": "^18.2.0", + "@angular/cdk": "^18.2.14", "@angular/common": "^18.2.0", "@angular/compiler": "^18.2.0", "@angular/core": "^18.2.0", "@angular/forms": "^18.2.0", + "@angular/material": "^18.2.14", "@angular/platform-browser": "^18.2.0", "@angular/platform-browser-dynamic": "^18.2.0", "@angular/platform-server": "^18.2.0", "@angular/router": "^18.2.0", - "@angular/ssr": "^18.2.12", + "@angular/ssr": "^18.2.18", + "@primeng/themes": "^19.1.0", "express": "^4.18.2", "luxon": "^3.5.0", "ngx-cookie-service": "^18.0.0", "primeicons": "^7.0.0", - "primeng": "^17.18.10", + "primeng": "^18.0.2", "quill": "^2.0.3", "review-front": "file:", "rxjs": "~7.8.0", @@ -33,8 +36,8 @@ "zone.js": "~0.14.10" }, "devDependencies": { - "@angular-devkit/build-angular": "^18.2.12", - "@angular/cli": "^18.2.12", + "@angular-devkit/build-angular": "^18.2.18", + "@angular/cli": "^18.2.18", "@angular/compiler-cli": "^18.2.0", "@types/express": "^4.17.17", "@types/jasmine": "~5.1.0", diff --git a/public/icon.jpg b/public/icon.jpg deleted file mode 100644 index 7da39e5..0000000 Binary files a/public/icon.jpg and /dev/null differ diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..b511a7f Binary files /dev/null and b/public/icon.png differ diff --git a/src/app/app.component.html b/src/app/app.component.html index d12ecd3..9db0daf 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,5 +1,5 @@ -@if (isBrowser()) { +@if (isBrowser() && (authService.isSessionExpired() && authService.isAuthenticated())) { Votre session a expiré ! Il va falloir se reconnecter.
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 6d1e015..823f68b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -7,12 +7,12 @@ import {DialogModule} from 'primeng/dialog'; import {isPlatformBrowser} from '@angular/common'; import {Button} from 'primeng/button'; import {AuthService} from './auth.service'; -import {CookieService} from 'ngx-cookie-service'; +import {Router, RouterOutlet} from '@angular/router'; @Component({ selector: 'app-root', standalone: true, - imports: [MenubarModule, FloatLabelModule, ToastModule, DialogModule, Button], + imports: [MenubarModule, FloatLabelModule, ToastModule, DialogModule, Button, RouterOutlet], providers: [ MessageService, ], @@ -23,17 +23,18 @@ export class AppComponent implements OnInit { isSessionExpired: boolean = false; constructor(@Inject(PLATFORM_ID) private platformId: object, - private authService: AuthService, - private cookieService: CookieService) { + protected authService: AuthService, + private router: Router,) { } isBrowser(): boolean { - return isPlatformBrowser(this.platformId); + return isPlatformBrowser(this.platformId) } setSessionExpiredFalse(): void { this.isSessionExpired = false; this.authService.setSessionExpired(false); + this.router.navigate(['/logout']); } ngOnInit(): void { diff --git a/src/app/app.config.ts b/src/app/app.config.ts index a955765..ef61b55 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,10 +1,12 @@ import {ApplicationConfig, importProvidersFrom, provideZoneChangeDetection} from '@angular/core'; import {provideRouter} from '@angular/router'; - import {routes} from './app.routes'; import {provideClientHydration} from '@angular/platform-browser'; import {provideHttpClient, withFetch} from '@angular/common/http'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {provideAnimationsAsync} from '@angular/platform-browser/animations/async'; +import {providePrimeNG} from 'primeng/config'; +import {myPreset} from './preset' export const appConfig: ApplicationConfig = { providers: [ @@ -12,5 +14,14 @@ export const appConfig: ApplicationConfig = { provideRouter(routes), provideClientHydration(), provideHttpClient(withFetch()), - importProvidersFrom([BrowserAnimationsModule])] + provideAnimationsAsync(), + providePrimeNG({ + theme: { + preset: myPreset, + options: { + darkModeSelector: '.my-app-dark' // class css pour activer le dark mode + } + } + }), + importProvidersFrom([BrowserAnimationsModule]), provideAnimationsAsync()] }; diff --git a/src/app/auth.service.ts b/src/app/auth.service.ts index 8e341bf..3471c95 100644 --- a/src/app/auth.service.ts +++ b/src/app/auth.service.ts @@ -1,7 +1,6 @@ -import { Injectable } from '@angular/core'; -import { CookieService } from 'ngx-cookie-service'; -import { Author } from './models/author'; -import { BehaviorSubject } from 'rxjs'; +import {Injectable} from '@angular/core'; +import {Author} from './models/author'; +import {BehaviorSubject} from 'rxjs'; import {DateTime} from 'luxon'; @Injectable({ @@ -11,33 +10,38 @@ export class AuthService { private sessionExpiredSubject = new BehaviorSubject(false); sessionExpired$ = this.sessionExpiredSubject.asObservable(); - constructor(private cookieService: CookieService) { + constructor() { this.checkSessionExpiration(); } isAuthenticated(): boolean { - return this.cookieService.check("author") && - this.cookieService.check("token") && - this.cookieService.check("token-expiration-date") && - this.cookieService.get("author") !== '' && - this.cookieService.get("token-expiration-date") !== '' && - this.cookieService.get("token") !== ''; + return sessionStorage.getItem("author") !== null && + sessionStorage.getItem("token") !== null && + sessionStorage.getItem("token-expiration-date") !== null; } - getTokenExpirationDate(): DateTime { - return DateTime.fromISO(this.cookieService.get("token-expiration-date")); + getTokenExpirationDate(): string | null { + return sessionStorage.getItem("token-expiration-date"); } isSessionExpired(): boolean { - return this.getTokenExpirationDate() < DateTime.now() && this.isAuthenticated(); + const tokenExpirationDate = this.getTokenExpirationDate(); + if (tokenExpirationDate) { + return DateTime.fromISO(tokenExpirationDate) < DateTime.now() && this.isAuthenticated(); + } + return true } - getAuthenticatedAuthor(): Author { - return JSON.parse(this.cookieService.get('author')); + getAuthenticatedAuthor(): Author | null { + const authorStr = sessionStorage.getItem('author') + if (authorStr) { + return JSON.parse(authorStr); + } + return null; } - getAuthenticatedAuthorToken(): string { - return this.cookieService.get('token'); + getAuthenticatedAuthorToken(): string | null{ + return sessionStorage.getItem('token'); } setSessionExpired(expired: boolean) { diff --git a/src/app/components/comment-form/comment-form.component.ts b/src/app/components/comment-form/comment-form.component.ts index 56d2dca..e23278e 100644 --- a/src/app/components/comment-form/comment-form.component.ts +++ b/src/app/components/comment-form/comment-form.component.ts @@ -1,21 +1,19 @@ import {Component, EventEmitter, Input, Output} from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; -import {InputTextareaModule} from 'primeng/inputtextarea'; import {Button} from 'primeng/button'; import {CommentService} from '../../services/comment.service'; -import {Author} from '../../models/author'; -import {Subscription} from 'rxjs'; +import {Subscription, switchMap} from 'rxjs'; import {Comment} from '../../models/comment'; import {MessageService} from 'primeng/api'; import {NgStyle} from '@angular/common'; import {AuthService} from '../../auth.service'; +import {AuthorService} from '../../services/author.service'; @Component({ selector: 'app-comment-form', standalone: true, imports: [ ReactiveFormsModule, - InputTextareaModule, Button, NgStyle ], @@ -29,25 +27,36 @@ export class CommentFormComponent { @Input({required: true}) postId: bigint = BigInt(1); @Output() commentToEmit = new EventEmitter(); subs: Subscription[] = []; + createdComment: Comment = {} as Comment; constructor(private commentService: CommentService, private messageService: MessageService, - private authService: AuthService,) { + private authService: AuthService, + private authorService: AuthorService) { } onSubmit() { - let token: string = this.authService.getAuthenticatedAuthorToken(); - let author: Author = this.authService.getAuthenticatedAuthor(); + let token = this.authService.getAuthenticatedAuthorToken(); + let author = this.authService.getAuthenticatedAuthor(); if (this.commentForm.valid && author && token && this.commentForm.value.content) { // get l'image de profile après avoir créé le commentaire - this.subs.push(this.commentService.create(this.commentForm.value.content, this.postId, author.id, token).subscribe({ - next: (comment: Comment) => { - comment.authorId = author.id; - comment.authorName = author.name; - comment.profilePicture = author.profilePicture; - comment.authorRole = author.role; + this.subs.push(this.commentService.create(this.commentForm.value.content, this.postId, author.id, token).pipe( + switchMap((comment: Comment) => { + this.createdComment = { ... this.createdComment } // attention a bien mettre à jour la ref sinon ça casse + this.createdComment.authorId = author.id; + this.createdComment.content = comment.content; + this.createdComment.id = comment.id; + this.createdComment.commentDate = comment.commentDate; + this.createdComment.authorName = author.name; + this.createdComment.authorRole = author.role; this.commentForm.value.content = ""; - this.commentToEmit.emit(comment); + return this.authorService.getAvatar(author?.id) + }) + ).subscribe({ + next: (profilePicture: string) => { + this.createdComment.profilePicture = profilePicture; + this.commentForm.value.content = ""; + this.commentToEmit.emit(this.createdComment); this.successMessage("Succès", "Commentaire créé avec succès"); }, error: (error) => { diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts index 1ff54ca..bce565c 100644 --- a/src/app/components/header/header.component.ts +++ b/src/app/components/header/header.component.ts @@ -25,8 +25,9 @@ export class HeaderComponent { } private initializeMenu(): void { - if (!(this.authService.isSessionExpired()) && this.authService.isAuthenticated()) { - this.actualAuthor = this.authService.getAuthenticatedAuthor(); + const authenticatedAuthor = this.authService.getAuthenticatedAuthor(); + if (!(this.authService.isSessionExpired()) && this.authService.isAuthenticated() && authenticatedAuthor) { + this.actualAuthor = authenticatedAuthor; } if (this.actualAuthor) { diff --git a/src/app/components/loading/loading.component.css b/src/app/components/loading/loading.component.css new file mode 100644 index 0000000..22d260e --- /dev/null +++ b/src/app/components/loading/loading.component.css @@ -0,0 +1,24 @@ +div { + margin-top: 10em; + display: flex; + justify-content: center; + align-items: center; +} + +img { + animation-name: spin; + animation-duration: 2000ms; + animation-iteration-count: infinite; + animation-timing-function: linear; + max-width: 40%; + max-height: 40%; +} + +@keyframes spin { + from { + transform:rotate(0deg); + } + to { + transform:rotate(360deg); + } +} diff --git a/src/app/components/loading/loading.component.html b/src/app/components/loading/loading.component.html new file mode 100644 index 0000000..f989d89 --- /dev/null +++ b/src/app/components/loading/loading.component.html @@ -0,0 +1,3 @@ +
+ loadign +
diff --git a/src/app/components/loading/loading.component.ts b/src/app/components/loading/loading.component.ts new file mode 100644 index 0000000..0e1870f --- /dev/null +++ b/src/app/components/loading/loading.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import {NgOptimizedImage} from '@angular/common'; + +@Component({ + selector: 'app-loading', + standalone: true, + imports: [ + NgOptimizedImage + ], + templateUrl: './loading.component.html', + styleUrl: './loading.component.css' +}) +export class LoadingComponent { + +} diff --git a/src/app/components/modal/preview-modal/preview-modal.component.css b/src/app/components/modal/preview-modal/preview-modal.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/modal/preview-modal/preview-modal.component.html b/src/app/components/modal/preview-modal/preview-modal.component.html new file mode 100644 index 0000000..5554195 --- /dev/null +++ b/src/app/components/modal/preview-modal/preview-modal.component.html @@ -0,0 +1,15 @@ +@if (post) { + + + +} diff --git a/src/app/components/modal/preview-modal/preview-modal.component.ts b/src/app/components/modal/preview-modal/preview-modal.component.ts new file mode 100644 index 0000000..c446233 --- /dev/null +++ b/src/app/components/modal/preview-modal/preview-modal.component.ts @@ -0,0 +1,21 @@ +import {Component, Input} from '@angular/core'; +import {Dialog} from 'primeng/dialog'; +import {PostHomeComponent} from '../../post-home/post-home.component'; +import {Post} from '../../../models/post'; + +@Component({ + selector: 'app-preview-modal', + standalone: true, + imports: [ + Dialog, + PostHomeComponent + ], + templateUrl: './preview-modal.component.html', + styleUrl: './preview-modal.component.css' +}) +export class PreviewModalComponent { + opened: boolean = true; + @Input({required: true}) post: Post | undefined; + @Input() username: string = ''; + @Input() profilePicture: string = ''; +} diff --git a/src/app/components/modal/update-modal/update-modal.component.css b/src/app/components/modal/update-modal/update-modal.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/modal/update-modal/update-modal.component.html b/src/app/components/modal/update-modal/update-modal.component.html new file mode 100644 index 0000000..1f4b39a --- /dev/null +++ b/src/app/components/modal/update-modal/update-modal.component.html @@ -0,0 +1,15 @@ +@if (post) { + + + +} diff --git a/src/app/components/modal/update-modal/update-modal.component.ts b/src/app/components/modal/update-modal/update-modal.component.ts new file mode 100644 index 0000000..3e55abc --- /dev/null +++ b/src/app/components/modal/update-modal/update-modal.component.ts @@ -0,0 +1,33 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {Dialog} from 'primeng/dialog'; +import {PostFormComponent} from '../../post-form/post-form.component'; +import {Post} from '../../../models/post'; +import {AuthService} from '../../../auth.service'; +import {Author} from '../../../models/author'; + +@Component({ + selector: 'app-update-modal', + standalone: true, + imports: [ + Dialog, + PostFormComponent + ], + templateUrl: './update-modal.component.html', + styleUrl: './update-modal.component.css' +}) +export class UpdateModalComponent { + actualAuthor: Author | undefined; + opened: boolean = true; + @Input({required: true}) post: Post | undefined; + @Output() updatedPost: EventEmitter = new EventEmitter(); + + constructor(private authService: AuthService) { + this.authService.getAuthenticatedAuthor(); + } + + + onSubmit(updatedPost: Post) { + this.updatedPost.emit(updatedPost); + this.opened = false + } +} diff --git a/src/app/components/post-form/post-form.component.html b/src/app/components/post-form/post-form.component.html index 0fdaae2..a2eace2 100644 --- a/src/app/components/post-form/post-form.component.html +++ b/src/app/components/post-form/post-form.component.html @@ -1,13 +1,20 @@
- - + + + + - - + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + = new EventEmitter(); subs: Subscription[] = []; form: FormGroup; uploadedFile: File | undefined; - editorModules = { - toolbar: [ - ['bold', 'italic', 'underline', 'code'], // Styles de texte - [{header: [2, false]}], // Permet d'ajouter un `

` - [{list: 'ordered'}, {list: 'bullet'}], // Listes - ['link', 'image', 'video'], // Ajout de liens et images - ], - }; constructor( private formBuilder: FormBuilder, @@ -105,12 +103,13 @@ export class PostFormComponent implements OnDestroy { if (this.isUpdateMode && this.postId) { this.subs.push( - this.postService.updatePost(this.postId, postData, this.authService.getAuthenticatedAuthorToken()).pipe( + this.postService.updatePost(this.postId, postData, this.authService.getAuthenticatedAuthorToken()!).pipe( mergeMap((_) => { - return this.postService.changeIllustration(this.postId, this.uploadedFile, this.authService.getAuthenticatedAuthorToken()); + return this.postService.changeIllustration(this.postId, this.uploadedFile, this.authService.getAuthenticatedAuthorToken()!); }) ).subscribe({ next: (_) => { + this.postUpdate.emit(_); this.successMessage('Succès', 'Post mis à jour avec succès') }, error: (err) => this.failureMessage('Erreur', err.error.message) @@ -118,11 +117,11 @@ export class PostFormComponent implements OnDestroy { ); } else { this.subs.push( - this.postService.createPost(postData, this.authService.getAuthenticatedAuthorToken()).pipe( + this.postService.createPost(postData, this.authService.getAuthenticatedAuthorToken()!).pipe( mergeMap(post => - this.authorService.attributePost(this.actualAuthor?.id, post.id, this.authService.getAuthenticatedAuthorToken()).pipe( + this.authorService.attributePost(this.actualAuthor?.id, post.id, this.authService.getAuthenticatedAuthorToken()!).pipe( mergeMap((_) => - this.postService.changeIllustration(post.id, this.uploadedFile, this.authService.getAuthenticatedAuthorToken()), + this.postService.changeIllustration(post.id, this.uploadedFile, this.authService.getAuthenticatedAuthorToken()!), ) ) ) @@ -140,6 +139,7 @@ export class PostFormComponent implements OnDestroy { } private transformYouTubeLinksToIframes(html: string): string { + // Magie noire return html.replace(/]*href="(https?:\/\/(?:www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)[^"]*)".*?<\/a>/g, (_, _url, _prefix, videoId) => { return ``; diff --git a/src/app/components/post-home/post-home.component.html b/src/app/components/post-home/post-home.component.html index c5613d0..9afc6f8 100644 --- a/src/app/components/post-home/post-home.component.html +++ b/src/app/components/post-home/post-home.component.html @@ -5,7 +5,7 @@ {{ category }} Publié le {{ date | date : "dd/MM/yyyy à HH:mm" }} {{ description }} - + Lire la suite

- @if (actualAuthor) { @if (concernedAuthor.id === actualAuthor.id) { @@ -30,5 +29,5 @@ } @else { -

Loading...

+ } diff --git a/src/app/pages/profile/profile.component.ts b/src/app/pages/profile/profile.component.ts index ad47caf..84d88a2 100644 --- a/src/app/pages/profile/profile.component.ts +++ b/src/app/pages/profile/profile.component.ts @@ -10,6 +10,7 @@ import {Button} from 'primeng/button'; import {DialogModule} from 'primeng/dialog'; import {UpdateProfileFormComponent} from '../../components/update-profile-form/update-profile-form.component'; import {AuthService} from '../../auth.service'; +import {LoadingComponent} from '../../components/loading/loading.component'; @Component({ selector: 'app-profile', @@ -21,6 +22,7 @@ import {AuthService} from '../../auth.service'; Button, DialogModule, UpdateProfileFormComponent, + LoadingComponent, ], templateUrl: './profile.component.html', styleUrl: './profile.component.css' @@ -31,7 +33,6 @@ export class ProfileComponent implements OnDestroy { authorName: string = ""; subs: Subscription[] = []; updateProfileDialog: boolean = false; - changePasswordDialog: boolean = false; constructor(private route: ActivatedRoute, private authorService: AuthorService, @@ -43,7 +44,12 @@ export class ProfileComponent implements OnDestroy { })); }) if (!(this.authService.isSessionExpired()) && this.authService.isAuthenticated()) { - this.actualAuthor = this.authService.getAuthenticatedAuthor(); + const authenticatedAuthor = this.authService.getAuthenticatedAuthor(); + if (authenticatedAuthor) { + this.actualAuthor = authenticatedAuthor; + } else { + console.error("Profil mal chargé"); + } } else { this.authService.checkSessionExpiration(); } diff --git a/src/app/preset.ts b/src/app/preset.ts new file mode 100644 index 0000000..f40e495 --- /dev/null +++ b/src/app/preset.ts @@ -0,0 +1,21 @@ +import {definePreset} from '@primeng/themes'; +import Aura from '@primeng/themes/aura'; + + +export const myPreset = definePreset(Aura, { + semantic: { + primary: { + 50: '{indigo.50}', + 100: '{indigo.100}', + 200: '{indigo.200}', + 300: '{indigo.300}', + 400: '{indigo.400}', + 500: '{indigo.500}', + 600: '{indigo.600}', + 700: '{indigo.700}', + 800: '{indigo.800}', + 900: '{indigo.900}', + 950: '{indigo.950}' + } + } +}); diff --git a/src/app/services/author.service.ts b/src/app/services/author.service.ts index a594b7f..910b955 100644 --- a/src/app/services/author.service.ts +++ b/src/app/services/author.service.ts @@ -62,6 +62,11 @@ export class AuthorService { } } + getAvatar(id: string): Observable { + return this.httpClient.get(`${this.apiUrl}/${id}/avatar`, { responseType: 'text' }); + } + + getAuthor(id: string | null): Observable { if (id) { return this.httpClient.get(`${this.apiUrl}/${id}`); @@ -89,6 +94,14 @@ export class AuthorService { 'Authorization': `Bearer ${token}` }) } - return this.httpClient.post(`${this.apiUrl}/register/admin`, {name: username, password: password, role: role}, httpOptions); + return this.httpClient.post(`${this.apiUrl}/register/admin`, { + name: username, + password: password, + role: role + }, httpOptions); + } + + getAuthorAvatar(id: string) { + return this.httpClient.get(`${this.apiUrl}/${id}/avatar`); } } diff --git a/src/assets/icon.png b/src/assets/icon.png new file mode 100644 index 0000000..b511a7f Binary files /dev/null and b/src/assets/icon.png differ diff --git a/src/index.html b/src/index.html index 127cabd..bb90503 100644 --- a/src/index.html +++ b/src/index.html @@ -6,10 +6,12 @@ A BON ENTENDEUR - + + + - +