Compare commits

2 Commits

Author SHA1 Message Date
Florian THIERRY
b3a52f6a4b Extract search bar into a standalone component and fix header design. 2024-08-30 09:52:00 +02:00
Florian THIERRY
090143fdae Add search publication page and fix category access 2024-08-29 17:24:01 +02:00
20 changed files with 395 additions and 148 deletions

View File

@@ -1,5 +1,5 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideRouter, withRouterConfig } from '@angular/router';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
@@ -8,7 +8,13 @@ import { JwtInterceptor } from './core/interceptor/jwt.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideRouter(
routes,
withRouterConfig({
paramsInheritanceStrategy: 'always',
onSameUrlNavigation: 'reload'
})
),
provideAnimationsAsync(),
provideHttpClient(withInterceptorsFromDi()),
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },

View File

@@ -21,6 +21,10 @@ export const routes: Routes = [
path: 'publications/:publicationId/edit',
loadComponent: () => import('./pages/publication-edition/publication-edition.component').then(module => module.PublicationEditionComponent)
},
{
path: 'publications',
loadComponent: () => import('./pages/search-publications/search-publications.component').then(module => module.SearchPublicationsComponent)
},
{
path: '**',
loadComponent: () => import('./pages/home/home.component').then(module => module.HomeComponent)

View File

@@ -1,5 +1,5 @@
<div>
<button type="button" (click)="sideMenu.open()">
<div class="left">
<button type="button" (click)="sideMenu.open()" matTooltip="Click to show side menu">
<mat-icon>menu</mat-icon>
</button>
<a [routerLink]="['/home']">
@@ -7,15 +7,12 @@
<span class="title">Codiki</span>
</a>
</div>
<div>
<div class="search-bar">
<input name="search-query" placeholder="Search something..." />
<button type="button">
<mat-icon>search</mat-icon>
</button>
</div>
<div class="middle">
<app-publications-search-bar></app-publications-search-bar>
</div>
<div>
<div class="right">
<ng-container *ngIf="isAuthenticated; else anonymousRightMenu">
<a [routerLink]="['/disconnect']" class="button" matRipple>Disconnect</a>
</ng-container>

View File

@@ -14,15 +14,17 @@ $headerHeight: 3.5em;
display: flex;
flex-direction: row;
justify-content: center;
position: relative;
height: $headerHeight;
&:nth-child(1) {
&.left {
position: absolute;
top: 0;
left: 0;
align-items: center;
gap: 1em;
padding: 0 1em;
z-index: 2;
button {
background-color: #3f51b5;
@@ -70,79 +72,56 @@ $headerHeight: 3.5em;
}
}
&:nth-child(2) {
&.middle {
flex: 1;
$borderRadiusValue: 10em;
position: relative;
transition: max-width .2s ease-in-out;
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
.search-bar {
flex: 1;
$borderRadiusValue: 10em;
position: relative;
app-publications-search-bar {
width: 100%;
max-width: 12em;
transition: max-width .2s ease-in-out;
@media screen and (min-width: 435px) {
max-width: 16em;
}
@media screen and (min-width: 500px) {
max-width: 20em;
}
@media screen and (min-width: 700px) {
max-width: 24em;
}
@media screen and (min-width: 800px) {
max-width: 32em;
}
@media screen and (min-width: 900px) {
max-width: 38em;
}
@media screen and (min-width: 1000px) {
max-width: 45em;
}
@media screen and (min-width: 1100px) {
max-width: 50em;
}
input {
flex: 1;
width: 60%;
max-width: 50em;
border-radius: $borderRadiusValue;
background-color: white;
border: solid 1px #ddd;
padding: .2em 2.7em .2em 1em;
height: 2em;
}
button {
position: absolute;
display: flex;
align-items: center;
border-radius: $borderRadiusValue;
background-color: white;
border: none;
margin: .5em 0;
right: 1.1em;
color: #aaaaaa;
&:hover {
background-color: #eee;
cursor: pointer;
}
}
}
}
&:nth-child(3) {
&.right {
position: absolute;
top: 0;
right: 0;
z-index: 2;
a {
display: flex;
justify-content: center;

View File

@@ -6,18 +6,36 @@ import { AuthenticationService } from '../../core/service/authentication.service
import { CommonModule } from '@angular/common';
import { SideMenuComponent } from '../side-menu/side-menu.component';
import { MatRippleModule } from '@angular/material/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { PublicationsSearchBarComponent } from '../publications-search-bar/publications-search-bar.component';
import { MatTooltipModule } from '@angular/material/tooltip';
@Component({
selector: 'app-header',
standalone: true,
imports: [CommonModule, MatButtonModule, MatIconModule, RouterModule, SideMenuComponent, MatRippleModule],
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
RouterModule,
SideMenuComponent,
MatRippleModule,
ReactiveFormsModule,
PublicationsSearchBarComponent,
MatTooltipModule
],
templateUrl: './header.component.html',
styleUrl: './header.component.scss',
})
export class HeaderComponent {
private authenticationService = inject(AuthenticationService);
searchControl = new FormControl('');
get isAuthenticated(): boolean {
return this.authenticationService.isAuthenticated();
}
searchPublications(): void {
}
}

View File

@@ -0,0 +1,16 @@
@for(publication of publications$ | async; track publication) {
<a [routerLink]="['/publications/' + publication.id]" class="publication">
<img src="/pictures/{{ publication.illustrationId }}"/>
<div class="body">
<h1>{{publication.title}}</h1>
<h2>{{publication.description}}</h2>
</div>
<div class="footer">
<img src="/pictures/{{ publication.author.image }}" [matTooltip]="publication.author.name"/>
Publication posted by {{publication.author.name}}
<span class="publication-date">
({{ publication.creationDate | date: 'short' : 'fr-Fr' }})
</span>
</div>
</a>
}

View File

@@ -0,0 +1,87 @@
$cardBorderRadius: .5em;
:host {
display: flex;
flex-direction: column;
gap: 2em;
max-width: 50em;
width: 90%;
margin: auto;
.publication {
display: flex;
flex-direction: column;
border-radius: $cardBorderRadius;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12);
transition: box-shadow .2s ease-in-out;
text-decoration: none;
color: black;
background-color: #ffffff;
&:hover {
box-shadow: 0 4px 8px 0 rgba(0,0,0,.24),0 4px 14px 0 rgba(0,0,0,.16);
}
img {
object-fit: cover;
height: 15em;
border-radius: $cardBorderRadius $cardBorderRadius 0 0;
transition: height .2s ease-in-out;
@media screen and (min-width: 450px) {
height: 20em;
}
@media screen and (min-width: 600px) {
height: 25em;
}
@media screen and (min-width: 750px) {
height: 32em;
}
}
.body {
display: flex;
flex-direction: column;
padding: 1.5em 2em;
h1 {
font-size: 1.8em;
margin-bottom: .5em;
}
h2 {
font-size: 1em;
line-height: 1.4em;
margin: 0;
color: #747373;
font-weight: 400;
}
}
.footer {
display: flex;
flex-direction: row;
align-items: center;
background-color: #f0f0f0;
border-radius: 0 0 $cardBorderRadius $cardBorderRadius;
padding: 1em 2em;
gap: 1em;
color: #6c757d;
img {
$imageSize: 4em;
border-radius: 10em;
width: $imageSize;
height: $imageSize;
object-fit: cover;
}
.publication-date {
font-style: italic;
color: #bdbdbd;
}
}
}
}

View File

@@ -0,0 +1,18 @@
import { Component, Input } from "@angular/core";
import { Publication } from "../../core/rest-services/publications/model/publication";
import { Observable } from "rxjs";
import { CommonModule } from "@angular/common";
import { RouterModule } from "@angular/router";
import { MatTooltipModule } from "@angular/material/tooltip";
@Component({
selector: 'app-publication-list',
standalone: true,
templateUrl: './publication-list.component.html',
styleUrl: './publication-list.component.scss',
imports: [CommonModule, RouterModule, MatTooltipModule]
})
export class PublicationListComponent {
@Input()
publications$!: Observable<Publication[]>;
}

View File

@@ -0,0 +1,6 @@
<form [formGroup]="formGroup">
<input name="search-query" placeholder="Search something..." formControlName="criteria"/>
<button type="submit" (click)="searchPublications()">
<mat-icon>search</mat-icon>
</button>
</form>

View File

@@ -0,0 +1,37 @@
:host {
$borderRadiusValue: 10em;
position: relative;
flex-direction: row;
align-items: center;
form {
display: flex;
input {
flex: 1;
border-radius: $borderRadiusValue;
background-color: white;
border: solid 1px #ddd;
padding: .2em 2.7em .2em 1em;
height: 2em;
width: 100%;
}
button {
position: absolute;
display: flex;
align-items: center;
border-radius: $borderRadiusValue;
background-color: white;
border: none;
top: .4em;
right: 0;
color: #aaaaaa;
&:hover {
background-color: #eee;
cursor: pointer;
}
}
}
}

View File

@@ -0,0 +1,31 @@
import { HttpParams } from "@angular/common/http";
import { Component, inject } from "@angular/core";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { MatIconModule } from "@angular/material/icon";
import { Router } from "@angular/router";
@Component({
selector: 'app-publications-search-bar',
templateUrl: './publications-search-bar.component.html',
styleUrl: './publications-search-bar.component.scss',
standalone: true,
imports: [
ReactiveFormsModule,
MatIconModule
],
providers: []
})
export class PublicationsSearchBarComponent {
private formBuilder = inject(FormBuilder);
private router = inject(Router);
formGroup = this.formBuilder.group({
criteria: new FormControl<string | undefined>('', [Validators.required])
});
searchPublications(): void {
let queryParams = new HttpParams();
queryParams = queryParams.set('query', this.formGroup.controls.criteria.value ?? '');
this.router.navigate(['/publications'], {queryParams});
}
}

View File

@@ -0,0 +1,18 @@
import { HttpParams } from "@angular/common/http";
import { inject, Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { BehaviorSubject } from "rxjs";
@Injectable()
export class PublicationsSearchBarService {
private router = inject(Router);
private criteriaSubject = new BehaviorSubject<string>('');
private cri
searchPublications(): void {
let queryParams = new HttpParams();
queryParams = queryParams.set('query', this.criteriaSubject.value);
this.router.navigate(['/publications'], {queryParams});
}
}

View File

@@ -6,7 +6,7 @@
</div>
<div class="sub-category-container {{category.isOpenned ? 'displayed' : ''}}">
@for(subCategory of category.subCategories; track subCategory) {
<a [routerLink]="['/categories/' + subCategory.id]" class="sub-category">
<a [routerLink]="['/publications']" [queryParams]="{'category-id': subCategory.id}" (click)="categoryClicked.emit()" class="sub-category">
{{subCategory.name}}
</a>
}

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, inject, OnInit } from "@angular/core";
import { Component, EventEmitter, inject, OnInit, Output } from "@angular/core";
import { MatIconModule } from "@angular/material/icon";
import { DisplayableCategory, SideMenuService } from "../side-menu.service";
import { Observable } from "rxjs";
@@ -14,6 +14,8 @@ import { RouterModule } from "@angular/router";
})
export class CategoriesMenuComponent implements OnInit {
private sideMenuService = inject(SideMenuService);
@Output()
categoryClicked = new EventEmitter<void>();
ngOnInit(): void {
this.sideMenuService.loadCategories();

View File

@@ -9,6 +9,6 @@
</button>
</h1>
<h2>Catégories</h2>
<app-categories-menu></app-categories-menu>
<app-categories-menu (categoryClicked)="close()"></app-categories-menu>
</div>
<div class="overlay {{ isOpenned ? 'displayed' : ''}}" (click)="close()"></div>

View File

@@ -1,94 +1,6 @@
$cardBorderRadius: .5em;
:host {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.publication-container {
display: flex;
flex-direction: column;
gap: 2em;
max-width: 50em;
width: 90%;
margin: auto;
.publication {
display: flex;
flex-direction: column;
border-radius: $cardBorderRadius;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12);
transition: box-shadow .2s ease-in-out;
text-decoration: none;
color: black;
background-color: #ffffff;
&:hover {
box-shadow: 0 4px 8px 0 rgba(0,0,0,.24),0 4px 14px 0 rgba(0,0,0,.16);
}
img {
object-fit: cover;
height: 15em;
border-radius: $cardBorderRadius $cardBorderRadius 0 0;
transition: height .2s ease-in-out;
@media screen and (min-width: 450px) {
height: 20em;
}
@media screen and (min-width: 600px) {
height: 25em;
}
@media screen and (min-width: 750px) {
height: 32em;
}
}
.body {
display: flex;
flex-direction: column;
padding: 1.5em 2em;
h1 {
font-size: 1.8em;
margin-bottom: .5em;
}
h2 {
font-size: 1em;
line-height: 1.4em;
margin: 0;
color: #747373;
font-weight: 400;
}
}
.footer {
display: flex;
flex-direction: row;
align-items: center;
background-color: #f0f0f0;
border-radius: 0 0 $cardBorderRadius $cardBorderRadius;
padding: 1em 2em;
gap: 1em;
color: #6c757d;
img {
$imageSize: 4em;
border-radius: 10em;
width: $imageSize;
height: $imageSize;
object-fit: cover;
}
.publication-date {
font-style: italic;
color: #bdbdbd;
}
}
}
}
}

View File

@@ -0,0 +1,13 @@
<h1>Search results</h1>
@if((isLoading$ | async) === true) {
<h2>Search in progress...</h2>
<mat-spinner></mat-spinner>
} @else if((isLoaded$ | async) === true) {
@if((publications$ | async)?.length) {
<app-publication-list [publications$]="publications$"></app-publication-list>
} @else {
No any result.
}
} @else {
No any result.
}

View File

@@ -0,0 +1,6 @@
:host {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}

View File

@@ -0,0 +1,48 @@
import { CommonModule } from "@angular/common";
import { Component, inject, OnDestroy, OnInit } from "@angular/core";
import { MatProgressSpinner } from "@angular/material/progress-spinner";
import { ActivatedRoute } from "@angular/router";
import { Observable, Subscription } from "rxjs";
import { PublicationListComponent } from "../../components/publication-list/publication-list.component";
import { Publication } from "../../core/rest-services/publications/model/publication";
import { SearchPublicationsService } from "./search-publications.service";
@Component({
selector: 'app-search-publications',
templateUrl: './search-publications.component.html',
styleUrl: './search-publications.component.scss',
standalone: true,
imports: [CommonModule, MatProgressSpinner, PublicationListComponent],
providers: [SearchPublicationsService]
})
export class SearchPublicationsComponent implements OnInit, OnDestroy {
private searchPublicationsService = inject(SearchPublicationsService);
private activatedRoute = inject(ActivatedRoute);
private queryParamsSubscription?: Subscription;
ngOnInit(): void {
this.queryParamsSubscription = this.activatedRoute.queryParamMap
.subscribe(params => {
const categoryId = params.get('category-id');
if (categoryId) {
this.searchPublicationsService.loadPublications(`category_id=${categoryId}`);
}
});
}
ngOnDestroy(): void {
this.queryParamsSubscription?.unsubscribe();
}
get publications$(): Observable<Publication[]> {
return this.searchPublicationsService.publications$;
}
get isLoading$(): Observable<boolean> {
return this.searchPublicationsService.isLoading$;
}
get isLoaded$(): Observable<boolean> {
return this.searchPublicationsService.isLoaded$;
}
}

View File

@@ -0,0 +1,49 @@
import { inject, Injectable } from "@angular/core";
import { PublicationRestService } from "../../core/rest-services/publications/publication.rest-service";
import { BehaviorSubject, Observable } from "rxjs";
import { Publication } from "../../core/rest-services/publications/model/publication";
import { MatSnackBar } from "@angular/material/snack-bar";
@Injectable()
export class SearchPublicationsService {
private publicationRestService = inject(PublicationRestService);
private publicationsSubject = new BehaviorSubject<Publication[]>([]);
private isLoadingSubject = new BehaviorSubject<boolean>(false);
private isLoadedSubject = new BehaviorSubject<boolean>(false);
private snackBar = inject(MatSnackBar);
get publications$(): Observable<Publication[]> {
return this.publicationsSubject.asObservable();
}
get isLoading$(): Observable<boolean> {
return this.isLoadingSubject.asObservable();
}
get isLoaded$(): Observable<boolean> {
return this.isLoadedSubject.asObservable();
}
loadPublications(searchCriteria: string): void {
this.isLoadingSubject.next(true);
this.isLoadedSubject.next(false);
this.publicationsSubject.next([]);
this.publicationRestService.search(searchCriteria)
.then(publications => {
this.publicationsSubject.next(publications);
})
.catch(error => {
if (error.status !== 404) {
const errorMessage = 'An error occured while retrieving publications.';
console.error(errorMessage, error);
this.snackBar.open(errorMessage, 'Close', { duration: 5000 });
}
})
.finally(() => {
this.isLoadingSubject.next(false);
this.isLoadedSubject.next(true);
});
}
}