Compare commits

7 Commits

Author SHA1 Message Date
Florian THIERRY
d324b94ddb Add SQL script to rebuild categories hierarchy. 2024-04-22 16:05:59 +02:00
Florian THIERRY
4985889c58 Styling header. 2024-04-22 16:05:35 +02:00
Florian THIERRY
7f5d52dce5 Add side menu for header. 2024-04-22 15:57:22 +02:00
Florian THIERRY
fae709a254 Fix class not found error. 2024-04-22 14:37:20 +02:00
Florian THIERRY
45355f6c42 Refactor publication parser location. 2024-04-22 14:22:36 +02:00
Florian THIERRY
db492b6316 Add parsed text to publication entities. 2024-04-22 14:13:17 +02:00
Florian THIERRY
c54e1c57d7 Creation of side-menu. 2024-04-02 16:18:03 +02:00
28 changed files with 650 additions and 46 deletions

View File

@@ -33,6 +33,10 @@
<groupId>com.auth0</groupId> <groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId> <artifactId>java-jwt</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId> <artifactId>junit-jupiter-api</artifactId>

View File

@@ -0,0 +1,144 @@
package org.codiki.application.publication;
import org.springframework.stereotype.Service;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.apache.commons.lang3.StringEscapeUtils.escapeHtml4;
@Service
public class ParserService {
private static final String REG_CODE = "\\[code lg=&quot;([a-z]+)&quot;\\](.*)\\[\\/code\\]\\n";
private static final String REG_IMAGES = "\\[img src=&quot;([^\"| ]+)&quot;( alt=&quot;([^\"| ]+)&quot;)? \\/\\]";
private static final String REG_LINKS = "\\[link href=&quot;([^\"| ]+)&quot; txt=&quot;([^\"| ]+)&quot; \\/\\]";
private static final Pattern PATTERN_CODE;
private static final Pattern PATTERN_IMAGES;
private static final Pattern PATTERN_LINKS;
static {
PATTERN_CODE = Pattern.compile(REG_CODE);
PATTERN_IMAGES = Pattern.compile(REG_IMAGES);
PATTERN_LINKS = Pattern.compile(REG_LINKS);
}
public String parse(String pSource) {
return unParseDolars(parseCode(parseHeaders(parseImages(parseLinks(parseBackSpaces(escapeHtml4(parseDolars(pSource))))))));
}
private String parseDolars(final String pSource) {
return pSource.replace("$", "£ø");
}
private String unParseDolars(final String pSource) {
return pSource.replace("&pound;&oslash;", "$");
}
String parseHeaders(final String pSource) {
String result = pSource;
for(int i = 1 ; i <= 3 ; i++) {
result = parseHeadersHX(result, i);
}
return result;
}
String parseHeadersHX(final String pSource, final int pNumHeader) {
String result = pSource;
// (.*)(\[hX\](.*)\[\/hX\])+(.*)
final String regex = concat("(.*)(\\[h", pNumHeader, "\\](.*)\\[\\/h", pNumHeader, "\\])+(.*)");
final Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(result);
while(matcher.find()) {
// \1<hX>\3</hX>\4
result = matcher.replaceFirst(concat(matcher.group(1),
"<h", pNumHeader, ">", matcher.group(3), "</h", pNumHeader, ">", matcher.group(4)));
matcher = pattern.matcher(result);
}
return result;
}
String parseBackSpaces(final String pSource) {
return pSource.replaceAll("\r?\n", "<br/>").replaceAll("\\[\\/code\\]<br\\/><br\\/>", "[/code]\n");
}
String parseImages(final String pSource) {
String result = pSource;
Matcher matcher = PATTERN_IMAGES.matcher(result);
while(matcher.find()) {
String altStr = matcher.group(3);
if(altStr == null) {
altStr = "";
}
result = matcher.replaceFirst(concat("<img src=\"", matcher.group(1), "\" alt=\"", altStr, "\" />"));
matcher = PATTERN_IMAGES.matcher(result);
}
return result;
}
String parseLinks(final String pSource) {
String result = pSource;
Matcher matcher = PATTERN_LINKS.matcher(result);
while(matcher.find()) {
result = matcher.replaceFirst(concat("<a href=\"", matcher.group(1), "\">", matcher.group(2), "</a>"));
matcher = PATTERN_LINKS.matcher(result);
}
return result;
}
protected String parseCode(final String pSource) {
String result = pSource;
Matcher matcher = PATTERN_CODE.matcher(result);
while(matcher.find()) {
// replace the '<br/>' in group by '\n'
String codeContent = matcher.group(2).replaceAll("<br\\/>", "\n");
if(codeContent.startsWith("\n")) {
codeContent = codeContent.substring(1);
}
result = matcher.replaceFirst(
concat(
"<pre class=\"line-numbers\"><code class=\"language-",
matcher.group(1),
"\">",
codeContent,
"</code></pre>"
)
);
matcher = PATTERN_CODE.matcher(result);
}
return result;
}
/**
* Concatenate the parameters to form just one single string.
*
* @param pArgs
* The strings to concatenate.
* @return The parameters concatenated.
*/
private static String concat(final Object... pArgs) {
final StringBuilder result = new StringBuilder();
for (final Object arg : pArgs) {
result.append(arg);
}
return result.toString();
}
}

View File

@@ -9,6 +9,8 @@ import java.util.UUID;
import static org.codiki.domain.publication.model.builder.AuthorBuilder.anAuthor; import static org.codiki.domain.publication.model.builder.AuthorBuilder.anAuthor;
import static org.codiki.domain.publication.model.builder.PublicationBuilder.aPublication; import static org.codiki.domain.publication.model.builder.PublicationBuilder.aPublication;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.application.category.CategoryUseCases; import org.codiki.application.category.CategoryUseCases;
import org.codiki.application.picture.PictureUseCases; import org.codiki.application.picture.PictureUseCases;
import org.codiki.application.user.UserUseCases; import org.codiki.application.user.UserUseCases;
@@ -31,6 +33,7 @@ public class PublicationUseCases {
private final CategoryUseCases categoryUseCases; private final CategoryUseCases categoryUseCases;
private final Clock clock; private final Clock clock;
private final KeyGenerator keyGenerator; private final KeyGenerator keyGenerator;
private final ParserService parserService;
private final PictureUseCases pictureUseCases; private final PictureUseCases pictureUseCases;
private final PublicationCreationRequestValidator publicationCreationRequestValidator; private final PublicationCreationRequestValidator publicationCreationRequestValidator;
private final PublicationPort publicationPort; private final PublicationPort publicationPort;
@@ -42,6 +45,7 @@ public class PublicationUseCases {
CategoryUseCases categoryUseCases, CategoryUseCases categoryUseCases,
Clock clock, Clock clock,
KeyGenerator keyGenerator, KeyGenerator keyGenerator,
ParserService parserService,
PictureUseCases pictureUseCases, PictureUseCases pictureUseCases,
PublicationCreationRequestValidator publicationCreationRequestValidator, PublicationCreationRequestValidator publicationCreationRequestValidator,
PublicationPort publicationPort, PublicationPort publicationPort,
@@ -52,6 +56,7 @@ public class PublicationUseCases {
this.categoryUseCases = categoryUseCases; this.categoryUseCases = categoryUseCases;
this.clock = clock; this.clock = clock;
this.keyGenerator = keyGenerator; this.keyGenerator = keyGenerator;
this.parserService = parserService;
this.publicationCreationRequestValidator = publicationCreationRequestValidator; this.publicationCreationRequestValidator = publicationCreationRequestValidator;
this.publicationPort = publicationPort; this.publicationPort = publicationPort;
this.publicationUpdateRequestValidator = publicationUpdateRequestValidator; this.publicationUpdateRequestValidator = publicationUpdateRequestValidator;
@@ -83,6 +88,7 @@ public class PublicationUseCases {
.withKey(keyGenerator.generateKey()) .withKey(keyGenerator.generateKey())
.withTitle(request.title()) .withTitle(request.title())
.withText(request.text()) .withText(request.text())
.withParsedText(parserService.parse(request.text()))
.withDescription(request.description()) .withDescription(request.description())
.withCreationDate(ZonedDateTime.now(clock)) .withCreationDate(ZonedDateTime.now(clock))
.withIllustrationId(request.illustrationId()) .withIllustrationId(request.illustrationId())
@@ -116,6 +122,7 @@ public class PublicationUseCases {
if (!isNull(request.text())) { if (!isNull(request.text())) {
publicationBuilder.withText(request.text()); publicationBuilder.withText(request.text());
publicationBuilder.withParsedText(parserService.parse(request.text()));
} }
if (!isNull(request.description())) { if (!isNull(request.description())) {
@@ -163,7 +170,19 @@ public class PublicationUseCases {
} }
public Optional<Publication> findById(UUID publicationId) { public Optional<Publication> findById(UUID publicationId) {
return publicationPort.findById(publicationId); return publicationPort.findById(publicationId)
.map(publication -> {
Publication result = publication;
if (isEmpty(publication.parsedText())) {
Publication editedPublication = aPublication()
.basedOn(publication)
.withParsedText(parserService.parse(publication.text()))
.build();
publicationPort.save(editedPublication);
result = editedPublication;
}
return result;
});
} }
public List<Publication> searchPublications(String searchQuery) { public List<Publication> searchPublications(String searchQuery) {

View File

@@ -8,6 +8,7 @@ public record Publication(
String key, String key,
String title, String title,
String text, String text,
String parsedText,
String description, String description,
ZonedDateTime creationDate, ZonedDateTime creationDate,
UUID illustrationId, UUID illustrationId,

View File

@@ -1,17 +1,17 @@
package org.codiki.domain.publication.model.builder; package org.codiki.domain.publication.model.builder;
import org.codiki.domain.publication.model.Author;
import org.codiki.domain.publication.model.Publication;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.UUID; import java.util.UUID;
import org.codiki.domain.category.model.Category;
import org.codiki.domain.publication.model.Author;
import org.codiki.domain.publication.model.Publication;
public class PublicationBuilder { public class PublicationBuilder {
private UUID id; private UUID id;
private String key; private String key;
private String title; private String title;
private String text; private String text;
private String parsedText;
private String description; private String description;
private ZonedDateTime creationDate; private ZonedDateTime creationDate;
private UUID illustrationId; private UUID illustrationId;
@@ -30,6 +30,7 @@ public class PublicationBuilder {
.withKey(publication.key()) .withKey(publication.key())
.withTitle(publication.title()) .withTitle(publication.title())
.withText(publication.text()) .withText(publication.text())
.withParsedText(publication.parsedText())
.withDescription(publication.description()) .withDescription(publication.description())
.withCreationDate(publication.creationDate()) .withCreationDate(publication.creationDate())
.withIllustrationId(publication.illustrationId()) .withIllustrationId(publication.illustrationId())
@@ -57,6 +58,11 @@ public class PublicationBuilder {
return this; return this;
} }
public PublicationBuilder withParsedText(String parsedText) {
this.parsedText = parsedText;
return this;
}
public PublicationBuilder withDescription(String description) { public PublicationBuilder withDescription(String description) {
this.description = description; this.description = description;
return this; return this;
@@ -88,6 +94,7 @@ public class PublicationBuilder {
key, key,
title, title,
text, text,
parsedText,
description, description,
creationDate, creationDate,
illustrationId, illustrationId,

View File

@@ -1,16 +1,16 @@
package org.codiki.exposition.publication.model; package org.codiki.exposition.publication.model;
import org.codiki.domain.publication.model.Publication;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.UUID; import java.util.UUID;
import org.codiki.domain.publication.model.Publication;
import org.codiki.exposition.category.model.CategoryDto;
public record PublicationDto( public record PublicationDto(
UUID id, UUID id,
String key, String key,
String title, String title,
String text, String text,
String parsedText,
String description, String description,
ZonedDateTime creationDate, ZonedDateTime creationDate,
UUID illustrationId, UUID illustrationId,
@@ -23,6 +23,7 @@ public record PublicationDto(
publication.key(), publication.key(),
publication.title(), publication.title(),
publication.text(), publication.text(),
publication.parsedText(),
publication.description(), publication.description(),
publication.creationDate(), publication.creationDate(),
publication.illustrationId(), publication.illustrationId(),

View File

@@ -1,5 +1,6 @@
package org.codiki.infrastructure.category.repository; package org.codiki.infrastructure.category.repository;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.codiki.infrastructure.category.model.CategoryEntity; import org.codiki.infrastructure.category.model.CategoryEntity;
@@ -16,4 +17,7 @@ public interface CategoryRepository extends JpaRepository<CategoryEntity, UUID>
) > 0 ) > 0
""", nativeQuery = true) """, nativeQuery = true)
boolean existsAnyAssociatedPublication(@Param("categoryId") UUID categoryId); boolean existsAnyAssociatedPublication(@Param("categoryId") UUID categoryId);
@Query("SELECT c FROM CategoryEntity c JOIN FETCH c.subCategories")
List<CategoryEntity> findAll();
} }

View File

@@ -1,11 +1,5 @@
package org.codiki.infrastructure.publication; package org.codiki.infrastructure.publication;
import static java.util.Collections.reverseOrder;
import static java.util.Comparator.comparingInt;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.domain.publication.model.Publication; import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion; import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.port.PublicationPort; import org.codiki.domain.publication.port.PublicationPort;
@@ -14,6 +8,13 @@ import org.codiki.infrastructure.publication.model.PublicationSearchResult;
import org.codiki.infrastructure.publication.repository.PublicationRepository; import org.codiki.infrastructure.publication.repository.PublicationRepository;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static java.util.Collections.reverseOrder;
import static java.util.Comparator.comparingInt;
@Component @Component
public class PublicationJpaAdapter implements PublicationPort { public class PublicationJpaAdapter implements PublicationPort {
private final PublicationRepository repository; private final PublicationRepository repository;

View File

@@ -1,22 +1,16 @@
package org.codiki.infrastructure.publication.model; package org.codiki.infrastructure.publication.model;
import java.time.ZonedDateTime; import jakarta.persistence.*;
import java.util.UUID;
import org.codiki.domain.publication.model.Publication;
import org.codiki.infrastructure.category.model.CategoryEntity;
import static jakarta.persistence.FetchType.LAZY;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import org.codiki.domain.publication.model.Publication;
import java.time.ZonedDateTime;
import java.util.UUID;
import static jakarta.persistence.FetchType.LAZY;
@Entity @Entity
@Table(name = "publication") @Table(name = "publication")
@@ -34,6 +28,8 @@ public class PublicationEntity {
@Column(nullable = false) @Column(nullable = false)
private String text; private String text;
@Column(nullable = false) @Column(nullable = false)
private String parsedText;
@Column(nullable = false)
private String description; private String description;
@Column(nullable = false) @Column(nullable = false)
private ZonedDateTime creationDate; private ZonedDateTime creationDate;
@@ -51,6 +47,7 @@ public class PublicationEntity {
publication.key(), publication.key(),
publication.title(), publication.title(),
publication.text(), publication.text(),
publication.parsedText(),
publication.description(), publication.description(),
publication.creationDate(), publication.creationDate(),
publication.illustrationId(), publication.illustrationId(),
@@ -65,6 +62,7 @@ public class PublicationEntity {
key, key,
title, title,
text, text,
parsedText,
description, description,
creationDate, creationDate,
illustrationId, illustrationId,

View File

@@ -10,3 +10,27 @@ insert into user_role values
insert into category values insert into category values
('172fa901-3f4b-4540-92f3-1c15820e8ec9', 'Main category', null), ('172fa901-3f4b-4540-92f3-1c15820e8ec9', 'Main category', null),
('3f4b4540-a901-92f3-1c15-8ec9172f820e', 'Sub category', '172fa901-3f4b-4540-92f3-1c15820e8ec9'); ('3f4b4540-a901-92f3-1c15-8ec9172f820e', 'Sub category', '172fa901-3f4b-4540-92f3-1c15820e8ec9');
UPDATE public.category
SET parent_category_id='04347de5-2814-4aff-9fe9-51b34c1c743e'
WHERE id in (
'0f4c4d7c-2ccc-4725-88b6-672aa518da90',
'2cad9c28-ab5d-4c8f-b7da-70ff8bc02586'
);
UPDATE public.category
SET parent_category_id='3dec7c5a-e7d6-4b21-beb1-209cdf5be067'
WHERE id in (
'61f9fbf3-3340-4ea4-9661-04089377bb2e',
'753570cc-3403-4bac-b9da-6c19875d98b7',
'1515ff79-e42e-4d84-9496-6cdcf1cb74f2',
'b58bda0b-2f45-4c7a-8ece-1a206fb32a7a',
'49b4df8a-19f5-459b-b508-6b7c71332523'
);
UPDATE public.category
SET parent_category_id='41b2792e-6f65-48be-8718-82ac58101aa8'
WHERE id in (
'7234cd9e-3834-45c5-973b-1574f5c3c4c6',
'f46fb104-4f53-4732-b33b-6a3ef8c2c0a3'
);

View File

@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS publication (
key VARCHAR(14) NOT NULL, key VARCHAR(14) NOT NULL,
title VARCHAR NOT NULL, title VARCHAR NOT NULL,
text VARCHAR NOT NULL, text VARCHAR NOT NULL,
parsed_text VARCHAR,
description VARCHAR NOT NULL, description VARCHAR NOT NULL,
creation_date TIMESTAMP NOT NULL, creation_date TIMESTAMP NOT NULL,
illustration_id UUID NOT NULL, illustration_id UUID NOT NULL,

View File

@@ -19,6 +19,7 @@
<java-jwt.version>4.4.0</java-jwt.version> <java-jwt.version>4.4.0</java-jwt.version>
<postgresql.version>42.7.0</postgresql.version> <postgresql.version>42.7.0</postgresql.version>
<tika-core.version>2.9.0</tika-core.version> <tika-core.version>2.9.0</tika-core.version>
<commons-lang3.version>3.14.0</commons-lang3.version>
</properties> </properties>
<modules> <modules>
@@ -78,6 +79,13 @@
<artifactId>tika-core</artifactId> <artifactId>tika-core</artifactId>
<version>${tika-core.version}</version> <version>${tika-core.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

View File

@@ -1,5 +1,5 @@
<div> <div>
<button type="button"> <button type="button" (click)="sideMenu.open()">
<mat-icon>menu</mat-icon> <mat-icon>menu</mat-icon>
</button> </button>
<a [routerLink]="['/home']"> <a [routerLink]="['/home']">
@@ -21,3 +21,4 @@
<a [routerLink]="['/login']">Login</a> <a [routerLink]="['/login']">Login</a>
</ng-template> </ng-template>
</div> </div>
<app-side-menu #sideMenu></app-side-menu>

View File

@@ -7,11 +7,10 @@ $headerHeight: 3.5em;
background-color: #3f51b5; background-color: #3f51b5;
color: white; color: white;
position: relative; position: relative;
border: 1px solid black;
height: $headerHeight; height: $headerHeight;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12);
div { div {
border: 1px solid black;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
@@ -25,6 +24,22 @@ $headerHeight: 3.5em;
gap: 1em; gap: 1em;
padding: 0 1em; padding: 0 1em;
button {
background-color: #3f51b5;
color: white;
border: none;
border-radius: 10em;
transition: background-color .2s ease-in-out;
display: flex;
justify-content: center;
align-items: center;
&:hover {
cursor: pointer;
background-color: #5c6bc0;
}
}
a { a {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -91,3 +106,7 @@ $headerHeight: 3.5em;
} }
} }
} }
app-side-menu {
height: 100%;
}

View File

@@ -4,11 +4,12 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AuthenticationService } from '../../core/service/authentication.service'; import { AuthenticationService } from '../../core/service/authentication.service';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { SideMenuComponent } from '../side-menu/side-menu.component';
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
standalone: true, standalone: true,
imports: [CommonModule, MatButtonModule, MatIconModule, RouterModule], imports: [CommonModule, MatButtonModule, MatIconModule, RouterModule, SideMenuComponent],
templateUrl: './header.component.html', templateUrl: './header.component.html',
styleUrl: './header.component.scss', styleUrl: './header.component.scss',
}) })

View File

@@ -0,0 +1,11 @@
<div class="category {{category.isOpenned ? 'openned' : ''}}" *ngFor="let category of categories$ | async">
<div id="category-{{category.id}}" class="category-header" (click)="setOpenned(category)">
{{category.name}}
<mat-icon>chevron_right</mat-icon>
</div>
<div class="sub-category-container {{category.isOpenned ? 'displayed' : ''}}">
<a [routerLink]="['/']" class="sub-category" *ngFor="let subCategory of category.subCategories">
{{subCategory.name}}
</a>
</div>
</div>

View File

@@ -0,0 +1,57 @@
:host {
display: flex;
flex-direction: column;
.category {
transition: background-color .2s ease-in-out;
&:hover {
cursor: pointer;
background-color: #5c6bc0;
}
.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 {
display: flex;
flex-direction: column;
overflow: hidden;
max-height: 0;
transition: max-height .2s ease-in-out;
background-color: #303f9f;
.sub-category {
padding: .5em 1em .5em 2em;
text-decoration: none;
color: inherit;
transition: background-color .2s ease-in-out;
&:hover {
background-color: #5c6bc0;
}
}
}
}
}

View File

@@ -0,0 +1,52 @@
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";
import { RouterModule } from "@angular/router";
@Component({
selector: 'app-categories-menu',
standalone: true,
imports: [CommonModule, RouterModule, MatIconModule],
templateUrl: './categories-menu.component.html',
styleUrl: './categories-menu.component.scss'
})
export class CategoriesMenuComponent {
private sideMenuService = inject(SideMenuService);
get categories$(): Observable<DisplayableCategory[]> {
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`;
}
}

View File

@@ -0,0 +1,14 @@
<div class="menu {{ isOpenned ? 'displayed' : '' }}">
<h1>
<span>
<img src="assets/images/codiki.png" alt="logo"/>
Codiki
</span>
<button type="button" (click)="close()" matTooltip="Close the menu">
<mat-icon>close</mat-icon>
</button>
</h1>
<h2>Catégories</h2>
<app-categories-menu></app-categories-menu>
</div>
<div class="overlay {{ isOpenned ? 'displayed' : ''}}" (click)="close()"></div>

View File

@@ -0,0 +1,83 @@
:host {
.menu {
display: flex;
flex-direction: column;
background-color: #3f51b5;
color: white;
$categoriesMenuWidth: 20em;
position: fixed;
top: 0;
left: -$categoriesMenuWidth - 1em - 1;
bottom: 0;
transition: left .2s ease-in-out;
width: $categoriesMenuWidth;
z-index: 2;
padding: 1em 0;
&.displayed {
left: 0;
}
h1 {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 1em;
span {
display: flex;
flex-direction: row;
justify-content: start;
align-items: center;
gap: .5em;
img {
$imageSize: 1.2em;
width: $imageSize;
height: $imageSize;
}
}
button {
border-radius: 10em;
border: none;
display: flex;
justify-content: center;
align-items: center;
width: 2.5em;
height: 2.5em;
color: white;
background-color: #3f51b5;
transition: background-color .2s ease-in-out;
&:hover {
cursor: pointer;
background-color: #5c6bc0;
}
}
}
h2 {
padding: 0 1em;
}
}
.overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background-color: #000;
opacity: .2;
&.displayed {
display: block;
}
}
}

View File

@@ -0,0 +1,23 @@
import {Component} from '@angular/core';
import {MatIconModule} from '@angular/material/icon';
import {CategoriesMenuComponent} from './categories-menu/categories-menu.component';
import {MatTooltipModule} from '@angular/material/tooltip';
@Component({
selector: 'app-side-menu',
standalone: true,
imports: [CategoriesMenuComponent, MatIconModule, MatTooltipModule],
templateUrl: './side-menu.component.html',
styleUrl: './side-menu.component.scss'
})
export class SideMenuComponent {
isOpenned: boolean = false;
open(): void {
this.isOpenned = true;
}
close(): void {
this.isOpenned = false;
}
}

View File

@@ -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<DisplayableCategory[]>([]);
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<DisplayableCategory[]> {
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);
}
}
}

View File

@@ -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<Category[]> {
return lastValueFrom(this.httpClient.get<Category[]>('/api/categories'));
}
}

View File

@@ -0,0 +1,5 @@
export interface Category {
id: string;
name: string;
subCategories: Category[];
}

View File

@@ -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<Category[]>([]);
private get categories(): Category[] {
return this.categoriesSubject.value;
}
get categories$(): Observable<Category[]> {
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();
}
}

View File

@@ -2,8 +2,8 @@ import { Injectable, inject } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { copy } from '../../core/utils/ObjectUtils'; import { copy } from '../../core/utils/ObjectUtils';
import { FormError } from '../../core/model/FormError'; import { FormError } from '../../core/model/FormError';
import { UserRestService } from '../../core/rest-services/user.rest-service'; import { UserRestService } from '../../core/rest-services/user/user.rest-service';
import { LoginRequest } from '../../core/rest-services/model/login.model'; import { LoginRequest } from '../../core/rest-services/user/model/login.model';
import { AuthenticationService } from '../../core/service/authentication.service'; import { AuthenticationService } from '../../core/service/authentication.service';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router'; import { Router } from '@angular/router';