diff --git a/backend/codiki-infrastructure/src/main/java/org/codiki/infrastructure/category/repository/CategoryRepository.java b/backend/codiki-infrastructure/src/main/java/org/codiki/infrastructure/category/repository/CategoryRepository.java index e842783..bd152b0 100644 --- a/backend/codiki-infrastructure/src/main/java/org/codiki/infrastructure/category/repository/CategoryRepository.java +++ b/backend/codiki-infrastructure/src/main/java/org/codiki/infrastructure/category/repository/CategoryRepository.java @@ -1,5 +1,6 @@ package org.codiki.infrastructure.category.repository; +import java.util.List; import java.util.UUID; import org.codiki.infrastructure.category.model.CategoryEntity; @@ -16,4 +17,7 @@ public interface CategoryRepository extends JpaRepository ) > 0 """, nativeQuery = true) boolean existsAnyAssociatedPublication(@Param("categoryId") UUID categoryId); + + @Query("SELECT c FROM CategoryEntity c JOIN FETCH c.subCategories") + List findAll(); } diff --git a/frontend/src/app/components/side-menu/side-menu.component.html b/frontend/src/app/components/side-menu/side-menu.component.html new file mode 100644 index 0000000..464a407 --- /dev/null +++ b/frontend/src/app/components/side-menu/side-menu.component.html @@ -0,0 +1,15 @@ +

Codiki

+

Catégories

+
+
+
+ {{category.name}} + chevron_right +
+
+
+ {{subCategory.name}} +
+
+
+
diff --git a/frontend/src/app/components/side-menu/side-menu.component.scss b/frontend/src/app/components/side-menu/side-menu.component.scss new file mode 100644 index 0000000..e713008 --- /dev/null +++ b/frontend/src/app/components/side-menu/side-menu.component.scss @@ -0,0 +1,52 @@ +:host { + display: flex; + flex-direction: column; + + .categories-container { + display: flex; + flex-direction: column; + + .category { + border: 1px solid blue; + + &:hover { + cursor: pointer; + } + + .category-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: .5em 1em; + + mat-icon { + transition: transform .2s ease-in-out; + } + } + + &.openned { + .category-header { + mat-icon { + transform: rotate(90deg); + } + } + + .sub-category-container { + max-height: none; + } + } + + .sub-category-container { + border: 1px solid red; + overflow: hidden; + max-height: 0; + transition: max-height .2s ease-in-out; + + .sub-category { + padding: .5em 1em .5em 2em; + } + } + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/side-menu/side-menu.component.ts b/frontend/src/app/components/side-menu/side-menu.component.ts new file mode 100644 index 0000000..fb283c1 --- /dev/null +++ b/frontend/src/app/components/side-menu/side-menu.component.ts @@ -0,0 +1,51 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { DisplayableCategory, SideMenuService } from './side-menu.service'; +import { Observable } from 'rxjs'; + +@Component({ + selector: 'app-side-menu', + standalone: true, + imports: [CommonModule, MatIconModule], + templateUrl: './side-menu.component.html', + styleUrl: './side-menu.component.scss' +}) +export class SideMenuComponent { + private sideMenuService = inject(SideMenuService); + + get categories$(): Observable { + return this.sideMenuService.categories$; + } + + setOpenned(category: DisplayableCategory): void { + if (category.isOpenned) { + const categoryDiv = document.getElementById(`category-${category.id}`); + if (categoryDiv) { + this.closeAccordion(categoryDiv); + } + } else { + const categoriesDivs = document.getElementsByClassName('category-header'); + Array.from(categoriesDivs) + .map(category => category as HTMLElement) + .forEach(categoryDiv => this.closeAccordion(categoryDiv)); + + const categoryDiv = document.getElementById(`category-${category.id}`); + if (categoryDiv) { + this.openAccordion(categoryDiv); + } + } + + this.sideMenuService.setOpenned(category); + } + + private closeAccordion(categoryDiv: HTMLElement): void { + const divContent = categoryDiv?.nextElementSibling as HTMLElement; + divContent.style.maxHeight = '0'; + } + + private openAccordion(categoryDiv: HTMLElement): void { + const divContent = categoryDiv?.nextElementSibling as HTMLElement; + divContent.style.maxHeight = `${divContent.scrollHeight}px`; + } +} diff --git a/frontend/src/app/components/side-menu/side-menu.service.ts b/frontend/src/app/components/side-menu/side-menu.service.ts new file mode 100644 index 0000000..bf4ce5b --- /dev/null +++ b/frontend/src/app/components/side-menu/side-menu.service.ts @@ -0,0 +1,86 @@ +import { Injectable, OnDestroy, inject } from '@angular/core'; +import { CategoryService } from '../../core/service/category.service'; +import { BehaviorSubject, Observable, Subscription, map } from 'rxjs'; +import { Category } from '../../core/rest-services/category/model/category'; + +export interface DisplayableCategory { + id: string; + name: string; + subCategories: DisplayableSubCategory[]; + isOpenned: boolean; +} + +export interface DisplayableSubCategory { + id: string; + name: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class SideMenuService implements OnDestroy { + private categoryService = inject(CategoryService); + private categoriesSubject = new BehaviorSubject([]); + private categoriesSubscription: Subscription | undefined; + + constructor() { + this.categoriesSubscription = this.categoryService.categories$ + .pipe( + map(categories => + categories + .filter(category => category.subCategories?.length) + .map(category => + this.mapToDisplayableCategory(category) + ) + ) + ) + .subscribe(categories => this.categoriesSubject.next(categories)); + } + + ngOnDestroy(): void { + this.categoriesSubscription?.unsubscribe(); + } + + private mapToDisplayableCategory(category: Category): DisplayableCategory { + return { + id: category.id, + name: category.name, + subCategories: category.subCategories.map(subCategory => this.mapToDisplayableSubCategory(subCategory)), + isOpenned: false + }; + } + + private mapToDisplayableSubCategory(subCategory: Category): DisplayableSubCategory { + return { + id: subCategory.id, + name: subCategory.name + } + } + + get categories$(): Observable { + return this.categoriesSubject.asObservable(); + } + + private get categories(): DisplayableCategory[] { + return this.categoriesSubject.value; + } + + private save(categories: DisplayableCategory[]): void { + this.categoriesSubject.next(categories); + } + + setOpenned(category: DisplayableCategory): void { + const categories = this.categories; + const matchingCategory = categories.find(categoryTemp => categoryTemp.id === category.id); + if (matchingCategory) { + const actualOpennedCategory = categories.find(category => category.isOpenned); + if (actualOpennedCategory && actualOpennedCategory.id === matchingCategory.id) { + matchingCategory.isOpenned = false; + } else { + categories.forEach(categoryTemp => categoryTemp.isOpenned = false); + matchingCategory.isOpenned = true; + } + this.save(categories); + } + } +} diff --git a/frontend/src/app/core/rest-services/category/category.rest-service.ts b/frontend/src/app/core/rest-services/category/category.rest-service.ts new file mode 100644 index 0000000..9ad8e7b --- /dev/null +++ b/frontend/src/app/core/rest-services/category/category.rest-service.ts @@ -0,0 +1,15 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { lastValueFrom } from 'rxjs'; +import { Category } from './model/category'; + +@Injectable({ + providedIn: 'root' +}) +export class CategoryRestService { + private httpClient = inject(HttpClient); + + getCategories(): Promise { + return lastValueFrom(this.httpClient.get('/api/categories')); + } +} diff --git a/frontend/src/app/core/rest-services/category/model/category.ts b/frontend/src/app/core/rest-services/category/model/category.ts new file mode 100644 index 0000000..e7e0a43 --- /dev/null +++ b/frontend/src/app/core/rest-services/category/model/category.ts @@ -0,0 +1,5 @@ +export interface Category { + id: string; + name: string; + subCategories: Category[]; +} \ No newline at end of file diff --git a/frontend/src/app/core/rest-services/model/login.model.ts b/frontend/src/app/core/rest-services/user/model/login.model.ts similarity index 100% rename from frontend/src/app/core/rest-services/model/login.model.ts rename to frontend/src/app/core/rest-services/user/model/login.model.ts diff --git a/frontend/src/app/core/rest-services/user.rest-service.ts b/frontend/src/app/core/rest-services/user/user.rest-service.ts similarity index 100% rename from frontend/src/app/core/rest-services/user.rest-service.ts rename to frontend/src/app/core/rest-services/user/user.rest-service.ts diff --git a/frontend/src/app/core/service/category.service.ts b/frontend/src/app/core/service/category.service.ts new file mode 100644 index 0000000..1ba3429 --- /dev/null +++ b/frontend/src/app/core/service/category.service.ts @@ -0,0 +1,25 @@ +import { Injectable, inject } from '@angular/core'; +import { CategoryRestService } from '../rest-services/category/category.rest-service'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { Category } from '../rest-services/category/model/category'; + +@Injectable({ + providedIn: 'root' +}) +export class CategoryService { + private categoryRestService = inject(CategoryRestService); + private categoriesSubject = new BehaviorSubject([]); + + private get categories(): Category[] { + return this.categoriesSubject.value; + } + + get categories$(): Observable { + if (!this.categories?.length) { + this.categoryRestService.getCategories() + .then(categories => this.categoriesSubject.next(categories)) + .catch(error => console.error('An error occured while loading categories.', error)); + } + return this.categoriesSubject.asObservable(); + } +} diff --git a/frontend/src/app/pages/home/home.component.html b/frontend/src/app/pages/home/home.component.html index 26e0443..cdaa426 100644 --- a/frontend/src/app/pages/home/home.component.html +++ b/frontend/src/app/pages/home/home.component.html @@ -1 +1,2 @@

Welcome to Codiki application!

+ \ No newline at end of file diff --git a/frontend/src/app/pages/home/home.component.ts b/frontend/src/app/pages/home/home.component.ts index deb69c4..46417d8 100644 --- a/frontend/src/app/pages/home/home.component.ts +++ b/frontend/src/app/pages/home/home.component.ts @@ -1,9 +1,10 @@ import { Component } from '@angular/core'; +import { SideMenuComponent } from '../../components/side-menu/side-menu.component'; @Component({ selector: 'app-home', standalone: true, - imports: [], + imports: [SideMenuComponent], templateUrl: './home.component.html', styleUrl: './home.component.scss' }) diff --git a/frontend/src/app/pages/login/login.service.ts b/frontend/src/app/pages/login/login.service.ts index 84fd89f..209f90a 100644 --- a/frontend/src/app/pages/login/login.service.ts +++ b/frontend/src/app/pages/login/login.service.ts @@ -2,8 +2,8 @@ import { Injectable, inject } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { copy } from '../../core/utils/ObjectUtils'; import { FormError } from '../../core/model/FormError'; -import { UserRestService } from '../../core/rest-services/user.rest-service'; -import { LoginRequest } from '../../core/rest-services/model/login.model'; +import { UserRestService } from '../../core/rest-services/user/user.rest-service'; +import { LoginRequest } from '../../core/rest-services/user/model/login.model'; import { AuthenticationService } from '../../core/service/authentication.service'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router';