First commit with existing project files

This commit is contained in:
2025-10-11 16:10:15 +02:00
commit b7f00bc125
68 changed files with 15871 additions and 0 deletions

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

27
README.md Normal file
View File

@@ -0,0 +1,27 @@
# PlayingCards
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.20.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

98
angular.json Normal file
View File

@@ -0,0 +1,98 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"playing-cards": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/playing-cards",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"@angular/material/prebuilt-themes/azure-blue.css",
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kB",
"maximumError": "4kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "playing-cards:build:production"
},
"development": {
"buildTarget": "playing-cards:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"@angular/material/prebuilt-themes/azure-blue.css",
"src/styles.css"
],
"scripts": []
}
}
}
}
}
}

14158
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "playing-cards",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^18.2.0",
"@angular/cdk": "^18.2.14",
"@angular/common": "^18.2.0",
"@angular/compiler": "^18.2.0",
"@angular/core": "^18.2.0",
"@angular/forms": "^18.2.0",
"@angular/material": "^18.2.14",
"@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.10"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.2.20",
"@angular/cli": "^18.2.20",
"@angular/compiler-cli": "^18.2.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.2.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.5.2"
}
}

BIN
public/assets/img/bulb.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
public/assets/img/car.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

BIN
public/assets/img/char.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

BIN
public/assets/img/fire.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

BIN
public/assets/img/pik.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
public/assets/img/plant.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/assets/img/water.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

10
src/app/app.component.css Normal file
View File

@@ -0,0 +1,10 @@
mat-toolbar {
display: flex;
align-items: center;
padding: 0 20px;
p {
flex-grow: 1;
margin: 0;
}
}

View File

@@ -0,0 +1,15 @@
@if (loginService.user()) {
<mat-toolbar>
<button mat-icon-button (click)="navigateHome()">
<mat-icon>home</mat-icon>
</button>
@if (loginService.user()?.firstName && loginService.user()?.lastName) {
<p class="mat-title">Welcome, {{ loginService.user()?.firstName }} {{ loginService.user()?.lastName }}</p>
} @else {
<p>Welcome, {{ loginService.user()?.username }}</p>
}
<button mat-flat-button color="warn" (click)="logout()">Logout</button>
</mat-toolbar>
}
<router-outlet></router-outlet>

View File

@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'playing-cards' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('playing-cards');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, playing-cards');
});
});

51
src/app/app.component.ts Normal file
View File

@@ -0,0 +1,51 @@
import {Component, inject, OnDestroy} from '@angular/core';
import {Router, RouterOutlet} from '@angular/router';
import {MatToolbar} from '@angular/material/toolbar';
import {MatButton, MatIconButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon';
import {LoginService} from './services/login/login.service';
import {Subscription} from 'rxjs';
@Component({
selector: 'app-root',
standalone: true,
imports: [
RouterOutlet,
MatToolbar,
MatIconButton,
MatIcon,
MatButton
],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
})
export class AppComponent implements OnDestroy {
private readonly router: Router = inject(Router);
protected readonly loginService: LoginService = inject(LoginService);
private logoutSubscription: Subscription | null = null;
ngOnDestroy() {
this.logoutSubscription?.unsubscribe();
}
logout() {
this.logoutSubscription = this.loginService.logout().subscribe({
next: () => {
this.navigateToLogin();
},
error: (error) => {
this.navigateToLogin()
}
});
}
navigateToLogin() {
this.router.navigate(['/login']).then();
}
navigateHome() {
this.router.navigate(['/home']).then();
}
}

19
src/app/app.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
import {provideHttpClient, withInterceptors} from '@angular/common/http';
import {authTokenInterceptor} from './interceptors/auth-token.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({eventCoalescing: true}),
provideRouter(routes),
provideAnimationsAsync(),
provideHttpClient(withInterceptors([
authTokenInterceptor
])
)
]
};

42
src/app/app.routes.ts Normal file
View File

@@ -0,0 +1,42 @@
import {Routes} from '@angular/router';
import {MonsterListComponent} from './pages/monster-list/monster-list.component';
import {MonsterComponent} from './pages/monster/monster.component';
import {NotFoundComponent} from './pages/not-found/not-found.component';
import {LoginComponent} from './pages/login/login.component';
import {isLoggedInGuard} from './guards/is-logged-in.guard';
export const routes: Routes = [
{
path: '',
redirectTo: 'home',
pathMatch: 'full'
},
{
path: 'home',
component: MonsterListComponent,
//canActivate: [isLoggedInGuard]
},
{
path: 'login',
component: LoginComponent
},
{
path: 'monster',
children: [
{
path: '',
component: MonsterComponent,
//canActivate: [isLoggedInGuard]
},
{
path: ':id',
component: MonsterComponent,
//canActivate: [isLoggedInGuard]
}
]
},
{
path: '**',
component: NotFoundComponent
}
];

View File

@@ -0,0 +1,8 @@
<h2 mat-dialog-title>Delete Monster</h2>
<mat-dialog-content>
<p>Do you want to delete the selected monster ?</p>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button [mat-dialog-close]="false">No</button>
<button mat-button [mat-dialog-close]="true">Yes</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DeleteMonsterConfirmationDialogComponent } from './delete-monster-confirmation-dialog.component';
describe('DeleteMonsterConfirmationDialogComponent', () => {
let component: DeleteMonsterConfirmationDialogComponent;
let fixture: ComponentFixture<DeleteMonsterConfirmationDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DeleteMonsterConfirmationDialogComponent]
})
.compileComponents();
fixture = TestBed.createComponent(DeleteMonsterConfirmationDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,20 @@
import { Component } from '@angular/core';
import {MatDialogActions, MatDialogClose, MatDialogContent, MatDialogTitle} from '@angular/material/dialog';
import {MatButton} from '@angular/material/button';
@Component({
selector: 'app-delete-monster-confirmation-dialog',
standalone: true,
imports: [
MatDialogTitle,
MatDialogActions,
MatDialogContent,
MatButton,
MatDialogClose
],
templateUrl: './delete-monster-confirmation-dialog.component.html',
styleUrl: './delete-monster-confirmation-dialog.component.css'
})
export class DeleteMonsterConfirmationDialogComponent {
}

View File

@@ -0,0 +1,97 @@
#card {
display: block;
width: 250px;
height: 350px;
padding: 10px;
border-radius: 10px;
box-shadow: 5px 5px 10px 0 rgba(0, 0, 0, 0.1);
background-color: rgb(221, 221, 221);
}
#inside {
padding: 5px;
background-color: rgb(255, 247, 97);
}
header {
display: flex;
font-size: 16px;
font-weight: bold;
margin-bottom: 5px;
.left,
.right {
display: flex;
width: 50%;
align-items: center;
}
.right {
flex-direction: row;
justify-content: flex-end;
gap: 10px;
}
.right #hp,
.right img {
vertical-align: middle;
}
}
.icon.energy {
width: 15px;
height: 15px;
}
figure#art {
margin: 0;
padding: 5px 5px 0 5px;
width: calc(100% - 10px);
background-color: lightgray;
img {
width: 100%;
height: 175px;
border-radius: 5px;
}
figcaption {
text-align: center;
font-size: smaller;
padding-bottom: 5px;
}
}
#capacities {
display: flex;
flex-direction: column;
justify-content: center;
height: 100px;
}
.capacity {
display: flex;
flex-direction: column;
margin: 10px 0;
.main {
display: flex;
flex-direction: row;
gap: 10px;
font-weight: bold;
}
.name {
flex-grow: 1;
}
.cost {
display: flex;
gap: 5px;
align-items: center;
}
}
.description {
font-size: smaller
}

View File

@@ -0,0 +1,32 @@
<div id="card">
<div id="inside" [style.background-color]="backgroundColor()">
<header>
<div class="left">
<div id="name">{{ monster().name }}</div>
</div>
<div class="right">
<div id="hp">{{ monster().hp }}</div>
<img class="energy icon" [src]="MonsterTypeIcon()" alt="Monster Type Icon">
</div>
</header>
<figure id="art">
<img [src]="monster().image" alt="{{ monster().name }}" />
<figcaption>{{ monster().figureCaption }}</figcaption>
</figure>
<div id="capacities">
<div class="capacity">
<div class="main">
<div class="cost">
<img class="energy icon" src="assets/img/electric.png" alt="Energy Icon">
<img class="energy icon" src="assets/img/electric.png" alt="Energy Icon">
</div>
<div class="name">{{ monster().attackName }}</div>
<div class="damage">{{ monster().attackStrength }}</div>
</div>
</div>
<div class="description">
{{ monster().attackDescription }}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PlayingCardComponent } from './playing-card.component';
describe('PlayingCardComponent', () => {
let component: PlayingCardComponent;
let fixture: ComponentFixture<PlayingCardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PlayingCardComponent]
})
.compileComponents();
fixture = TestBed.createComponent(PlayingCardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,23 @@
import {Component, computed, input, Input, OnChanges, SimpleChanges} from '@angular/core';
import { Monster } from '../../models/monster.model';
import {MonsterTypeProperties} from '../../utils/monster.utils';
@Component({
selector: 'app-playing-card',
standalone: true,
imports: [],
templateUrl: './playing-card.component.html',
styleUrl: './playing-card.component.css'
})
export class PlayingCardComponent {
monster = input(new Monster());
MonsterTypeIcon = computed(() => {
return MonsterTypeProperties[this.monster().type].imageUrl;
});
backgroundColor = computed(() => {
return MonsterTypeProperties[this.monster().type].color;
});
}

View File

@@ -0,0 +1,29 @@
#search-bar {
display: flex;
background-color: white;
border-radius: 40px;
margin: 50px auto;
box-shadow: 3px 3px 10px -3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
input {
all: unset;
flex-grow: 1;
padding: 10px 20px;
}
button {
border: none;
margin: 5px;
padding: 5px;
border-radius: 40px;
background-color: rgb(205, 205, 255);
width: 50px;
cursor: pointer;
transition: background-color 0.3s ease;
}
button:hover {
background-color: rgb(179, 179, 255);
}

View File

@@ -0,0 +1,4 @@
<div id="search-bar">
<input type="text" placeholder="Search..." [ngModel]="search" (ngModelChange)="updateSearch($event)" />
<button (click)="searchClick()"><img src="assets/img/search.png" width="20" height="20" alt=""></button>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchBarComponent } from './search-bar.component';
describe('SearchBarComponent', () => {
let component: SearchBarComponent;
let fixture: ComponentFixture<SearchBarComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SearchBarComponent]
})
.compileComponents();
fixture = TestBed.createComponent(SearchBarComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,25 @@
import { Component, Output, EventEmitter, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-search-bar',
standalone: true,
imports: [FormsModule],
templateUrl: './search-bar.component.html',
styleUrl: './search-bar.component.css'
})
export class SearchBarComponent {
@Input() search = '';
@Output() searchChange = new EventEmitter<string>();
@Output() searchButtonClicked = new EventEmitter<void>();
searchClick() {
this.searchButtonClicked.emit();
}
updateSearch(value: string) {
this.searchChange.emit(value);
}
}

View File

@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { isLoggedInGuard } from './is-logged-in.guard';
describe('isLoggedInGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => isLoggedInGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeGuard).toBeTruthy();
});
});

View File

@@ -0,0 +1,25 @@
import {CanActivateFn, Router} from '@angular/router';
import {inject} from '@angular/core';
import {LoginService} from '../services/login/login.service';
import {catchError, map} from 'rxjs';
export const isLoggedInGuard: CanActivateFn = (route, state) => {
const loginService: LoginService = inject(LoginService);
const router: Router = inject(Router);
if (loginService.user() === undefined) {
return loginService.getUsers().pipe(
map(_ => {
return true;
}),
catchError(_ => router.navigate(['login']))
)
}
if (loginService.user() === null) {
router.navigate(['login']).then();
}
return true;
};

View File

@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { HttpInterceptorFn } from '@angular/common/http';
import { authTokenInterceptor } from './auth-token.interceptor';
describe('authTokenInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => authTokenInterceptor(req, next));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});

View File

@@ -0,0 +1,15 @@
import { HttpInterceptorFn } from '@angular/common/http';
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem('token');
let requestToSend = req;
if(token) {
const headers = req.headers.set('Authorization', `Token ${token}`);
requestToSend = req.clone({
headers: headers
});
}
return next(requestToSend);
};

View File

@@ -0,0 +1,13 @@
export interface IMonster {
id?: number
name: string
image: string
type: string
hp: number
figureCaption: string
attackName: string
attackStrength: number
attackDescription: string
}

View File

@@ -0,0 +1,4 @@
export interface Credentials {
username: string;
password: string;
}

View File

@@ -0,0 +1,31 @@
import {MonsterType} from "../utils/monster.utils";
import {IMonster} from '../interfaces/monster';
export class Monster implements IMonster {
id: number = -1;
name: string = 'Monster';
image: string = 'assets/img/pik.webp';
type: MonsterType = MonsterType.ELECTRIC;
hp: number = 60;
figureCaption: string = 'N°001 Monster';
attackName: string = 'Standard Attack';
attackStrength: number = 10;
attackDescription: string = 'This is an attack description...';
copy(): Monster {
return Object.assign(new Monster(), this);
}
static fromJson(monsterJson: IMonster): Monster {
return Object.assign(new Monster(), monsterJson);
}
toJson(): IMonster {
const monsterJson: IMonster = Object.assign({}, this)
delete monsterJson.id;
return monsterJson;
}
}

View File

@@ -0,0 +1,5 @@
export class User {
username: string = '';
firstName: string = '';
lastName: string = '';
}

View File

@@ -0,0 +1,20 @@
#container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
form {
background-color: white;
padding: 20px;
border-radius: 10px;
display: flex;
flex-direction: column;
width: 400px;
}
mat-error {
text-align: center;
}

View File

@@ -0,0 +1,17 @@
<div id="container">
<form (submit)="login()" [formGroup]="loginFormGroup">
<h3>Login</h3>
<mat-form-field>
<mat-label>Username</mat-label>
<input matInput formControlName="username">
</mat-form-field>
<mat-form-field>
<mat-label>Password</mat-label>
<input matInput type="password" formControlName="password">
</mat-form-field>
<button mat-flat-button [disabled]="loginFormGroup.invalid">Login</button>
@if (invalidCredentials) {
<mat-error>Invalid credentials</mat-error>
}
</form>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LoginComponent } from './login.component';
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LoginComponent]
})
.compileComponents();
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,65 @@
import {Component, inject, OnDestroy} from '@angular/core';
import {MatError, MatFormField, MatLabel} from '@angular/material/form-field';
import {MatInput} from '@angular/material/input';
import {MatButton} from '@angular/material/button';
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {LoginService} from '../../services/login/login.service';
import {Router} from '@angular/router';
import {Subscription} from 'rxjs';
import {Credentials} from '../../models/credentials';
import {User} from '../../models/user.model';
@Component({
selector: 'app-login',
standalone: true,
imports: [
MatFormField,
MatLabel,
MatInput,
MatButton,
MatError,
ReactiveFormsModule
],
templateUrl: './login.component.html',
styleUrl: './login.component.css'
})
export class LoginComponent implements OnDestroy {
private readonly formBuilder: FormBuilder = inject(FormBuilder);
private readonly loginService: LoginService = inject(LoginService);
private readonly router: Router = inject(Router);
private loginSubscription: Subscription | null = null;
loginFormGroup = this.formBuilder.group({
username: ['', [
Validators.required
]],
password: ['', [
Validators.required
]]
});
invalidCredentials = false;
ngOnDestroy() {
this.loginSubscription?.unsubscribe();
}
login() {
this.loginSubscription = this.loginService.login(
this.loginFormGroup.value as Credentials).subscribe({
next: (result: User | null | undefined) => {
this.navigateHome();
},
error: (error) => {
console.log(error);
this.invalidCredentials = true;
}
});
}
navigateHome() {
this.router.navigate(['/home']).then();
}
}

View File

@@ -0,0 +1,21 @@
#card-list {
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
.center {
margin-top: 40px;
text-align: center;
}
app-playing-card {
cursor: pointer;
transition: transform 0.2s ease-in-out;
&:hover {
transform: scale(1.05);
}
}

View File

@@ -0,0 +1,16 @@
<app-search-bar [(search)]="search" />
<div id="card-list">
@for (monster of filteredMonsters() ; track monster.name) {
<app-playing-card [monster]="monster" (click)="openMonster(monster)"/>
}
</div>
@if (filteredMonsters()?.length === 0) {
<div class="center">No monsters found!</div>
} @else {
<div class="center">Found {{filteredMonsters()?.length }} monsters!</div>
}
<div class="center">
<button mat-flat-button (click)="addMonster()">Add monster</button>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MonsterListComponent } from './monster-list.component';
describe('MonsterListComponent', () => {
let component: MonsterListComponent;
let fixture: ComponentFixture<MonsterListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MonsterListComponent]
})
.compileComponents();
fixture = TestBed.createComponent(MonsterListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,42 @@
import {Component, computed, inject, model} from '@angular/core';
import {PlayingCardComponent} from "../../components/playing-card/playing-card.component";
import {SearchBarComponent} from "../../components/search-bar/search-bar.component";
import {Monster} from '../../models/monster.model';
import {MonsterService} from '../../services/monster/monster.service';
import {Router} from '@angular/router';
import {toSignal} from '@angular/core/rxjs-interop';
import {MatButton} from '@angular/material/button';
@Component({
selector: 'app-monster-list',
standalone: true,
imports: [
PlayingCardComponent,
SearchBarComponent,
MatButton
],
templateUrl: './monster-list.component.html',
styleUrl: './monster-list.component.css'
})
export class MonsterListComponent {
private readonly router: Router = inject(Router);
private readonly monsterService: MonsterService = inject(MonsterService);
monsters = toSignal(this.monsterService.getAll());
search = model('');
filteredMonsters = computed(() => {
return this.monsters()?.filter(monster =>
monster.name.toLowerCase().includes(this.search().toLowerCase() ?? [])
);
});
openMonster(monster: Monster) {
this.router.navigate(['/monster', monster.id]).then();
}
addMonster() {
this.router.navigate(['/monster']).then();
}
}

View File

@@ -0,0 +1,68 @@
form {
max-width: 500px;
margin: 20px;
padding: 20px;
background-color: white;
border-radius: 10px;
}
mat-form-field {
width: 100%;
}
.form-field {
display: flex;
flex-direction: column;
margin-bottom: 20px;
}
label {
font-size: small;
}
.ng-dirty.ng-invalid,
.ng-touched.ng-invalid {
border-color: red;
border-width: 1px;
}
.error {
font-size: x-small;
color: red;
}
.nav {
display: flex;
align-items: center;
margin-bottom: 20px;
.nav-btn {
gap: 10px;
padding: 10px;
cursor: pointer;
}
}
.preview, .main {
display: inline-block;
vertical-align: top;
}
.preview {
width: 300px;
margin: 20px;
}
.main {
width: calc(100% - 340px);
}
.button-container.left, .button-container.right {
display: inline-block;
width: 50%;
}
.button-container.right {
text-align: right;
}

View File

@@ -0,0 +1,121 @@
<div>
<div class="nav">
@if (monsterId >= 1) {
<p
(click)="monsterId > 1 && previous()"
class="nav-btn"
[class.disabled]="monsterId <= 1"
[attr.aria-disabled]="monsterId <= 1 ? true : null"
>&lt;&lt;</p>
<p>Monster {{ monsterId }}</p>
} @else {
<p>No monster selected</p>
}
<p
(click)="hasNextMonster() && next()"
class="nav-btn"
[class.disabled]="!hasNextMonster()"
[attr.aria-disabled]="!hasNextMonster() ? true : null"
>&gt;&gt;</p>
</div>
</div>
<hr>
<div class="preview">
<app-playing-card [monster]="monster"></app-playing-card>
</div>
<div class="main">
<form [formGroup]="formGroup" (submit)="submit ($event) ">
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput formControlName="name" id="name" name="name" type="text"/>
@if (isFieldValid('name')) {
<div class="error">This field is required.</div>
}
</mat-form-field>
<div class="form-field">
<button mat-raised-button (click)="imageInput.click()" type="button">{{imageInput.files?.[0]?.name || 'Upload file...'}}</button>
<input (change)="onFileChange($event)" #imageInput id="image" name="image" type="file" hidden/>
@if (isFieldValid('image')) {
<div class="error">This field is required.</div>
}
</div>
<mat-form-field class="form-field">
<mat-label>Type</mat-label>
<mat-select formControlName="type" id="type" name="type">
@for (type of monsterTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>HP</mat-label>
<input matInput formControlName="hp" id="hp" name="hp" type="number"/>
@if (isFieldValid('hp')) {
@if (formGroup.get('hp')!.hasError('required')) {
<div class="error">This field is required.</div>
}
@if (formGroup.get('hp')!.hasError('min')) {
<div class="error">This field must be bigger than 0.</div>
}
@if (formGroup.get('hp')!.hasError('max')) {
<div class="error">This field must be smaller or equal to 200.</div>
}
}
</mat-form-field>
<mat-form-field>
<mat-label>Figure caption</mat-label>
<input matInput formControlName="figureCaption" id="figureCaption" name="figureCaption" type="text"/>
@if (isFieldValid('figureCaption')) {
<div class="error">This field is required.</div>
}
</mat-form-field>
<mat-form-field>
<mat-label>Attack name</mat-label>
<input matInput formControlName="attackName" id="attackName" name="attackName" type="text"/>
@if (isFieldValid('attackName')) {
<div class="error">This field is required.</div>
}
</mat-form-field>
<mat-form-field>
<mat-label>Attack strength</mat-label>
<input matInput formControlName="attackStrength" id="attackStrength" name="attackStrength" type="number"/>
@if (isFieldValid('attackStrength')) {
@if (formGroup.get('attackStrength')!.hasError('required')) {
<div class="error">This field is required.</div>
}
@if (formGroup.get('attackStrength')!.hasError('min')) {
<div class="error">This field must be bigger than 0.</div>
}
@if (formGroup.get('attackStrength')!.hasError('max')) {
<div class="error">This field must be smaller or equal to 200.</div>
}
}
</mat-form-field>
<mat-form-field>
<mat-label>Attack description</mat-label>
<input matInput formControlName="attackDescription" id="attackDescription" name="attackDescription" type="text"/>
@if (isFieldValid('attackDescription')) {
<div class="error">This field is required</div>
}
</mat-form-field>
<div class="button-container left">
@if (monsterId !== -1) {
<button mat-flat-button color="warn" (click)="deleteMonster()" type="button">Delete</button>
}
</div>
<div class="button-container right">
<button mat-button (click)="navigateBack()">Back</button>
<button mat-flat-button type="submit" [disabled]="formGroup.invalid">Save</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MonsterComponent } from './monster.component';
describe('MonsterComponent', () => {
let component: MonsterComponent;
let fixture: ComponentFixture<MonsterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MonsterComponent]
})
.compileComponents();
fixture = TestBed.createComponent(MonsterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,175 @@
import {Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
import {ActivatedRoute, Params, Router} from '@angular/router';
import {filter, Observable, of, Subscription, switchMap} from 'rxjs';
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {MonsterType} from '../../utils/monster.utils';
import {PlayingCardComponent} from '../../components/playing-card/playing-card.component';
import {Monster} from '../../models/monster.model';
import {MonsterService} from '../../services/monster/monster.service';
import {MatButtonModule} from '@angular/material/button';
import {MatFormField, MatLabel} from '@angular/material/form-field';
import {MatInput} from '@angular/material/input';
import {MatOption, MatSelect} from '@angular/material/select';
import {MatDialog} from '@angular/material/dialog';
import {
DeleteMonsterConfirmationDialogComponent
} from '../../components/delete-monster-confirmation-dialog/delete-monster-confirmation-dialog.component';
@Component({
selector: 'app-monster',
standalone: true,
imports: [
ReactiveFormsModule,
PlayingCardComponent,
MatButtonModule,
MatFormField,
MatInput,
MatLabel,
MatSelect,
MatOption
],
templateUrl: './monster.component.html',
styleUrl: './monster.component.css'
})
export class MonsterComponent implements OnInit, OnDestroy {
private readonly route: ActivatedRoute = inject(ActivatedRoute);
private readonly router: Router = inject(Router);
private readonly fb: FormBuilder = inject(FormBuilder);
private readonly dialog: MatDialog = inject(MatDialog);
private readonly monsterService = inject(MonsterService);
subscriptions: Subscription = new Subscription();
formGroup = this.fb.group({
name: ['', [
Validators.required
]],
image: ['', [
Validators.required
]],
type: [MonsterType.ELECTRIC, [
Validators.required
]],
hp: [0, [
Validators.required,
Validators.min(1),
Validators.max(200)
]],
figureCaption: ['', [
Validators.required
]],
attackName: ['', [
Validators.required
]],
attackStrength: [0, [
Validators.required,
Validators.min(1),
Validators.max(200)
]],
attackDescription: ['', [
Validators.required
]],
});
monster = Object.assign(new Monster(), this.formGroup.value);
monsterTypes = Object.values(MonsterType);
monsterId = -1;
ngOnInit() {
const formValuesSubscription = this.formGroup.valueChanges.subscribe(data => {
this.monster = Object.assign(new Monster(), data);
});
this.subscriptions.add(formValuesSubscription);
const routeSubscription = this.route.params.pipe(
switchMap(params => {
if (params['id']) {
this.monsterId = parseInt(params['id']);
return this.monsterService.get(this.monsterId) ?? of(null)
} else {
return of(null);
}
})
).subscribe((monster) => {
if (monster) {
this.monster = monster;
this.formGroup.patchValue(monster);
}
});
this.subscriptions.add(routeSubscription);
}
ngOnDestroy() {
this.subscriptions?.unsubscribe();
}
hasNextMonster(): boolean {
return this.monsterService.get(this.monsterId + 1) !== undefined;
}
previous() {
let previousId = this.monsterId;
previousId--;
if (previousId < 1) previousId = 1;
this.router.navigate(['/monster/' + previousId]).then();
}
next() {
let nextId = this.monsterId;
if (nextId === -1 || nextId === 0) {
nextId = 0;
}
nextId++;
this.router.navigate(['/monster/' + nextId]).then();
}
deleteMonster() {
const dialogRef = this.dialog.open(DeleteMonsterConfirmationDialogComponent);
const deleteSubscription = dialogRef.afterClosed().pipe(
filter(confirmation => confirmation),
switchMap(_ => this.monsterService.delete(this.monsterId))
).subscribe(_ => {
this.navigateBack();
});
this.subscriptions.add(deleteSubscription);
}
navigateBack() {
this.router.navigate(['/home']).then();
}
submit(event: Event) {
event.preventDefault();
let saveObservable: Observable<Monster>;
if (this.monsterId === -1) {
saveObservable = this.monsterService.add(this.monster);
} else {
this.monster.id = this.monsterId;
saveObservable = this.monsterService.update(this.monster);
}
const saveSubscription = saveObservable.subscribe(_ => {
this.navigateBack();
});
this.subscriptions.add(saveSubscription);
}
isFieldValid(fieldName: string): boolean {
const formControl = this.formGroup.get(fieldName);
return formControl ? formControl.invalid && (formControl.dirty || formControl.touched) : false;
}
onFileChange(event: any) {
const reader = new FileReader();
if (event.target.files && event.target.files.length) {
const [file] = event.target.files;
reader.readAsDataURL(file);
reader.onload = () => {
this.formGroup.patchValue({
image: reader.result as string
})
}
}
}
}

View File

@@ -0,0 +1 @@
<p>not-found works!</p>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NotFoundComponent } from './not-found.component';
describe('NotFoundComponent', () => {
let component: NotFoundComponent;
let fixture: ComponentFixture<NotFoundComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NotFoundComponent]
})
.compileComponents();
fixture = TestBed.createComponent(NotFoundComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-not-found',
standalone: true,
imports: [],
templateUrl: './not-found.component.html',
styleUrl: './not-found.component.css'
})
export class NotFoundComponent {
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { LoginService } from './login.service';
describe('LoginService', () => {
let service: LoginService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(LoginService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,51 @@
import {inject, Injectable, signal, WritableSignal} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {User} from '../../models/user.model';
import {Credentials} from '../../models/credentials';
import {map, Observable, tap} from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class LoginService {
private readonly http: HttpClient = inject(HttpClient);
private readonly BASE_URL = 'http://localhost:8000';
user = signal<User | null | undefined>(undefined);
login(credentials: Credentials): Observable<User | null | undefined> {
return this.http.post<User>(this.BASE_URL + '/sessions/login/', credentials).pipe(
tap((result: any) => {
localStorage.setItem('token', result.token);
const user: User = Object.assign(new User(), result['user']);
this.user.set(user);
}),
map((result: any) => {
return this.user();
})
);
}
logout(): Observable<null> {
return this.http.get(this.BASE_URL + '/sessions/logout/').pipe(
tap(() => {
localStorage.removeItem('token');
this.user.set(null);
}),
map(() => null)
);
}
getUsers(): Observable<User | null | undefined> {
return this.http.get(this.BASE_URL + '/sessions/me/').pipe(
tap((result: any) => {
const user: User = Object.assign(new User(), result);
this.user.set(user);
}),
map((result: any) => {
return this.user();
})
);
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { MonsterService } from './monster.service';
describe('MonsterService', () => {
let service: MonsterService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(MonsterService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,50 @@
import {inject, Injectable} from '@angular/core';
import {Monster} from '../../models/monster.model';
import {HttpClient} from '@angular/common/http';
import {IMonster} from '../../interfaces/monster';
import {map, Observable} from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class MonsterService {
private readonly http: HttpClient = inject(HttpClient);
private readonly BASE_URL = 'http://localhost:8000/monsters/';
getAll(): Observable<Monster[]> {
return this.http.get<IMonster[]>(this.BASE_URL).pipe(
map((monsters: IMonster[]) => {
return monsters.map(monsterJson => Monster.fromJson(monsterJson));
})
)
}
get(id: number): Observable<Monster> | undefined {
return this.http.get<IMonster>(this.BASE_URL + id + '/').pipe(
map((monsterJson: IMonster) => {
return Monster.fromJson(monsterJson);
})
);
}
add(monster: Monster): Observable<Monster> {
return this.http.post<IMonster>(this.BASE_URL, monster.toJson()).pipe(
map((monsterJson: IMonster) => {
return Monster.fromJson(monsterJson);
})
);
}
update(monster: Monster): Observable<Monster> {
return this.http.put<IMonster>(this.BASE_URL + monster.id + '/', monster.toJson()).pipe(
map((monsterJson: IMonster) => {
return Monster.fromJson(monsterJson);
})
);
}
delete(id: number): Observable<void> {
return this.http.delete<void>(this.BASE_URL + id + '/');
}
}

View File

@@ -0,0 +1,30 @@
export enum MonsterType {
PLANT = 'plant',
ELECTRIC = 'electric',
FIRE = 'fire',
WATER = 'water'
}
export interface IMonsterProperties {
imageUrl: string;
color: string;
}
export const MonsterTypeProperties: {[key: string]: IMonsterProperties} = {
[MonsterType.PLANT]: {
imageUrl: 'assets/img/plant.png',
color: '#4CAF50'
},
[MonsterType.ELECTRIC]: {
imageUrl: 'assets/img/electric.png',
color: '#FFEB3B'
},
[MonsterType.FIRE]: {
imageUrl: 'assets/img/fire.png',
color: '#FF5722'
},
[MonsterType.WATER]: {
imageUrl: 'assets/img/water.png',
color: '#2196F3'
}
};

15
src/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PlayingCards</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

6
src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

9
src/styles.css Normal file
View File

@@ -0,0 +1,9 @@
body, html {
font-family: Roboto, sans-serif;
height: 100%;
background: linear-gradient(
545deg,
rgb(252, 228, 252),
rgb(218, 228, 255)
);
}

15
tsconfig.app.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

33
tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": [
"ES2022",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

15
tsconfig.spec.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}