large update wip
This commit is contained in:
14
README.md
14
README.md
@@ -62,17 +62,11 @@ Angular CLI does not come with an end-to-end testing framework by default. You c
|
|||||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
|
|
||||||
|
|
||||||
-fix picture upload and adjust picture scale -WIP
|
-implement total visits and total clicks on register button
|
||||||
-implement websockets
|
|
||||||
|
|
||||||
-implement graph
|
|
||||||
-implement total visits
|
minor things
|
||||||
-search and filters (by name, city, age, etc.)
|
-centralize scss check the same styles for differnet buttons
|
||||||
-navigation between candidates (next/previous) (signals) wip change info make signals
|
|
||||||
-edit page
|
|
||||||
-implement live update
|
|
||||||
-adjust to mobile format
|
-adjust to mobile format
|
||||||
|
|
||||||
|
|
||||||
****animations transitions (prev next cards)
|
****animations transitions (prev next cards)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"@angular/material/prebuilt-themes/magenta-violet.css",
|
"@angular/material/prebuilt-themes/magenta-violet.css",
|
||||||
|
"node_modules/leaflet/dist/leaflet.css",
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
],
|
],
|
||||||
"scripts": []
|
"scripts": []
|
||||||
|
|||||||
1012
package-lock.json
generated
1012
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -4,7 +4,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"start:remote-api":"ng serve --configuration=remote-api",
|
"start:remote-api": "ng serve --configuration=remote-api",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test"
|
"test": "ng test"
|
||||||
@@ -21,7 +21,14 @@
|
|||||||
"@angular/platform-browser": "^19.1.0",
|
"@angular/platform-browser": "^19.1.0",
|
||||||
"@angular/platform-browser-dynamic": "^19.1.0",
|
"@angular/platform-browser-dynamic": "^19.1.0",
|
||||||
"@angular/router": "^19.1.0",
|
"@angular/router": "^19.1.0",
|
||||||
|
"@maptiler/leaflet-maptilersdk": "^4.1.1",
|
||||||
|
"@swimlane/ngx-charts": "^23.0.1",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
|
"d3": "^7.9.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"ng2-charts": "^8.0.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
@@ -29,7 +36,9 @@
|
|||||||
"@angular-devkit/build-angular": "^19.1.6",
|
"@angular-devkit/build-angular": "^19.1.6",
|
||||||
"@angular/cli": "^19.1.6",
|
"@angular/cli": "^19.1.6",
|
||||||
"@angular/compiler-cli": "^19.1.0",
|
"@angular/compiler-cli": "^19.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"@types/leaflet": "^1.9.20",
|
||||||
"jasmine-core": "~5.5.0",
|
"jasmine-core": "~5.5.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -8,5 +8,4 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrl: './app.component.scss'
|
styleUrl: './app.component.scss'
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent { }
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { RegistrationComponent } from './registration/registration.component';
|
import { RegistrationComponent } from './components/registration/registration.component';
|
||||||
import { LandingComponent } from './landing/landing.component';
|
import { LandingComponent } from './components/landing/landing.component';
|
||||||
import { ApplicationListComponent } from './application-list/application-list.component';
|
import { ApplicationListComponent } from './components/application-list/application-list.component';
|
||||||
import { ApplicationComponent } from './application/application.component';
|
import { ApplicationComponent } from './components/application/application.component';
|
||||||
|
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: 'landing', component: LandingComponent },
|
{ path: 'landing', component: LandingComponent },
|
||||||
{ path: 'registration', component: RegistrationComponent },
|
{ path: 'application/new', component: RegistrationComponent },
|
||||||
|
{ path: 'application/:id/edit', component: RegistrationComponent },
|
||||||
{ path: 'application-list', component: ApplicationListComponent },
|
{ path: 'application-list', component: ApplicationListComponent },
|
||||||
{ path: 'application/:id', component: ApplicationComponent },
|
{ path: 'application/:id', component: ApplicationComponent },
|
||||||
{ path: '', redirectTo: '/landing', pathMatch: 'full' },
|
{ path: '', redirectTo: '/landing', pathMatch: 'full' },
|
||||||
{ path: '**', redirectTo: '/landing' },
|
{ path: '**', component: PageNotFoundComponent },
|
||||||
// component: PageNotFoundComponent
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
@for(application of this.applicationList; track application.id){
|
|
||||||
<div class="listItem" [routerLink]="'/application/'+ application.id">
|
|
||||||
<p>{{application.fullName}}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
.listItem {
|
|
||||||
margin: 2rem;
|
|
||||||
background-color: aquamarine;
|
|
||||||
padding: 1rem;
|
|
||||||
&:hover{
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: aqua;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Component, inject, OnInit } from '@angular/core';
|
|
||||||
import { CandidateDataService } from '../candidate-data.service';
|
|
||||||
import { RouterLink } from '@angular/router';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-application-list',
|
|
||||||
imports: [RouterLink],
|
|
||||||
templateUrl: './application-list.component.html',
|
|
||||||
styleUrl: './application-list.component.scss'
|
|
||||||
})
|
|
||||||
export class ApplicationListComponent implements OnInit {
|
|
||||||
|
|
||||||
dataService = inject(CandidateDataService);
|
|
||||||
applicationList: any[] = [];
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.dataService.loadCandidateList().subscribe((data) => {
|
|
||||||
this.applicationList = data;
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{{this.application}}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Component, inject, OnInit } from '@angular/core';
|
|
||||||
import { CandidateDataService } from '../candidate-data.service';
|
|
||||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-application',
|
|
||||||
imports: [RouterModule],
|
|
||||||
templateUrl: './application.component.html',
|
|
||||||
styleUrl: './application.component.scss'
|
|
||||||
})
|
|
||||||
export class ApplicationComponent implements OnInit {
|
|
||||||
dataService = inject(CandidateDataService);
|
|
||||||
activatedRoute = inject(ActivatedRoute);
|
|
||||||
application: any = {};
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const id = this.activatedRoute.snapshot.paramMap.get('id');
|
|
||||||
if (!id) {
|
|
||||||
alert('invalid route');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const applicationId = Number.parseInt(id, 10);
|
|
||||||
this.dataService.getApplicationDetails(applicationId).subscribe((data) => {
|
|
||||||
this.application = JSON.stringify(data);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { HttpClient } from "@angular/common/http";
|
|
||||||
import { inject, Injectable } from "@angular/core";
|
|
||||||
import { environment } from "../environments/environment.development";
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class CandidateDataService {
|
|
||||||
httpClient = inject(HttpClient)
|
|
||||||
|
|
||||||
|
|
||||||
loadCandidateList() {
|
|
||||||
return this.httpClient.get<any>(`${environment.hostUrl}/app/candidates`);
|
|
||||||
}
|
|
||||||
getApplicationDetails(id: number) {
|
|
||||||
return this.httpClient.get(`${environment.hostUrl}/app/candidate/${id}`)
|
|
||||||
}
|
|
||||||
submitCandidateForm(formData: Object) {
|
|
||||||
return this.httpClient.post(`${environment.hostUrl}/app/register`, formData);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<div class="container app-list">
|
||||||
|
<div class="header-row">
|
||||||
|
<div class="list-header-top">
|
||||||
|
<button mat-button type="button" (click)="goBack()" class="back-button">
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
<h2 class="page-title">Applications</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!isLoading) {
|
||||||
|
<div class="charts-row">
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Age Distribution</h3>
|
||||||
|
<canvas baseChart
|
||||||
|
[data]="ageChartData()"
|
||||||
|
[options]="chartOptions"
|
||||||
|
[type]="'pie'">
|
||||||
|
</canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>City Distribution</h3>
|
||||||
|
<canvas baseChart
|
||||||
|
[data]="cityChartData()"
|
||||||
|
[options]="chartOptions"
|
||||||
|
[type]="'pie'">
|
||||||
|
</canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!isLoading) {
|
||||||
|
<div class="list-header">
|
||||||
|
<mat-form-field appearance="outline" class="search-field">
|
||||||
|
<mat-label>Search</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[ngModel]="searchTerm()"
|
||||||
|
(ngModelChange)="searchTerm.set($event)"
|
||||||
|
placeholder="Type to search..."
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="filter-field">
|
||||||
|
<mat-label>City</mat-label>
|
||||||
|
<mat-select
|
||||||
|
[ngModel]="filterCity()"
|
||||||
|
(ngModelChange)="filterCity.set($event)"
|
||||||
|
>
|
||||||
|
<mat-option value="">All</mat-option>
|
||||||
|
@for (city of availableCities(); track city.name) {
|
||||||
|
<mat-option [value]="city.name">{{ city.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="sort-field">
|
||||||
|
<mat-label>Sort by</mat-label>
|
||||||
|
<mat-select
|
||||||
|
[ngModel]="sortField()"
|
||||||
|
(ngModelChange)="sortField.set($event)"
|
||||||
|
>
|
||||||
|
@for (option of sortFields; track option.value) {
|
||||||
|
<mat-option [value]="option.value">
|
||||||
|
{{ option.viewValue }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isLoading) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner diameter="50"></mat-spinner>
|
||||||
|
<p class="loading-text">Loading applications...</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="list">
|
||||||
|
@for (application of sortedList(); track application.id) {
|
||||||
|
<div class="listItem" [routerLink]="'/application/' + application.id">
|
||||||
|
@if (application.profileImage) {
|
||||||
|
<img
|
||||||
|
[src]="environment.hostUrl + '/uploads/' + application.profileImage"
|
||||||
|
alt="{{ application.fullName }}"
|
||||||
|
class="profile-photo"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<div class="info">
|
||||||
|
<p class="app-name">{{ application.fullName }}</p>
|
||||||
|
<p>Age: {{ application.age }}</p>
|
||||||
|
<p>City: {{ application.cityOrRegion }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/* Fullscreen container */
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2rem;
|
||||||
|
background: radial-gradient(circle at top, #0a0f2c 0%, #000000 100%);
|
||||||
|
color: #e0e0ff;
|
||||||
|
font-family: 'Orbitron', Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header row */
|
||||||
|
.list-header-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #00ffff;
|
||||||
|
border: 1px solid #00ffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button:hover {
|
||||||
|
background: rgba(0, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 0 20px #00ffff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shining header */
|
||||||
|
.page-title {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(90deg, #00ffff, #ffffff, #00ffff);
|
||||||
|
background-size: 200% auto;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: shine 3s linear infinite;
|
||||||
|
text-shadow: 0 0 12px rgba(0, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters row centered */
|
||||||
|
.list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fields sizing */
|
||||||
|
.search-field, .filter-field, .sort-field {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-field {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Applications list */
|
||||||
|
.app-list .list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.2rem;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.profile-photo {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 10%;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 255, 255, 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Applicant card layout */
|
||||||
|
.listItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.2rem;
|
||||||
|
padding: 1rem 1.4rem;
|
||||||
|
border: 1px solid #00ffff;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem:hover {
|
||||||
|
background: rgba(0, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 0 22px #00ffff;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Applicant text info */
|
||||||
|
.listItem .info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem p {
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e0e0ff;
|
||||||
|
text-shadow: 0 0 6px rgba(0, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #00ffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.charts-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
width: 300px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 1.2rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 18px rgba(0, 255, 255, 0.15);
|
||||||
|
border: 1px solid rgba(0, 255, 255, 0.3);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 0 0 6px rgba(0, 255, 255, 0.5);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #00ffff;
|
||||||
|
text-shadow: 0 0 6px rgba(0, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 400px;
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #e0e0ff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Orbitron', Arial, sans-serif;
|
||||||
|
text-shadow: 0 0 6px rgba(0, 255, 255, 0.6);
|
||||||
|
animation: pulse 2s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shining animation */
|
||||||
|
@keyframes shine {
|
||||||
|
0% { background-position: -200% center; }
|
||||||
|
100% { background-position: 200% center; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { Component, OnInit, inject, signal, computed, effect } from '@angular/core';
|
||||||
|
import { CandidateDataService } from '../../services/candidate-data.service';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { City, CITY_LIST } from '../../shared/cities';
|
||||||
|
import { environment } from '../../../environments/environment.development';
|
||||||
|
import { SocketIOService } from '../../services/socket-io.service';
|
||||||
|
import { BaseChartDirective } from 'ng2-charts';
|
||||||
|
import { ChartData, ChartOptions, Chart, registerables } from 'chart.js';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
|
||||||
|
Chart.register(...registerables);
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-application-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
FormsModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
BaseChartDirective,
|
||||||
|
],
|
||||||
|
templateUrl: './application-list.component.html',
|
||||||
|
styleUrls: ['./application-list.component.scss']
|
||||||
|
})
|
||||||
|
export class ApplicationListComponent implements OnInit {
|
||||||
|
private dataService = inject(CandidateDataService);
|
||||||
|
private socketService = inject(SocketIOService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
environment = environment;
|
||||||
|
|
||||||
|
applicationList = signal<any[]>([]);
|
||||||
|
searchTerm = signal('');
|
||||||
|
filterCity = signal('');
|
||||||
|
sortField = signal<string>('fullName');
|
||||||
|
|
||||||
|
availableCities = signal<City[]>(CITY_LIST);
|
||||||
|
|
||||||
|
filteredList = computed(() => {
|
||||||
|
const apps = this.applicationList();
|
||||||
|
const term = this.searchTerm().toLowerCase();
|
||||||
|
const city = this.filterCity();
|
||||||
|
|
||||||
|
return apps.filter(app => {
|
||||||
|
const matchesSearch = term
|
||||||
|
? app.fullName?.toLowerCase().includes(term) ||
|
||||||
|
app.cityOrRegion?.toLowerCase().includes(term)
|
||||||
|
: true;
|
||||||
|
|
||||||
|
const matchesCity = city ? app.cityOrRegion === city : true;
|
||||||
|
return matchesSearch && matchesCity;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedList = computed(() => {
|
||||||
|
const field = this.sortField();
|
||||||
|
return [...this.filteredList()].sort((a, b) => {
|
||||||
|
let valueA = a[field];
|
||||||
|
let valueB = b[field];
|
||||||
|
|
||||||
|
if (typeof valueA === 'string' && typeof valueB === 'string') {
|
||||||
|
return valueA.toLowerCase().localeCompare(valueB.toLowerCase());
|
||||||
|
}
|
||||||
|
return valueA < valueB ? -1 : valueA > valueB ? 1 : 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ageChartData = signal<ChartData<'pie'>>({ labels: [], datasets: [] });
|
||||||
|
cityChartData = signal<ChartData<'pie'>>({ labels: [], datasets: [] });
|
||||||
|
|
||||||
|
chartOptions: ChartOptions<'pie'> = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom', labels: { color: '#e0e0ff' } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sortFields = [
|
||||||
|
{ value: 'fullName', viewValue: 'Name' },
|
||||||
|
{ value: 'age', viewValue: 'Age' },
|
||||||
|
{ value: 'date', viewValue: 'Date' },
|
||||||
|
{ value: 'cityOrRegion', viewValue: 'City' }
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const data = this.applicationList();
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
this.ageChartData.set({ labels: [], datasets: [] });
|
||||||
|
this.cityChartData.set({ labels: [], datasets: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageGroups: Record<string, number> = {
|
||||||
|
'18-25': 0,
|
||||||
|
'26-35': 0,
|
||||||
|
'36-45': 0,
|
||||||
|
'46-60': 0,
|
||||||
|
'60+': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
data.forEach(app => {
|
||||||
|
if (app.age <= 25) ageGroups['18-25']++;
|
||||||
|
else if (app.age <= 35) ageGroups['26-35']++;
|
||||||
|
else if (app.age <= 45) ageGroups['36-45']++;
|
||||||
|
else if (app.age <= 60) ageGroups['46-60']++;
|
||||||
|
else ageGroups['60+']++;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ageChartData.set({
|
||||||
|
labels: Object.keys(ageGroups),
|
||||||
|
datasets: [{
|
||||||
|
data: Object.values(ageGroups),
|
||||||
|
backgroundColor: [
|
||||||
|
'rgba(0, 255, 255, 0.6)',
|
||||||
|
'rgba(0, 200, 255, 0.6)',
|
||||||
|
'rgba(170, 120, 255, 0.6)',
|
||||||
|
'rgba(255, 180, 80, 0.6)',
|
||||||
|
'rgba(255, 80, 120, 0.6)',
|
||||||
|
],
|
||||||
|
borderColor: '#00ffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const cityCounts: Record<string, number> = {};
|
||||||
|
data.forEach(app => {
|
||||||
|
const city = app.cityOrRegion || 'Unknown';
|
||||||
|
cityCounts[city] = (cityCounts[city] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedCities = Object.entries(cityCounts).sort((a, b) => b[1] - a[1]);
|
||||||
|
const topCities = sortedCities.slice(0, 6);
|
||||||
|
const others = sortedCities.slice(6);
|
||||||
|
|
||||||
|
const labels = topCities.map(([city]) => city);
|
||||||
|
const values = topCities.map(([_, count]) => count);
|
||||||
|
|
||||||
|
if (others.length) {
|
||||||
|
labels.push('Others');
|
||||||
|
values.push(others.reduce((sum, [_, count]) => sum + count, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cityChartData.set({
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
data: values,
|
||||||
|
backgroundColor: [
|
||||||
|
'rgba(0, 255, 255, 0.6)',
|
||||||
|
'rgba(0, 200, 255, 0.6)',
|
||||||
|
'rgba(170, 120, 255, 0.6)',
|
||||||
|
'rgba(255, 180, 80, 0.6)',
|
||||||
|
'rgba(255, 80, 120, 0.6)',
|
||||||
|
'rgba(80, 255, 150, 0.6)',
|
||||||
|
'rgba(120, 120, 120, 0.6)',
|
||||||
|
],
|
||||||
|
borderColor: '#00ffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get isLoading() {
|
||||||
|
return this.dataService.isCandidatesListLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.dataService.loadCandidateList().subscribe(data => {
|
||||||
|
this.applicationList.set(data);
|
||||||
|
this.availableCities.set(this.getUniqueCities(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socketService.onCandidateRegistered().subscribe(newCandidate => {
|
||||||
|
this.applicationList.update(list => [newCandidate, ...list]);
|
||||||
|
this.availableCities.set(this.getUniqueCities(this.applicationList()));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socketService.onCandidateUpdated().subscribe(updatedCandidate => {
|
||||||
|
this.applicationList.update(list =>
|
||||||
|
list.map(app => app.id === updatedCandidate.id ? updatedCandidate : app)
|
||||||
|
);
|
||||||
|
this.availableCities.set(this.getUniqueCities(this.applicationList()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
this.router.navigate(['/landing']);
|
||||||
|
}
|
||||||
|
|
||||||
|
getUniqueCities(data: any[]): City[] {
|
||||||
|
const seen: string[] = [];
|
||||||
|
return data
|
||||||
|
.map(app => app.cityOrRegion)
|
||||||
|
.filter(city => city && !seen.includes(city) && seen.push(city))
|
||||||
|
.map(cityName => {
|
||||||
|
const found = CITY_LIST.find(c => c.name === cityName);
|
||||||
|
return found ?? { name: cityName, lat: 0, lng: 0 };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/app/components/application/application.component.html
Normal file
150
src/app/components/application/application.component.html
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<div class="container application">
|
||||||
|
<header class="header">
|
||||||
|
<button mat-button type="button" (click)="goBack()" class="back-button">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
<h2>🛰 Application Details</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (isApplicationDetailsLoading) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<mat-spinner diameter="50"></mat-spinner>
|
||||||
|
<p class="loading-text">Loading application details...</p>
|
||||||
|
</div>
|
||||||
|
} @else if (hasApplicationData) {
|
||||||
|
<div class="content-wrapper">
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="navigation-controls">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
[disabled]="!canGoToPrevious()"
|
||||||
|
(click)="goToPrevious()"
|
||||||
|
class="nav-button"
|
||||||
|
title="Previous Application"
|
||||||
|
>
|
||||||
|
<mat-icon>chevron_left</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="navigation-info">
|
||||||
|
{{ currentIndex() + 1 }} of {{ applicationList().length }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
[disabled]="!canGoToNext()"
|
||||||
|
(click)="goToNext()"
|
||||||
|
class="nav-button"
|
||||||
|
title="Next Application"
|
||||||
|
>
|
||||||
|
<mat-icon>chevron_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Application Card -->
|
||||||
|
<div class="application-card">
|
||||||
|
|
||||||
|
<!-- Profile Section -->
|
||||||
|
<div class="profile-section">
|
||||||
|
@if (currentApplication().profileImage) {
|
||||||
|
<div class="profile-image-container">
|
||||||
|
<img
|
||||||
|
[src]="environment.hostUrl + '/uploads/' + currentApplication().profileImage"
|
||||||
|
[alt]="currentApplication().fullName"
|
||||||
|
class="profile-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="profile-info">
|
||||||
|
<h3 class="candidate-name">{{ currentApplication().fullName }}</h3>
|
||||||
|
<div class="basic-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<mat-icon>person</mat-icon>
|
||||||
|
<span>{{ currentApplication().age }} years old</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<mat-icon>location_on</mat-icon>
|
||||||
|
<span>{{ currentApplication().cityOrRegion }}</span>
|
||||||
|
</div>
|
||||||
|
@if (currentApplication().email) {
|
||||||
|
<div class="info-item">
|
||||||
|
<mat-icon>email</mat-icon>
|
||||||
|
<span>{{ currentApplication().email }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (currentApplication().phoneNumber) {
|
||||||
|
<div class="info-item">
|
||||||
|
<mat-icon>phone</mat-icon>
|
||||||
|
<span>{{ currentApplication().phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Application Details -->
|
||||||
|
<div class="details-grid">
|
||||||
|
@if (getHobbiesArray().length > 0) {
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4><mat-icon>favorite</mat-icon> Hobbies</h4>
|
||||||
|
<div class="hobbies-container">
|
||||||
|
@for (hobby of getHobbiesArray(); track hobby) {
|
||||||
|
<mat-chip class="hobby-chip">{{ hobby }}</mat-chip>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (currentApplication().justification) {
|
||||||
|
<div class="detail-section full-width">
|
||||||
|
<h4><mat-icon>rocket_launch</mat-icon> Journey Justification?</h4>
|
||||||
|
<p class="detail-content">{{ currentApplication().justification }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (currentApplication().createdAt) {
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4><mat-icon>calendar_today</mat-icon> Application Date</h4>
|
||||||
|
<p class="detail-content">
|
||||||
|
{{ formatDate(currentApplication().createdAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Button or Info -->
|
||||||
|
<div class="edit-controls">
|
||||||
|
@if (canEdit()) {
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
(click)="editApplication()"
|
||||||
|
class="edit-button"
|
||||||
|
>
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
Edit Application
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<p class="edit-info">You can edit your application only during 3 days</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Raw JSON Data -->
|
||||||
|
<details class="raw-data-section">
|
||||||
|
<summary>View Raw Data</summary>
|
||||||
|
<pre class="raw-data">{{ currentApplication() | json }}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="no-data">
|
||||||
|
<mat-icon class="no-data-icon">inbox</mat-icon>
|
||||||
|
<p>No application data available</p>
|
||||||
|
<button mat-button (click)="goBack()" class="back-button">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
Return to List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
417
src/app/components/application/application.component.scss
Normal file
417
src/app/components/application/application.component.scss
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: radial-gradient(circle at top, #0a0f2c 0%, #000000 100%);
|
||||||
|
color: #e0e0ff;
|
||||||
|
font-family: 'Orbitron', Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shining text effect */
|
||||||
|
@keyframes shine {
|
||||||
|
0% { background-position: -200% center; }
|
||||||
|
100% { background-position: 200% center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0px); }
|
||||||
|
50% { transform: translateY(-10px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(90deg, #00ffff, #ffffff, #00ffff);
|
||||||
|
background-size: 200% auto;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: shine 3s linear infinite;
|
||||||
|
text-shadow: 0 0 12px rgba(0, 255, 255, 0.6);
|
||||||
|
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content wrapper */
|
||||||
|
.content-wrapper {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Controls */
|
||||||
|
.navigation-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(0, 255, 255, 0.2);
|
||||||
|
|
||||||
|
.navigation-info {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #00ffff;
|
||||||
|
text-shadow: 0 0 8px rgba(0, 255, 255, 0.4);
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
background: rgba(0, 255, 255, 0.1);
|
||||||
|
border: 2px solid #00ffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #00ffff;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgba(0, 255, 255, 0.3);
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
|
||||||
|
animation: float 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Application Card */
|
||||||
|
.application-card {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
border: 1px solid rgba(0, 255, 255, 0.2);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 255, 255, 0.1);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile Section */
|
||||||
|
.profile-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
border-bottom: 2px solid rgba(0, 255, 255, 0.2);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-image-container {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.profile-image {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 3px solid #00ffff;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 255, 255, 0.4);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 0 30px rgba(0, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.candidate-name {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
background: linear-gradient(135deg, #00ffff, #ffffff);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 255, 0.3);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.basic-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: #e0e0ff;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #00ffff;
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Details Grid */
|
||||||
|
.details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
background: rgba(0, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid rgba(0, 255, 255, 0.2);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 255, 255, 0.1);
|
||||||
|
border-color: rgba(0, 255, 255, 0.4);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #00ffff;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #e0e0ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hobbies */
|
||||||
|
.hobbies-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hobby-chip {
|
||||||
|
background: rgba(255, 0, 150, 0.2);
|
||||||
|
color: #ff0096;
|
||||||
|
border: 1px solid rgba(255, 0, 150, 0.4);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 0, 150, 0.3);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.back-button {
|
||||||
|
background: transparent;
|
||||||
|
color: #00ffff;
|
||||||
|
border: 2px solid #00ffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 255, 255, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 400px;
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #e0e0ff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 0 6px rgba(0, 255, 255, 0.6);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No Data State */
|
||||||
|
.no-data {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 400px;
|
||||||
|
|
||||||
|
.no-data-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
color: rgba(0, 255, 255, 0.4);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #e0e0ff;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Raw Data Section */
|
||||||
|
.raw-data-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
border-top: 2px solid rgba(0, 255, 255, 0.2);
|
||||||
|
padding-top: 2rem;
|
||||||
|
|
||||||
|
summary {
|
||||||
|
color: #00ffff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-shadow: 0 0 8px rgba(0, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-data {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #00ffff;
|
||||||
|
border: 1px solid rgba(0, 255, 255, 0.2);
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}.edit-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 2rem;
|
||||||
|
|
||||||
|
.edit-button {
|
||||||
|
background: #00ffff;
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 255, 255, 0.3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 20px #00ffff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-info {
|
||||||
|
color: #aaaaff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/app/components/application/application.component.ts
Normal file
170
src/app/components/application/application.component.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { Component, inject, OnInit, signal, computed } from '@angular/core';
|
||||||
|
import { CandidateDataService } from '../../services/candidate-data.service';
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { SocketIOService } from '../../services/socket-io.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-application',
|
||||||
|
imports: [
|
||||||
|
RouterModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatChipsModule,
|
||||||
|
CommonModule
|
||||||
|
],
|
||||||
|
templateUrl: './application.component.html',
|
||||||
|
styleUrls: ['./application.component.scss']
|
||||||
|
})
|
||||||
|
export class ApplicationComponent implements OnInit{
|
||||||
|
dataService = inject(CandidateDataService);
|
||||||
|
socketService = inject(SocketIOService);
|
||||||
|
activatedRoute = inject(ActivatedRoute);
|
||||||
|
router = inject(Router);
|
||||||
|
|
||||||
|
currentApplication = signal<any>(null);
|
||||||
|
applicationList = signal<any[]>([]);
|
||||||
|
currentIndex = signal<number>(-1);
|
||||||
|
environment = environment;
|
||||||
|
|
||||||
|
get isApplicationDetailsLoading() {
|
||||||
|
return this.dataService.isApplicationDetailsLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasApplicationData() {
|
||||||
|
return this.currentApplication() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
canGoToPrevious = computed(() => this.currentIndex() > 0);
|
||||||
|
canGoToNext = computed(() =>
|
||||||
|
this.currentIndex() >= 0 &&
|
||||||
|
this.currentIndex() < this.applicationList().length - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
canEdit = computed(() => {
|
||||||
|
const app = this.currentApplication();
|
||||||
|
if (!app?.createdAt) return false;
|
||||||
|
const created = new Date(app.createdAt);
|
||||||
|
const now = new Date();
|
||||||
|
return now.getTime() - created.getTime() <= 3 * 24 * 60 * 60 * 1000;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (this.dataService.cachedApplicationList.length > 0) {
|
||||||
|
this.applicationList.set(this.dataService.cachedApplicationList);
|
||||||
|
this.initializeApplication();
|
||||||
|
} else {
|
||||||
|
this.dataService.loadCandidateList().subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.applicationList.set(data);
|
||||||
|
this.initializeApplication();
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Error loading candidate list:', error);
|
||||||
|
alert('Error loading candidate list');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editApplication(): void {
|
||||||
|
const app = this.currentApplication();
|
||||||
|
if (!app) return;
|
||||||
|
this.router.navigate(['/application', app.id, 'edit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeApplication(): void {
|
||||||
|
const id = this.activatedRoute.snapshot.paramMap.get('id');
|
||||||
|
if (!id) {
|
||||||
|
alert('Invalid route');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicationId = Number.parseInt(id, 10);
|
||||||
|
const foundIndex = this.applicationList().findIndex(app => app.id === applicationId);
|
||||||
|
|
||||||
|
if (foundIndex === -1) {
|
||||||
|
alert('Application not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentIndex() !== foundIndex) {
|
||||||
|
this.currentIndex.set(foundIndex);
|
||||||
|
this.loadApplication(applicationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadApplication(id: number): void {
|
||||||
|
if (this.currentApplication()?.id === id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentApplication.set(null);
|
||||||
|
this.dataService.getApplicationDetails(id).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.currentApplication.set(data);
|
||||||
|
const currentRoute = this.router.url;
|
||||||
|
const targetRoute = `/application/${id}`;
|
||||||
|
if (currentRoute !== targetRoute) {
|
||||||
|
this.router.navigate(['/application', id], { replaceUrl: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Error loading application details:', error);
|
||||||
|
alert('Error loading application details');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPrevious(): void {
|
||||||
|
if (this.canGoToPrevious()) {
|
||||||
|
const newIndex = this.currentIndex() - 1;
|
||||||
|
this.currentIndex.set(newIndex);
|
||||||
|
const prevId = this.applicationList()[newIndex].id;
|
||||||
|
this.loadApplication(prevId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goToNext(): void {
|
||||||
|
if (this.canGoToNext()) {
|
||||||
|
const newIndex = this.currentIndex() + 1;
|
||||||
|
this.currentIndex.set(newIndex);
|
||||||
|
const nextId = this.applicationList()[newIndex].id;
|
||||||
|
this.loadApplication(nextId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
this.router.navigate(['/application-list']);
|
||||||
|
}
|
||||||
|
|
||||||
|
getHobbiesArray(): string[] {
|
||||||
|
const app = this.currentApplication();
|
||||||
|
if (!app || !app.hobbies) return [];
|
||||||
|
|
||||||
|
if (typeof app.hobbies === 'string') {
|
||||||
|
return app.hobbies.split(',').map((hobby: string) => hobby.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return Array.isArray(app.hobbies) ? app.hobbies : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(dateString: string): string {
|
||||||
|
if (!dateString) return 'Not specified';
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString();
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
40
src/app/components/image-input/image-input.component.html
Normal file
40
src/app/components/image-input/image-input.component.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<div class="fileUploadWrapper">
|
||||||
|
<mat-label>Profile Photo</mat-label>
|
||||||
|
|
||||||
|
<div class="fileUploadContainer" [ngStyle]="{'margin-top': value ? '5px' : '20px'}">
|
||||||
|
<!-- Image preview -->
|
||||||
|
@if (value) {
|
||||||
|
<div class="previewContainer">
|
||||||
|
<img [src]="previewUrl" class="previewImage" />
|
||||||
|
<button
|
||||||
|
class="deleteButton"
|
||||||
|
mat-icon-button
|
||||||
|
type="button"
|
||||||
|
(click)="clear()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Upload UI when no image -->
|
||||||
|
@if (!value) {
|
||||||
|
<div class="uploadPrompt">
|
||||||
|
<mat-icon style="opacity: 60%;">file_upload</mat-icon>
|
||||||
|
<button mat-raised-button color="primary" type="button" (click)="fileInput.click()">
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
<small style="margin: 10px; display:block;">Drag and drop here</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Hidden file input -->
|
||||||
|
<input
|
||||||
|
#fileInput
|
||||||
|
class="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
(change)="setFileData($event)"
|
||||||
|
[disabled]="disabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
75
src/app/components/image-input/image-input.component.scss
Normal file
75
src/app/components/image-input/image-input.component.scss
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
.fileUploadContainer {
|
||||||
|
position: relative;
|
||||||
|
padding: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border: 2px double hsla(180, 100%, 50%, 0.324);
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: rgba(0, 15, 44, 0.8);
|
||||||
|
/* Preview image */
|
||||||
|
.previewImage {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
border-radius: 0;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Upload prompt when no image */
|
||||||
|
.uploadPrompt {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #00ffff;
|
||||||
|
gap: 5px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadPrompt:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete button */
|
||||||
|
.deleteButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
z-index: 10;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
color: #000;
|
||||||
|
background-color: rgba(0, 255, 255, 0.7); /* neon highlight */
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton:hover {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.2);
|
||||||
|
background-color: rgba(0, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden file input */
|
||||||
|
.fileInput {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/app/components/image-input/image-input.component.ts
Normal file
78
src/app/components/image-input/image-input.component.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Component, forwardRef, inject } from '@angular/core';
|
||||||
|
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-image-input',
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => ImageInputComponent),
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
templateUrl: './image-input.component.html',
|
||||||
|
styleUrl: './image-input.component.scss'
|
||||||
|
})
|
||||||
|
export class ImageInputComponent implements ControlValueAccessor {
|
||||||
|
value: File | null = null;
|
||||||
|
disabled = false;
|
||||||
|
previewUrl: string | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
onChange = (value: any) => { };
|
||||||
|
onTouched = () => { };
|
||||||
|
|
||||||
|
writeValue(value: File | null): void {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: any): void {
|
||||||
|
this.onChange = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnTouched(fn: any): void {
|
||||||
|
this.onTouched = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabledState(isDisabled: boolean): void {
|
||||||
|
this.disabled = isDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
setFileData(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input?.files?.[0]) {
|
||||||
|
this.value = input.files[0];
|
||||||
|
|
||||||
|
this.previewUrl = URL.createObjectURL(this.value);
|
||||||
|
this.onChange(this.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.value = null;
|
||||||
|
if (this.previewUrl) {
|
||||||
|
URL.revokeObjectURL(this.previewUrl);
|
||||||
|
}
|
||||||
|
this.previewUrl = null;
|
||||||
|
this.onChange(this.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
14
src/app/components/landing/landing.component.html
Normal file
14
src/app/components/landing/landing.component.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
<div class="container landing">
|
||||||
|
<h2>Welcome to IISA</h2>
|
||||||
|
<p class="intro">Join the mission or track your application below.</p>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<button mat-button routerLink="/application/new" class="primary-btn">
|
||||||
|
Apply for Registration
|
||||||
|
</button>
|
||||||
|
<button mat-button routerLink="/application-list" class="secondary-btn">
|
||||||
|
View Applications
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
115
src/app/components/landing/landing.component.scss
Normal file
115
src/app/components/landing/landing.component.scss
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap');
|
||||||
|
|
||||||
|
/* Fullscreen container */
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2rem;
|
||||||
|
background: radial-gradient(circle at top, #0a0f2c 0%, #000000 100%);
|
||||||
|
color: #e0e0ff;
|
||||||
|
font-family: 'Orbitron', Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shining text animation */
|
||||||
|
@keyframes shine {
|
||||||
|
0% {
|
||||||
|
background-position: -200% center;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shining header */
|
||||||
|
.landing h2 {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
background: linear-gradient(90deg, #00ffff, #ffffff, #00ffff);
|
||||||
|
background-size: 200% auto;
|
||||||
|
background-clip: text; /* Standard */
|
||||||
|
-webkit-background-clip: text; /* Chrome/Safari */
|
||||||
|
color: transparent;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: shine 3s linear infinite;
|
||||||
|
text-shadow: 0 0 12px rgba(0, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Intro paragraph */
|
||||||
|
.landing .intro {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #a0a0ff;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
text-shadow: 0 0 6px rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button group */
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary button */
|
||||||
|
button.primary-btn {
|
||||||
|
background: #00ffff;
|
||||||
|
color: #000;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary-btn:hover {
|
||||||
|
box-shadow: 0 0 25px #00ffff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary button */
|
||||||
|
button.secondary-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: #00ffff;
|
||||||
|
border: 1px solid #00ffff;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary-btn:hover {
|
||||||
|
background: rgba(0, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 0 20px #00ffff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.landing h2 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing .intro {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary-btn,
|
||||||
|
button.secondary-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { RouterLinkActive } from "../../../node_modules/@angular/router/router_module.d-Bx9ArA6K";
|
import { RouterLinkActive } from "../../../../node_modules/@angular/router/router_module.d-Bx9ArA6K";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-landing',
|
selector: 'app-landing',
|
||||||
23
src/app/components/leaflet-map/leaflet-map.component.html
Normal file
23
src/app/components/leaflet-map/leaflet-map.component.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<mat-form-field appearance="outline" style="width:100%;">
|
||||||
|
<mat-label>Select City</mat-label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[value]="inputValue"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[matAutocomplete]="auto"
|
||||||
|
placeholder="Start typing..."
|
||||||
|
(input)="onInput($event)"
|
||||||
|
(blur)="onTouched()"
|
||||||
|
(keydown.enter)="onEnter()"
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="onSelectCity($event.option.value)" >
|
||||||
|
@for (c of filteredCities; track c.name) {
|
||||||
|
<mat-option [value]="c.name">{{ c.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-autocomplete>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="map-container" #mapEl></div>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.map-container {
|
||||||
|
height: 300px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 2px solid #00ffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 0 15px rgba(0, 255, 255, 0.4);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
114
src/app/components/leaflet-map/leaflet-map.component.ts
Normal file
114
src/app/components/leaflet-map/leaflet-map.component.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Component, AfterViewInit, ViewChild, ElementRef, forwardRef } from '@angular/core';
|
||||||
|
import * as L from 'leaflet';
|
||||||
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { CITY_LIST, City } from '../../shared/cities';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-leaflet-map',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, MatFormFieldModule, MatInputModule, MatAutocompleteModule],
|
||||||
|
templateUrl: './leaflet-map.component.html',
|
||||||
|
styleUrls: ['./leaflet-map.component.scss'],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => LeafletMapComponent),
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class LeafletMapComponent implements AfterViewInit, ControlValueAccessor {
|
||||||
|
cities = [...CITY_LIST].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
filteredCities: City[] = this.cities;
|
||||||
|
inputValue = '';
|
||||||
|
disabled = false;
|
||||||
|
|
||||||
|
map!: L.Map;
|
||||||
|
marker?: L.Marker;
|
||||||
|
|
||||||
|
@ViewChild('mapEl', { static: true }) mapEl!: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
|
onChange: (value: string) => void = () => { };
|
||||||
|
onTouched: () => void = () => { };
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.map = L.map(this.mapEl.nativeElement).setView([31.7683, 35.2137], 7);
|
||||||
|
|
||||||
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
}).addTo(this.map);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
onInput(raw: string | Event): void {
|
||||||
|
const value = typeof raw === 'string' ? raw : (raw.target as HTMLInputElement).value;
|
||||||
|
this.inputValue = value;
|
||||||
|
|
||||||
|
const v = value.toLowerCase().trim();
|
||||||
|
this.filteredCities = !v
|
||||||
|
? this.cities
|
||||||
|
: this.cities.filter(c => c.name.toLowerCase().includes(v));
|
||||||
|
this.onChange(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectCity(cityName: string): void {
|
||||||
|
this.inputValue = cityName;
|
||||||
|
this.onChange(cityName);
|
||||||
|
this.onTouched();
|
||||||
|
|
||||||
|
const city = this.cities.find(c => c.name === cityName);
|
||||||
|
if (!city || !this.map) return;
|
||||||
|
|
||||||
|
if (this.marker) this.map.removeLayer(this.marker);
|
||||||
|
this.marker = L.marker([city.lat, city.lng]).addTo(this.map);
|
||||||
|
this.map.setView([city.lat, city.lng], 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEnter(): void {
|
||||||
|
const exact = this.cities.find(c => c.name.toLowerCase() === this.inputValue.toLowerCase().trim());
|
||||||
|
if (exact) this.onSelectCity(exact.name);
|
||||||
|
|
||||||
|
else this.onTouched();
|
||||||
|
console.log(this.marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
writeValue(value: string | null): void {
|
||||||
|
this.inputValue = value ?? '';
|
||||||
|
const city = this.cities.find(c => c.name === value);
|
||||||
|
if (city && this.map) {
|
||||||
|
if (this.marker) this.map.removeLayer(this.marker);
|
||||||
|
this.marker = L.marker([city.lat, city.lng]).addTo(this.map);
|
||||||
|
this.map.setView([city.lat, city.lng], 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: (value: string) => void): void {
|
||||||
|
this.onChange = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnTouched(fn: () => void): void {
|
||||||
|
this.onTouched = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabledState(isDisabled: boolean): void {
|
||||||
|
this.disabled = isDisabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/app/components/registration/registration.component.html
Normal file
114
src/app/components/registration/registration.component.html
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="registration-header">
|
||||||
|
@if(editMode()){
|
||||||
|
<button mat-button type="button" routerLink="/application-list" class="button">
|
||||||
|
Cancel Editing
|
||||||
|
</button>
|
||||||
|
}@else {
|
||||||
|
<button mat-button type="button" (click)="goBack()" class="button">
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if(editMode()){
|
||||||
|
<h2>Spaceflight Candidate Registration Update</h2>
|
||||||
|
}
|
||||||
|
@else {
|
||||||
|
<h2>Spaceflight Candidate Registration</h2>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="registration-form" [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
|
|
||||||
|
<!-- Profile Image Upload -->
|
||||||
|
<div class="image-upload">
|
||||||
|
<app-image-input formControlName="profileImage"></app-image-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full Name -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Full Name</mat-label>
|
||||||
|
<input matInput formControlName="fullName" />
|
||||||
|
@if (form.get('fullName')?.hasError('required')) {
|
||||||
|
<mat-error>Name is required</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Email</mat-label>
|
||||||
|
<input matInput formControlName="email" />
|
||||||
|
@if (form.get('email')?.hasError('email')) {
|
||||||
|
<mat-error>Invalid email</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Phone Number -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Phone Number</mat-label>
|
||||||
|
<input matInput formControlName="phoneNumber" placeholder="+972-XXXXXXX" />
|
||||||
|
@if (form.get('phoneNumber')?.hasError('required')) {
|
||||||
|
<mat-error>Phone number is required</mat-error>
|
||||||
|
}
|
||||||
|
@if (form.get('phoneNumber')?.hasError('pattern')) {
|
||||||
|
<mat-error>Invalid phone number</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Age -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Age</mat-label>
|
||||||
|
<input matInput type="number" formControlName="age" min="0" />
|
||||||
|
@if (form.get('age')?.hasError('min') || form.get('age')?.hasError('max')) {
|
||||||
|
<mat-error>Only applicants of age 18 - 70 allowed</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- City -->
|
||||||
|
|
||||||
|
<app-leaflet-map
|
||||||
|
formControlName="cityOrRegion"
|
||||||
|
class="app-leaflet-map">
|
||||||
|
</app-leaflet-map>
|
||||||
|
|
||||||
|
@if (form.get('cityOrRegion')?.invalid && (form.get('cityOrRegion')?.touched || form.get('cityOrRegion')?.dirty)) {
|
||||||
|
@if (form.get('cityOrRegion')?.hasError('required')) {
|
||||||
|
<mat-error style="margin-bottom: 8px; padding: 0 16px;">City selection is required</mat-error>
|
||||||
|
}
|
||||||
|
@if (form.get('cityOrRegion')?.hasError('invalidCity')) {
|
||||||
|
<mat-error style="margin-bottom: 8px; padding: 0 16px;">Please select a valid city from the list</mat-error>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Hobbies -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Hobbies</mat-label>
|
||||||
|
<textarea matInput rows="2" formControlName="hobbies"></textarea>
|
||||||
|
@if (form.get('hobbies')?.hasError('maxLength')) {
|
||||||
|
<mat-error>Maximum length is 300 characters</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Justification -->
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Why I am the perfect candidate</mat-label>
|
||||||
|
<textarea matInput rows="4" formControlName="justification"></textarea>
|
||||||
|
@if (form.get('justification')?.hasError('required')) {
|
||||||
|
<mat-error>Field is required</mat-error>
|
||||||
|
}
|
||||||
|
@if (form.get('justification')?.hasError('maxLength')) {
|
||||||
|
<mat-error>Maximum length is 300 characters</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<button mat-raised-button color="accent" type="submit">
|
||||||
|
{{ editMode() ? 'Update Application' : 'Submit Application' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
🚀 Powered by <a href="#">Israeli Imaginary Space Agency</a> | © 2025
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
190
src/app/components/registration/registration.component.scss
Normal file
190
src/app/components/registration/registration.component.scss
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap');
|
||||||
|
|
||||||
|
/* Fullscreen container */
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2rem;
|
||||||
|
background: radial-gradient(circle at top, #0a0f2c 0%, #000000 100%);
|
||||||
|
color: #e0e0ff;
|
||||||
|
font-family: 'Orbitron', Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shining text animation */
|
||||||
|
@keyframes shine {
|
||||||
|
0% { background-position: -200% center; }
|
||||||
|
100% { background-position: 200% center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header shine */
|
||||||
|
.container h2 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 2rem;
|
||||||
|
background: linear-gradient(90deg, #00ffff, #ffffff, #00ffff);
|
||||||
|
background-size: 200% auto;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: shine 3s linear infinite;
|
||||||
|
text-shadow: 0 0 12px rgba(0, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styling */
|
||||||
|
.registration-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 700px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile image upload */
|
||||||
|
.image-upload {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload img {
|
||||||
|
max-height: 150px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #00ffff;
|
||||||
|
box-shadow: 0 0 15px #00ffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Material form field overrides */
|
||||||
|
.mat-form-field {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-form-field-appearance-outline .mat-form-field-outline {
|
||||||
|
color: #00ffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-input-element {
|
||||||
|
color: #e0e0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-form-field.mat-focused .mat-form-field-outline-thick {
|
||||||
|
border-color: #00ffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs and textareas */
|
||||||
|
input, textarea {
|
||||||
|
background: transparent;
|
||||||
|
color: #e0e0ff;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error messages */
|
||||||
|
mat-error {
|
||||||
|
color: #ff4c4c;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-shadow: 0 0 2px #ff4c4c;
|
||||||
|
font-family: 'Orbitron', Arial, sans-serif;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="submit"] {
|
||||||
|
background: #00ffff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="submit"]:hover {
|
||||||
|
box-shadow: 0 0 25px #00ffff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="button"] {
|
||||||
|
background: transparent;
|
||||||
|
color: #00ffff;
|
||||||
|
border: 1px solid #00ffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="button"]:hover {
|
||||||
|
background: rgba(0, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 0 20px #00ffff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #8888ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #00ffff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.app-leaflet-map {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Chrome, Safari, Edge, Opera */
|
||||||
|
input::-webkit-outer-spin-button,
|
||||||
|
input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
input[type=number] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
.registration-form {
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
174
src/app/components/registration/registration.component.ts
Normal file
174
src/app/components/registration/registration.component.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { AfterViewInit, Component, ElementRef, inject, input, OnInit, QueryList, signal, ViewChildren } from '@angular/core';
|
||||||
|
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { ImageInputComponent } from "../image-input/image-input.component";
|
||||||
|
import { CandidateDataService } from '../../services/candidate-data.service';
|
||||||
|
import "@maptiler/leaflet-maptilersdk";
|
||||||
|
import { LeafletMapComponent } from "../leaflet-map/leaflet-map.component";
|
||||||
|
import { CITY_LIST } from '../../shared/cities';
|
||||||
|
import { cityValidator } from '../../validators/city.validator';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { SocketIOService } from '../../services/socket-io.service';
|
||||||
|
|
||||||
|
const CITY_NAMES = CITY_LIST.map(c => c.name).sort();
|
||||||
|
const israeliPhoneRegex = /^(?:(?:(\+?972|\(\+?972\)|\+?\(972\))(?:\s|\.|-)?([1-9]\d?))|(0[23489]{1})|(0[57]{1}[0-9]))(?:\s|\.|-)?([^0\D]{1}\d{2}(?:\s|\.|-)?\d{4})$/;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-registration',
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
ImageInputComponent,
|
||||||
|
LeafletMapComponent,
|
||||||
|
MatSnackBarModule,
|
||||||
|
RouterLink
|
||||||
|
],
|
||||||
|
templateUrl: './registration.component.html',
|
||||||
|
styleUrls: ['./registration.component.scss'],
|
||||||
|
})
|
||||||
|
export class RegistrationComponent implements OnInit {
|
||||||
|
|
||||||
|
|
||||||
|
dataService = inject(CandidateDataService);
|
||||||
|
fb = inject(FormBuilder);
|
||||||
|
router = inject(Router);
|
||||||
|
activatedRoute = inject(ActivatedRoute);
|
||||||
|
snackBar = inject(MatSnackBar)
|
||||||
|
socketService = inject(SocketIOService);
|
||||||
|
previewUrl: string | ArrayBuffer | null = null;
|
||||||
|
|
||||||
|
editMode = signal(false);
|
||||||
|
applicationId = signal<number | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
form = this.fb.group({
|
||||||
|
fullName: ['', [Validators.required, Validators.minLength(3)]],
|
||||||
|
email: ['', [Validators.required, Validators.email]],
|
||||||
|
phoneNumber: ['', [Validators.required, Validators.pattern(israeliPhoneRegex)]],
|
||||||
|
age: [0, [Validators.required, Validators.min(18), Validators.max(70)]],
|
||||||
|
cityOrRegion: ['', [Validators.required, cityValidator(CITY_NAMES)]],
|
||||||
|
hobbies: ['', [Validators.maxLength(300)]],
|
||||||
|
justification: ['', [Validators.required, Validators.maxLength(300)]],
|
||||||
|
profileImage: this.fb.control<File | null>(null, Validators.required),
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const idParam = this.activatedRoute.snapshot.paramMap.get('id');
|
||||||
|
const url = this.activatedRoute.snapshot.url.map(s => s.path);
|
||||||
|
|
||||||
|
if (url.includes('edit') && idParam) {
|
||||||
|
this.editMode.set(true);
|
||||||
|
this.applicationId.set(+idParam);
|
||||||
|
this.loadCandidate(+idParam);
|
||||||
|
} else {
|
||||||
|
this.editMode.set(false);
|
||||||
|
this.applicationId.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCandidate(id: number) {
|
||||||
|
this.dataService.getApplicationDetails(id).subscribe({
|
||||||
|
next: (candidate: any) => {
|
||||||
|
this.form.patchValue({
|
||||||
|
fullName: candidate.fullName,
|
||||||
|
email: candidate.email,
|
||||||
|
phoneNumber: candidate.phoneNumber,
|
||||||
|
age: candidate.age,
|
||||||
|
cityOrRegion: candidate.cityOrRegion,
|
||||||
|
hobbies: candidate.hobbies,
|
||||||
|
justification: candidate.justification,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (candidate.profileImageUrl) {
|
||||||
|
this.previewUrl = candidate.profileImageUrl;
|
||||||
|
this.form.get('profileImage')?.clearValidators();
|
||||||
|
this.form.get('profileImage')?.updateValueAndValidity();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: err => console.error('Failed to load candidate', err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
scrollToFirstInvalidField() {
|
||||||
|
for (const key of Object.keys(this.form.controls)) {
|
||||||
|
const control = this.form.get(key);
|
||||||
|
if (control && control.invalid) {
|
||||||
|
const element = document.querySelector(
|
||||||
|
`[formControlName="${key}"]`
|
||||||
|
) as HTMLElement;
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
if (!this.form.valid) {
|
||||||
|
this.scrollToFirstInvalidField();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
const value = this.form.value;
|
||||||
|
|
||||||
|
if (value.fullName) formData.append('fullName', value.fullName);
|
||||||
|
if (value.email) formData.append('email', value.email);
|
||||||
|
if (value.phoneNumber) formData.append('phoneNumber', value.phoneNumber);
|
||||||
|
if (value.age !== null && value.age !== undefined) formData.append('age', value.age.toString());
|
||||||
|
if (value.cityOrRegion) formData.append('cityOrRegion', value.cityOrRegion);
|
||||||
|
if (value.hobbies) formData.append('hobbies', value.hobbies);
|
||||||
|
if (value.justification) formData.append('justification', value.justification);
|
||||||
|
|
||||||
|
const imageFile = this.form.get('profileImage')?.value;
|
||||||
|
|
||||||
|
if (imageFile) {
|
||||||
|
formData.append('profileImage', imageFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.editMode() && this.applicationId()) {
|
||||||
|
this.dataService.updateCandidateForm(this.applicationId()!, formData).subscribe({
|
||||||
|
next: (updatedCandidate) => {
|
||||||
|
this.snackBar.open('✅ Application updated!', 'Close', {
|
||||||
|
duration: 5000,
|
||||||
|
horizontalPosition: 'center',
|
||||||
|
verticalPosition: 'top',
|
||||||
|
});
|
||||||
|
this.socketService.socket.emit('candidateUpdated', updatedCandidate);
|
||||||
|
this.router.navigate(['/application-list']);
|
||||||
|
},
|
||||||
|
error: err => console.error('Error updating application', err),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.dataService.submitCandidateForm(formData).subscribe({
|
||||||
|
next: (newCandidate) => {
|
||||||
|
this.snackBar.open('✅ Application saved!', 'Close', {
|
||||||
|
duration: 5000,
|
||||||
|
horizontalPosition: 'center',
|
||||||
|
verticalPosition: 'top',
|
||||||
|
});
|
||||||
|
this.socketService.socket.emit('candidateRegistered', newCandidate);
|
||||||
|
this.form.reset();
|
||||||
|
},
|
||||||
|
error: err => console.error('Error submitting form', err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
this.router.navigate(['/landing']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<div style="padding:20px">
|
|
||||||
<h1>Angular material image input </h1>
|
|
||||||
<form id="editForm" name="editForm" [formGroup]="editForm">
|
|
||||||
<mat-form-field appearance="fill">
|
|
||||||
<mat-label>Photo</mat-label>
|
|
||||||
<div class="fileUploadContainer" [ngStyle]="{'margin-top' : editForm.get('photo')!.value ? '5px' : '20px'}">
|
|
||||||
@if(editForm.get('photo')!.value){
|
|
||||||
<img [src]="editForm.get('photo')!.value" />
|
|
||||||
<button class="deleteButton" mat-icon-button (click)="fileInput.value = ''; editForm.get('photo')?.setValue(null);">
|
|
||||||
<mat-icon>close</mat-icon>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<!-- no image -->
|
|
||||||
@if(!editForm.get('photo')!.value){
|
|
||||||
<div fxLayout="column" fxLayoutAlign="center center" fxLayoutGap="10px">
|
|
||||||
<mat-icon style="opacity: 60%;">file_upload</mat-icon>
|
|
||||||
<button mat-raised-button color="primary" style="width:100%; opacity: 80%;">Browser</button>
|
|
||||||
<small style="margin: 20px">Drag and drop here</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<!-- put on top of the fileUploadContainer with opacity 0 -->
|
|
||||||
<input #fileInput class="fileInput" type="file" multiple="multiple" accept="image/*"
|
|
||||||
(change)="setFileData($event)" />
|
|
||||||
</div>
|
|
||||||
<input matInput formControlName="photo" readonly [hidden]="true " />
|
|
||||||
</mat-form-field>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
.fileUploadContainer {
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 150px;
|
|
||||||
height: 150px;
|
|
||||||
border: dashed 1px #979797;
|
|
||||||
text-align: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
max-height: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noImageContainet {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 11px;
|
|
||||||
button {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleteButton{
|
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
top: -25px;
|
|
||||||
inset-inline-end: -10px;
|
|
||||||
opacity:50%
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileInput {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 9;
|
|
||||||
opacity: 0;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
left: 0px;
|
|
||||||
top: 0px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { FormsModule, ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-image-input',
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
MatButtonModule,
|
|
||||||
MatIconModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
MatInputModule,
|
|
||||||
FormsModule,
|
|
||||||
ReactiveFormsModule
|
|
||||||
],
|
|
||||||
templateUrl: './image-input.component.html',
|
|
||||||
styleUrl: './image-input.component.scss'
|
|
||||||
})
|
|
||||||
export class ImageInputComponent {
|
|
||||||
|
|
||||||
fb = inject(UntypedFormBuilder);
|
|
||||||
|
|
||||||
editForm = this.fb.group({
|
|
||||||
photo: []
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
setFileData(event: Event): void {
|
|
||||||
const eventTarget: HTMLInputElement | null = event.target as HTMLInputElement | null;
|
|
||||||
if (eventTarget?.files?.[0]) {
|
|
||||||
const file: File = eventTarget.files[0];
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.addEventListener('load', () => {
|
|
||||||
this.editForm.get('photo')?.setValue(reader.result as string);
|
|
||||||
});
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<p>landing works!</p>
|
|
||||||
<button mat-button routerLink="/registration">
|
|
||||||
to registration
|
|
||||||
</button>
|
|
||||||
<button mat-button routerLink="/application-list">
|
|
||||||
to application list
|
|
||||||
</button>
|
|
||||||
7
src/app/page-not-found/page-not-found.component.html
Normal file
7
src/app/page-not-found/page-not-found.component.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h2>404 - Page Not Found</h2>
|
||||||
|
<p class="message">
|
||||||
|
The page you're looking for doesn’t exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<button mat-button type="button" (click)="goHome()" class="primary-btn">Go to Home</button>
|
||||||
|
</div>
|
||||||
94
src/app/page-not-found/page-not-found.component.scss
Normal file
94
src/app/page-not-found/page-not-found.component.scss
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap');
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2rem;
|
||||||
|
background: radial-gradient(circle at top, #0a0f2c 0%, #000000 100%);
|
||||||
|
color: #e0e0ff;
|
||||||
|
font-family: 'Orbitron', Arial, sans-serif;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(90deg, #00ffff, #ffffff, #00ffff);
|
||||||
|
background-size: 200% auto;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: shine 3s linear infinite;
|
||||||
|
text-shadow: 0 0 12px rgba(0, 255, 255, 0.6);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary-btn {
|
||||||
|
background: #00ffff;
|
||||||
|
color: #000;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary-btn:hover {
|
||||||
|
box-shadow: 0 0 25px #00ffff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: #ccccff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes shine {
|
||||||
|
0% {
|
||||||
|
background-position: -200% center;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
h2 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary-btn {
|
||||||
|
background: #00ffff;
|
||||||
|
color: #000;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary-btn:hover {
|
||||||
|
box-shadow: 0 0 25px #00ffff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app/page-not-found/page-not-found.component.ts
Normal file
17
src/app/page-not-found/page-not-found.component.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { MatButton, MatButtonModule } from "@angular/material/button";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-page-not-found',
|
||||||
|
templateUrl: './page-not-found.component.html',
|
||||||
|
styleUrls: ['./page-not-found.component.scss'],
|
||||||
|
imports: [MatButtonModule]
|
||||||
|
})
|
||||||
|
export class PageNotFoundComponent {
|
||||||
|
constructor(private router: Router) { }
|
||||||
|
|
||||||
|
goHome(): void {
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
<div class="registration-container">
|
|
||||||
<h2 class="title">🚀 Spaceflight Candidate Registration</h2>
|
|
||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="form">
|
|
||||||
<!-- <div class="upload-section">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
(change)="onFileSelected($event)"
|
|
||||||
/>
|
|
||||||
<mat-label>Candidate Profile Picture</mat-label>
|
|
||||||
<div class="preview">
|
|
||||||
<img [src]="previewUrl" alt="Preview" />
|
|
||||||
</div>
|
|
||||||
</div> -->
|
|
||||||
<!-- <app-image-input></app-image-input> -->
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Full Name</mat-label>
|
|
||||||
<input matInput formControlName="fullName" />
|
|
||||||
@if (form.get('fullName')?.hasError('required')) {
|
|
||||||
<mat-error>
|
|
||||||
Name is required
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Email</mat-label>
|
|
||||||
<input matInput formControlName="email" />
|
|
||||||
@if (form.get('email')?.hasError('email')) {
|
|
||||||
<mat-error>
|
|
||||||
Invalid email
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Phone Number</mat-label>
|
|
||||||
<input matInput formControlName="phoneNumber" placeholder="+972-XXXXXXX"/>
|
|
||||||
@if (form.get('phoneNumber')?.hasError('required')) {
|
|
||||||
<mat-error>
|
|
||||||
Phone number is required
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
@if (form.get('phoneNumber')?.hasError('pattern')){
|
|
||||||
<mat-error>
|
|
||||||
Invalid phone number
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Age</mat-label>
|
|
||||||
<input matInput type="number" formControlName="age" />
|
|
||||||
@if (form.get('age')?.hasError('min') || form.get('age')?.hasError('max')) {
|
|
||||||
<mat-error>
|
|
||||||
Only aplicants of age 18 - 70 allowed
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>City / Region</mat-label>
|
|
||||||
<input matInput formControlName="cityOrRegion" />
|
|
||||||
@if (form.get('cityOrRegion')?.hasError('required')) {
|
|
||||||
<mat-error>
|
|
||||||
Field is required
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Hobbies</mat-label>
|
|
||||||
<textarea matInput rows="2" formControlName="hobbies"></textarea>
|
|
||||||
@if (form.get('hobbies')?.hasError('maxLength')) {
|
|
||||||
<mat-error>
|
|
||||||
Maximum length is 300 characters
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Why I am the perfect candidate</mat-label>
|
|
||||||
<textarea matInput rows="4" formControlName="justification"></textarea>
|
|
||||||
@if (form.get('justification')?.hasError('required')) {
|
|
||||||
<mat-error>
|
|
||||||
Field is required
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
@if (form.get('justification')?.hasError('maxLength')) {
|
|
||||||
<mat-error>
|
|
||||||
Maximum length is 300 characters
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
|
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="accent"
|
|
||||||
class="full-width"
|
|
||||||
type="submit"
|
|
||||||
[disabled]="form.invalid"
|
|
||||||
>
|
|
||||||
Submit Application
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
(click)="onCheckErrors()">
|
|
||||||
check
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
/* Cosmic-themed container styling */
|
|
||||||
.registration-container {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 2rem auto;
|
|
||||||
padding: 2.5rem;
|
|
||||||
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
|
|
||||||
color: #e0e0e0;
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cosmic background overlay */
|
|
||||||
.registration-container::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 70%);
|
|
||||||
opacity: 0.3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form title with space theme */
|
|
||||||
.title {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 2.2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #00d4ff;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 3px;
|
|
||||||
text-shadow: 0 0 12px rgba(0, 212, 255, 0.6);
|
|
||||||
animation: glow 2s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glowing title animation */
|
|
||||||
@keyframes glow {
|
|
||||||
from {
|
|
||||||
text-shadow: 0 0 8px rgba(0, 212, 255, 0.4);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
text-shadow: 0 0 16px rgba(0, 212, 255, 0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form styling */
|
|
||||||
.form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Full-width form fields */
|
|
||||||
.full-width {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
// /* Material form field customization */
|
|
||||||
// ::ng-deep .mat-form-field {
|
|
||||||
// .mat-form-field-label {
|
|
||||||
// color: #b0bec5;
|
|
||||||
// font-weight: 500;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// .mat-form-field-underline {
|
|
||||||
// background-color: #00d4ff !important;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// .mat-form-field-ripple {
|
|
||||||
// background-color: #00d4ff !important;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// input, textarea {
|
|
||||||
// color: #e0e0e0;
|
|
||||||
// background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
// border-radius: 6px;
|
|
||||||
// padding: 0.75rem;
|
|
||||||
// transition: background-color 0.3s ease;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// input:focus, textarea:focus {
|
|
||||||
// background-color: rgba(255, 255, 255, 0.12);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// textarea {
|
|
||||||
// resize: vertical;
|
|
||||||
// min-height: 80px;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
/* Error message styling */
|
|
||||||
// ::ng-deep .mat-error {
|
|
||||||
// color: #ff6b6b;
|
|
||||||
// font-size: 0.9rem;
|
|
||||||
// }
|
|
||||||
|
|
||||||
/* Upload section */
|
|
||||||
.upload-section {
|
|
||||||
align-items: center;
|
|
||||||
margin: 2rem 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
|
|
||||||
.preview {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 250px;
|
|
||||||
height: 250px;
|
|
||||||
border: 4px solid #00d4ff;
|
|
||||||
box-shadow: 0 0 20px rgba(0, 212, 255, 0.4);
|
|
||||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
img:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
box-shadow: 0 0 25px rgba(0, 212, 255, 0.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.registration-container {
|
|
||||||
margin: 1.5rem;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-section img {
|
|
||||||
max-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ::ng-deep .mat-raised-button {
|
|
||||||
// padding: 0.75rem 1.5rem;
|
|
||||||
// font-size: 0.95rem;
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.registration-container {
|
|
||||||
margin: 1rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-section img {
|
|
||||||
max-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { Component, inject, OnInit } from '@angular/core';
|
|
||||||
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatCardModule } from '@angular/material/card';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { ImageInputComponent } from "../image-input/image-input.component";
|
|
||||||
import { CandidateDataService } from '../candidate-data.service';
|
|
||||||
|
|
||||||
const israeliPhoneRegex = /^(?:(?:(\+?972|\(\+?972\)|\+?\(972\))(?:\s|\.|-)?([1-9]\d?))|(0[23489]{1})|(0[57]{1}[0-9]))(?:\s|\.|-)?([^0\D]{1}\d{2}(?:\s|\.|-)?\d{4})$/;
|
|
||||||
@Component({
|
|
||||||
selector: 'app-registration',
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
MatInputModule,
|
|
||||||
MatButtonModule,
|
|
||||||
MatCardModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
ImageInputComponent
|
|
||||||
],
|
|
||||||
templateUrl: './registration.component.html',
|
|
||||||
styleUrls: ['./registration.component.scss'],
|
|
||||||
})
|
|
||||||
export class RegistrationComponent implements OnInit {
|
|
||||||
dataService = inject(CandidateDataService);
|
|
||||||
fb = inject(FormBuilder);
|
|
||||||
previewUrl: string | ArrayBuffer | null = null;
|
|
||||||
|
|
||||||
form = this.fb.group({
|
|
||||||
fullName: ['', [Validators.required, Validators.minLength(3)]],
|
|
||||||
email: ['', [Validators.required, Validators.email]],
|
|
||||||
phoneNumber: ['', [Validators.required, Validators.pattern(israeliPhoneRegex)]],
|
|
||||||
age: [0, [Validators.required, Validators.min(18), Validators.max(70)]],
|
|
||||||
cityOrRegion: ['', Validators.required],
|
|
||||||
hobbies: ['', [Validators.maxLength(300)]],
|
|
||||||
justification: ['', [Validators.required, Validators.maxLength(300)]],
|
|
||||||
// profileImage: this.fb.control<string | null>(null, Validators.required),
|
|
||||||
});
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const savedData = localStorage.getItem('registration');
|
|
||||||
if (!savedData) return;
|
|
||||||
|
|
||||||
const parsed = JSON.parse(savedData);
|
|
||||||
const dayMilliseconds = 1000 * 60 * 60 * 24;
|
|
||||||
if (Date.now() - parsed.timestamp < dayMilliseconds * 3) {
|
|
||||||
this.form.patchValue(parsed.data);
|
|
||||||
this.previewUrl = parsed.data.profileImage;
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('registration');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// onFileSelected(event: any) {
|
|
||||||
// const file = event.target.files[0];
|
|
||||||
// if (file) {
|
|
||||||
// const reader = new FileReader();
|
|
||||||
// reader.onload = () => {
|
|
||||||
// if (typeof reader.result === 'string') {
|
|
||||||
// this.previewUrl = reader.result;
|
|
||||||
// this.form.patchValue({ profileImage: reader.result });
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
// reader.readAsDataURL(file);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
onSubmit() {
|
|
||||||
if (!this.form.valid) {
|
|
||||||
alert("Invalid Form");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(JSON.stringify(this.form.value));
|
|
||||||
this.dataService.submitCandidateForm(this.form.value).subscribe(() => {
|
|
||||||
alert('✅ Application saved! You can re-edit within 3 days.')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onCheckErrors() {
|
|
||||||
console.log(this.form.controls.age.errors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
46
src/app/services/candidate-data.service.ts
Normal file
46
src/app/services/candidate-data.service.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { HttpClient } from "@angular/common/http";
|
||||||
|
import { inject, Injectable, signal } from "@angular/core";
|
||||||
|
import { environment } from "../../environments/environment.development";
|
||||||
|
import { delay, tap } from "rxjs";
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class CandidateDataService {
|
||||||
|
httpClient = inject(HttpClient)
|
||||||
|
isCandidatesListLoading = signal(false)
|
||||||
|
isApplicationDetailsLoading = signal(false)
|
||||||
|
cachedApplicationList: any[] = []
|
||||||
|
|
||||||
|
loadCandidateList() {
|
||||||
|
this.isCandidatesListLoading.set(true)
|
||||||
|
return this.httpClient.get<any>(`${environment.hostUrl}/app/candidates`).pipe(
|
||||||
|
delay(500),
|
||||||
|
tap((data) => {
|
||||||
|
this.isCandidatesListLoading.set(false);
|
||||||
|
this.cachedApplicationList = data;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getApplicationDetails(id: number) {
|
||||||
|
this.isApplicationDetailsLoading.set(true);
|
||||||
|
return this.httpClient.get(`${environment.hostUrl}/app/candidate/${id}`).pipe(
|
||||||
|
delay(500),
|
||||||
|
tap(() => { this.isApplicationDetailsLoading.set(false) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitCandidateForm(data: FormData) {
|
||||||
|
return this.httpClient.post(`${environment.hostUrl}/app/register`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCandidateForm(id: number, data: FormData) {
|
||||||
|
return this.httpClient.put(
|
||||||
|
`${environment.hostUrl}/app/candidate/${id}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
35
src/app/services/socket-io.service.ts
Normal file
35
src/app/services/socket-io.service.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class SocketIOService {
|
||||||
|
|
||||||
|
socket = io('ws://localhost:3000');
|
||||||
|
|
||||||
|
onCandidateRegistered(): Observable<any> {
|
||||||
|
return new Observable(observer => {
|
||||||
|
this.socket.on('candidateRegistered', (data) => {
|
||||||
|
observer.next(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onCandidateUpdated(): Observable<any> {
|
||||||
|
return new Observable(observer => {
|
||||||
|
this.socket.on('candidateUpdated', (data) => {
|
||||||
|
observer.next(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.socket.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
28
src/app/shared/cities.ts
Normal file
28
src/app/shared/cities.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export type City = {
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CITY_LIST: City[] = [
|
||||||
|
{ name: 'Jerusalem', lat: 31.7683, lng: 35.2137 },
|
||||||
|
{ name: 'Tel Aviv', lat: 32.0853, lng: 34.7818 },
|
||||||
|
{ name: 'Haifa', lat: 32.7940, lng: 34.9896 },
|
||||||
|
{ name: 'Rishon LeZion', lat: 31.9730, lng: 34.7925 },
|
||||||
|
{ name: 'Petah Tikva', lat: 32.0840, lng: 34.8878 },
|
||||||
|
{ name: 'Ashdod', lat: 31.8014, lng: 34.6435 },
|
||||||
|
{ name: 'Netanya', lat: 32.3215, lng: 34.8532 },
|
||||||
|
{ name: 'Beer Sheva', lat: 31.2520, lng: 34.7915 },
|
||||||
|
{ name: 'Holon', lat: 32.0158, lng: 34.7874 },
|
||||||
|
{ name: 'Bnei Brak', lat: 32.0836, lng: 34.8337 },
|
||||||
|
{ name: 'Bat Yam', lat: 32.0231, lng: 34.7500 },
|
||||||
|
{ name: 'Ashkelon', lat: 31.6693, lng: 34.5715 },
|
||||||
|
{ name: 'Herzliya', lat: 32.1663, lng: 34.8436 },
|
||||||
|
{ name: 'Kfar Saba', lat: 32.1782, lng: 34.9076 },
|
||||||
|
{ name: 'Ra’anana', lat: 32.1848, lng: 34.8713 },
|
||||||
|
{ name: 'Ramat Gan', lat: 32.0684, lng: 34.8248 },
|
||||||
|
{ name: 'Lod', lat: 31.9516, lng: 34.8883 },
|
||||||
|
{ name: 'Nazareth', lat: 32.6996, lng: 35.3035 },
|
||||||
|
{ name: 'Eilat', lat: 29.5577, lng: 34.9519 },
|
||||||
|
{ name: 'Tiberias', lat: 32.7959, lng: 35.5309 },
|
||||||
|
];
|
||||||
11
src/app/validators/city.validator.ts
Normal file
11
src/app/validators/city.validator.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
|
||||||
|
|
||||||
|
export function cityValidator(cities: string[]): ValidatorFn {
|
||||||
|
return (control: AbstractControl): ValidationErrors | null => {
|
||||||
|
const value = control.value?.trim();
|
||||||
|
if (!value) return null;
|
||||||
|
return cities.some(c => c.toLowerCase() === value.toLowerCase())
|
||||||
|
? null
|
||||||
|
: { invalidCity: true };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
hostUrl: 'http://localhost:3000'
|
hostUrl: 'http://localhost:3000',
|
||||||
|
mapTilerApiKey: '9LJZ0OppHyT3LzvQW3ce'
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
hostUrl: 'https://iisa.novikov.click'
|
hostUrl: 'https://iisa.novikov.click',
|
||||||
|
mapTilerApiKey: '9LJZ0OppHyT3LzvQW3ce'
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
hostUrl: ''
|
hostUrl: '',
|
||||||
|
mapTilerApiKey:''
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<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/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600&display=swap" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user