Mess commit.

This commit is contained in:
Florian THIERRY
2024-08-19 22:42:42 +02:00
parent 32ab1d79c8
commit 56ac024cba
23 changed files with 428 additions and 36 deletions

View File

@@ -1,6 +1,7 @@
package org.codiki.application.picture;
import java.io.File;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -48,4 +49,11 @@ public class PictureUseCases {
public boolean existsById(UUID pictureId) {
return picturePort.existsById(pictureId);
}
public List<Picture> getAllOfCurrentUser() {
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
return picturePort.findAllByPublisherId(authenticatedUser.id());
}
}

View File

@@ -1,10 +1,12 @@
package org.codiki.domain.picture.model;
import java.io.File;
import java.time.ZonedDateTime;
import java.util.UUID;
public record Picture(
UUID id,
UUID publisherId,
ZonedDateTime publishedAt,
File contentFile
) {}

View File

@@ -1,6 +1,7 @@
package org.codiki.domain.picture.model.builder;
import java.io.File;
import java.time.ZonedDateTime;
import java.util.UUID;
import org.codiki.domain.picture.model.Picture;
@@ -9,6 +10,7 @@ import org.codiki.domain.user.model.User;
public class PictureBuilder {
private UUID id;
private UUID publisherId;
private ZonedDateTime publishedAt;
private File contentFile;
private PictureBuilder() {}
@@ -37,12 +39,17 @@ public class PictureBuilder {
return withPublisherId(publisher.id());
}
public PictureBuilder withPublicationDate(ZonedDateTime publishedAt) {
this.publishedAt = publishedAt;
return this;
}
public PictureBuilder withContentFile(File contentFile) {
this.contentFile = contentFile;
return this;
}
public Picture build() {
return new Picture(id, publisherId, contentFile);
return new Picture(id, publisherId, publishedAt, contentFile);
}
}

View File

@@ -1,5 +1,6 @@
package org.codiki.domain.picture.port;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -13,4 +14,6 @@ public interface PicturePort {
void save(Picture picture);
void deleteById(UUID pictureId);
List<Picture> findAllByPublisherId(UUID id);
}

View File

@@ -1,6 +1,7 @@
package org.codiki.exposition.picture;
import java.io.File;
import java.util.List;
import java.util.UUID;
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE;
@@ -8,6 +9,7 @@ import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE;
import org.codiki.application.picture.PictureUseCases;
import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.picture.model.Picture;
import org.codiki.exposition.picture.model.PictureDto;
import org.springframework.core.io.FileSystemResource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -44,4 +46,12 @@ public class PictureController {
.orElseThrow(() -> new PictureNotFoundException(pictureId));
return new FileSystemResource(picture.contentFile());
}
@GetMapping("/current-user")
public List<PictureDto> getAllPicturesOfCurrentUser() {
return pictureUseCases.getAllOfCurrentUser()
.stream()
.map(PictureDto::new)
.toList();
}
}

View File

@@ -0,0 +1,15 @@
package org.codiki.exposition.picture.model;
import org.codiki.domain.picture.model.Picture;
import java.time.ZonedDateTime;
import java.util.UUID;
public record PictureDto(
UUID id,
ZonedDateTime publishedAt
) {
public PictureDto(Picture picture) {
this(picture.id(), picture.publishedAt());
}
}

View File

@@ -1,6 +1,7 @@
package org.codiki.infrastructure.picture;
import java.io.File;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -66,4 +67,12 @@ public class PictureJpaAdapter implements PicturePort {
public void deleteById(UUID pictureId) {
repository.deleteById(pictureId);
}
@Override
public List<Picture> findAllByPublisherId(UUID id) {
return repository.findAllByPublisherId(id)
.stream()
.map(PictureEntity::toDomain)
.toList();
}
}

View File

@@ -1,5 +1,6 @@
package org.codiki.infrastructure.picture.model;
import java.time.ZonedDateTime;
import java.util.UUID;
import org.codiki.domain.picture.model.Picture;
@@ -24,6 +25,8 @@ public class PictureEntity {
private UUID id;
@Column(nullable = false)
private UUID publisherId;
@Column(nullable = false)
private ZonedDateTime publishedAt;
public PictureEntity(Picture picture) {
id = picture.id();
@@ -31,6 +34,6 @@ public class PictureEntity {
}
public Picture toDomain() {
return new Picture(id, publisherId, null);
return new Picture(id, publisherId, publishedAt, null);
}
}

View File

@@ -1,9 +1,12 @@
package org.codiki.infrastructure.picture.repository;
import java.util.List;
import java.util.UUID;
import org.codiki.domain.picture.model.Picture;
import org.codiki.infrastructure.picture.model.PictureEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PictureRepository extends JpaRepository<PictureEntity, UUID> {
List<PictureEntity> findAllByPublisherId(UUID id);
}

View File

@@ -36,6 +36,7 @@ CREATE INDEX category_parent_category_id_idx ON category (parent_category_id);
CREATE TABLE IF NOT EXISTS picture (
id UUID NOT NULL,
publisher_id UUID NOT NULL,
published_at TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT picture_pk PRIMARY KEY (id),
CONSTRAINT picture_publisher_id_fk FOREIGN KEY (publisher_id) REFERENCES "user" (id)
);

View File

@@ -1,14 +1,16 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
import { JwtInterceptor } from './core/interceptor/jwt.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimationsAsync(),
provideHttpClient()
provideHttpClient(withInterceptorsFromDi()),
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },
]
};

View File

@@ -0,0 +1,23 @@
import { Observable } from 'rxjs';
import { inject, Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { AuthenticationService } from '../service/authentication.service';
@Injectable()
export class JwtInterceptor implements HttpInterceptor {
private readonly authenticationService = inject(AuthenticationService);
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const jwt = this.authenticationService.getToken();
if (jwt) {
const cloned = request.clone({
headers: request.headers.set('Authorization', `Bearer ${jwt}`)
});
return next.handle(cloned);
}
return next.handle(request);
}
}

View File

@@ -0,0 +1,4 @@
export interface Picture {
id: string,
publishedAt: Date
}

View File

@@ -0,0 +1,21 @@
import { HttpClient, HttpParams } from "@angular/common/http";
import { inject, Injectable } from "@angular/core";
import { Picture } from "./model/picture";
import { lastValueFrom } from "rxjs";
@Injectable({
providedIn: 'root'
})
export class PictureRestService {
private readonly httpClient = inject(HttpClient);
getAllOfCurrentUser(): Promise<Picture[]> {
return lastValueFrom(this.httpClient.get<Picture[]>('/api/pictures/current-user'));
}
uploadPicture(pictureFile: File): Promise<string> {
const formData = new FormData();
formData.append("file", pictureFile);
return lastValueFrom(this.httpClient.post<string>('/api/pictures', formData));
}
}

View File

@@ -36,6 +36,14 @@ export class AuthenticationService {
return result;
}
getAuthenticatedUser(): User | undefined {
return this.extractUserFromLocalStorage();
}
getToken(): string | undefined {
return localStorage.getItem(JWT_PARAM) ?? undefined;
}
private extractUserFromLocalStorage(): User | undefined {
let result: User | undefined = undefined;

View File

@@ -0,0 +1,30 @@
<button type="button" class="close" (click)="closeDialog()">
<mat-icon>close</mat-icon>
</button>
<header>
<h1>Select an illustration:</h1>
</header>
<div class="picture-container">
@if (isLoading) {
<h2>Pictures loading...</h2>
<mat-spinner></mat-spinner>
} @else {
@if (pictures.length) {
@for(picture of pictures; track picture) {
<img src="/api/pictures/{{picture.id}}" (click)="selectPicture(picture)" matTooltip="Choose this illustration"/>
}
} @else {
<h2>There is no any picture.</h2>
}
}
</div>
<footer>
<button type="button" class="secondary" matRipple (click)="closeDialog()">
Cancel
</button>
<button type="button" (click)="fileUpload.click()" matRipple>
<mat-icon>upload_file</mat-icon>
Add new picture
</button>
<input type="file" (change)="uploadPicture($event)" #fileUpload/>
</footer>

View File

@@ -0,0 +1,97 @@
:host {
display: flex;
flex-direction: column;
padding: 1em;
gap: 1em;
position: relative;
max-height: 90vh;
.close {
position: absolute;
top: 1em;
right: 1em;
width: 2.5em;
height: 2.5em;
border-radius: 10em;
border: none;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
header {
flex: 1;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.picture-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 1em;
max-height: 30em;
overflow-y: auto;
min-height: 10em;
padding: .5em 0;
img {
width: 15em;
height: 10em;
object-fit: cover;
border-radius: 1em;
opacity: .9;
transition: opacity .2s ease-in-out, box-shadow .2s ease-in-out;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12);
&:hover {
opacity: 1;
cursor: pointer;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.32),0 2px 10px 0 rgba(0,0,0,.24);
}
}
}
footer {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
button {
padding: .8em 1.2em;
border-radius: 10em;
border: none;
background-color: #3f51b5;
color: white;
transition: background-color .2s ease-in-out;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
&:hover {
background-color: #5b6ed8;
}
&.secondary {
color: #3f51b5;
background-color: white;
&:hover {
background-color: #f2f4ff;
cursor: pointer;
}
}
}
input[type=file] {
display: none;
}
}
}

View File

@@ -0,0 +1,55 @@
import { Component, inject, OnInit } from "@angular/core";
import { Picture } from "../../../core/rest-services/picture/model/picture";
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import { MatSnackBar } from "@angular/material/snack-bar";
import { PictureRestService } from "../../../core/rest-services/picture/picture.rest-service";
import { MatIcon } from "@angular/material/icon";
import { MatDialogRef } from "@angular/material/dialog";
import {MatRippleModule} from '@angular/material/core';
import { MatTooltip } from "@angular/material/tooltip";
@Component({
selector: 'app-picture-selection',
standalone: true,
imports: [MatProgressSpinnerModule, MatIcon, MatRippleModule, MatTooltip],
templateUrl: './picture-selection-dialog.component.html',
styleUrl: './picture-selection-dialog.component.scss',
})
export class PictureSelectionDialog implements OnInit {
private readonly pictureRestService = inject(PictureRestService);
private readonly snackBar = inject(MatSnackBar);
private readonly dialogRef = inject(MatDialogRef<PictureSelectionDialog>);
isLoading: boolean = false;
isLoaded: boolean = false;
pictures: Picture[] = [];
ngOnInit(): void {
this.isLoading = true;
this.pictureRestService.getAllOfCurrentUser()
.then(pictures => {
this.pictures = pictures;
})
.catch(error => {
const errorMessage = 'An error occured while loading pictures.';
console.error(errorMessage, error);
this.snackBar.open(errorMessage, 'Close', { duration: 5000 });
})
.finally(() => {
this.isLoading = false;
this.isLoaded = true;
})
}
selectPicture(picture: Picture): void {
console.log(picture.id);
}
closeDialog(): void {
this.dialogRef.close();
}
uploadPicture(file: any): void {
console.log("uploadFile", file);
}
}

View File

@@ -0,0 +1,24 @@
import { inject, Injectable } from "@angular/core";
import { PictureRestService } from "../../../core/rest-services/picture/picture.rest-service";
import { MatSnackBar } from "@angular/material/snack-bar";
import { MatDialogRef } from "@angular/material/dialog";
import { PictureSelectionDialog } from "./picture-selection-dialog.component";
@Injectable()
export class PictureSelectionDialogService {
private pictureRestService = inject(PictureRestService);
private snackBar = inject(MatSnackBar);
private readonly dialogRef = inject(MatDialogRef<PictureSelectionDialog>);
uploadPicture(pictureFile: File): void {
this.pictureRestService.uploadPicture(pictureFile)
.then(pictureId => {
this.dialogRef.close(pictureId);
})
.catch(error => {
const errorMessage = 'An error occured while uploading a picture...';
console.error(errorMessage, error);
this.snackBar.open(errorMessage, 'Close', { duration: 5000 });
});
}
}

View File

@@ -2,35 +2,36 @@
<mat-spinner></mat-spinner>
}
@else {
<!-- <ng-template #afterLoadingPart> -->
@if (publication) {
<form [formGroup]="publicationEditionForm" (submit)="save()" ngNativeValidate>
<header>
<h1>Modification de l'article {{ publication.title }}</h1>
</header>
<mat-tab-group dynamicHeight>
<mat-tab label="Edition">
<div class="form-content">
<mat-form-field class="example-form-field">
<div class="first-part">
<div>
<mat-form-field>
<mat-label>Title</mat-label>
<input matInput type="text" formControlName="title" />
</mat-form-field>
<mat-form-field class="example-form-field">
<mat-label>Picture</mat-label>
<input matInput type="text" formControlName="illustrationId" />
</mat-form-field>
<mat-form-field class="example-form-field">
<mat-form-field>
<mat-label>Description</mat-label>
<input matInput type="text" formControlName="description" />
</mat-form-field>
</div>
<div>
<img src="/api/pictures/{{publication.illustrationId}}" (click)="displayPictureSectionDialog()" matTooltip="Click to change illustration"/>
</div>
</div>
<mat-form-field class="example-form-field">
<mat-label>Content</mat-label>
<textarea matInput formControlName="text"></textarea>
<textarea matInput formControlName="text" class="text-input"></textarea>
</mat-form-field>
</div>
</mat-tab>
@@ -55,9 +56,4 @@
<h1>Publication failed to load...</h1>
</div>
}
<!-- <ng-template #loadingFailedMessage>
</ng-template> -->
<!-- </ng-template> -->
}

View File

@@ -59,7 +59,44 @@
.form-content {
padding: 2em;
padding-bottom: 0;
display: flex;
flex-direction: column;
gap: 1em;
gap: .5em;
mat-form-field {
textarea {
height: 20em;
}
}
.first-part {
display: flex;
flex-direction: column-reverse;
gap: 1em;
@media screen and (min-width: 600px) {
flex-direction: row;
div {
max-width: 50%;
}
}
div {
flex: 1 0 50%;
display: flex;
flex-direction: column;
justify-content: center;
&:nth-last-child {
img {
flex: 1;
object-fit: cover;
width: 100%;
cursor: pointer;
}
}
}
}
}

View File

@@ -7,11 +7,14 @@ import { MatTabsModule } from '@angular/material/tabs';
import { debounceTime, map, Observable, Subscription } from 'rxjs';
import { Publication } from '../../core/rest-services/publications/model/publication';
import { PublicationEditionService } from './publication-edition.service';
import {MatDialogModule} from '@angular/material/dialog';
import { PictureSelectionDialog } from './picture-selection-dialog/picture-selection-dialog.component';
import { MatTooltipModule } from '@angular/material/tooltip';
@Component({
selector: 'app-publication-edition',
standalone: true,
imports: [ReactiveFormsModule, MatInputModule, MatProgressSpinner, MatTabsModule, CommonModule],
imports: [ReactiveFormsModule, MatInputModule, MatProgressSpinner, MatTabsModule, MatDialogModule, CommonModule, PictureSelectionDialog, MatTooltipModule],
templateUrl: './publication-edition.component.html',
styleUrl: './publication-edition.component.scss',
providers: [PublicationEditionService]
@@ -85,4 +88,8 @@ export class PublicationEditionComponent implements OnInit, OnDestroy {
save(): void {
}
displayPictureSectionDialog(): void {
this.publicationEditionService.displayPictureSectionDialog();
}
}

View File

@@ -1,11 +1,14 @@
import { Location } from "@angular/common";
import { inject, Injectable } from "@angular/core";
import { inject, Injectable, OnDestroy } from "@angular/core";
import { MatSnackBar } from "@angular/material/snack-bar";
import { ActivatedRoute } from "@angular/router";
import { BehaviorSubject, Observable } from "rxjs";
import { BehaviorSubject, Observable, Subscription } from "rxjs";
import { Publication } from "../../core/rest-services/publications/model/publication";
import { PublicationRestService } from "../../core/rest-services/publications/publication.rest-service";
import { copy } from '../../core/utils/ObjectUtils';
import { MatDialog } from "@angular/material/dialog";
import { PictureSelectionDialog } from "./picture-selection-dialog/picture-selection-dialog.component";
const DEFAULT_PUBLICATION: Publication = {
id: '',
@@ -25,14 +28,20 @@ const DEFAULT_PUBLICATION: Publication = {
};
@Injectable()
export class PublicationEditionService {
private activatedRoute = inject(ActivatedRoute);
private publicationRestService = inject(PublicationRestService);
private location = inject(Location);
private snackBar = inject(MatSnackBar);
export class PublicationEditionService implements OnDestroy {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly publicationRestService = inject(PublicationRestService);
private readonly location = inject(Location);
private readonly snackBar = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
private isLoadingSubject = new BehaviorSubject<boolean>(false);
private publicationSubject = new BehaviorSubject<Publication>(copy(DEFAULT_PUBLICATION));
private subscriptions: Subscription[] = [];
ngOnDestroy(): void {
this.subscriptions.forEach(subscription => subscription.unsubscribe());
}
private get _publication(): Publication {
return this.publicationSubject.value;
@@ -90,4 +99,22 @@ export class PublicationEditionService {
publication.text = text;
this._save(publication);
}
editIllustrationId(pictureId: string): void {
const publication = this._publication;
publication.illustrationId = pictureId
this._save(publication);
}
displayPictureSectionDialog(): void {
const dialogRef = this.dialog.open(PictureSelectionDialog);
const afterDialogCloseSubscription = dialogRef.afterClosed()
.subscribe(newPictureId => {
if (newPictureId) {
this.editIllustrationId(newPictureId);
}
});
this.subscriptions.push(afterDialogCloseSubscription);
}
}