large update wip
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
@@ -8,5 +8,4 @@ import { RouterOutlet } from '@angular/router';
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss'
|
||||
})
|
||||
export class AppComponent {
|
||||
}
|
||||
export class AppComponent { }
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { RegistrationComponent } from './registration/registration.component';
|
||||
import { LandingComponent } from './landing/landing.component';
|
||||
import { ApplicationListComponent } from './application-list/application-list.component';
|
||||
import { ApplicationComponent } from './application/application.component';
|
||||
import { RegistrationComponent } from './components/registration/registration.component';
|
||||
import { LandingComponent } from './components/landing/landing.component';
|
||||
import { ApplicationListComponent } from './components/application-list/application-list.component';
|
||||
import { ApplicationComponent } from './components/application/application.component';
|
||||
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ 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/:id', component: ApplicationComponent },
|
||||
{ path: '', redirectTo: '/landing', pathMatch: 'full' },
|
||||
{ path: '**', redirectTo: '/landing' },
|
||||
// component: PageNotFoundComponent
|
||||
{ path: '**', 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 { MatButtonModule } from '@angular/material/button';
|
||||
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({
|
||||
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 };
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user