From 0dd761f79f371bb8ba64316373150244d83b57db Mon Sep 17 00:00:00 2001 From: Gus Date: Sun, 7 Jan 2024 23:17:18 -0600 Subject: [PATCH 01/30] feat(angular-app): add front end functionality --- apps/angular-app/src/app/app.component.html | 1 + apps/angular-app/src/app/app.component.ts | 4 +- apps/angular-app/src/app/app.config.ts | 3 +- apps/angular-app/src/app/app.routes.ts | 21 +++- .../add-post/add-post.component.html | 3 + .../add-post/add-post.component.scss | 16 +++ .../add-post/add-post.component.spec.ts | 21 ++++ .../components/add-post/add-post.component.ts | 19 +++ .../categories/categories.component.html | 8 ++ .../categories/categories.component.scss | 47 +++++++ .../categories/categories.component.spec.ts | 22 ++++ .../categories/categories.component.ts | 21 ++++ .../components/header/header.component.html | 8 ++ .../components/header/header.component.scss | 22 ++++ .../header/header.component.spec.ts | 21 ++++ .../app/components/header/header.component.ts | 11 ++ .../post-grid/post-grid.component.html | 19 +++ .../post-grid/post-grid.component.scss | 95 ++++++++++++++ .../post-grid/post-grid.component.spec.ts | 21 ++++ .../post-grid/post-grid.component.ts | 37 ++++++ .../post-modal/post-modal.component.html | 61 +++++++++ .../post-modal/post-modal.component.scss | 38 ++++++ .../post-modal/post-modal.component.spec.ts | 21 ++++ .../post-modal/post-modal.component.ts | 97 ++++++++++++++ .../post-modal/post-modal.service.spec.ts | 16 +++ .../post-modal/post-modal.service.ts | 33 +++++ apps/angular-app/src/app/models/Category.ts | 9 ++ apps/angular-app/src/app/models/Comment.ts | 11 ++ apps/angular-app/src/app/models/Post.ts | 32 +++++ apps/angular-app/src/app/services/.gitkeep | 0 .../category/category.service.spec.ts | 16 +++ .../app/services/category/category.service.ts | 33 +++++ .../app/services/post/post.service.spec.ts | 16 +++ .../src/app/services/post/post.service.ts | 118 ++++++++++++++++++ .../src/app/shared/url-validator.ts | 10 ++ .../angular-app/src/app/shared/variables.scss | 3 + apps/angular-app/src/app/views/.gitkeep | 0 .../src/app/views/home/home.component.html | 5 + .../home/home.component.scss} | 0 .../src/app/views/home/home.component.spec.ts | 21 ++++ .../src/app/views/home/home.component.ts | 69 ++++++++++ .../page-not-found.component.html | 1 + .../page-not-found.component.scss} | 0 .../page-not-found.component.spec.ts | 21 ++++ .../page-not-found.component.ts | 11 ++ .../src/app/views/post/post.component.html | 43 +++++++ .../src/app/views/post/post.component.scss | 107 ++++++++++++++++ .../src/app/views/post/post.component.spec.ts | 21 ++++ .../src/app/views/post/post.component.ts | 41 ++++++ apps/angular-app/src/index.html | 1 + apps/angular-app/src/styles.scss | 80 ++++++++++++ 51 files changed, 1352 insertions(+), 3 deletions(-) create mode 100644 apps/angular-app/src/app/components/add-post/add-post.component.html create mode 100644 apps/angular-app/src/app/components/add-post/add-post.component.scss create mode 100644 apps/angular-app/src/app/components/add-post/add-post.component.spec.ts create mode 100644 apps/angular-app/src/app/components/add-post/add-post.component.ts create mode 100644 apps/angular-app/src/app/components/categories/categories.component.html create mode 100644 apps/angular-app/src/app/components/categories/categories.component.scss create mode 100644 apps/angular-app/src/app/components/categories/categories.component.spec.ts create mode 100644 apps/angular-app/src/app/components/categories/categories.component.ts create mode 100644 apps/angular-app/src/app/components/header/header.component.html create mode 100644 apps/angular-app/src/app/components/header/header.component.scss create mode 100644 apps/angular-app/src/app/components/header/header.component.spec.ts create mode 100644 apps/angular-app/src/app/components/header/header.component.ts create mode 100644 apps/angular-app/src/app/components/post-grid/post-grid.component.html create mode 100644 apps/angular-app/src/app/components/post-grid/post-grid.component.scss create mode 100644 apps/angular-app/src/app/components/post-grid/post-grid.component.spec.ts create mode 100644 apps/angular-app/src/app/components/post-grid/post-grid.component.ts create mode 100644 apps/angular-app/src/app/components/post-modal/post-modal.component.html create mode 100644 apps/angular-app/src/app/components/post-modal/post-modal.component.scss create mode 100644 apps/angular-app/src/app/components/post-modal/post-modal.component.spec.ts create mode 100644 apps/angular-app/src/app/components/post-modal/post-modal.component.ts create mode 100644 apps/angular-app/src/app/components/post-modal/post-modal.service.spec.ts create mode 100644 apps/angular-app/src/app/components/post-modal/post-modal.service.ts create mode 100644 apps/angular-app/src/app/models/Category.ts create mode 100644 apps/angular-app/src/app/models/Comment.ts create mode 100644 apps/angular-app/src/app/models/Post.ts delete mode 100644 apps/angular-app/src/app/services/.gitkeep create mode 100644 apps/angular-app/src/app/services/category/category.service.spec.ts create mode 100644 apps/angular-app/src/app/services/category/category.service.ts create mode 100644 apps/angular-app/src/app/services/post/post.service.spec.ts create mode 100644 apps/angular-app/src/app/services/post/post.service.ts create mode 100644 apps/angular-app/src/app/shared/url-validator.ts delete mode 100644 apps/angular-app/src/app/views/.gitkeep create mode 100644 apps/angular-app/src/app/views/home/home.component.html rename apps/angular-app/src/app/{components/.gitkeep => views/home/home.component.scss} (100%) create mode 100644 apps/angular-app/src/app/views/home/home.component.spec.ts create mode 100644 apps/angular-app/src/app/views/home/home.component.ts create mode 100644 apps/angular-app/src/app/views/page-not-found/page-not-found.component.html rename apps/angular-app/src/app/{models/.gitkeep => views/page-not-found/page-not-found.component.scss} (100%) create mode 100644 apps/angular-app/src/app/views/page-not-found/page-not-found.component.spec.ts create mode 100644 apps/angular-app/src/app/views/page-not-found/page-not-found.component.ts create mode 100644 apps/angular-app/src/app/views/post/post.component.html create mode 100644 apps/angular-app/src/app/views/post/post.component.scss create mode 100644 apps/angular-app/src/app/views/post/post.component.spec.ts create mode 100644 apps/angular-app/src/app/views/post/post.component.ts diff --git a/apps/angular-app/src/app/app.component.html b/apps/angular-app/src/app/app.component.html index 0680b43f..9721b000 100644 --- a/apps/angular-app/src/app/app.component.html +++ b/apps/angular-app/src/app/app.component.html @@ -1 +1,2 @@ + diff --git a/apps/angular-app/src/app/app.component.ts b/apps/angular-app/src/app/app.component.ts index 74c1d539..1d489374 100644 --- a/apps/angular-app/src/app/app.component.ts +++ b/apps/angular-app/src/app/app.component.ts @@ -1,9 +1,11 @@ import { Component } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { HeaderComponent } from './components/header/header.component'; + @Component({ standalone: true, - imports: [RouterModule], + imports: [HeaderComponent, RouterModule], selector: 'mfee-project-root', templateUrl: './app.component.html', styleUrl: './app.component.scss' diff --git a/apps/angular-app/src/app/app.config.ts b/apps/angular-app/src/app/app.config.ts index 0cf96af2..17b41722 100644 --- a/apps/angular-app/src/app/app.config.ts +++ b/apps/angular-app/src/app/app.config.ts @@ -1,8 +1,9 @@ +import { provideHttpClient } from '@angular/common/http'; import { ApplicationConfig } from '@angular/core'; import { provideRouter } from '@angular/router'; import { appRoutes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [provideRouter(appRoutes)] + providers: [provideRouter(appRoutes), provideHttpClient()] }; diff --git a/apps/angular-app/src/app/app.routes.ts b/apps/angular-app/src/app/app.routes.ts index 8762dfe2..75bb3733 100644 --- a/apps/angular-app/src/app/app.routes.ts +++ b/apps/angular-app/src/app/app.routes.ts @@ -1,3 +1,22 @@ import { Route } from '@angular/router'; -export const appRoutes: Route[] = []; +import { HomeComponent } from './views/home/home.component'; +import { PageNotFoundComponent } from './views/page-not-found/page-not-found.component'; +import { PostComponent } from './views/post/post.component'; + +export const appRoutes: Route[] = [ + { + path: '', + pathMatch: 'full', + component: HomeComponent + }, + { + path: 'post/:id', + component: PostComponent + }, + { + path: '**', + component: PageNotFoundComponent + } +]; + diff --git a/apps/angular-app/src/app/components/add-post/add-post.component.html b/apps/angular-app/src/app/components/add-post/add-post.component.html new file mode 100644 index 00000000..4bf91363 --- /dev/null +++ b/apps/angular-app/src/app/components/add-post/add-post.component.html @@ -0,0 +1,3 @@ + diff --git a/apps/angular-app/src/app/components/add-post/add-post.component.scss b/apps/angular-app/src/app/components/add-post/add-post.component.scss new file mode 100644 index 00000000..79728ece --- /dev/null +++ b/apps/angular-app/src/app/components/add-post/add-post.component.scss @@ -0,0 +1,16 @@ +.mfee-add-post-button { + position: fixed; + width: 3rem; + height: 3rem; + border-radius: 99px; + background-color: orange; + border: none; + right: 1rem; + top: 3rem; + box-shadow: 0px 8px 15px rgba(0, 0, 0, 0.1); + cursor: pointer; + color: white; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/apps/angular-app/src/app/components/add-post/add-post.component.spec.ts b/apps/angular-app/src/app/components/add-post/add-post.component.spec.ts new file mode 100644 index 00000000..27a3f263 --- /dev/null +++ b/apps/angular-app/src/app/components/add-post/add-post.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AddPostComponent } from './add-post.component'; + +describe('AddPostComponent', () => { + let component: AddPostComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AddPostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AddPostComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/add-post/add-post.component.ts b/apps/angular-app/src/app/components/add-post/add-post.component.ts new file mode 100644 index 00000000..601c67d4 --- /dev/null +++ b/apps/angular-app/src/app/components/add-post/add-post.component.ts @@ -0,0 +1,19 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Output } from '@angular/core'; + +@Component({ + selector: 'mfee-add-post', + standalone: true, + imports: [CommonModule], + templateUrl: './add-post.component.html', + styleUrl: './add-post.component.scss' +}) +export class AddPostComponent { + @Output() clickAction: EventEmitter = new EventEmitter(); + + constructor() {} + + public addPost(): void { + this.clickAction.emit(); + } +} diff --git a/apps/angular-app/src/app/components/categories/categories.component.html b/apps/angular-app/src/app/components/categories/categories.component.html new file mode 100644 index 00000000..ec7d7a0f --- /dev/null +++ b/apps/angular-app/src/app/components/categories/categories.component.html @@ -0,0 +1,8 @@ +
+
    +
  • + {{ category.name }} +
  • +
+
\ No newline at end of file diff --git a/apps/angular-app/src/app/components/categories/categories.component.scss b/apps/angular-app/src/app/components/categories/categories.component.scss new file mode 100644 index 00000000..1271c17a --- /dev/null +++ b/apps/angular-app/src/app/components/categories/categories.component.scss @@ -0,0 +1,47 @@ +.mfee-category-container { + display: flex; + justify-content: center; +} + +.mfee-category { + list-style: none; + display: flex; + padding: 0; + + @media (max-width: 767px) { + flex-direction: column; + width: 95%; + } + + &__item { + padding: 0.6rem 1.2rem; + border: 1px solid #ccc; + border-bottom: none; + cursor: pointer; + + &:last-of-type { + border-bottom: 1px solid #ccc; + } + + @media (min-width: 767px) { + border-right: none; + border-bottom: 1px solid #ccc; + + &:first-of-type { + border-top-left-radius: 0.2rem; + border-bottom-left-radius: 0.2rem; + } + + &:last-of-type { + border-right: 1px solid #ccc; + border-top-right-radius: 0.2rem; + border-bottom-right-radius: 0.2rem; + } + } + + &--active, + &:hover { + background-color: #ccc; + } + } +} diff --git a/apps/angular-app/src/app/components/categories/categories.component.spec.ts b/apps/angular-app/src/app/components/categories/categories.component.spec.ts new file mode 100644 index 00000000..ccd269ad --- /dev/null +++ b/apps/angular-app/src/app/components/categories/categories.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CategoriesComponent } from './categories.component'; + +describe('CategoriesComponent', () => { + let component: CategoriesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CategoriesComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(CategoriesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/categories/categories.component.ts b/apps/angular-app/src/app/components/categories/categories.component.ts new file mode 100644 index 00000000..a93c03bb --- /dev/null +++ b/apps/angular-app/src/app/components/categories/categories.component.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { Category } from '../../models/Category'; + +@Component({ + selector: 'mfee-categories', + standalone: true, + imports: [CommonModule], + templateUrl: './categories.component.html', + styleUrl: './categories.component.scss' +}) +export class CategoriesComponent { + @Input() selectedCategory: string; + @Input() categories: Array; + @Output() categoryChange: EventEmitter = new EventEmitter(); + + setCategory(categoryId: string) { + this.categoryChange.emit(categoryId) + } +} diff --git a/apps/angular-app/src/app/components/header/header.component.html b/apps/angular-app/src/app/components/header/header.component.html new file mode 100644 index 00000000..9c004bb4 --- /dev/null +++ b/apps/angular-app/src/app/components/header/header.component.html @@ -0,0 +1,8 @@ +
+
+ [ + + ] +
+

Discovering the World

+
\ No newline at end of file diff --git a/apps/angular-app/src/app/components/header/header.component.scss b/apps/angular-app/src/app/components/header/header.component.scss new file mode 100644 index 00000000..b5ef0ad0 --- /dev/null +++ b/apps/angular-app/src/app/components/header/header.component.scss @@ -0,0 +1,22 @@ +.mfee-header { + display: flex; + flex-direction: column; + align-items: center; + padding: 1.5rem 0; + text-align: center; + + &__slogan { + color: orange; + font-size: 0.8rem; + } + + &__slogan-icon { + font-size: 2em; + padding: 0 0.5rem; + } + + &__title { + margin: 0; + font-size: 2.5rem; + } +} diff --git a/apps/angular-app/src/app/components/header/header.component.spec.ts b/apps/angular-app/src/app/components/header/header.component.spec.ts new file mode 100644 index 00000000..3096a746 --- /dev/null +++ b/apps/angular-app/src/app/components/header/header.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HeaderComponent } from './header.component'; + +describe('HeaderComponent', () => { + let component: HeaderComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HeaderComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/header/header.component.ts b/apps/angular-app/src/app/components/header/header.component.ts new file mode 100644 index 00000000..6d240ee4 --- /dev/null +++ b/apps/angular-app/src/app/components/header/header.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'mfee-header', + standalone: true, + imports: [CommonModule], + templateUrl: './header.component.html', + styleUrl: './header.component.scss', +}) +export class HeaderComponent {} diff --git a/apps/angular-app/src/app/components/post-grid/post-grid.component.html b/apps/angular-app/src/app/components/post-grid/post-grid.component.html new file mode 100644 index 00000000..26d5fbcb --- /dev/null +++ b/apps/angular-app/src/app/components/post-grid/post-grid.component.html @@ -0,0 +1,19 @@ +
+
+

{{ post.title }}

+ {{ post.comments.count }} Comments + forum +

{{ post.description }}

+ +
+
\ No newline at end of file diff --git a/apps/angular-app/src/app/components/post-grid/post-grid.component.scss b/apps/angular-app/src/app/components/post-grid/post-grid.component.scss new file mode 100644 index 00000000..abfd2011 --- /dev/null +++ b/apps/angular-app/src/app/components/post-grid/post-grid.component.scss @@ -0,0 +1,95 @@ +@import '../../shared/variables'; + +.mfee-grid-container { + display: flex; + flex-wrap: wrap; +} + +.mfee-grid-post { + $post: &; + + background-position: center; + background-repeat: no-repeat; + background-size: cover; + box-sizing: border-box; + color: #fff; + display: flex; + flex-direction: column; + justify-content: flex-end; + min-height: 40vh; + padding: 1.2rem; + text-decoration: none; + background-color: $color-gray; + + @media (min-width: 768px) { + width: 50%; + } + + &:hover { + #{$post}__actions { + display: flex; + } + } + + &__title { + } + + &__comments { + font-weight: lighter; + font-style: italic; + align-items: flex-end; + display: flex; + + i { + font-size: inherit; + margin-left: 0.4rem; + } + } + + &__description { + font-size: 0.875rem; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + + @media (min-width: 768px) { + width: 75%; + } + } + + &__footer { + display: flex; + justify-content: space-between; + min-height: 1.75rem; + } + + &__actions { + list-style: none; + padding: 0; + margin: 0; + display: none; + } + + &__actions-item { + width: 1.75rem; + height: 1.75rem; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + cursor: pointer; + color: #ccc; + } + } + + &__tag { + font-size: 0.875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 3px; + align-items: flex-end; + display: flex; + } +} diff --git a/apps/angular-app/src/app/components/post-grid/post-grid.component.spec.ts b/apps/angular-app/src/app/components/post-grid/post-grid.component.spec.ts new file mode 100644 index 00000000..05eeac02 --- /dev/null +++ b/apps/angular-app/src/app/components/post-grid/post-grid.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PostGridComponent } from './post-grid.component'; + +describe('PostGridComponent', () => { + let component: PostGridComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PostGridComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PostGridComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/post-grid/post-grid.component.ts b/apps/angular-app/src/app/components/post-grid/post-grid.component.ts new file mode 100644 index 00000000..2dc2517f --- /dev/null +++ b/apps/angular-app/src/app/components/post-grid/post-grid.component.ts @@ -0,0 +1,37 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Post } from '../../models/Post'; + +@Component({ + selector: 'mfee-post-grid', + standalone: true, + imports: [CommonModule], + templateUrl: './post-grid.component.html', + styleUrl: './post-grid.component.scss' +}) +export class PostGridComponent { + @Input() posts: Array; + @Output() deletePost: EventEmitter = new EventEmitter(); + @Output() editPost: EventEmitter = new EventEmitter(); + + constructor(private router: Router) {} + + public onClick(event, postId: string): void { + if ( + !event.target.className.includes('mfee-grid-post__actions-item') && + !event.target.parentNode.className.includes('mfee-grid-post__actions-item') + ) { + this.router.navigate(['/post', postId]); + } + } + + public onEditPost(postId: string): void { + this.editPost.emit(postId); + } + + public onDeletePost(postId: string): void { + this.deletePost.emit(postId); + } +} diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.component.html b/apps/angular-app/src/app/components/post-modal/post-modal.component.html new file mode 100644 index 00000000..f73e8bc4 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.component.html @@ -0,0 +1,61 @@ +
+
+

{{ postModalService.title$ | async }}

+ +
+ + + + + + + + + +
+
+
\ No newline at end of file diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.component.scss b/apps/angular-app/src/app/components/post-modal/post-modal.component.scss new file mode 100644 index 00000000..2456c850 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.component.scss @@ -0,0 +1,38 @@ +@import '../../shared/variables'; + +.mfee-modal { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + + &__content { + background-color: white; + width: 90vw; + padding: 1.5rem; + + @media (min-width: 768px) { + width: 60vw; + } + } + + &__form { + display: flex; + flex-direction: column; + } + + &__title { + margin-top: 0; + text-align: center; + } + + &__footer { + display: flex; + justify-content: flex-end; + } +} diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.component.spec.ts b/apps/angular-app/src/app/components/post-modal/post-modal.component.spec.ts new file mode 100644 index 00000000..30308527 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PostModalComponent } from './post-modal.component'; + +describe('PostModalComponent', () => { + let component: PostModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PostModalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PostModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.component.ts b/apps/angular-app/src/app/components/post-modal/post-modal.component.ts new file mode 100644 index 00000000..e5065fce --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.component.ts @@ -0,0 +1,97 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { Observable, ReplaySubject, firstValueFrom, switchMap, take, takeUntil, tap } from 'rxjs'; +import { Category } from '../../models/Category'; +import { CategoryService } from '../../services/category/category.service'; +import { PostService } from '../../services/post/post.service'; +import { urlValidator } from '../../shared/url-validator'; +import { PostModalService } from './post-modal.service'; + +@Component({ + selector: 'mfee-post-modal', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './post-modal.component.html', + styleUrl: './post-modal.component.scss' +}) +export class PostModalComponent implements OnInit, OnDestroy { + private destroyed$: ReplaySubject = new ReplaySubject(1); + public categories$: Observable; + + postForm = this.fb.group({ + title: ['', Validators.required], + description: ['', Validators.required], + category: ['', Validators.required], + image: ['', [Validators.required, urlValidator]] + }); + + constructor( + public postModalService: PostModalService, + private categoryService: CategoryService, + private postService: PostService, + private fb: FormBuilder + ) {} + + ngOnInit(): void { + this.categories$ = this.categoryService.getCategories(false); + + this.postModalService.post$.pipe(takeUntil(this.destroyed$)).subscribe((post) => { + this.postForm.setValue({ + title: post?.title ?? '', + description: post?.description ?? '', + category: post?.category.id ?? '', + image: post?.image ?? '' + }); + }); + } + + ngOnDestroy() { + this.destroyed$.next(true); + this.destroyed$.complete(); + } + + close(): void { + this.postModalService.close(); + } + + async onSubmit(): Promise { + await firstValueFrom( + this.postModalService.post$.pipe( + take(1), + switchMap((post) => { + const payload = { + ...this.postForm.value + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + return this.postService.upsertPost(post ? { ...post, ...payload } : payload); + }), + tap(() => { + console.log('Update posts'); + // TODO : Update posts + }) + ) + ); + + this.postForm.reset(); + this.close(); + } + + get title() { + return this.postForm.get('title'); + } + + get description() { + return this.postForm.get('description'); + } + + get category() { + return this.postForm.get('category'); + } + + get image() { + return this.postForm.get('image'); + } +} diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.service.spec.ts b/apps/angular-app/src/app/components/post-modal/post-modal.service.spec.ts new file mode 100644 index 00000000..2ad8a208 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { PostModalService } from './post-modal.service'; + +describe('PostModalService', () => { + let service: PostModalService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(PostModalService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.service.ts b/apps/angular-app/src/app/components/post-modal/post-modal.service.ts new file mode 100644 index 00000000..a1515a83 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +import { Post } from '../../models/Post'; + +@Injectable({ + providedIn: 'root' +}) +export class PostModalService { + private display: BehaviorSubject = new BehaviorSubject(false); + public display$: Observable = this.display.asObservable(); + + private title: BehaviorSubject = new BehaviorSubject(''); + public title$: Observable = this.title.asObservable(); + + private post: BehaviorSubject = new BehaviorSubject(null); + public post$: Observable = this.post.asObservable(); + + constructor() {} + + open(): void { + this.display.next(true); + } + + close(): void { + this.display.next(false); + } + + setInfo(title: string, post: Post = null): void { + this.title.next(title); + this.post.next(post); + } +} diff --git a/apps/angular-app/src/app/models/Category.ts b/apps/angular-app/src/app/models/Category.ts new file mode 100644 index 00000000..0df829f8 --- /dev/null +++ b/apps/angular-app/src/app/models/Category.ts @@ -0,0 +1,9 @@ +export type Category = { + id: string; + name: string; +}; + +export type GetCategoryResponse = { + _id: string; + name: string; +}; diff --git a/apps/angular-app/src/app/models/Comment.ts b/apps/angular-app/src/app/models/Comment.ts new file mode 100644 index 00000000..3f74e4a2 --- /dev/null +++ b/apps/angular-app/src/app/models/Comment.ts @@ -0,0 +1,11 @@ +export type Comment = { + id: string; + author: string; + content: string; +}; + +export type CommentResponse = { + _id: string; + author: string; + content: string; +}; diff --git a/apps/angular-app/src/app/models/Post.ts b/apps/angular-app/src/app/models/Post.ts new file mode 100644 index 00000000..94d33653 --- /dev/null +++ b/apps/angular-app/src/app/models/Post.ts @@ -0,0 +1,32 @@ +import { Category, GetCategoryResponse } from './Category'; +import { Comment, CommentResponse } from './Comment'; + +export type Post = { + id: string; + title: string; + image: string; + description: string; + category: Category; + comments: { + count: number; + data?: Array; + }; +}; + +export type GetPostsResponse = { + _id: string; + title: string; + image: string; + description: string; + category: GetCategoryResponse; + comments: Array; +}; + +export type GetPostResponse = { + _id: string; + title: string; + image: string; + description: string; + category: GetCategoryResponse; + comments: Array; +}; diff --git a/apps/angular-app/src/app/services/.gitkeep b/apps/angular-app/src/app/services/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/angular-app/src/app/services/category/category.service.spec.ts b/apps/angular-app/src/app/services/category/category.service.spec.ts new file mode 100644 index 00000000..56585079 --- /dev/null +++ b/apps/angular-app/src/app/services/category/category.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { CategoryService } from './category.service'; + +describe('CategoryService', () => { + let service: CategoryService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CategoryService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/services/category/category.service.ts b/apps/angular-app/src/app/services/category/category.service.ts new file mode 100644 index 00000000..cf55e192 --- /dev/null +++ b/apps/angular-app/src/app/services/category/category.service.ts @@ -0,0 +1,33 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, map, take } from 'rxjs'; + +import { Category, GetCategoryResponse } from '../../models/Category'; + +@Injectable({ + providedIn: 'root' +}) +export class CategoryService { + private apiUrl = 'http://localhost:4200/api/categories'; + + constructor(private http: HttpClient) {} + + public getCategories(includeAll: boolean = true): Observable { + return this.http.get(this.apiUrl).pipe( + take(1), + map((categories) => { + const newCategories = categories.map((c) => ({ id: c._id, name: c.name })); + + return includeAll + ? [ + { + id: 'all', + name: 'All' + }, + ...newCategories + ] + : newCategories; + }) + ); + } +} diff --git a/apps/angular-app/src/app/services/post/post.service.spec.ts b/apps/angular-app/src/app/services/post/post.service.spec.ts new file mode 100644 index 00000000..913642b8 --- /dev/null +++ b/apps/angular-app/src/app/services/post/post.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { PostService } from './post.service'; + +describe('PostService', () => { + let service: PostService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(PostService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/services/post/post.service.ts b/apps/angular-app/src/app/services/post/post.service.ts new file mode 100644 index 00000000..b53575ae --- /dev/null +++ b/apps/angular-app/src/app/services/post/post.service.ts @@ -0,0 +1,118 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, map, take } from 'rxjs'; + +import { Comment, CommentResponse } from '../../models/Comment'; +import { GetPostResponse, GetPostsResponse, Post } from '../../models/Post'; + +@Injectable({ + providedIn: 'root' +}) +export class PostService { + private postsApiUrl = 'http://localhost:4200/api/posts'; + + constructor(private http: HttpClient) {} + + public getPosts(selectedCategory: string): Observable> { + const url = selectedCategory === 'all' ? this.postsApiUrl : `${this.postsApiUrl}/category/${selectedCategory}`; + + return this.http.get(url).pipe( + take(1), + map((posts) => + posts.map((p) => ({ + id: p._id, + title: p.title, + image: p.image, + description: p.description, + category: { + id: p.category._id, + name: p.category.name + }, + comments: { + count: p.comments.length + } + })) + ) + ); + } + + public getPost(postId: string): Observable { + return this.http.get(`${this.postsApiUrl}/${postId}`).pipe( + take(1), + map((post) => ({ + id: post._id, + title: post.title, + image: post.image, + description: post.description, + category: { + id: post.category._id, + name: post.category.name + }, + comments: { + count: post.comments.length, + data: post.comments.map((c) => ({ + id: c._id, + author: c.author, + content: c.content + })) + } + })) + ); + } + + public upsertPost(post: Partial): Observable { + const format = (post: GetPostResponse) => ({ + id: post._id, + title: post.title, + image: post.image, + description: post.description, + category: { + id: post.category._id, + name: post.category.name + }, + comments: { + count: post.comments.length, + data: post.comments.map((c) => ({ + id: c._id, + author: c.author, + content: c.content + })) + } + }); + + if (post.id) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, comments, ...payload } = post; + // Edit post + return this.http.patch(`${this.postsApiUrl}/${post.id}`, payload).pipe( + take(1), + map((post) => format(post)) + ); + } + + // Create post + return this.http.post(this.postsApiUrl, post).pipe( + take(1), + map((post) => format(post)) + ); + } + + public deletePost(postId: string): Observable { + return this.http.delete(`${this.postsApiUrl}/${postId}`).pipe(take(1)); + } + + public addComment(postId: string, comment: string): Observable { + const payload = { + author: 'Anonymous', + content: comment + }; + return this.http.post(`${this.postsApiUrl}/${postId}/comments`, payload).pipe( + take(1), + map((comment) => ({ + id: comment._id, + author: comment.author, + content: comment.content + })) + ); + } +} diff --git a/apps/angular-app/src/app/shared/url-validator.ts b/apps/angular-app/src/app/shared/url-validator.ts new file mode 100644 index 00000000..a9559e90 --- /dev/null +++ b/apps/angular-app/src/app/shared/url-validator.ts @@ -0,0 +1,10 @@ +import { AbstractControl } from '@angular/forms'; + +export function urlValidator(control: AbstractControl): { [key: string]: boolean } | null { + const url = control.value; + + const URL_REGEXP = + /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/; + + return URL_REGEXP.test(url) ? null : { invalidUrl: true }; +} diff --git a/apps/angular-app/src/app/shared/variables.scss b/apps/angular-app/src/app/shared/variables.scss index e69de29b..02ef634a 100644 --- a/apps/angular-app/src/app/shared/variables.scss +++ b/apps/angular-app/src/app/shared/variables.scss @@ -0,0 +1,3 @@ +$color-blue: #3f3fc9; +$color-blue-light: #5b5bc0; +$color-gray: #ccc; diff --git a/apps/angular-app/src/app/views/.gitkeep b/apps/angular-app/src/app/views/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/angular-app/src/app/views/home/home.component.html b/apps/angular-app/src/app/views/home/home.component.html new file mode 100644 index 00000000..417747b1 --- /dev/null +++ b/apps/angular-app/src/app/views/home/home.component.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/angular-app/src/app/components/.gitkeep b/apps/angular-app/src/app/views/home/home.component.scss similarity index 100% rename from apps/angular-app/src/app/components/.gitkeep rename to apps/angular-app/src/app/views/home/home.component.scss diff --git a/apps/angular-app/src/app/views/home/home.component.spec.ts b/apps/angular-app/src/app/views/home/home.component.spec.ts new file mode 100644 index 00000000..5dd05d2c --- /dev/null +++ b/apps/angular-app/src/app/views/home/home.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HomeComponent } from './home.component'; + +describe('HomeComponent', () => { + let component: HomeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HomeComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HomeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/views/home/home.component.ts b/apps/angular-app/src/app/views/home/home.component.ts new file mode 100644 index 00000000..1786d9a6 --- /dev/null +++ b/apps/angular-app/src/app/views/home/home.component.ts @@ -0,0 +1,69 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; + +import { BehaviorSubject, Observable, catchError, of, switchMap, take, tap, withLatestFrom } from 'rxjs'; +import { AddPostComponent } from '../../components/add-post/add-post.component'; +import { CategoriesComponent } from '../../components/categories/categories.component'; +import { PostGridComponent } from '../../components/post-grid/post-grid.component'; +import { PostModalComponent } from '../../components/post-modal/post-modal.component'; +import { PostModalService } from '../../components/post-modal/post-modal.service'; +import { Category } from '../../models/Category'; +import { Post } from '../../models/Post'; +import { CategoryService } from '../../services/category/category.service'; +import { PostService } from '../../services/post/post.service'; + +@Component({ + selector: 'mfee-home', + standalone: true, + imports: [CommonModule, AddPostComponent, CategoriesComponent, PostGridComponent, PostModalComponent], + templateUrl: './home.component.html', + styleUrl: './home.component.scss' +}) +export class HomeComponent implements OnInit { + public categories$: Observable; + public posts$: Observable; + + private selectedCategory = new BehaviorSubject('all'); + public selectedCategory$ = this.selectedCategory.asObservable(); + + constructor(private postService: PostService, private categoryService: CategoryService, private postModalService: PostModalService) {} + + ngOnInit(): void { + this.categories$ = this.categoryService.getCategories(); + + this.posts$ = this.selectedCategory$.pipe(switchMap((selectedCategory) => this.postService.getPosts(selectedCategory))); + } + + public onCategoryChange(categoryId: string): void { + this.selectedCategory.next(categoryId); + } + + public onAddPost(): void { + this.postModalService.setInfo('Create Post'); + this.postModalService.open(); + } + + public onEditPost(postId: string): void { + this.postService.getPost(postId).subscribe((post) => { + this.postModalService.setInfo('Edit Post', post); + this.postModalService.open(); + }); + } + + public onDeletePost(postId: string): void { + this.postService + .deletePost(postId) + .pipe( + take(1), + withLatestFrom(this.selectedCategory$), + tap(([, selectedCategory]) => { + this.onCategoryChange(selectedCategory); + }), + catchError(() => { + // TODO : Show an error message to the user + return of(null); + }) + ) + .subscribe(); + } +} diff --git a/apps/angular-app/src/app/views/page-not-found/page-not-found.component.html b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.html new file mode 100644 index 00000000..35fc2c27 --- /dev/null +++ b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.html @@ -0,0 +1 @@ +

Ups! Something went wrong, we couldn't find this page.

diff --git a/apps/angular-app/src/app/models/.gitkeep b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.scss similarity index 100% rename from apps/angular-app/src/app/models/.gitkeep rename to apps/angular-app/src/app/views/page-not-found/page-not-found.component.scss diff --git a/apps/angular-app/src/app/views/page-not-found/page-not-found.component.spec.ts b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.spec.ts new file mode 100644 index 00000000..0ec6519d --- /dev/null +++ b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PageNotFoundComponent } from './page-not-found.component'; + +describe('PageNotFoundComponent', () => { + let component: PageNotFoundComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PageNotFoundComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PageNotFoundComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/views/page-not-found/page-not-found.component.ts b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.ts new file mode 100644 index 00000000..bba9d8ff --- /dev/null +++ b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'mfee-page-not-found', + standalone: true, + imports: [CommonModule], + templateUrl: './page-not-found.component.html', + styleUrl: './page-not-found.component.scss', +}) +export class PageNotFoundComponent {} diff --git a/apps/angular-app/src/app/views/post/post.component.html b/apps/angular-app/src/app/views/post/post.component.html new file mode 100644 index 00000000..736ce009 --- /dev/null +++ b/apps/angular-app/src/app/views/post/post.component.html @@ -0,0 +1,43 @@ +
+
+ +

{{ post.title }}

+
+
+
+

{{ post.description }}

+ +
+

Comments

+ +
+ + + +
+
    +
  • +
    + person + {{ comment.author }} +
    +

    {{ comment.content }}

    +
  • +
+
+
+
+
\ No newline at end of file diff --git a/apps/angular-app/src/app/views/post/post.component.scss b/apps/angular-app/src/app/views/post/post.component.scss new file mode 100644 index 00000000..ddef47cb --- /dev/null +++ b/apps/angular-app/src/app/views/post/post.component.scss @@ -0,0 +1,107 @@ +.mfee-post { + &__header { + display: flex; + color: white; + min-height: 50vh; + position: relative; + align-items: center; + justify-content: center; + background-position: center top; + background-size: cover; + background-repeat: no-repeat; + } + + &__actions { + position: absolute; + top: 0; + left: 0; + padding: 10px 0 0 10px; + } + + &__action { + display: flex; + align-items: center; + text-decoration: none; + color: white; + + &:hover { + color: #ccc; + } + } + + &__title { + font-size: 2.25rem; + text-align: center; + } + + &__content-wrapper { + background-color: #ececec; + display: flex; + } + + &__content { + padding: 0 1rem; + margin: 0 auto; + flex-grow: 1; + + @media (min-width: 576px) { + max-width: 540px; + } + + @media (min-width: 768px) { + max-width: 720px; + } + + @media (min-width: 992px) { + max-width: 960px; + } + + @media (min-width: 1200px) { + max-width: 1140px; + } + } + + &__description { + white-space: pre-line; + } + + &-comments { + margin: 0 auto; + + @media (min-width: 768px) { + max-width: 480px; + } + } + + &__comment-wrapper { + padding: 0; + list-style: none; + } + + &__comment { + background-color: white; + box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.1); + margin-bottom: 8px; + padding: 8px; + + &-name { + display: flex; + align-items: center; + font-weight: bold; + margin-bottom: 8px; + } + + &-avatar { + background-color: black; + color: white; + border-radius: 99px; + padding: 2px; + margin-right: 8px; + } + + &-description { + margin: 0; + margin-left: 36px; + } + } +} diff --git a/apps/angular-app/src/app/views/post/post.component.spec.ts b/apps/angular-app/src/app/views/post/post.component.spec.ts new file mode 100644 index 00000000..15c9dacd --- /dev/null +++ b/apps/angular-app/src/app/views/post/post.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PostComponent } from './post.component'; + +describe('PostComponent', () => { + let component: PostComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PostComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/views/post/post.component.ts b/apps/angular-app/src/app/views/post/post.component.ts new file mode 100644 index 00000000..67622978 --- /dev/null +++ b/apps/angular-app/src/app/views/post/post.component.ts @@ -0,0 +1,41 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { Observable, switchMap } from 'rxjs'; + +import { Post } from '../../models/Post'; +import { PostService } from '../../services/post/post.service'; + +@Component({ + selector: 'mfee-post', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterLink], + templateUrl: './post.component.html', + styleUrl: './post.component.scss' +}) +export class PostComponent implements OnInit { + public post$: Observable; + + commentForm = this.fb.group({ + comment: ['', Validators.required] + }); + + constructor(private route: ActivatedRoute, private fb: FormBuilder, private postService: PostService) {} + + ngOnInit(): void { + this.post$ = this.route.params.pipe(switchMap(({ id }) => this.postService.getPost(id))); + } + + onSubmit(postId: string) { + this.postService.addComment(postId, this.commentForm.value.comment).subscribe((comment) => { + // TODO : Add comment to UI + console.log(comment); + }); + this.commentForm.reset(); + } + + get comment() { + return this.commentForm.get('comment'); + } +} diff --git a/apps/angular-app/src/index.html b/apps/angular-app/src/index.html index da0603dd..f7be7978 100644 --- a/apps/angular-app/src/index.html +++ b/apps/angular-app/src/index.html @@ -7,6 +7,7 @@ + diff --git a/apps/angular-app/src/styles.scss b/apps/angular-app/src/styles.scss index cf488252..8884a5ec 100644 --- a/apps/angular-app/src/styles.scss +++ b/apps/angular-app/src/styles.scss @@ -4,3 +4,83 @@ body { font-family: 'Open Sans', sans-serif; } + +.mfee-button { + border: none; + background: $color-blue; + color: white; + font-size: 0.875rem; + padding: 0.5rem 1.5rem; + border-radius: 2px; + cursor: pointer; + + &:hover { + background-color: $color-blue-light; + } + + &--link { + background-color: transparent; + color: black; + + &:hover { + text-decoration: underline; + background-color: transparent; + } + } + + &:disabled { + color: $color-gray; + cursor: not-allowed; + } +} + +.mfee-form-control { + display: flex; + flex-direction: column; + margin-bottom: 1rem; + font-size: 0.75rem; + position: relative; + + &__input { + border: none; + border-bottom: 1px solid $color-gray; + outline: none !important; + margin-top: 2px; + padding: 0.25rem 0; + resize: none; + background-color: transparent; + + &:active, + &:focus { + border-bottom-color: $color-blue; + } + + &.ng-dirty.ng-invalid, + &.ng-touched.ng-invalid { + border-bottom-color: red; + } + } + + &__error { + position: absolute; + bottom: 0; + right: 0; + color: red; + } +} + +::-webkit-scrollbar { + width: 6px; + background-color: #f5f5f5; +} + +::-webkit-scrollbar-thumb { + border-radius: 10px; + -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); + background-color: #555; +} + +::-webkit-scrollbar-track { + -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); + background-color: #f5f5f5; +} From ab5d1586243fe5d7387b547b091da4046f4a2c21 Mon Sep 17 00:00:00 2001 From: Gus Date: Sun, 7 Jan 2024 23:54:06 -0600 Subject: [PATCH 02/30] feat(api): add crud operations for category --- apps/api/src/main.ts | 101 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index e5fad103..86dd5db2 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -5,8 +5,105 @@ const port = process.env.PORT ? Number(process.env.PORT) : 3000; const app = express(); -app.get('/', (req, res) => { - res.send({ message: 'Hello MFEE!' }); +app.use(express.json()); + +// Initialize categories array to save data in memory +const categories = []; + +// Get all categories +app.get('/api/categories', (req, res) => { + // Return all the categories with a 200 status code + res.status(200).json(categories); +}); + +// Get category by id +app.get('/api/categories/:id', (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + // Check if we have a category with that id + const category = categories.find((p) => p.id === id); + + if (!category) { + // If we don't find the category return a 404 status code with a message + return res.status(404).json({ message: 'Category not found' }); + // Note: Remember that json method doesn't interrupt the workflow + // therefore is important to add a "return" to break the process + } + + // Return the category with a 200 status code + res.status(200).json(category); +}); + +// Create category +app.post('/api/categories', (req, res) => { + // Retrieve the name from the request body + const { name } = req.body; + + if (!name) { + // If name is empty or undefined return a 400 status code with a message + return res.status(400).json({ message: 'The name is required.' }); + } + + // Generate a new category + const newCategory = { + id: Date.now().toString(), // Convert id to string to match the value in get by id endpoint + name + }; + // Add the new category to our array + categories.push(newCategory); + + // Return the created category with a 201 status code + res.status(201).json(newCategory); +}); + +// Update category +app.patch('/api/categories/:id', (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + // Retrieve the index of the category in the array + const categoryIndex = categories.findIndex((p) => p.id === id); + + // "findIndex" will return -1 if there is no match + if (categoryIndex === -1) { + // If we don't find the category return a 404 status code with a message + return res.status(404).json({ message: 'Category not found' }); + } + + // Generate a copy of our cateogory + const updatedCategory = { ...categories[categoryIndex] }; + // Retrieve the name from the request body + const { name } = req.body; + + // Check if we have a name, if so update the property + if (name) { + updatedCategory.name = name; + } + + // Update the category in our array + categories[categoryIndex] = updatedCategory; + + // Return the updated category with a 200 status code + res.status(200).json(updatedCategory); +}); + +// Delete category +app.delete('/api/categories/:id', (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + // Retrieve the index of the category in the array + const categoryIndex = categories.findIndex((p) => p.id === id); + + // "findIndex" will return -1 if there is no match + if (categoryIndex === -1) { + // If we don't find the category return a 404 status code with a message + return res.status(404).json({ message: 'Category not found' }); + } + + // Remove the category from the array + categories.splice(categoryIndex, 1); + + // Return a 204 status code + res.status(204).send(); }); app.listen(port, host, () => { From 4f3b7352ed1bbacb51792ee04d42ba2c868c5741 Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 00:14:35 -0600 Subject: [PATCH 03/30] feat(api): add router --- apps/api/src/main.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 86dd5db2..99e3de71 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -4,6 +4,7 @@ const host = process.env.HOST ?? 'localhost'; const port = process.env.PORT ? Number(process.env.PORT) : 3000; const app = express(); +const router = express.Router(); app.use(express.json()); @@ -11,13 +12,13 @@ app.use(express.json()); const categories = []; // Get all categories -app.get('/api/categories', (req, res) => { +router.get('/', (req, res) => { // Return all the categories with a 200 status code res.status(200).json(categories); }); // Get category by id -app.get('/api/categories/:id', (req, res) => { +router.get('/:id', (req, res) => { // Retrieve the id from the route params const { id } = req.params; // Check if we have a category with that id @@ -35,7 +36,7 @@ app.get('/api/categories/:id', (req, res) => { }); // Create category -app.post('/api/categories', (req, res) => { +router.post('/', (req, res) => { // Retrieve the name from the request body const { name } = req.body; @@ -57,7 +58,7 @@ app.post('/api/categories', (req, res) => { }); // Update category -app.patch('/api/categories/:id', (req, res) => { +router.patch('/:id', (req, res) => { // Retrieve the id from the route params const { id } = req.params; // Retrieve the index of the category in the array @@ -87,7 +88,7 @@ app.patch('/api/categories/:id', (req, res) => { }); // Delete category -app.delete('/api/categories/:id', (req, res) => { +router.delete('/:id', (req, res) => { // Retrieve the id from the route params const { id } = req.params; // Retrieve the index of the category in the array @@ -106,6 +107,8 @@ app.delete('/api/categories/:id', (req, res) => { res.status(204).send(); }); +app.use('/api/categories', router); + app.listen(port, host, () => { console.log(`[ ready ] http://${host}:${port}`); }); From 1ed66331d725002b9400e8b20f14b9d8868ba7af Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 00:15:52 -0600 Subject: [PATCH 04/30] feat(api): refactor crud operations to new file --- apps/api/src/main.ts | 105 +----------------------------- apps/api/src/routes/.gitkeep | 0 apps/api/src/routes/categories.ts | 103 +++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 102 deletions(-) delete mode 100644 apps/api/src/routes/.gitkeep create mode 100644 apps/api/src/routes/categories.ts diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 99e3de71..a59003ab 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,113 +1,14 @@ import express from 'express'; +import categories from './routes/categories'; + const host = process.env.HOST ?? 'localhost'; const port = process.env.PORT ? Number(process.env.PORT) : 3000; const app = express(); -const router = express.Router(); app.use(express.json()); - -// Initialize categories array to save data in memory -const categories = []; - -// Get all categories -router.get('/', (req, res) => { - // Return all the categories with a 200 status code - res.status(200).json(categories); -}); - -// Get category by id -router.get('/:id', (req, res) => { - // Retrieve the id from the route params - const { id } = req.params; - // Check if we have a category with that id - const category = categories.find((p) => p.id === id); - - if (!category) { - // If we don't find the category return a 404 status code with a message - return res.status(404).json({ message: 'Category not found' }); - // Note: Remember that json method doesn't interrupt the workflow - // therefore is important to add a "return" to break the process - } - - // Return the category with a 200 status code - res.status(200).json(category); -}); - -// Create category -router.post('/', (req, res) => { - // Retrieve the name from the request body - const { name } = req.body; - - if (!name) { - // If name is empty or undefined return a 400 status code with a message - return res.status(400).json({ message: 'The name is required.' }); - } - - // Generate a new category - const newCategory = { - id: Date.now().toString(), // Convert id to string to match the value in get by id endpoint - name - }; - // Add the new category to our array - categories.push(newCategory); - - // Return the created category with a 201 status code - res.status(201).json(newCategory); -}); - -// Update category -router.patch('/:id', (req, res) => { - // Retrieve the id from the route params - const { id } = req.params; - // Retrieve the index of the category in the array - const categoryIndex = categories.findIndex((p) => p.id === id); - - // "findIndex" will return -1 if there is no match - if (categoryIndex === -1) { - // If we don't find the category return a 404 status code with a message - return res.status(404).json({ message: 'Category not found' }); - } - - // Generate a copy of our cateogory - const updatedCategory = { ...categories[categoryIndex] }; - // Retrieve the name from the request body - const { name } = req.body; - - // Check if we have a name, if so update the property - if (name) { - updatedCategory.name = name; - } - - // Update the category in our array - categories[categoryIndex] = updatedCategory; - - // Return the updated category with a 200 status code - res.status(200).json(updatedCategory); -}); - -// Delete category -router.delete('/:id', (req, res) => { - // Retrieve the id from the route params - const { id } = req.params; - // Retrieve the index of the category in the array - const categoryIndex = categories.findIndex((p) => p.id === id); - - // "findIndex" will return -1 if there is no match - if (categoryIndex === -1) { - // If we don't find the category return a 404 status code with a message - return res.status(404).json({ message: 'Category not found' }); - } - - // Remove the category from the array - categories.splice(categoryIndex, 1); - - // Return a 204 status code - res.status(204).send(); -}); - -app.use('/api/categories', router); +app.use('/api/categories', categories); app.listen(port, host, () => { console.log(`[ ready ] http://${host}:${port}`); diff --git a/apps/api/src/routes/.gitkeep b/apps/api/src/routes/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/routes/categories.ts b/apps/api/src/routes/categories.ts new file mode 100644 index 00000000..f14d6e29 --- /dev/null +++ b/apps/api/src/routes/categories.ts @@ -0,0 +1,103 @@ +import express from 'express'; + +const router = express.Router(); +// Initialize categories array to save data in memory +const categories = []; + +// Get all categories +router.get('/', (req, res) => { + // Return all the categories with a 200 status code + res.status(200).json(categories); +}); + +// Get category by id +router.get('/:id', (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + // Check if we have a category with that id + const category = categories.find((p) => p.id === id); + + if (!category) { + // If we don't find the category return a 404 status code with a message + return res.status(404).json({ message: 'Category not found' }); + // Note: Remember that json method doesn't interrupt the workflow + // therefore is important to add a "return" to break the process + } + + // Return the category with a 200 status code + res.status(200).json(category); +}); + +// Create category +router.post('/', (req, res) => { + // Retrieve the name from the request body + const { name } = req.body; + + if (!name) { + // If name is empty or undefined return a 400 status code with a message + return res.status(400).json({ message: 'The name is required.' }); + } + + // Generate a new category + const newCategory = { + id: Date.now().toString(), // Convert id to string to match the value in get by id endpoint + name + }; + // Add the new category to our array + categories.push(newCategory); + + // Return the created category with a 201 status code + res.status(201).json(newCategory); +}); + +// Update category +router.patch('/:id', (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + // Retrieve the index of the category in the array + const categoryIndex = categories.findIndex((p) => p.id === id); + + // "findIndex" will return -1 if there is no match + if (categoryIndex === -1) { + // If we don't find the category return a 404 status code with a message + return res.status(404).json({ message: 'Category not found' }); + } + + // Generate a copy of our cateogory + const updatedCategory = { ...categories[categoryIndex] }; + // Retrieve the name from the request body + const { name } = req.body; + + // Check if we have a name, if so update the property + if (name) { + updatedCategory.name = name; + } + + // Update the category in our array + categories[categoryIndex] = updatedCategory; + + // Return the updated category with a 200 status code + res.status(200).json(updatedCategory); +}); + +// Delete category +router.delete('/:id', (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + // Retrieve the index of the category in the array + const categoryIndex = categories.findIndex((p) => p.id === id); + + // "findIndex" will return -1 if there is no match + if (categoryIndex === -1) { + // If we don't find the category return a 404 status code with a message + return res.status(404).json({ message: 'Category not found' }); + } + + // Remove the category from the array + categories.splice(categoryIndex, 1); + + // Return a 204 status code + res.status(204).send(); +}); + +export default router; From 41fcc8fca6e270141bd8eb7628cbe1bb9ae25242 Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 08:40:53 -0600 Subject: [PATCH 05/30] refactor(api): move logic from route to controller --- apps/api/src/controllers/category.ts | 106 +++++++++++++++++++++++++++ apps/api/src/routes/categories.ts | 95 ++---------------------- 2 files changed, 113 insertions(+), 88 deletions(-) create mode 100644 apps/api/src/controllers/category.ts diff --git a/apps/api/src/controllers/category.ts b/apps/api/src/controllers/category.ts new file mode 100644 index 00000000..e94d41cb --- /dev/null +++ b/apps/api/src/controllers/category.ts @@ -0,0 +1,106 @@ +// Initialize categories array to save data in memory +const categories = []; + +// Get all categories +const getCategories = (req, res) => { + // Return all the categories with a 200 status code + res.status(200).json(categories); +}; + +// Get category by id +const getCategoryById = (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + // Check if we have a category with that id + const category = categories.find((p) => p.id === id); + + if (!category) { + // If we don't find the category return a 404 status code with a message + return res.status(404).json({ message: 'Category not found' }); + // Note: Remember that json method doesn't interrupt the workflow + // therefore is important to add a "return" to break the process + } + + // Return the category with a 200 status code + res.status(200).json(category); +}; + +// Create category +const createCategory = (req, res) => { + // Retrieve the name from the request body + const { name } = req.body; + + if (!name) { + // If name is empty or undefined return a 400 status code with a message + return res.status(400).json({ message: 'The name is required.' }); + } + + // Generate a new category + const newCategory = { + id: Date.now().toString(), // Convert id to string to match the value in get by id endpoint + name + }; + // Add the new category to our array + categories.push(newCategory); + + // Return the created category with a 201 status code + res.status(201).json(newCategory); +}; + +// Update category +const updateCategory = (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + // Retrieve the index of the category in the array + const categoryIndex = categories.findIndex((p) => p.id === id); + + // "findIndex" will return -1 if there is no match + if (categoryIndex === -1) { + // If we don't find the category return a 404 status code with a message + return res.status(404).json({ message: 'Category not found' }); + } + + // Generate a copy of our cateogory + const updatedCategory = { ...categories[categoryIndex] }; + // Retrieve the name from the request body + const { name } = req.body; + + // Check if we have a name, if so update the property + if (name) { + updatedCategory.name = name; + } + + // Update the category in our array + categories[categoryIndex] = updatedCategory; + + // Return the updated category with a 200 status code + res.status(200).json(updatedCategory); +}; + +// Delete category +const deleteCategory = (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + // Retrieve the index of the category in the array + const categoryIndex = categories.findIndex((p) => p.id === id); + + // "findIndex" will return -1 if there is no match + if (categoryIndex === -1) { + // If we don't find the category return a 404 status code with a message + return res.status(404).json({ message: 'Category not found' }); + } + + // Remove the category from the array + categories.splice(categoryIndex, 1); + + // Return a 204 status code + res.status(204).send(); +}; + +export default { + getCategories, + getCategoryById, + createCategory, + updateCategory, + deleteCategory +}; diff --git a/apps/api/src/routes/categories.ts b/apps/api/src/routes/categories.ts index f14d6e29..78e120a1 100644 --- a/apps/api/src/routes/categories.ts +++ b/apps/api/src/routes/categories.ts @@ -1,103 +1,22 @@ import express from 'express'; +import categoryController from '../controllers/category'; + const router = express.Router(); -// Initialize categories array to save data in memory -const categories = []; // Get all categories -router.get('/', (req, res) => { - // Return all the categories with a 200 status code - res.status(200).json(categories); -}); +router.get('/', categoryController.getCategories); // Get category by id -router.get('/:id', (req, res) => { - // Retrieve the id from the route params - const { id } = req.params; - // Check if we have a category with that id - const category = categories.find((p) => p.id === id); - - if (!category) { - // If we don't find the category return a 404 status code with a message - return res.status(404).json({ message: 'Category not found' }); - // Note: Remember that json method doesn't interrupt the workflow - // therefore is important to add a "return" to break the process - } - - // Return the category with a 200 status code - res.status(200).json(category); -}); +router.get('/:id', categoryController.getCategoryById); // Create category -router.post('/', (req, res) => { - // Retrieve the name from the request body - const { name } = req.body; - - if (!name) { - // If name is empty or undefined return a 400 status code with a message - return res.status(400).json({ message: 'The name is required.' }); - } - - // Generate a new category - const newCategory = { - id: Date.now().toString(), // Convert id to string to match the value in get by id endpoint - name - }; - // Add the new category to our array - categories.push(newCategory); - - // Return the created category with a 201 status code - res.status(201).json(newCategory); -}); +router.post('/', categoryController.createCategory); // Update category -router.patch('/:id', (req, res) => { - // Retrieve the id from the route params - const { id } = req.params; - // Retrieve the index of the category in the array - const categoryIndex = categories.findIndex((p) => p.id === id); - - // "findIndex" will return -1 if there is no match - if (categoryIndex === -1) { - // If we don't find the category return a 404 status code with a message - return res.status(404).json({ message: 'Category not found' }); - } - - // Generate a copy of our cateogory - const updatedCategory = { ...categories[categoryIndex] }; - // Retrieve the name from the request body - const { name } = req.body; - - // Check if we have a name, if so update the property - if (name) { - updatedCategory.name = name; - } - - // Update the category in our array - categories[categoryIndex] = updatedCategory; - - // Return the updated category with a 200 status code - res.status(200).json(updatedCategory); -}); +router.patch('/:id', categoryController.updateCategory); // Delete category -router.delete('/:id', (req, res) => { - // Retrieve the id from the route params - const { id } = req.params; - // Retrieve the index of the category in the array - const categoryIndex = categories.findIndex((p) => p.id === id); - - // "findIndex" will return -1 if there is no match - if (categoryIndex === -1) { - // If we don't find the category return a 404 status code with a message - return res.status(404).json({ message: 'Category not found' }); - } - - // Remove the category from the array - categories.splice(categoryIndex, 1); - - // Return a 204 status code - res.status(204).send(); -}); +router.delete('/:id', categoryController.deleteCategory); export default router; From b8016470851601ea2010bf3a34a6ab269cd60183 Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 11:42:13 -0600 Subject: [PATCH 06/30] chore(api): remove .gitkeep from controllers --- apps/api/src/controllers/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/api/src/controllers/.gitkeep diff --git a/apps/api/src/controllers/.gitkeep b/apps/api/src/controllers/.gitkeep deleted file mode 100644 index e69de29b..00000000 From 081c58a75bfdcdf52c5547c4e48ec0e255d3b4ff Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 11:49:19 -0600 Subject: [PATCH 07/30] feat(api): create user model --- apps/api/src/models/.gitkeep | 0 apps/api/src/models/user.ts | 4 ++++ 2 files changed, 4 insertions(+) delete mode 100644 apps/api/src/models/.gitkeep create mode 100644 apps/api/src/models/user.ts diff --git a/apps/api/src/models/.gitkeep b/apps/api/src/models/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/models/user.ts b/apps/api/src/models/user.ts new file mode 100644 index 00000000..6ac7896e --- /dev/null +++ b/apps/api/src/models/user.ts @@ -0,0 +1,4 @@ +export type User = { + username: string; + password: string; +}; From 26140e1c605f869b5bef0a9c62fcdd5f68fa51f3 Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 11:52:50 -0600 Subject: [PATCH 08/30] feat(api): create auth controller --- apps/api/src/controllers/auth.ts | 97 ++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 apps/api/src/controllers/auth.ts diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts new file mode 100644 index 00000000..c5b90d02 --- /dev/null +++ b/apps/api/src/controllers/auth.ts @@ -0,0 +1,97 @@ +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +import { User } from '../models/user'; + +const users: User[] = []; + +const register = async (req, res) => { + const { username, password } = req.body; + + // Check that we have the correct payload + if (!username || !password) { + return res.status(400).json({ + message: 'Username and password are required' + }); + } + + // Check that we don't have duplicates + const duplicate = users.find((u) => u.username === username); + if (duplicate) { + return res.sendStatus(409); + } + + try { + // Encrypt the password + const hashedPassword = await bcrypt.hash(password, 10); + + // Store new user + users.push({ username, password: hashedPassword }); + + res.status(201).json({ message: 'User registered successfully' }); + } catch (e) { + res.status(500).json({ message: e.message }); + } +}; + +const login = async (req, res) => { + const { username, password } = req.body; + + // Check that we have the correct payload + if (!username || !password) { + return res.status(400).json({ + message: 'Username and password are required' + }); + } + + // Retrieve user + const user = users.find((u) => u.username === username); + + // Check if we found the user and the password matches + if (!user || !(await bcrypt.compare(password, user.password))) { + return res.status(401).json({ message: 'Invalid credentials' }); + } + + // Generate access token and refresh token + const accessToken = jwt.sign({ username }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' }); + const refreshToken = jwt.sign({ username }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '7d' }); + + // Save refresh token + res.cookie('refreshToken', refreshToken, { + httpOnly: true, + maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days in milliseconds + }); + + res.json({ accessToken }); +}; + +const refresh = (req, res) => { + // Get refresh token from cookies + const refreshToken = req.cookies.refreshToken; + + if (!refreshToken) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, { username }) => { + if (err) { + // Invalid token + return res.status(403).json({ message: 'Forbidden' }); + } + + const accessToken = jwt.sign({ username }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' }); + res.json({ accessToken }); + }); +}; + +const logout = (req, res) => { + res.clearCookie('refreshToken'); + res.json({ message: 'Logged out successfully' }); +}; + +export default { + register, + login, + refresh, + logout +}; From 18cc352656c4b6f6effa94a194762b3dcea612cc Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 11:54:05 -0600 Subject: [PATCH 09/30] feat(api): create auth route --- apps/api/src/routes/auth.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/api/src/routes/auth.ts diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 00000000..062e9bc9 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,15 @@ +import express from 'express'; + +import authController from '../controllers/auth'; + +const router = express.Router(); + +router.post('/register', authController.register); + +router.post('/login', authController.login); + +router.post('/refresh', authController.refresh); + +router.post('/logout', authController.logout); + +export default router; From 505f33416da05e67fc551bd6e4ffd1c74e1bf75e Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 11:55:57 -0600 Subject: [PATCH 10/30] feat(api): add route to main file --- apps/api/src/main.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a59003ab..dc2ee434 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,6 @@ import express from 'express'; +import auth from './routes/auth'; import categories from './routes/categories'; const host = process.env.HOST ?? 'localhost'; @@ -8,6 +9,7 @@ const port = process.env.PORT ? Number(process.env.PORT) : 3000; const app = express(); app.use(express.json()); +app.use('/api/auth', auth); app.use('/api/categories', categories); app.listen(port, host, () => { From d67968b7ab93cc90069269d580223651dd6306f7 Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 15:24:29 -0600 Subject: [PATCH 11/30] feat(api): add basic configuration --- apps/api/config/corsConfig.ts | 14 ++++++++++++++ apps/api/src/main.ts | 8 ++++++++ 2 files changed, 22 insertions(+) create mode 100644 apps/api/config/corsConfig.ts diff --git a/apps/api/config/corsConfig.ts b/apps/api/config/corsConfig.ts new file mode 100644 index 00000000..bd444ca9 --- /dev/null +++ b/apps/api/config/corsConfig.ts @@ -0,0 +1,14 @@ +const allowedOrigins = ['http://localhost:4200']; + +export const corsOptions = { + origin: (origin, callback) => { + if (allowedOrigins.indexOf(origin) !== -1) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + optionsSuccessStatus: 200 +}; + +export default { corsOptions }; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index e5fad103..d6b88d7e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,10 +1,18 @@ +import cors from 'cors'; import express from 'express'; +import helmet from 'helmet'; + +import { corsOptions } from '../config/corsConfig'; const host = process.env.HOST ?? 'localhost'; const port = process.env.PORT ? Number(process.env.PORT) : 3000; const app = express(); +app.use(express.json()); +app.use(helmet()); +app.use(cors(corsOptions)); + app.get('/', (req, res) => { res.send({ message: 'Hello MFEE!' }); }); From 7c1b48f46a406640936dd24be1f0984f6a83892c Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 21:41:58 -0600 Subject: [PATCH 12/30] feat(api): add verify token middleware --- apps/api/src/main.ts | 4 +++- apps/api/src/middleware/.gitkeep | 0 apps/api/src/middleware/auth.ts | 24 ++++++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) delete mode 100644 apps/api/src/middleware/.gitkeep create mode 100644 apps/api/src/middleware/auth.ts diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 6a099eb7..3ed56658 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -3,7 +3,7 @@ import express from 'express'; import helmet from 'helmet'; import { corsOptions } from '../config/corsConfig'; - +import { verifyToken } from './middleware/auth'; import auth from './routes/auth'; import categories from './routes/categories'; @@ -17,6 +17,8 @@ app.use(helmet()); app.use(cors(corsOptions)); app.use('/api/auth', auth); + +app.use(verifyToken); app.use('/api/categories', categories); app.listen(port, host, () => { diff --git a/apps/api/src/middleware/.gitkeep b/apps/api/src/middleware/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 00000000..193a5a9e --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -0,0 +1,24 @@ +import jwt from 'jsonwebtoken'; + +export const verifyToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + + if (!authHeader) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => { + if (err) { + // Invalid token + return res.status(403).json({ message: 'Forbidden' }); + } + + req.user = user; + next(); + }); +}; + +export default { + verifyToken +}; From d329c841fc7b84283c94ebff49ec7f38fc48d1fb Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 22:09:53 -0600 Subject: [PATCH 13/30] fix(api): relocate cors config --- apps/api/{ => src}/config/corsConfig.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/api/{ => src}/config/corsConfig.ts (100%) diff --git a/apps/api/config/corsConfig.ts b/apps/api/src/config/corsConfig.ts similarity index 100% rename from apps/api/config/corsConfig.ts rename to apps/api/src/config/corsConfig.ts From 65ac083de9ffca3b2166b45d01a7ed9184f7db37 Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 22:27:14 -0600 Subject: [PATCH 14/30] fix(api): cors issue for local development --- apps/api/src/config/corsConfig.ts | 5 +++-- apps/api/src/main.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/api/src/config/corsConfig.ts b/apps/api/src/config/corsConfig.ts index bd444ca9..045cf517 100644 --- a/apps/api/src/config/corsConfig.ts +++ b/apps/api/src/config/corsConfig.ts @@ -1,8 +1,9 @@ -const allowedOrigins = ['http://localhost:4200']; +const allowedOrigins = ['http://localhost:4200', 'http://localhost:3000']; export const corsOptions = { origin: (origin, callback) => { - if (allowedOrigins.indexOf(origin) !== -1) { + // Note: origin will be undefined from same route in local development + if (allowedOrigins.indexOf(origin) !== -1 || !origin) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index d6b88d7e..bb421d83 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -2,7 +2,7 @@ import cors from 'cors'; import express from 'express'; import helmet from 'helmet'; -import { corsOptions } from '../config/corsConfig'; +import { corsOptions } from './config/corsConfig'; const host = process.env.HOST ?? 'localhost'; const port = process.env.PORT ? Number(process.env.PORT) : 3000; From 0aec3673e2164139fa9d47ed295e28c9cf944adb Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 22:52:46 -0600 Subject: [PATCH 15/30] feat(api): add error handler middleware --- apps/api/src/main.ts | 3 +++ apps/api/src/middleware/errorHandler.ts | 9 +++++++++ 2 files changed, 12 insertions(+) create mode 100644 apps/api/src/middleware/errorHandler.ts diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a7c0793d..7964793b 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -4,6 +4,7 @@ import helmet from 'helmet'; import { corsOptions } from './config/corsConfig'; import { verifyToken } from './middleware/auth'; +import { errorHandler } from './middleware/errorHandler'; import auth from './routes/auth'; import categories from './routes/categories'; @@ -21,6 +22,8 @@ app.use('/api/auth', auth); app.use(verifyToken); app.use('/api/categories', categories); +app.use(errorHandler); + app.listen(port, host, () => { console.log(`[ ready ] http://${host}:${port}`); }); diff --git a/apps/api/src/middleware/errorHandler.ts b/apps/api/src/middleware/errorHandler.ts new file mode 100644 index 00000000..90969d56 --- /dev/null +++ b/apps/api/src/middleware/errorHandler.ts @@ -0,0 +1,9 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const errorHandler = (err, req, res, next) => { + // Log this for debug purposes + console.error(err.stack); + // Return custom error to user + res.status(500).json({ error: 'Internal Server Error' }); +}; + +export default { errorHandler }; From ca4222e69fb9d0edaedbf870dcb2c837f9705140 Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 22:54:00 -0600 Subject: [PATCH 16/30] chore(api): remove .gitkeep file from config --- apps/api/src/config/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/api/src/config/.gitkeep diff --git a/apps/api/src/config/.gitkeep b/apps/api/src/config/.gitkeep deleted file mode 100644 index e69de29b..00000000 From 05c4fbd10b7d04467033b5a510f0431155cd51b9 Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 23:29:19 -0600 Subject: [PATCH 17/30] feat(api): add connection to MongoDB --- apps/api/src/main.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 7964793b..daf9dc2e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,6 +1,7 @@ import cors from 'cors'; import express from 'express'; import helmet from 'helmet'; +import mongoose from 'mongoose'; import { corsOptions } from './config/corsConfig'; import { verifyToken } from './middleware/auth'; @@ -24,6 +25,15 @@ app.use('/api/categories', categories); app.use(errorHandler); -app.listen(port, host, () => { - console.log(`[ ready ] http://${host}:${port}`); -}); +mongoose + .connect(process.env.MONGO_URL) + .then(() => { + console.log('Connected to MongoDB'); + + app.listen(port, host, () => { + console.log(`[ ready ] http://${host}:${port}`); + }); + }) + .catch((e) => { + console.error(e); + }); From 837ac4c11006dd876392d8328064c614c7d4e9dc Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 23:34:05 -0600 Subject: [PATCH 18/30] feat(api): create category model --- apps/api/src/models/category.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 apps/api/src/models/category.ts diff --git a/apps/api/src/models/category.ts b/apps/api/src/models/category.ts new file mode 100644 index 00000000..e6775d12 --- /dev/null +++ b/apps/api/src/models/category.ts @@ -0,0 +1,18 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +interface ICategory extends Document { + name: string; +} + +export const categorySchema = new Schema( + { + name: String + }, + { + timestamps: true + } +); + +const Category = mongoose.model('Category', categorySchema); + +export default Category; From 2eec543dec1a2fb2bc70c3e7d8e27c3cc07d3d36 Mon Sep 17 00:00:00 2001 From: Gus Date: Mon, 8 Jan 2024 23:41:24 -0600 Subject: [PATCH 19/30] refactor(api): update required property in model --- apps/api/src/models/category.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/api/src/models/category.ts b/apps/api/src/models/category.ts index e6775d12..c69bbc87 100644 --- a/apps/api/src/models/category.ts +++ b/apps/api/src/models/category.ts @@ -6,7 +6,10 @@ interface ICategory extends Document { export const categorySchema = new Schema( { - name: String + name: { + type: String, + required: [true, 'Property is required'] + } }, { timestamps: true From 78a1a00757eecf9e846721e59765c768c1e281ae Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 9 Jan 2024 00:25:42 -0600 Subject: [PATCH 20/30] refactor(api): connect controller with model --- apps/api/src/controllers/category.ts | 133 +++++++++++++-------------- 1 file changed, 65 insertions(+), 68 deletions(-) diff --git a/apps/api/src/controllers/category.ts b/apps/api/src/controllers/category.ts index e94d41cb..edb7e409 100644 --- a/apps/api/src/controllers/category.ts +++ b/apps/api/src/controllers/category.ts @@ -1,100 +1,97 @@ -// Initialize categories array to save data in memory -const categories = []; +import Category from '../models/category'; // Get all categories -const getCategories = (req, res) => { - // Return all the categories with a 200 status code - res.status(200).json(categories); +const getCategories = async (req, res) => { + try { + const categories = await Category.find(); + // Return all the categories with a 200 status code + res.status(200).json(categories); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } }; // Get category by id -const getCategoryById = (req, res) => { +const getCategoryById = async (req, res) => { // Retrieve the id from the route params const { id } = req.params; - // Check if we have a category with that id - const category = categories.find((p) => p.id === id); - if (!category) { - // If we don't find the category return a 404 status code with a message - return res.status(404).json({ message: 'Category not found' }); - // Note: Remember that json method doesn't interrupt the workflow - // therefore is important to add a "return" to break the process + try { + // Check if we have a category with that id + const category = await Category.findById(id); + + if (!category) { + // If we don't find the category return a 404 status code with a message + return res.status(404).json({ message: 'Category not found' }); + // Note: Remember that json method doesn't interrupt the workflow + // therefore is important to add a "return" to break the process + } + + // Return the category with a 200 status code + res.status(200).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); } - - // Return the category with a 200 status code - res.status(200).json(category); }; // Create category -const createCategory = (req, res) => { - // Retrieve the name from the request body - const { name } = req.body; - - if (!name) { - // If name is empty or undefined return a 400 status code with a message - return res.status(400).json({ message: 'The name is required.' }); +const createCategory = async (req, res) => { + try { + const category = await Category.create(req.body); + // Return the created category with a 201 status code + res.status(201).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); } - - // Generate a new category - const newCategory = { - id: Date.now().toString(), // Convert id to string to match the value in get by id endpoint - name - }; - // Add the new category to our array - categories.push(newCategory); - - // Return the created category with a 201 status code - res.status(201).json(newCategory); }; // Update category -const updateCategory = (req, res) => { +const updateCategory = async (req, res) => { // Retrieve the id from the route params const { id } = req.params; - // Retrieve the index of the category in the array - const categoryIndex = categories.findIndex((p) => p.id === id); - // "findIndex" will return -1 if there is no match - if (categoryIndex === -1) { - // If we don't find the category return a 404 status code with a message - return res.status(404).json({ message: 'Category not found' }); - } + try { + // Check and update if we have a category with that id + const category = await Category.findByIdAndUpdate(id, req.body); - // Generate a copy of our cateogory - const updatedCategory = { ...categories[categoryIndex] }; - // Retrieve the name from the request body - const { name } = req.body; - - // Check if we have a name, if so update the property - if (name) { - updatedCategory.name = name; + // If we don't find the category return a 404 status code with a message + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + // Get the new values of the category + const updatedCategory = await Category.findById(id); + // Return the updated category with a 200 status code + res.status(200).json(updatedCategory); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); } - - // Update the category in our array - categories[categoryIndex] = updatedCategory; - - // Return the updated category with a 200 status code - res.status(200).json(updatedCategory); }; // Delete category -const deleteCategory = (req, res) => { +const deleteCategory = async (req, res) => { // Retrieve the id from the route params const { id } = req.params; - // Retrieve the index of the category in the array - const categoryIndex = categories.findIndex((p) => p.id === id); - // "findIndex" will return -1 if there is no match - if (categoryIndex === -1) { + try { + // Check and delete if we have a category with that id + const category = await Category.findByIdAndDelete(id); + // If we don't find the category return a 404 status code with a message - return res.status(404).json({ message: 'Category not found' }); + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + // Return a 200 status code + res.status(200).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); } - - // Remove the category from the array - categories.splice(categoryIndex, 1); - - // Return a 204 status code - res.status(204).send(); }; export default { From 4e2e2b7a04860d9a210d5d1bca02254f2d9f163e Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 9 Jan 2024 12:05:36 -0600 Subject: [PATCH 21/30] chore(api): add session 01 challenges --- apps/api/README.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/api/README.md b/apps/api/README.md index 6066cc0e..ba28da48 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -21,7 +21,30 @@ ## Challenges -### Session * +### Session 01 + +- Create `route` for `posts` endpoint with the following methods: + - `GET /posts` Return an array of all the posts with status code 200 + - `GET /posts/category/:category` Return an array of all the posts by category with status code 200 + - `GET /posts/:id` Return a post by id with category object and each comment object in the array with status code 200 + - `POST /posts` Create a new post and return the created post with status code 201 + - `POST /posts/:id/comments` Create a comment inside the post and return the comment with status code 201 + - `PATCH /posts/:id` Update post information and return the updated post with status code 200 + - `DELETE /posts/:id` Delete the post and return the deleted post with status code 200 or 204 if you decide to not return anything + * *Add 404 validation where needed* + +- Post model + - id: string + - title: string + - image: string + - description: string + - category: string *Id of the category* + - comments: array *Array of comment ids* + +- Comment model + - id: string + - author: string + - content: string ## How to From bedf0596a58648b9411a2b02caeb6b963b9327b4 Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 9 Jan 2024 12:08:59 -0600 Subject: [PATCH 22/30] chore(api): add session 02 challenges --- apps/api/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/api/README.md b/apps/api/README.md index ba28da48..f172a610 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -46,6 +46,10 @@ - author: string - content: string +### Session 02 + +- Refactor the code from last session to add a post controller + ## How to ### Run postman collection From ee6fae4cdc252a4b78ea5c34c8fe7b180bca4bf4 Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 9 Jan 2024 12:12:08 -0600 Subject: [PATCH 23/30] chore(api): add session 03 challenges --- apps/api/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/api/README.md b/apps/api/README.md index f172a610..c428f832 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -50,6 +50,10 @@ - Refactor the code from last session to add a post controller +### Session 03 + +- N/A + ## How to ### Run postman collection From 072672f7be407cd23fc83fbf74cc0eb2240bfaee Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 9 Jan 2024 12:13:36 -0600 Subject: [PATCH 24/30] chore(api): add session 04 challenges --- apps/api/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/api/README.md b/apps/api/README.md index c428f832..416f7e97 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -54,6 +54,10 @@ - N/A +### Session 04 + +- N/A + ## How to ### Run postman collection From 230782f8f4fcc1e69e195384cfbcdd593fe4d445 Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 9 Jan 2024 12:22:31 -0600 Subject: [PATCH 25/30] chore(api): add session 05 challenges --- apps/api/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/api/README.md b/apps/api/README.md index 416f7e97..658178b6 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -58,6 +58,16 @@ - N/A +### Session 05 + +- Create MongoDB database +- Connect to MongoDB database using mongoose +- Create models for Post and Comment +- Refactor the controller to retrieve information from database + - *Tip: Use `populate` method to get data from reference id* +- **Extra** + - Remove post comments from database when you delete the post + ## How to ### Run postman collection From 901ea2497ca95fb123d22b2555f7fd0a9418561b Mon Sep 17 00:00:00 2001 From: Gus Date: Wed, 10 Jan 2024 20:12:17 -0600 Subject: [PATCH 26/30] fix(api): refactor getCategory for future use --- apps/api/src/routes/categories.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/categories.ts b/apps/api/src/routes/categories.ts index f14d6e29..929c5552 100644 --- a/apps/api/src/routes/categories.ts +++ b/apps/api/src/routes/categories.ts @@ -1,5 +1,9 @@ import express from 'express'; +export const getCategory = (id: string) => { + return categories.find((p) => p.id === id); +}; + const router = express.Router(); // Initialize categories array to save data in memory const categories = []; @@ -15,7 +19,7 @@ router.get('/:id', (req, res) => { // Retrieve the id from the route params const { id } = req.params; // Check if we have a category with that id - const category = categories.find((p) => p.id === id); + const category = getCategory(id); if (!category) { // If we don't find the category return a 404 status code with a message From 1d0c66299561005cc3172531dce48a8bedaab6a7 Mon Sep 17 00:00:00 2001 From: Gus Date: Wed, 10 Jan 2024 22:45:51 -0600 Subject: [PATCH 27/30] refactor(api): update findByIdAndUpdate options --- apps/api/src/controllers/category.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/api/src/controllers/category.ts b/apps/api/src/controllers/category.ts index edb7e409..cf881549 100644 --- a/apps/api/src/controllers/category.ts +++ b/apps/api/src/controllers/category.ts @@ -55,17 +55,15 @@ const updateCategory = async (req, res) => { try { // Check and update if we have a category with that id - const category = await Category.findByIdAndUpdate(id, req.body); + const category = await Category.findByIdAndUpdate(id, req.body, { new: true }); // If we don't find the category return a 404 status code with a message if (!category) { return res.status(404).json({ message: 'Category not found' }); } - // Get the new values of the category - const updatedCategory = await Category.findById(id); // Return the updated category with a 200 status code - res.status(200).json(updatedCategory); + res.status(200).json(category); } catch (error) { const { message } = error; res.status(500).json({ message }); From 8e01bfa891d41cffef3ba150e67b16f2e00884b8 Mon Sep 17 00:00:00 2001 From: Gus Date: Sun, 7 Jan 2024 23:17:18 -0600 Subject: [PATCH 28/30] feat(angular-app): add front end functionality --- apps/angular-app/src/app/app.component.html | 1 + apps/angular-app/src/app/app.component.ts | 4 +- apps/angular-app/src/app/app.config.ts | 3 +- apps/angular-app/src/app/app.routes.ts | 21 +++- .../add-post/add-post.component.html | 3 + .../add-post/add-post.component.scss | 16 +++ .../add-post/add-post.component.spec.ts | 21 ++++ .../components/add-post/add-post.component.ts | 19 +++ .../categories/categories.component.html | 8 ++ .../categories/categories.component.scss | 47 +++++++ .../categories/categories.component.spec.ts | 22 ++++ .../categories/categories.component.ts | 21 ++++ .../components/header/header.component.html | 8 ++ .../components/header/header.component.scss | 22 ++++ .../header/header.component.spec.ts | 21 ++++ .../app/components/header/header.component.ts | 11 ++ .../post-grid/post-grid.component.html | 19 +++ .../post-grid/post-grid.component.scss | 95 ++++++++++++++ .../post-grid/post-grid.component.spec.ts | 21 ++++ .../post-grid/post-grid.component.ts | 37 ++++++ .../post-modal/post-modal.component.html | 61 +++++++++ .../post-modal/post-modal.component.scss | 38 ++++++ .../post-modal/post-modal.component.spec.ts | 21 ++++ .../post-modal/post-modal.component.ts | 97 ++++++++++++++ .../post-modal/post-modal.service.spec.ts | 16 +++ .../post-modal/post-modal.service.ts | 33 +++++ apps/angular-app/src/app/models/Category.ts | 9 ++ apps/angular-app/src/app/models/Comment.ts | 11 ++ apps/angular-app/src/app/models/Post.ts | 32 +++++ apps/angular-app/src/app/services/.gitkeep | 0 .../category/category.service.spec.ts | 16 +++ .../app/services/category/category.service.ts | 33 +++++ .../app/services/post/post.service.spec.ts | 16 +++ .../src/app/services/post/post.service.ts | 118 ++++++++++++++++++ .../src/app/shared/url-validator.ts | 10 ++ .../angular-app/src/app/shared/variables.scss | 3 + apps/angular-app/src/app/views/.gitkeep | 0 .../src/app/views/home/home.component.html | 5 + .../home/home.component.scss} | 0 .../src/app/views/home/home.component.spec.ts | 21 ++++ .../src/app/views/home/home.component.ts | 69 ++++++++++ .../page-not-found.component.html | 1 + .../page-not-found.component.scss} | 0 .../page-not-found.component.spec.ts | 21 ++++ .../page-not-found.component.ts | 11 ++ .../src/app/views/post/post.component.html | 43 +++++++ .../src/app/views/post/post.component.scss | 107 ++++++++++++++++ .../src/app/views/post/post.component.spec.ts | 21 ++++ .../src/app/views/post/post.component.ts | 41 ++++++ apps/angular-app/src/index.html | 1 + apps/angular-app/src/styles.scss | 80 ++++++++++++ 51 files changed, 1352 insertions(+), 3 deletions(-) create mode 100644 apps/angular-app/src/app/components/add-post/add-post.component.html create mode 100644 apps/angular-app/src/app/components/add-post/add-post.component.scss create mode 100644 apps/angular-app/src/app/components/add-post/add-post.component.spec.ts create mode 100644 apps/angular-app/src/app/components/add-post/add-post.component.ts create mode 100644 apps/angular-app/src/app/components/categories/categories.component.html create mode 100644 apps/angular-app/src/app/components/categories/categories.component.scss create mode 100644 apps/angular-app/src/app/components/categories/categories.component.spec.ts create mode 100644 apps/angular-app/src/app/components/categories/categories.component.ts create mode 100644 apps/angular-app/src/app/components/header/header.component.html create mode 100644 apps/angular-app/src/app/components/header/header.component.scss create mode 100644 apps/angular-app/src/app/components/header/header.component.spec.ts create mode 100644 apps/angular-app/src/app/components/header/header.component.ts create mode 100644 apps/angular-app/src/app/components/post-grid/post-grid.component.html create mode 100644 apps/angular-app/src/app/components/post-grid/post-grid.component.scss create mode 100644 apps/angular-app/src/app/components/post-grid/post-grid.component.spec.ts create mode 100644 apps/angular-app/src/app/components/post-grid/post-grid.component.ts create mode 100644 apps/angular-app/src/app/components/post-modal/post-modal.component.html create mode 100644 apps/angular-app/src/app/components/post-modal/post-modal.component.scss create mode 100644 apps/angular-app/src/app/components/post-modal/post-modal.component.spec.ts create mode 100644 apps/angular-app/src/app/components/post-modal/post-modal.component.ts create mode 100644 apps/angular-app/src/app/components/post-modal/post-modal.service.spec.ts create mode 100644 apps/angular-app/src/app/components/post-modal/post-modal.service.ts create mode 100644 apps/angular-app/src/app/models/Category.ts create mode 100644 apps/angular-app/src/app/models/Comment.ts create mode 100644 apps/angular-app/src/app/models/Post.ts delete mode 100644 apps/angular-app/src/app/services/.gitkeep create mode 100644 apps/angular-app/src/app/services/category/category.service.spec.ts create mode 100644 apps/angular-app/src/app/services/category/category.service.ts create mode 100644 apps/angular-app/src/app/services/post/post.service.spec.ts create mode 100644 apps/angular-app/src/app/services/post/post.service.ts create mode 100644 apps/angular-app/src/app/shared/url-validator.ts delete mode 100644 apps/angular-app/src/app/views/.gitkeep create mode 100644 apps/angular-app/src/app/views/home/home.component.html rename apps/angular-app/src/app/{components/.gitkeep => views/home/home.component.scss} (100%) create mode 100644 apps/angular-app/src/app/views/home/home.component.spec.ts create mode 100644 apps/angular-app/src/app/views/home/home.component.ts create mode 100644 apps/angular-app/src/app/views/page-not-found/page-not-found.component.html rename apps/angular-app/src/app/{models/.gitkeep => views/page-not-found/page-not-found.component.scss} (100%) create mode 100644 apps/angular-app/src/app/views/page-not-found/page-not-found.component.spec.ts create mode 100644 apps/angular-app/src/app/views/page-not-found/page-not-found.component.ts create mode 100644 apps/angular-app/src/app/views/post/post.component.html create mode 100644 apps/angular-app/src/app/views/post/post.component.scss create mode 100644 apps/angular-app/src/app/views/post/post.component.spec.ts create mode 100644 apps/angular-app/src/app/views/post/post.component.ts diff --git a/apps/angular-app/src/app/app.component.html b/apps/angular-app/src/app/app.component.html index 0680b43f..9721b000 100644 --- a/apps/angular-app/src/app/app.component.html +++ b/apps/angular-app/src/app/app.component.html @@ -1 +1,2 @@ + diff --git a/apps/angular-app/src/app/app.component.ts b/apps/angular-app/src/app/app.component.ts index 74c1d539..1d489374 100644 --- a/apps/angular-app/src/app/app.component.ts +++ b/apps/angular-app/src/app/app.component.ts @@ -1,9 +1,11 @@ import { Component } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { HeaderComponent } from './components/header/header.component'; + @Component({ standalone: true, - imports: [RouterModule], + imports: [HeaderComponent, RouterModule], selector: 'mfee-project-root', templateUrl: './app.component.html', styleUrl: './app.component.scss' diff --git a/apps/angular-app/src/app/app.config.ts b/apps/angular-app/src/app/app.config.ts index 0cf96af2..17b41722 100644 --- a/apps/angular-app/src/app/app.config.ts +++ b/apps/angular-app/src/app/app.config.ts @@ -1,8 +1,9 @@ +import { provideHttpClient } from '@angular/common/http'; import { ApplicationConfig } from '@angular/core'; import { provideRouter } from '@angular/router'; import { appRoutes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [provideRouter(appRoutes)] + providers: [provideRouter(appRoutes), provideHttpClient()] }; diff --git a/apps/angular-app/src/app/app.routes.ts b/apps/angular-app/src/app/app.routes.ts index 8762dfe2..75bb3733 100644 --- a/apps/angular-app/src/app/app.routes.ts +++ b/apps/angular-app/src/app/app.routes.ts @@ -1,3 +1,22 @@ import { Route } from '@angular/router'; -export const appRoutes: Route[] = []; +import { HomeComponent } from './views/home/home.component'; +import { PageNotFoundComponent } from './views/page-not-found/page-not-found.component'; +import { PostComponent } from './views/post/post.component'; + +export const appRoutes: Route[] = [ + { + path: '', + pathMatch: 'full', + component: HomeComponent + }, + { + path: 'post/:id', + component: PostComponent + }, + { + path: '**', + component: PageNotFoundComponent + } +]; + diff --git a/apps/angular-app/src/app/components/add-post/add-post.component.html b/apps/angular-app/src/app/components/add-post/add-post.component.html new file mode 100644 index 00000000..4bf91363 --- /dev/null +++ b/apps/angular-app/src/app/components/add-post/add-post.component.html @@ -0,0 +1,3 @@ + diff --git a/apps/angular-app/src/app/components/add-post/add-post.component.scss b/apps/angular-app/src/app/components/add-post/add-post.component.scss new file mode 100644 index 00000000..79728ece --- /dev/null +++ b/apps/angular-app/src/app/components/add-post/add-post.component.scss @@ -0,0 +1,16 @@ +.mfee-add-post-button { + position: fixed; + width: 3rem; + height: 3rem; + border-radius: 99px; + background-color: orange; + border: none; + right: 1rem; + top: 3rem; + box-shadow: 0px 8px 15px rgba(0, 0, 0, 0.1); + cursor: pointer; + color: white; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/apps/angular-app/src/app/components/add-post/add-post.component.spec.ts b/apps/angular-app/src/app/components/add-post/add-post.component.spec.ts new file mode 100644 index 00000000..27a3f263 --- /dev/null +++ b/apps/angular-app/src/app/components/add-post/add-post.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AddPostComponent } from './add-post.component'; + +describe('AddPostComponent', () => { + let component: AddPostComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AddPostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AddPostComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/add-post/add-post.component.ts b/apps/angular-app/src/app/components/add-post/add-post.component.ts new file mode 100644 index 00000000..601c67d4 --- /dev/null +++ b/apps/angular-app/src/app/components/add-post/add-post.component.ts @@ -0,0 +1,19 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Output } from '@angular/core'; + +@Component({ + selector: 'mfee-add-post', + standalone: true, + imports: [CommonModule], + templateUrl: './add-post.component.html', + styleUrl: './add-post.component.scss' +}) +export class AddPostComponent { + @Output() clickAction: EventEmitter = new EventEmitter(); + + constructor() {} + + public addPost(): void { + this.clickAction.emit(); + } +} diff --git a/apps/angular-app/src/app/components/categories/categories.component.html b/apps/angular-app/src/app/components/categories/categories.component.html new file mode 100644 index 00000000..ec7d7a0f --- /dev/null +++ b/apps/angular-app/src/app/components/categories/categories.component.html @@ -0,0 +1,8 @@ +
+
    +
  • + {{ category.name }} +
  • +
+
\ No newline at end of file diff --git a/apps/angular-app/src/app/components/categories/categories.component.scss b/apps/angular-app/src/app/components/categories/categories.component.scss new file mode 100644 index 00000000..1271c17a --- /dev/null +++ b/apps/angular-app/src/app/components/categories/categories.component.scss @@ -0,0 +1,47 @@ +.mfee-category-container { + display: flex; + justify-content: center; +} + +.mfee-category { + list-style: none; + display: flex; + padding: 0; + + @media (max-width: 767px) { + flex-direction: column; + width: 95%; + } + + &__item { + padding: 0.6rem 1.2rem; + border: 1px solid #ccc; + border-bottom: none; + cursor: pointer; + + &:last-of-type { + border-bottom: 1px solid #ccc; + } + + @media (min-width: 767px) { + border-right: none; + border-bottom: 1px solid #ccc; + + &:first-of-type { + border-top-left-radius: 0.2rem; + border-bottom-left-radius: 0.2rem; + } + + &:last-of-type { + border-right: 1px solid #ccc; + border-top-right-radius: 0.2rem; + border-bottom-right-radius: 0.2rem; + } + } + + &--active, + &:hover { + background-color: #ccc; + } + } +} diff --git a/apps/angular-app/src/app/components/categories/categories.component.spec.ts b/apps/angular-app/src/app/components/categories/categories.component.spec.ts new file mode 100644 index 00000000..ccd269ad --- /dev/null +++ b/apps/angular-app/src/app/components/categories/categories.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CategoriesComponent } from './categories.component'; + +describe('CategoriesComponent', () => { + let component: CategoriesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CategoriesComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(CategoriesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/categories/categories.component.ts b/apps/angular-app/src/app/components/categories/categories.component.ts new file mode 100644 index 00000000..a93c03bb --- /dev/null +++ b/apps/angular-app/src/app/components/categories/categories.component.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { Category } from '../../models/Category'; + +@Component({ + selector: 'mfee-categories', + standalone: true, + imports: [CommonModule], + templateUrl: './categories.component.html', + styleUrl: './categories.component.scss' +}) +export class CategoriesComponent { + @Input() selectedCategory: string; + @Input() categories: Array; + @Output() categoryChange: EventEmitter = new EventEmitter(); + + setCategory(categoryId: string) { + this.categoryChange.emit(categoryId) + } +} diff --git a/apps/angular-app/src/app/components/header/header.component.html b/apps/angular-app/src/app/components/header/header.component.html new file mode 100644 index 00000000..9c004bb4 --- /dev/null +++ b/apps/angular-app/src/app/components/header/header.component.html @@ -0,0 +1,8 @@ +
+
+ [ + + ] +
+

Discovering the World

+
\ No newline at end of file diff --git a/apps/angular-app/src/app/components/header/header.component.scss b/apps/angular-app/src/app/components/header/header.component.scss new file mode 100644 index 00000000..b5ef0ad0 --- /dev/null +++ b/apps/angular-app/src/app/components/header/header.component.scss @@ -0,0 +1,22 @@ +.mfee-header { + display: flex; + flex-direction: column; + align-items: center; + padding: 1.5rem 0; + text-align: center; + + &__slogan { + color: orange; + font-size: 0.8rem; + } + + &__slogan-icon { + font-size: 2em; + padding: 0 0.5rem; + } + + &__title { + margin: 0; + font-size: 2.5rem; + } +} diff --git a/apps/angular-app/src/app/components/header/header.component.spec.ts b/apps/angular-app/src/app/components/header/header.component.spec.ts new file mode 100644 index 00000000..3096a746 --- /dev/null +++ b/apps/angular-app/src/app/components/header/header.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HeaderComponent } from './header.component'; + +describe('HeaderComponent', () => { + let component: HeaderComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HeaderComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/header/header.component.ts b/apps/angular-app/src/app/components/header/header.component.ts new file mode 100644 index 00000000..6d240ee4 --- /dev/null +++ b/apps/angular-app/src/app/components/header/header.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'mfee-header', + standalone: true, + imports: [CommonModule], + templateUrl: './header.component.html', + styleUrl: './header.component.scss', +}) +export class HeaderComponent {} diff --git a/apps/angular-app/src/app/components/post-grid/post-grid.component.html b/apps/angular-app/src/app/components/post-grid/post-grid.component.html new file mode 100644 index 00000000..26d5fbcb --- /dev/null +++ b/apps/angular-app/src/app/components/post-grid/post-grid.component.html @@ -0,0 +1,19 @@ +
+
+

{{ post.title }}

+ {{ post.comments.count }} Comments + forum +

{{ post.description }}

+ +
+
\ No newline at end of file diff --git a/apps/angular-app/src/app/components/post-grid/post-grid.component.scss b/apps/angular-app/src/app/components/post-grid/post-grid.component.scss new file mode 100644 index 00000000..abfd2011 --- /dev/null +++ b/apps/angular-app/src/app/components/post-grid/post-grid.component.scss @@ -0,0 +1,95 @@ +@import '../../shared/variables'; + +.mfee-grid-container { + display: flex; + flex-wrap: wrap; +} + +.mfee-grid-post { + $post: &; + + background-position: center; + background-repeat: no-repeat; + background-size: cover; + box-sizing: border-box; + color: #fff; + display: flex; + flex-direction: column; + justify-content: flex-end; + min-height: 40vh; + padding: 1.2rem; + text-decoration: none; + background-color: $color-gray; + + @media (min-width: 768px) { + width: 50%; + } + + &:hover { + #{$post}__actions { + display: flex; + } + } + + &__title { + } + + &__comments { + font-weight: lighter; + font-style: italic; + align-items: flex-end; + display: flex; + + i { + font-size: inherit; + margin-left: 0.4rem; + } + } + + &__description { + font-size: 0.875rem; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + + @media (min-width: 768px) { + width: 75%; + } + } + + &__footer { + display: flex; + justify-content: space-between; + min-height: 1.75rem; + } + + &__actions { + list-style: none; + padding: 0; + margin: 0; + display: none; + } + + &__actions-item { + width: 1.75rem; + height: 1.75rem; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + cursor: pointer; + color: #ccc; + } + } + + &__tag { + font-size: 0.875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 3px; + align-items: flex-end; + display: flex; + } +} diff --git a/apps/angular-app/src/app/components/post-grid/post-grid.component.spec.ts b/apps/angular-app/src/app/components/post-grid/post-grid.component.spec.ts new file mode 100644 index 00000000..05eeac02 --- /dev/null +++ b/apps/angular-app/src/app/components/post-grid/post-grid.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PostGridComponent } from './post-grid.component'; + +describe('PostGridComponent', () => { + let component: PostGridComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PostGridComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PostGridComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/post-grid/post-grid.component.ts b/apps/angular-app/src/app/components/post-grid/post-grid.component.ts new file mode 100644 index 00000000..2dc2517f --- /dev/null +++ b/apps/angular-app/src/app/components/post-grid/post-grid.component.ts @@ -0,0 +1,37 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Post } from '../../models/Post'; + +@Component({ + selector: 'mfee-post-grid', + standalone: true, + imports: [CommonModule], + templateUrl: './post-grid.component.html', + styleUrl: './post-grid.component.scss' +}) +export class PostGridComponent { + @Input() posts: Array; + @Output() deletePost: EventEmitter = new EventEmitter(); + @Output() editPost: EventEmitter = new EventEmitter(); + + constructor(private router: Router) {} + + public onClick(event, postId: string): void { + if ( + !event.target.className.includes('mfee-grid-post__actions-item') && + !event.target.parentNode.className.includes('mfee-grid-post__actions-item') + ) { + this.router.navigate(['/post', postId]); + } + } + + public onEditPost(postId: string): void { + this.editPost.emit(postId); + } + + public onDeletePost(postId: string): void { + this.deletePost.emit(postId); + } +} diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.component.html b/apps/angular-app/src/app/components/post-modal/post-modal.component.html new file mode 100644 index 00000000..f73e8bc4 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.component.html @@ -0,0 +1,61 @@ +
+
+

{{ postModalService.title$ | async }}

+ +
+ + + + + + + + + +
+
+
\ No newline at end of file diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.component.scss b/apps/angular-app/src/app/components/post-modal/post-modal.component.scss new file mode 100644 index 00000000..2456c850 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.component.scss @@ -0,0 +1,38 @@ +@import '../../shared/variables'; + +.mfee-modal { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + + &__content { + background-color: white; + width: 90vw; + padding: 1.5rem; + + @media (min-width: 768px) { + width: 60vw; + } + } + + &__form { + display: flex; + flex-direction: column; + } + + &__title { + margin-top: 0; + text-align: center; + } + + &__footer { + display: flex; + justify-content: flex-end; + } +} diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.component.spec.ts b/apps/angular-app/src/app/components/post-modal/post-modal.component.spec.ts new file mode 100644 index 00000000..30308527 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PostModalComponent } from './post-modal.component'; + +describe('PostModalComponent', () => { + let component: PostModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PostModalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PostModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.component.ts b/apps/angular-app/src/app/components/post-modal/post-modal.component.ts new file mode 100644 index 00000000..e5065fce --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.component.ts @@ -0,0 +1,97 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { Observable, ReplaySubject, firstValueFrom, switchMap, take, takeUntil, tap } from 'rxjs'; +import { Category } from '../../models/Category'; +import { CategoryService } from '../../services/category/category.service'; +import { PostService } from '../../services/post/post.service'; +import { urlValidator } from '../../shared/url-validator'; +import { PostModalService } from './post-modal.service'; + +@Component({ + selector: 'mfee-post-modal', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './post-modal.component.html', + styleUrl: './post-modal.component.scss' +}) +export class PostModalComponent implements OnInit, OnDestroy { + private destroyed$: ReplaySubject = new ReplaySubject(1); + public categories$: Observable; + + postForm = this.fb.group({ + title: ['', Validators.required], + description: ['', Validators.required], + category: ['', Validators.required], + image: ['', [Validators.required, urlValidator]] + }); + + constructor( + public postModalService: PostModalService, + private categoryService: CategoryService, + private postService: PostService, + private fb: FormBuilder + ) {} + + ngOnInit(): void { + this.categories$ = this.categoryService.getCategories(false); + + this.postModalService.post$.pipe(takeUntil(this.destroyed$)).subscribe((post) => { + this.postForm.setValue({ + title: post?.title ?? '', + description: post?.description ?? '', + category: post?.category.id ?? '', + image: post?.image ?? '' + }); + }); + } + + ngOnDestroy() { + this.destroyed$.next(true); + this.destroyed$.complete(); + } + + close(): void { + this.postModalService.close(); + } + + async onSubmit(): Promise { + await firstValueFrom( + this.postModalService.post$.pipe( + take(1), + switchMap((post) => { + const payload = { + ...this.postForm.value + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + return this.postService.upsertPost(post ? { ...post, ...payload } : payload); + }), + tap(() => { + console.log('Update posts'); + // TODO : Update posts + }) + ) + ); + + this.postForm.reset(); + this.close(); + } + + get title() { + return this.postForm.get('title'); + } + + get description() { + return this.postForm.get('description'); + } + + get category() { + return this.postForm.get('category'); + } + + get image() { + return this.postForm.get('image'); + } +} diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.service.spec.ts b/apps/angular-app/src/app/components/post-modal/post-modal.service.spec.ts new file mode 100644 index 00000000..2ad8a208 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { PostModalService } from './post-modal.service'; + +describe('PostModalService', () => { + let service: PostModalService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(PostModalService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/components/post-modal/post-modal.service.ts b/apps/angular-app/src/app/components/post-modal/post-modal.service.ts new file mode 100644 index 00000000..a1515a83 --- /dev/null +++ b/apps/angular-app/src/app/components/post-modal/post-modal.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +import { Post } from '../../models/Post'; + +@Injectable({ + providedIn: 'root' +}) +export class PostModalService { + private display: BehaviorSubject = new BehaviorSubject(false); + public display$: Observable = this.display.asObservable(); + + private title: BehaviorSubject = new BehaviorSubject(''); + public title$: Observable = this.title.asObservable(); + + private post: BehaviorSubject = new BehaviorSubject(null); + public post$: Observable = this.post.asObservable(); + + constructor() {} + + open(): void { + this.display.next(true); + } + + close(): void { + this.display.next(false); + } + + setInfo(title: string, post: Post = null): void { + this.title.next(title); + this.post.next(post); + } +} diff --git a/apps/angular-app/src/app/models/Category.ts b/apps/angular-app/src/app/models/Category.ts new file mode 100644 index 00000000..0df829f8 --- /dev/null +++ b/apps/angular-app/src/app/models/Category.ts @@ -0,0 +1,9 @@ +export type Category = { + id: string; + name: string; +}; + +export type GetCategoryResponse = { + _id: string; + name: string; +}; diff --git a/apps/angular-app/src/app/models/Comment.ts b/apps/angular-app/src/app/models/Comment.ts new file mode 100644 index 00000000..3f74e4a2 --- /dev/null +++ b/apps/angular-app/src/app/models/Comment.ts @@ -0,0 +1,11 @@ +export type Comment = { + id: string; + author: string; + content: string; +}; + +export type CommentResponse = { + _id: string; + author: string; + content: string; +}; diff --git a/apps/angular-app/src/app/models/Post.ts b/apps/angular-app/src/app/models/Post.ts new file mode 100644 index 00000000..94d33653 --- /dev/null +++ b/apps/angular-app/src/app/models/Post.ts @@ -0,0 +1,32 @@ +import { Category, GetCategoryResponse } from './Category'; +import { Comment, CommentResponse } from './Comment'; + +export type Post = { + id: string; + title: string; + image: string; + description: string; + category: Category; + comments: { + count: number; + data?: Array; + }; +}; + +export type GetPostsResponse = { + _id: string; + title: string; + image: string; + description: string; + category: GetCategoryResponse; + comments: Array; +}; + +export type GetPostResponse = { + _id: string; + title: string; + image: string; + description: string; + category: GetCategoryResponse; + comments: Array; +}; diff --git a/apps/angular-app/src/app/services/.gitkeep b/apps/angular-app/src/app/services/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/angular-app/src/app/services/category/category.service.spec.ts b/apps/angular-app/src/app/services/category/category.service.spec.ts new file mode 100644 index 00000000..56585079 --- /dev/null +++ b/apps/angular-app/src/app/services/category/category.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { CategoryService } from './category.service'; + +describe('CategoryService', () => { + let service: CategoryService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CategoryService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/services/category/category.service.ts b/apps/angular-app/src/app/services/category/category.service.ts new file mode 100644 index 00000000..cf55e192 --- /dev/null +++ b/apps/angular-app/src/app/services/category/category.service.ts @@ -0,0 +1,33 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, map, take } from 'rxjs'; + +import { Category, GetCategoryResponse } from '../../models/Category'; + +@Injectable({ + providedIn: 'root' +}) +export class CategoryService { + private apiUrl = 'http://localhost:4200/api/categories'; + + constructor(private http: HttpClient) {} + + public getCategories(includeAll: boolean = true): Observable { + return this.http.get(this.apiUrl).pipe( + take(1), + map((categories) => { + const newCategories = categories.map((c) => ({ id: c._id, name: c.name })); + + return includeAll + ? [ + { + id: 'all', + name: 'All' + }, + ...newCategories + ] + : newCategories; + }) + ); + } +} diff --git a/apps/angular-app/src/app/services/post/post.service.spec.ts b/apps/angular-app/src/app/services/post/post.service.spec.ts new file mode 100644 index 00000000..913642b8 --- /dev/null +++ b/apps/angular-app/src/app/services/post/post.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { PostService } from './post.service'; + +describe('PostService', () => { + let service: PostService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(PostService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/services/post/post.service.ts b/apps/angular-app/src/app/services/post/post.service.ts new file mode 100644 index 00000000..b53575ae --- /dev/null +++ b/apps/angular-app/src/app/services/post/post.service.ts @@ -0,0 +1,118 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, map, take } from 'rxjs'; + +import { Comment, CommentResponse } from '../../models/Comment'; +import { GetPostResponse, GetPostsResponse, Post } from '../../models/Post'; + +@Injectable({ + providedIn: 'root' +}) +export class PostService { + private postsApiUrl = 'http://localhost:4200/api/posts'; + + constructor(private http: HttpClient) {} + + public getPosts(selectedCategory: string): Observable> { + const url = selectedCategory === 'all' ? this.postsApiUrl : `${this.postsApiUrl}/category/${selectedCategory}`; + + return this.http.get(url).pipe( + take(1), + map((posts) => + posts.map((p) => ({ + id: p._id, + title: p.title, + image: p.image, + description: p.description, + category: { + id: p.category._id, + name: p.category.name + }, + comments: { + count: p.comments.length + } + })) + ) + ); + } + + public getPost(postId: string): Observable { + return this.http.get(`${this.postsApiUrl}/${postId}`).pipe( + take(1), + map((post) => ({ + id: post._id, + title: post.title, + image: post.image, + description: post.description, + category: { + id: post.category._id, + name: post.category.name + }, + comments: { + count: post.comments.length, + data: post.comments.map((c) => ({ + id: c._id, + author: c.author, + content: c.content + })) + } + })) + ); + } + + public upsertPost(post: Partial): Observable { + const format = (post: GetPostResponse) => ({ + id: post._id, + title: post.title, + image: post.image, + description: post.description, + category: { + id: post.category._id, + name: post.category.name + }, + comments: { + count: post.comments.length, + data: post.comments.map((c) => ({ + id: c._id, + author: c.author, + content: c.content + })) + } + }); + + if (post.id) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, comments, ...payload } = post; + // Edit post + return this.http.patch(`${this.postsApiUrl}/${post.id}`, payload).pipe( + take(1), + map((post) => format(post)) + ); + } + + // Create post + return this.http.post(this.postsApiUrl, post).pipe( + take(1), + map((post) => format(post)) + ); + } + + public deletePost(postId: string): Observable { + return this.http.delete(`${this.postsApiUrl}/${postId}`).pipe(take(1)); + } + + public addComment(postId: string, comment: string): Observable { + const payload = { + author: 'Anonymous', + content: comment + }; + return this.http.post(`${this.postsApiUrl}/${postId}/comments`, payload).pipe( + take(1), + map((comment) => ({ + id: comment._id, + author: comment.author, + content: comment.content + })) + ); + } +} diff --git a/apps/angular-app/src/app/shared/url-validator.ts b/apps/angular-app/src/app/shared/url-validator.ts new file mode 100644 index 00000000..a9559e90 --- /dev/null +++ b/apps/angular-app/src/app/shared/url-validator.ts @@ -0,0 +1,10 @@ +import { AbstractControl } from '@angular/forms'; + +export function urlValidator(control: AbstractControl): { [key: string]: boolean } | null { + const url = control.value; + + const URL_REGEXP = + /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/; + + return URL_REGEXP.test(url) ? null : { invalidUrl: true }; +} diff --git a/apps/angular-app/src/app/shared/variables.scss b/apps/angular-app/src/app/shared/variables.scss index e69de29b..02ef634a 100644 --- a/apps/angular-app/src/app/shared/variables.scss +++ b/apps/angular-app/src/app/shared/variables.scss @@ -0,0 +1,3 @@ +$color-blue: #3f3fc9; +$color-blue-light: #5b5bc0; +$color-gray: #ccc; diff --git a/apps/angular-app/src/app/views/.gitkeep b/apps/angular-app/src/app/views/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/angular-app/src/app/views/home/home.component.html b/apps/angular-app/src/app/views/home/home.component.html new file mode 100644 index 00000000..417747b1 --- /dev/null +++ b/apps/angular-app/src/app/views/home/home.component.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/angular-app/src/app/components/.gitkeep b/apps/angular-app/src/app/views/home/home.component.scss similarity index 100% rename from apps/angular-app/src/app/components/.gitkeep rename to apps/angular-app/src/app/views/home/home.component.scss diff --git a/apps/angular-app/src/app/views/home/home.component.spec.ts b/apps/angular-app/src/app/views/home/home.component.spec.ts new file mode 100644 index 00000000..5dd05d2c --- /dev/null +++ b/apps/angular-app/src/app/views/home/home.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HomeComponent } from './home.component'; + +describe('HomeComponent', () => { + let component: HomeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HomeComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HomeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/views/home/home.component.ts b/apps/angular-app/src/app/views/home/home.component.ts new file mode 100644 index 00000000..1786d9a6 --- /dev/null +++ b/apps/angular-app/src/app/views/home/home.component.ts @@ -0,0 +1,69 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; + +import { BehaviorSubject, Observable, catchError, of, switchMap, take, tap, withLatestFrom } from 'rxjs'; +import { AddPostComponent } from '../../components/add-post/add-post.component'; +import { CategoriesComponent } from '../../components/categories/categories.component'; +import { PostGridComponent } from '../../components/post-grid/post-grid.component'; +import { PostModalComponent } from '../../components/post-modal/post-modal.component'; +import { PostModalService } from '../../components/post-modal/post-modal.service'; +import { Category } from '../../models/Category'; +import { Post } from '../../models/Post'; +import { CategoryService } from '../../services/category/category.service'; +import { PostService } from '../../services/post/post.service'; + +@Component({ + selector: 'mfee-home', + standalone: true, + imports: [CommonModule, AddPostComponent, CategoriesComponent, PostGridComponent, PostModalComponent], + templateUrl: './home.component.html', + styleUrl: './home.component.scss' +}) +export class HomeComponent implements OnInit { + public categories$: Observable; + public posts$: Observable; + + private selectedCategory = new BehaviorSubject('all'); + public selectedCategory$ = this.selectedCategory.asObservable(); + + constructor(private postService: PostService, private categoryService: CategoryService, private postModalService: PostModalService) {} + + ngOnInit(): void { + this.categories$ = this.categoryService.getCategories(); + + this.posts$ = this.selectedCategory$.pipe(switchMap((selectedCategory) => this.postService.getPosts(selectedCategory))); + } + + public onCategoryChange(categoryId: string): void { + this.selectedCategory.next(categoryId); + } + + public onAddPost(): void { + this.postModalService.setInfo('Create Post'); + this.postModalService.open(); + } + + public onEditPost(postId: string): void { + this.postService.getPost(postId).subscribe((post) => { + this.postModalService.setInfo('Edit Post', post); + this.postModalService.open(); + }); + } + + public onDeletePost(postId: string): void { + this.postService + .deletePost(postId) + .pipe( + take(1), + withLatestFrom(this.selectedCategory$), + tap(([, selectedCategory]) => { + this.onCategoryChange(selectedCategory); + }), + catchError(() => { + // TODO : Show an error message to the user + return of(null); + }) + ) + .subscribe(); + } +} diff --git a/apps/angular-app/src/app/views/page-not-found/page-not-found.component.html b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.html new file mode 100644 index 00000000..35fc2c27 --- /dev/null +++ b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.html @@ -0,0 +1 @@ +

Ups! Something went wrong, we couldn't find this page.

diff --git a/apps/angular-app/src/app/models/.gitkeep b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.scss similarity index 100% rename from apps/angular-app/src/app/models/.gitkeep rename to apps/angular-app/src/app/views/page-not-found/page-not-found.component.scss diff --git a/apps/angular-app/src/app/views/page-not-found/page-not-found.component.spec.ts b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.spec.ts new file mode 100644 index 00000000..0ec6519d --- /dev/null +++ b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PageNotFoundComponent } from './page-not-found.component'; + +describe('PageNotFoundComponent', () => { + let component: PageNotFoundComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PageNotFoundComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PageNotFoundComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/views/page-not-found/page-not-found.component.ts b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.ts new file mode 100644 index 00000000..bba9d8ff --- /dev/null +++ b/apps/angular-app/src/app/views/page-not-found/page-not-found.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'mfee-page-not-found', + standalone: true, + imports: [CommonModule], + templateUrl: './page-not-found.component.html', + styleUrl: './page-not-found.component.scss', +}) +export class PageNotFoundComponent {} diff --git a/apps/angular-app/src/app/views/post/post.component.html b/apps/angular-app/src/app/views/post/post.component.html new file mode 100644 index 00000000..736ce009 --- /dev/null +++ b/apps/angular-app/src/app/views/post/post.component.html @@ -0,0 +1,43 @@ +
+
+ +

{{ post.title }}

+
+
+
+

{{ post.description }}

+ +
+

Comments

+ +
+ + + +
+
    +
  • +
    + person + {{ comment.author }} +
    +

    {{ comment.content }}

    +
  • +
+
+
+
+
\ No newline at end of file diff --git a/apps/angular-app/src/app/views/post/post.component.scss b/apps/angular-app/src/app/views/post/post.component.scss new file mode 100644 index 00000000..ddef47cb --- /dev/null +++ b/apps/angular-app/src/app/views/post/post.component.scss @@ -0,0 +1,107 @@ +.mfee-post { + &__header { + display: flex; + color: white; + min-height: 50vh; + position: relative; + align-items: center; + justify-content: center; + background-position: center top; + background-size: cover; + background-repeat: no-repeat; + } + + &__actions { + position: absolute; + top: 0; + left: 0; + padding: 10px 0 0 10px; + } + + &__action { + display: flex; + align-items: center; + text-decoration: none; + color: white; + + &:hover { + color: #ccc; + } + } + + &__title { + font-size: 2.25rem; + text-align: center; + } + + &__content-wrapper { + background-color: #ececec; + display: flex; + } + + &__content { + padding: 0 1rem; + margin: 0 auto; + flex-grow: 1; + + @media (min-width: 576px) { + max-width: 540px; + } + + @media (min-width: 768px) { + max-width: 720px; + } + + @media (min-width: 992px) { + max-width: 960px; + } + + @media (min-width: 1200px) { + max-width: 1140px; + } + } + + &__description { + white-space: pre-line; + } + + &-comments { + margin: 0 auto; + + @media (min-width: 768px) { + max-width: 480px; + } + } + + &__comment-wrapper { + padding: 0; + list-style: none; + } + + &__comment { + background-color: white; + box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.1); + margin-bottom: 8px; + padding: 8px; + + &-name { + display: flex; + align-items: center; + font-weight: bold; + margin-bottom: 8px; + } + + &-avatar { + background-color: black; + color: white; + border-radius: 99px; + padding: 2px; + margin-right: 8px; + } + + &-description { + margin: 0; + margin-left: 36px; + } + } +} diff --git a/apps/angular-app/src/app/views/post/post.component.spec.ts b/apps/angular-app/src/app/views/post/post.component.spec.ts new file mode 100644 index 00000000..15c9dacd --- /dev/null +++ b/apps/angular-app/src/app/views/post/post.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PostComponent } from './post.component'; + +describe('PostComponent', () => { + let component: PostComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PostComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/angular-app/src/app/views/post/post.component.ts b/apps/angular-app/src/app/views/post/post.component.ts new file mode 100644 index 00000000..67622978 --- /dev/null +++ b/apps/angular-app/src/app/views/post/post.component.ts @@ -0,0 +1,41 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { Observable, switchMap } from 'rxjs'; + +import { Post } from '../../models/Post'; +import { PostService } from '../../services/post/post.service'; + +@Component({ + selector: 'mfee-post', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterLink], + templateUrl: './post.component.html', + styleUrl: './post.component.scss' +}) +export class PostComponent implements OnInit { + public post$: Observable; + + commentForm = this.fb.group({ + comment: ['', Validators.required] + }); + + constructor(private route: ActivatedRoute, private fb: FormBuilder, private postService: PostService) {} + + ngOnInit(): void { + this.post$ = this.route.params.pipe(switchMap(({ id }) => this.postService.getPost(id))); + } + + onSubmit(postId: string) { + this.postService.addComment(postId, this.commentForm.value.comment).subscribe((comment) => { + // TODO : Add comment to UI + console.log(comment); + }); + this.commentForm.reset(); + } + + get comment() { + return this.commentForm.get('comment'); + } +} diff --git a/apps/angular-app/src/index.html b/apps/angular-app/src/index.html index da0603dd..f7be7978 100644 --- a/apps/angular-app/src/index.html +++ b/apps/angular-app/src/index.html @@ -7,6 +7,7 @@ + diff --git a/apps/angular-app/src/styles.scss b/apps/angular-app/src/styles.scss index cf488252..8884a5ec 100644 --- a/apps/angular-app/src/styles.scss +++ b/apps/angular-app/src/styles.scss @@ -4,3 +4,83 @@ body { font-family: 'Open Sans', sans-serif; } + +.mfee-button { + border: none; + background: $color-blue; + color: white; + font-size: 0.875rem; + padding: 0.5rem 1.5rem; + border-radius: 2px; + cursor: pointer; + + &:hover { + background-color: $color-blue-light; + } + + &--link { + background-color: transparent; + color: black; + + &:hover { + text-decoration: underline; + background-color: transparent; + } + } + + &:disabled { + color: $color-gray; + cursor: not-allowed; + } +} + +.mfee-form-control { + display: flex; + flex-direction: column; + margin-bottom: 1rem; + font-size: 0.75rem; + position: relative; + + &__input { + border: none; + border-bottom: 1px solid $color-gray; + outline: none !important; + margin-top: 2px; + padding: 0.25rem 0; + resize: none; + background-color: transparent; + + &:active, + &:focus { + border-bottom-color: $color-blue; + } + + &.ng-dirty.ng-invalid, + &.ng-touched.ng-invalid { + border-bottom-color: red; + } + } + + &__error { + position: absolute; + bottom: 0; + right: 0; + color: red; + } +} + +::-webkit-scrollbar { + width: 6px; + background-color: #f5f5f5; +} + +::-webkit-scrollbar-thumb { + border-radius: 10px; + -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); + background-color: #555; +} + +::-webkit-scrollbar-track { + -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); + background-color: #f5f5f5; +} From 5eb29eb5b3605c3ff0c574f9648300d8b65d281d Mon Sep 17 00:00:00 2001 From: Gus Date: Wed, 10 Jan 2024 23:29:35 -0600 Subject: [PATCH 29/30] fix(api): add message to 409 error in auth register --- apps/api/src/controllers/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts index c5b90d02..96bb74b5 100644 --- a/apps/api/src/controllers/auth.ts +++ b/apps/api/src/controllers/auth.ts @@ -18,7 +18,7 @@ const register = async (req, res) => { // Check that we don't have duplicates const duplicate = users.find((u) => u.username === username); if (duplicate) { - return res.sendStatus(409); + return res.status(409).json({ message: 'User already exist' }); } try { From 4a4b658d9b9fc18cac849c3464e613e5c1471e9c Mon Sep 17 00:00:00 2001 From: David Date: Wed, 18 Sep 2024 21:48:23 -0600 Subject: [PATCH 30/30] Project completed --- apps/api/.env | 2 +- apps/api/src/controllers/post.ts | 161 +++++++++++++++++++++++++++++++ apps/api/src/main.ts | 2 + apps/api/src/models/category.ts | 2 +- apps/api/src/models/comment.ts | 26 +++++ apps/api/src/models/post.ts | 45 +++++++++ apps/api/src/routes/posts.ts | 28 ++++++ 7 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/controllers/post.ts create mode 100644 apps/api/src/models/comment.ts create mode 100644 apps/api/src/models/post.ts create mode 100644 apps/api/src/routes/posts.ts diff --git a/apps/api/.env b/apps/api/.env index ecaf5a7c..c2651885 100644 --- a/apps/api/.env +++ b/apps/api/.env @@ -1,3 +1,3 @@ ACCESS_TOKEN_SECRET=[YOUR_ACCESS_TOKEN_SECRET_HERE] REFRESH_TOKEN_SECRET=[YOUR_REFRESH_TOKEN_SECRET_HERE] -MONGO_URL=[YOUR_MONGO_CONNECTION_STRING_HERE] \ No newline at end of file +MONGO_URL=mongodb+srv://david:Cn5t$.@cluster0.btfyy.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0 \ No newline at end of file diff --git a/apps/api/src/controllers/post.ts b/apps/api/src/controllers/post.ts new file mode 100644 index 00000000..78217311 --- /dev/null +++ b/apps/api/src/controllers/post.ts @@ -0,0 +1,161 @@ +import Post from '../models/post'; +import Comment from '../models/comment'; + + + +// Get all posts +const getPosts = async (req, res) => { + try { + const posts = await Post.find().populate('category').populate('comments').exec(); + res.status(200).json(posts); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Get post by id +const getPostById = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check if we have a post with that id + const post = await Post.findById(id).populate('category').populate('comments').exec(); + + if (!post) { + // If we don't find the post return a 404 status code with a message + return res.status(404).json({ message: 'Post not found' }); + // Note: Remember that json method doesn't interrupt the workflow + // therefore is important to add a "return" to break the process + } + + // Return the post with a 200 status code + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Create post +const createPost = async (req, res) => { + try { + const post = await Post.create(req.body); + // Return the created post with a 201 status code + res.status(201).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Update post +const updatePost = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check and update if we have a post with that id + const post = await Post.findByIdAndUpdate(id, req.body, { new: true }); + + // If we don't find the post return a 404 status code with a message + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + + // Return the updated post with a 200 status code + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Delete post +const deletePost = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check and delete if we have a post with that id + const post = await Post.findByIdAndDelete(id); + + // If we don't find the post return a 404 status code with a message + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + + await Comment.deleteMany({ _id: { $in: post.comments } }); + + // Return a 200 status code + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Get post by category +const getPostsByCategory = async (req, res) => { + // Retrieve the category from the route params + const { category } = req.params; + + try { + // Check if we have a post with that id + const post = await Post.find({ category }).populate('category').populate('comments').exec(); + + if (!post) { + // If we don't find the post return a 404 status code with a message + return res.status(404).json({ message: 'Posts not found' }); + // Note: Remember that json method doesn't interrupt the workflow + // therefore is important to add a "return" to break the process + } + + // Return the post with a 200 status code + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Create post comment +const createPostComment = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check if we have a post with that id + const post = await Post.findById(id).populate('category').populate('comments').exec(); + + if (!post) { + // If we don't find the post return a 404 status code with a message + return res.status(404).json({ message: 'Post not found' }); + // Note: Remember that json method doesn't interrupt the workflow + // therefore is important to add a "return" to break the process + } + + const newComment = await Comment.create(req.body); + + post.comments.push(newComment.id); + await post.save(); + + res.status(201).json(newComment); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + + + +export default { + getPosts, + getPostById, + createPost, + updatePost, + deletePost, + getPostsByCategory, + createPostComment +}; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index daf9dc2e..96888e1e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -8,6 +8,7 @@ import { verifyToken } from './middleware/auth'; import { errorHandler } from './middleware/errorHandler'; import auth from './routes/auth'; import categories from './routes/categories'; +import posts from './routes/posts'; const host = process.env.HOST ?? 'localhost'; const port = process.env.PORT ? Number(process.env.PORT) : 3000; @@ -22,6 +23,7 @@ app.use('/api/auth', auth); app.use(verifyToken); app.use('/api/categories', categories); +app.use('/api/posts', posts); app.use(errorHandler); diff --git a/apps/api/src/models/category.ts b/apps/api/src/models/category.ts index c69bbc87..b65f2216 100644 --- a/apps/api/src/models/category.ts +++ b/apps/api/src/models/category.ts @@ -4,7 +4,7 @@ interface ICategory extends Document { name: string; } -export const categorySchema = new Schema( +const categorySchema = new Schema( { name: { type: String, diff --git a/apps/api/src/models/comment.ts b/apps/api/src/models/comment.ts new file mode 100644 index 00000000..57d88f6a --- /dev/null +++ b/apps/api/src/models/comment.ts @@ -0,0 +1,26 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +interface IComment extends Document { + author: string; + content: string; +} + +const commentSchema = new Schema( + { + author: { + type: String, + required: [true, 'Property is required'] + }, + content: { + type: String, + required: [true, 'Property is required'] + } + }, + { + timestamps: true + } +); + +const Comment = mongoose.model('Comment', commentSchema); + +export default Comment; diff --git a/apps/api/src/models/post.ts b/apps/api/src/models/post.ts new file mode 100644 index 00000000..63d34f1e --- /dev/null +++ b/apps/api/src/models/post.ts @@ -0,0 +1,45 @@ +import mongoose, { Document, Schema } from 'mongoose'; + + +interface IPost extends Document { + title: string; + image: string; + description: string; + category: mongoose.Types.ObjectId; + comments: mongoose.Types.ObjectId[]; +} + +const postSchema = new Schema( + { + title: { + type: String, + required: [true, 'Property is required'] + }, + image: { + type: String, + required: [true, 'Property is required'] + }, + description: { + type: String, + required: [true, 'Property is required'] + }, + category: { + type: Schema.Types.ObjectId, + ref: 'Category', + required: [true, 'Property is required'] + }, + comments: [ + { + type: Schema.Types.ObjectId, + ref: 'Comment' + } + ] + }, + { + timestamps: true + } +); + +const Post = mongoose.model('Post', postSchema); + +export default Post; diff --git a/apps/api/src/routes/posts.ts b/apps/api/src/routes/posts.ts new file mode 100644 index 00000000..519fcb87 --- /dev/null +++ b/apps/api/src/routes/posts.ts @@ -0,0 +1,28 @@ +import express from 'express'; + +import postController from '../controllers/post'; + +const router = express.Router(); + +// Get all posts +router.get('/', postController.getPosts); + +// Get post by id +router.get('/:id', postController.getPostById); + +// Create post +router.post('/', postController.createPost); + +// Update post +router.patch('/:id', postController.updatePost); + +// Delete post +router.delete('/:id', postController.deletePost); + +// Get all posts by category +router.get('/category/:category', postController.getPostsByCategory); + +// Create post comment +router.post('/:id/comments', postController.createPostComment); + +export default router;