large update wip

This commit is contained in:
2025-08-24 17:24:40 +03:00
parent ae2dce2871
commit ef93d51f77
50 changed files with 3459 additions and 621 deletions

View File

@@ -62,17 +62,11 @@ Angular CLI does not come with an end-to-end testing framework by default. You c
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
-fix picture upload and adjust picture scale -WIP -implement total visits and total clicks on register button
-implement websockets
-implement graph
-implement total visits minor things
-search and filters (by name, city, age, etc.) -centralize scss check the same styles for differnet buttons
-navigation between candidates (next/previous) (signals) wip change info make signals
-edit page
-implement live update
-adjust to mobile format -adjust to mobile format
****animations transitions (prev next cards) ****animations transitions (prev next cards)

View File

@@ -33,6 +33,7 @@
], ],
"styles": [ "styles": [
"@angular/material/prebuilt-themes/magenta-violet.css", "@angular/material/prebuilt-themes/magenta-violet.css",
"node_modules/leaflet/dist/leaflet.css",
"src/styles.scss" "src/styles.scss"
], ],
"scripts": [] "scripts": []
@@ -121,4 +122,4 @@
} }
} }
} }
} }

1012
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"start:remote-api":"ng serve --configuration=remote-api", "start:remote-api": "ng serve --configuration=remote-api",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test"
@@ -21,7 +21,14 @@
"@angular/platform-browser": "^19.1.0", "@angular/platform-browser": "^19.1.0",
"@angular/platform-browser-dynamic": "^19.1.0", "@angular/platform-browser-dynamic": "^19.1.0",
"@angular/router": "^19.1.0", "@angular/router": "^19.1.0",
"@maptiler/leaflet-maptilersdk": "^4.1.1",
"@swimlane/ngx-charts": "^23.0.1",
"chart.js": "^4.5.0",
"d3": "^7.9.0",
"leaflet": "^1.9.4",
"ng2-charts": "^8.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"socket.io-client": "^4.8.1",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
@@ -29,7 +36,9 @@
"@angular-devkit/build-angular": "^19.1.6", "@angular-devkit/build-angular": "^19.1.6",
"@angular/cli": "^19.1.6", "@angular/cli": "^19.1.6",
"@angular/compiler-cli": "^19.1.0", "@angular/compiler-cli": "^19.1.0",
"@types/geojson": "^7946.0.16",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"@types/leaflet": "^1.9.20",
"jasmine-core": "~5.5.0", "jasmine-core": "~5.5.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
@@ -38,4 +47,4 @@
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2" "typescript": "~5.7.2"
} }
} }

View File

@@ -1,5 +1,5 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
@Component({ @Component({
@@ -8,5 +8,4 @@ import { RouterOutlet } from '@angular/router';
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss' styleUrl: './app.component.scss'
}) })
export class AppComponent { export class AppComponent { }
}

View File

@@ -1,15 +1,17 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { RegistrationComponent } from './registration/registration.component'; import { RegistrationComponent } from './components/registration/registration.component';
import { LandingComponent } from './landing/landing.component'; import { LandingComponent } from './components/landing/landing.component';
import { ApplicationListComponent } from './application-list/application-list.component'; import { ApplicationListComponent } from './components/application-list/application-list.component';
import { ApplicationComponent } from './application/application.component'; import { ApplicationComponent } from './components/application/application.component';
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
export const routes: Routes = [ export const routes: Routes = [
{ path: 'landing', component: LandingComponent }, { path: 'landing', component: LandingComponent },
{ path: 'registration', component: RegistrationComponent }, { path: 'application/new', component: RegistrationComponent },
{ path: 'application/:id/edit', component: RegistrationComponent },
{ path: 'application-list', component: ApplicationListComponent }, { path: 'application-list', component: ApplicationListComponent },
{ path: 'application/:id', component: ApplicationComponent }, { path: 'application/:id', component: ApplicationComponent },
{ path: '', redirectTo: '/landing', pathMatch: 'full' }, { path: '', redirectTo: '/landing', pathMatch: 'full' },
{ path: '**', redirectTo: '/landing' }, { path: '**', component: PageNotFoundComponent },
// component: PageNotFoundComponent
]; ];

View File

@@ -1,5 +0,0 @@
@for(application of this.applicationList; track application.id){
<div class="listItem" [routerLink]="'/application/'+ application.id">
<p>{{application.fullName}}</p>
</div>
}

View File

@@ -1,9 +0,0 @@
.listItem {
margin: 2rem;
background-color: aquamarine;
padding: 1rem;
&:hover{
cursor: pointer;
background-color: aqua;
}
}

View File

@@ -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;
});
}
}

View File

@@ -1 +0,0 @@
{{this.application}}

View File

@@ -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);
})
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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));
}
}

View 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>

View 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;
}
}

View 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;
}
}
}

View 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>

View 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;
}
}

View 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);
}
}

View 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>

View 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;
}
}

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { RouterLinkActive } from "../../../node_modules/@angular/router/router_module.d-Bx9ArA6K"; import { RouterLinkActive } from "../../../../node_modules/@angular/router/router_module.d-Bx9ArA6K";
@Component({ @Component({
selector: 'app-landing', selector: 'app-landing',

View 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>

View File

@@ -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;
}

View 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: '&copy; 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;
}
}

View 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>

View 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%;
}
}

View 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']);
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View 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>

View File

@@ -0,0 +1,7 @@
<div class="container">
<h2>404 - Page Not Found</h2>
<p class="message">
The page you're looking for doesnt exist or has been moved.
</p>
<button mat-button type="button" (click)="goHome()" class="primary-btn">Go to Home</button>
</div>

View 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);
}
}

View 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(['/']);
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View 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
);
}
}

View 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
View 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: 'Raanana', 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 },
];

View 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 };
};
}

View File

@@ -1,3 +1,5 @@
export const environment = { export const environment = {
hostUrl: 'http://localhost:3000' hostUrl: 'http://localhost:3000',
mapTilerApiKey: '9LJZ0OppHyT3LzvQW3ce'
}; };

View File

@@ -1,3 +1,5 @@
export const environment = { export const environment = {
hostUrl: 'https://iisa.novikov.click' hostUrl: 'https://iisa.novikov.click',
mapTilerApiKey: '9LJZ0OppHyT3LzvQW3ce'
}; };

View File

@@ -1,3 +1,4 @@
export const environment = { export const environment = {
hostUrl: '' hostUrl: '',
mapTilerApiKey:''
}; };

View File

@@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head> </head>
<body> <body>