Add staff application feature with API integration and frontend form implementation

This commit is contained in:
akastijn 2025-09-24 22:26:17 +02:00
parent 2a0f38aa28
commit f886609a0e
13 changed files with 992 additions and 28 deletions

View File

@ -40,6 +40,10 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-mail:3.1.5") implementation("org.springframework.boot:spring-boot-starter-mail:3.1.5")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
//Open API
implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
implementation("io.swagger.core.v3:swagger-models:2.2.37")
//AOP //AOP
implementation("org.aspectj:aspectjrt:1.9.19") implementation("org.aspectj:aspectjrt:1.9.19")
implementation("org.aspectj:aspectjweaver:1.9.19") implementation("org.aspectj:aspectjweaver:1.9.19")

View File

@ -10,8 +10,8 @@ import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification; import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper; import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.mappers.AppealDataMapper; import com.alttd.altitudeweb.mappers.AppealDataMapper;
import com.alttd.altitudeweb.model.AppealResponseDto;
import com.alttd.altitudeweb.model.DiscordAppealDto; import com.alttd.altitudeweb.model.DiscordAppealDto;
import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.model.MinecraftAppealDto; import com.alttd.altitudeweb.model.MinecraftAppealDto;
import com.alttd.altitudeweb.model.UpdateMailDto; import com.alttd.altitudeweb.model.UpdateMailDto;
import com.alttd.altitudeweb.services.limits.RateLimit; import com.alttd.altitudeweb.services.limits.RateLimit;
@ -25,7 +25,6 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -41,13 +40,13 @@ public class AppealController implements AppealsApi {
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal") @RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
@Override @Override
public ResponseEntity<AppealResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) { public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported"); throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
} }
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal") @RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
@Override @Override
public ResponseEntity<AppealResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) { public ResponseEntity<FormResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
boolean success = true; boolean success = true;
CompletableFuture<Appeal> appealCompletableFuture = new CompletableFuture<>(); CompletableFuture<Appeal> appealCompletableFuture = new CompletableFuture<>();
@ -105,7 +104,7 @@ public class AppealController implements AppealsApi {
sqlSession.getMapper(AppealMapper.class) sqlSession.getMapper(AppealMapper.class)
.markAppealAsSent(appeal.id()); .markAppealAsSent(appeal.id());
}); });
AppealResponseDto appealResponseDto = new AppealResponseDto( FormResponseDto appealResponseDto = new FormResponseDto(
appeal.id().toString(), appeal.id().toString(),
"Your appeal has been submitted. You will be notified when it has been reviewed.", "Your appeal has been submitted. You will be notified when it has been reviewed.",
true); true);
@ -113,7 +112,8 @@ public class AppealController implements AppealsApi {
} }
@Override @Override
public ResponseEntity<AppealResponseDto> updateMail(UpdateMailDto updateMailDto) { public ResponseEntity<FormResponseDto> updateMail(UpdateMailDto updateMailDto) {
//TODO move to its own endpoint
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Updating mail is not yet supported"); throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Updating mail is not yet supported");
} }

View File

@ -0,0 +1,15 @@
package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.ApplicationsApi;
import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.model.StaffApplicationDto;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
public class ApplicationController implements ApplicationsApi {
@Override
public ResponseEntity<FormResponseDto> submitStaffApplication(StaffApplicationDto staffApplicationDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Staff applications are not yet supported");
}
}

View File

@ -126,6 +126,14 @@ export const routes: Routes = [
requiredAuthorizations: ['SCOPE_user'] requiredAuthorizations: ['SCOPE_user']
} }
}, },
{
path: 'forms/staff-application',
loadComponent: () => import('./pages/forms/staff-application/staff-application.component').then(m => m.StaffApplicationComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_user']
}
},
{ {
path: 'community', path: 'community',
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent) loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)

View File

@ -17,6 +17,14 @@
</p> </p>
</a> </a>
</div> </div>
<div class="columnParagraph">
<a [routerLink]="['/forms/staff-application']">
<h2>Staff Application</h2>
<p>
Interested in becoming a moderator on our server? Apply here.
</p>
</a>
</div>
</div> </div>
</section> </section>
</section> </section>

View File

@ -0,0 +1,309 @@
<div>
<app-header [current_page]="'staff-application'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Staff Application</h1>
</div>
</app-header>
<main>
<section class="darkmodeSection staff-application-container">
<div class="form-container">
<div class="pages">
<!-- Welcome Page -->
@if (currentPageIndex === 0) {
<section class="formPage">
<img ngSrc="/public/img/logos/logo.png" alt="Logo" height="319" width="550"/>
<h1>Moderator Application</h1>
<p>Thank you for your interest in becoming a moderator on our Minecraft server.</p>
<p>Please take your time to fill out this application thoroughly.</p>
<button mat-raised-button (click)="nextPage()">
Get Started
</button>
</section>
}
<!-- Confirmation Page -->
@if (currentPageIndex === 1) {
<section class="formPage">
<div class="description">
<p>You are logged in as <strong>{{ authService.username() }}</strong>. If this is the correct account
please continue</p>
<br>
<p><strong>Notice: </strong> Submitting a staff application is <strong>not</strong> an instant process.
We will review your application carefully and get back to you if we think you're a good fit.</p>
<p style="font-style: italic;">Applications that seem to have been made with
little to no effort will be automatically rejected.</p>
</div>
<button mat-raised-button (click)="nextPage()" [disabled]="authService.username() == null">
I, {{ authService.username() }}, understand and agree
</button>
</section>
}
<form [formGroup]="form">
<!-- Basic Information Page -->
@if (currentPageIndex === 2) {
<section class="formPage">
<div class="description">
<h2>Basic Information</h2>
<!-- Email -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Email</mat-label>
<input matInput
formControlName="email"
placeholder="Email">
@if (form.controls.email.invalid && form.controls.email.touched) {
<mat-error>
@if (form.controls.email.errors?.['required']) {
Email is required
} @else if (form.controls.email.errors?.['email']) {
Please enter a valid email address
}
</mat-error>
}
</mat-form-field>
@if (emailIsValid()) {
<div class="valid-email">
<ng-container matSuffix>
<mat-icon>check</mat-icon>
<span>You have validated your email previously, and can continue to the next page!</span>
</ng-container>
</div>
}
<!-- Age -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Age</mat-label>
<input matInput
formControlName="age"
placeholder="Age">
@if (form.controls.age.invalid && form.controls.age.touched) {
<mat-error>
@if (form.controls.age.errors?.['required']) {
Age is required
} @else if (form.controls.age.errors?.['min']) {
You must be at least 13 years old
} @else if (form.controls.age.errors?.['pattern']) {
Please enter a valid number
}
</mat-error>
}
</mat-form-field>
<!-- Discord Username -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Discord Username</mat-label>
<input matInput
formControlName="discordUsername"
placeholder="Discord Username">
@if (form.controls.discordUsername.invalid && form.controls.discordUsername.touched) {
<mat-error>
Discord username is required
</mat-error>
}
</mat-form-field>
<!-- PC Requirements -->
<div class="checkbox-field">
<mat-checkbox formControlName="meetsRequirements">
I confirm that I meet the PC requirements (able to record video at 30fps 720p or higher, and able to talk in voice chat)
</mat-checkbox>
@if (form.controls.meetsRequirements.invalid && form.controls.meetsRequirements.touched) {
<mat-error class="checkbox-error">
You must meet the PC requirements to apply
</mat-error>
}
</div>
<!-- Pronouns (Optional) -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Pronouns (Optional)</mat-label>
<input matInput
formControlName="pronouns"
placeholder="Pronouns">
</mat-form-field>
</div>
<button mat-raised-button (click)="validateMailOrNextPage()"
[disabled]="form.controls.email.invalid || form.controls.age.invalid || form.controls.discordUsername.invalid || !form.controls.meetsRequirements.value">
Next
</button>
</section>
}
<!-- Experience Page -->
@if (currentPageIndex === 3) {
<section class="formPage">
<div class="description">
<h2>Experience & Availability</h2>
<!-- Join Date -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>When did you join our server? (Estimate)</mat-label>
<input matInput [matDatepicker]="picker" formControlName="joinDate">
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
@if (form.controls.joinDate.invalid && form.controls.joinDate.touched) {
<mat-error>
Join date is required
</mat-error>
}
</mat-form-field>
<!-- Weekly Playtime -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Average expected playtime in a week (hours)</mat-label>
<input matInput
formControlName="weeklyPlaytime"
placeholder="Hours per week">
@if (form.controls.weeklyPlaytime.invalid && form.controls.weeklyPlaytime.touched) {
<mat-error>
@if (form.controls.weeklyPlaytime.errors?.['required']) {
Weekly playtime is required
} @else if (form.controls.weeklyPlaytime.errors?.['min']) {
Weekly playtime must be at least 1 hour
}
</mat-error>
}
</mat-form-field>
<!-- Available Days -->
<div class="field-container">
<label class="field-label">Available days for moderating:</label>
<div class="days-container">
@for (day of availableDays; track day) {
<div class="day-chip" [class.selected]="form.controls.availableDays.value.includes(day)" (click)="toggleDay(day)">
{{ day }}
</div>
}
</div>
@if (form.controls.availableDays.invalid && form.controls.availableDays.touched) {
<mat-error>
Please select at least one day
</mat-error>
}
</div>
<!-- Available Times -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Available times (Your timezone: {{ userTimezone }})</mat-label>
<textarea matInput
formControlName="availableTimes"
placeholder="e.g., 6PM-10PM weekdays, 2PM-8PM weekends"
rows="2"></textarea>
@if (form.controls.availableTimes.invalid && form.controls.availableTimes.touched) {
<mat-error>
Available times are required
</mat-error>
}
</mat-form-field>
</div>
<button mat-raised-button (click)="nextPage()"
[disabled]="form.controls.joinDate.invalid || form.controls.weeklyPlaytime.invalid || form.controls.availableDays.invalid || form.controls.availableTimes.invalid">
Next
</button>
</section>
}
<!-- Qualifications Page -->
@if (currentPageIndex === 4) {
<section class="formPage">
<div class="description">
<h2>Qualifications & Expectations</h2>
<!-- Previous Experience -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Previous experience (here or in other relevant places)</mat-label>
<textarea matInput
formControlName="previousExperience"
placeholder="Describe your previous experience"
rows="4"></textarea>
@if (form.controls.previousExperience.invalid && form.controls.previousExperience.touched) {
<mat-error>
@if (form.controls.previousExperience.errors?.['required']) {
Previous experience is required
} @else if (form.controls.previousExperience.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<!-- Plugin Experience -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Experience with plugins that players use on our server</mat-label>
<textarea matInput
formControlName="pluginExperience"
placeholder="Describe your experience with our server plugins"
rows="4"></textarea>
@if (form.controls.pluginExperience.invalid && form.controls.pluginExperience.touched) {
<mat-error>
@if (form.controls.pluginExperience.errors?.['required']) {
Plugin experience is required
} @else if (form.controls.pluginExperience.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<!-- Moderator Expectations -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>What do you believe the expectations of a moderator are?</mat-label>
<textarea matInput
formControlName="moderatorExpectations"
placeholder="Describe what you think a moderator should do"
rows="4"></textarea>
@if (form.controls.moderatorExpectations.invalid && form.controls.moderatorExpectations.touched) {
<mat-error>
@if (form.controls.moderatorExpectations.errors?.['required']) {
Moderator expectations are required
} @else if (form.controls.moderatorExpectations.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<!-- Additional Information -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Additional Information (Optional)</mat-label>
<textarea matInput
formControlName="additionalInfo"
placeholder="Any additional information you'd like to share"
rows="4"></textarea>
</mat-form-field>
</div>
<button mat-raised-button (click)="onSubmit()"
[disabled]="form.controls.previousExperience.invalid || form.controls.pluginExperience.invalid || form.controls.moderatorExpectations.invalid">
Submit Application
</button>
</section>
}
</form>
</div>
<!-- Navigation dots -->
@if (totalPages.length > 1) {
<div class="form-navigation">
<button mat-icon-button class="nav-button" (click)="previousPage()" [disabled]="isFirstPage()">
<mat-icon>navigate_before</mat-icon>
</button>
@for (i of totalPages; track i) {
<div
class="nav-dot"
[class.active]="i === currentPageIndex"
(click)="goToPage(i)">
</div>
}
<button mat-icon-button class="nav-button" (click)="nextPage()" [disabled]="isLastPage()">
<mat-icon>navigate_next</mat-icon>
</button>
</div>
}
</div>
</section>
</main>
</div>

View File

@ -0,0 +1,164 @@
:host {
display: block;
}
.staff-application-container {
display: flex;
flex-direction: column;
min-height: 80vh;
}
main {
flex: 1;
display: flex;
flex-direction: column;
}
.form-container {
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
flex: 1;
}
.formPage {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
height: 100%;
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.navigation-buttons {
display: flex;
gap: 16px;
margin-top: 20px;
}
.form-navigation {
display: flex;
justify-content: center;
gap: 10px;
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.nav-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: background-color 0.3s ease;
margin-top: auto;
margin-bottom: auto;
&.active {
background-color: #fff;
}
}
.nav-button {
color: #1f9bde;
}
.pages {
margin-top: auto;
margin-bottom: auto;
}
.description {
max-width: 75ch;
text-align: left;
}
.valid-email {
display: flex;
align-items: center;
color: #4CAF50;
margin: 10px 0;
padding: 8px 12px;
border-radius: 4px;
background-color: rgba(76, 175, 80, 0.1);
}
.valid-email mat-icon {
color: #4CAF50;
margin-right: 10px;
}
.valid-email span {
color: #4CAF50;
font-weight: 500;
}
.checkbox-field {
margin: 16px 0;
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.checkbox-error {
color: #f44336;
font-size: 12px;
margin-top: 4px;
}
.field-container {
margin: 16px 0;
width: 100%;
}
.field-label {
font-size: 16px;
margin-bottom: 8px;
display: block;
color: rgba(255, 255, 255, 0.7);
}
.days-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.day-chip {
padding: 8px 12px;
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.3s ease;
&.selected {
background-color: #1f9bde;
color: white;
}
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
}
mat-form-field {
margin-bottom: 16px;
}

View File

@ -0,0 +1,348 @@
import {
AfterViewInit,
Component,
computed,
ElementRef,
inject,
OnDestroy,
OnInit,
Renderer2,
signal
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {ApplicationsService, EmailEntry, MailService, StaffApplication} from '@api';
import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {AuthService} from '@services/auth.service';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatSelectModule} from '@angular/material/select';
import {MatInputModule} from '@angular/material/input';
import {MatDialog} from '@angular/material/dialog';
import {VerifyMailDialogComponent} from '@pages/forms/verify-mail-dialog/verify-mail-dialog.component';
import {Router} from '@angular/router';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {MatDatepickerModule} from '@angular/material/datepicker';
import {MatNativeDateModule} from '@angular/material/core';
import {MatChipsModule} from '@angular/material/chips';
@Component({
selector: 'app-staff-application',
imports: [
HeaderComponent,
NgOptimizedImage,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
ReactiveFormsModule,
MatCheckboxModule,
MatDatepickerModule,
MatNativeDateModule,
MatChipsModule
],
templateUrl: './staff-application.component.html',
styleUrl: './staff-application.component.scss'
})
export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewInit {
private mailService = inject(MailService);
public authService = inject(AuthService);
public staffApplicationService = inject(ApplicationsService)
private resizeObserver: ResizeObserver | null = null;
private boundHandleResize: any;
protected form: FormGroup<StaffApplicationForm>;
private emails = signal<EmailEntry[]>([]);
protected verifiedEmails = computed(() => this.emails()
.filter(email => email.verified)
.map(email => email.email.toLowerCase()));
protected emailIsValid = signal<boolean>(false);
protected dialog = inject(MatDialog);
protected availableDays: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
protected selectedDays: string[] = [];
protected userTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
constructor(
private elementRef: ElementRef,
private renderer: Renderer2
) {
const staffApplication: StaffApplicationForm = {
email: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email, Validators.maxLength(320)]
}),
age: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.min(13), Validators.pattern('^[0-9]*$')]
}),
discordUsername: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(32)]
}),
meetsRequirements: new FormControl(false, {
nonNullable: true,
validators: [Validators.requiredTrue]
}),
pronouns: new FormControl('', {
nonNullable: true,
validators: [Validators.maxLength(32)]
}),
joinDate: new FormControl('', {
nonNullable: true,
validators: [Validators.required]
}),
weeklyPlaytime: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.min(1)]
}),
availableDays: new FormControl([], {
nonNullable: true,
validators: [Validators.required]
}),
availableTimes: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(1000)]
}),
previousExperience: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(4000)]
}),
pluginExperience: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(4000)]
}),
moderatorExpectations: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(4000)]
}),
additionalInfo: new FormControl('', {
nonNullable: true,
validators: [Validators.maxLength(4000)]
})
}
this.form = new FormGroup(staffApplication);
this.mailService.getUserEmails().subscribe(emails => {
this.emails.set(emails);
});
this.form.valueChanges.subscribe(() => {
if (this.verifiedEmails().includes(this.form.getRawValue().email.toLowerCase())) {
this.emailIsValid.set(true);
} else {
this.emailIsValid.set(false);
}
});
computed(() => {
if (this.verifiedEmails().length > 0) {
this.form.get('email')?.setValue(this.verifiedEmails()[0]);
this.emailIsValid.set(true);
}
});
}
ngOnInit() {
const uuid = this.authService.getUuid();
if (uuid === null) {
alert('Error retrieving token, please relog on the website and try again')
throw new Error('JWT subject is null, are you logged in?');
}
}
ngAfterViewInit() {
this.setupResizeObserver();
this.updateContainerHeight();
this.boundHandleResize = this.handleResize.bind(this);
window.addEventListener('resize', this.boundHandleResize);
setTimeout(() => this.updateContainerHeight(), 0);
}
ngOnDestroy() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.boundHandleResize) {
window.removeEventListener('resize', this.boundHandleResize);
}
}
private handleResize() {
this.updateContainerHeight();
}
private setupResizeObserver() {
this.resizeObserver = new ResizeObserver(() => {
this.updateContainerHeight();
});
const headerElement = document.querySelector('app-header');
if (headerElement) {
this.resizeObserver.observe(headerElement);
}
const footerElement = document.querySelector('footer');
if (footerElement) {
this.resizeObserver.observe(footerElement);
}
}
private updateContainerHeight() {
const headerElement = document.querySelector('app-header');
const footerElement = document.querySelector('footer');
const container = this.elementRef.nativeElement.querySelector('.staff-application-container');
if (headerElement && footerElement && container) {
const headerHeight = headerElement.getBoundingClientRect().height;
const footerHeight = footerElement.getBoundingClientRect().height;
const calculatedHeight = `calc(100vh - ${headerHeight}px - ${footerHeight}px)`;
this.renderer.setStyle(container, 'min-height', calculatedHeight);
}
}
public onSubmit() {
if (this.form === undefined) {
console.error('Form is undefined');
return
}
if (this.form.valid) {
this.sendForm()
} else {
// Mark all fields as touched to show validation errors
Object.keys(this.form.controls).forEach(field => {
const control = this.form.get(field);
control?.markAsTouched();
});
}
}
private router = inject(Router)
private sendForm() {
const staffApplication: StaffApplication = this.mapToStaffApplication(this.form.getRawValue());
this.staffApplicationService.submitStaffApplication(staffApplication).subscribe(result => {
//TODO route to mail page
// Navigate to the sent page
this.router.navigate(['/forms/sent'], {
state: {message: 'Your staff application has been submitted successfully. We will review your application and get back to you soon.'}
}).then();
})
}
public currentPageIndex: number = 0;
public totalPages: number[] = [0, 1, 2, 3, 4];
public goToPage(pageIndex: number): void {
if (pageIndex >= 0 && pageIndex < this.totalPages.length) {
this.currentPageIndex = pageIndex;
}
}
public previousPage() {
this.goToPage(this.currentPageIndex - 1);
}
public nextPage() {
this.goToPage(this.currentPageIndex + 1);
}
public isFirstPage(): boolean {
return this.currentPageIndex === 0;
}
public isLastPage(): boolean {
return this.currentPageIndex === this.totalPages.length - 1;
}
protected validateMailOrNextPage() {
if (this.emailIsValid()) {
this.nextPage();
return;
}
const dialogRef = this.dialog.open(VerifyMailDialogComponent, {
data: {email: this.form.getRawValue().email},
});
dialogRef.afterClosed().subscribe(result => {
if (result === true) {
this.emailIsValid.set(true);
this.nextPage();
}
});
}
toggleDay(day: string) {
const availableDaysControl = this.form.get('availableDays');
const currentDays = [...(availableDaysControl?.value || [])];
if (currentDays.includes(day)) {
const index = currentDays.indexOf(day);
currentDays.splice(index, 1);
} else {
currentDays.push(day);
}
availableDaysControl?.setValue(currentDays);
}
private mapToStaffApplication(formData: any): StaffApplication {
let joinDateString: string;
if (formData.joinDate instanceof Date) {
joinDateString = formData.joinDate.toISOString();
} else if (typeof formData.joinDate === 'string' && formData.joinDate.trim() !== '') {
const parsedDate = new Date(formData.joinDate);
if (isNaN(parsedDate.getTime())) {
throw new Error('Invalid date string');
}
joinDateString = parsedDate.toISOString();
} else {
throw new Error('Invalid date string');
}
return {
email: formData.email,
age: Number(formData.age),
discordUsername: formData.discordUsername,
meetsRequirements: formData.meetsRequirements,
pronouns: formData.pronouns || '',
joinDate: joinDateString,
weeklyPlaytime: Number(formData.weeklyPlaytime),
availableDays: formData.availableDays,
availableTimes: formData.availableTimes,
previousExperience: formData.previousExperience,
pluginExperience: formData.pluginExperience,
moderatorExpectations: formData.moderatorExpectations,
additionalInfo: formData.additionalInfo || ''
};
}
}
interface StaffApplicationForm {
email: FormControl<string>;
age: FormControl<string>;
discordUsername: FormControl<string>;
meetsRequirements: FormControl<boolean>;
pronouns: FormControl<string>;
joinDate: FormControl<string>;
weeklyPlaytime: FormControl<string>;
availableDays: FormControl<string[]>;
availableTimes: FormControl<string>;
previousExperience: FormControl<string>;
pluginExperience: FormControl<string>;
moderatorExpectations: FormControl<string>;
additionalInfo: FormControl<string>;
}

View File

@ -43,9 +43,9 @@ sourceSets {
dependencies { dependencies {
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("io.swagger.core.v3:swagger-annotations:2.2.20") implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
implementation("io.swagger.core.v3:swagger-models:2.2.8") implementation("io.swagger.core.v3:swagger-models:2.2.37")
implementation("io.swagger.core.v3:swagger-core:2.2.8") implementation("io.swagger.core.v3:swagger-core:2.2.37")
implementation("org.openapitools:jackson-databind-nullable:0.2.6") implementation("org.openapitools:jackson-databind-nullable:0.2.6")
implementation("org.springframework.hateoas:spring-hateoas:2.2.0") implementation("org.springframework.hateoas:spring-hateoas:2.2.0")

View File

@ -55,6 +55,8 @@ paths:
$ref: './schemas/forms/appeal/appeal.yml#/MinecraftAppeal' $ref: './schemas/forms/appeal/appeal.yml#/MinecraftAppeal'
/api/appeal/discord-appeal: /api/appeal/discord-appeal:
$ref: './schemas/forms/appeal/appeal.yml#/DiscordAppeal' $ref: './schemas/forms/appeal/appeal.yml#/DiscordAppeal'
/api/apply/staff-application:
$ref: './schemas/forms/staff_apply/staff_apply.yml#/StaffApply'
/api/login/requestNewUserLogin/{uuid}: /api/login/requestNewUserLogin/{uuid}:
$ref: './schemas/login/login.yml#/RequestNewUserLogin' $ref: './schemas/login/login.yml#/RequestNewUserLogin'
/api/login/userLogin/{code}: /api/login/userLogin/{code}:

View File

@ -23,7 +23,7 @@ UpdateMail:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/AppealResponse' $ref: '../../generic/components.yml#/components/schemas/FormResponse'
default: default:
description: Unexpected error description: Unexpected error
content: content:
@ -49,7 +49,7 @@ MinecraftAppeal:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/AppealResponse' $ref: '../../generic/components.yml#/components/schemas/FormResponse'
default: default:
description: Unexpected error description: Unexpected error
content: content:
@ -75,7 +75,7 @@ DiscordAppeal:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/AppealResponse' $ref: '../../generic/components.yml#/components/schemas/FormResponse'
default: default:
description: Unexpected error description: Unexpected error
content: content:
@ -136,22 +136,6 @@ components:
appeal: appeal:
type: string type: string
description: Appeal text explaining why the punishment should be reconsidered description: Appeal text explaining why the punishment should be reconsidered
AppealResponse:
type: object
required:
- id
- message
- verified_mail
properties:
id:
type: string
description: Unique identifier for the submitted appeal for referring to it later
message:
type: string
description: Confirmation message
verified_mail:
type: boolean
description: If this user has verified their mail already
UpdateMail: UpdateMail:
type: object type: object
required: required:

View File

@ -0,0 +1,104 @@
StaffApply:
post:
tags:
- applications
summary: Submit a Staff appeal
description: Submit an staff application
operationId: submitStaffApplication
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/StaffApplication'
responses:
'201':
description: Application created please verify email
content:
application/json:
schema:
$ref: '../../generic/components.yml#/components/schemas/FormResponse'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
components:
schemas:
StaffApplication:
type: object
description: Schema for staff application
required:
- email
- age
- discordUsername
- meetsRequirements
- pronouns
- joinDate
- weeklyPlaytime
- availableDays
- availableTimes
- previousExperience
- pluginExperience
- moderatorExpectations
- additionalInfo
properties:
email:
type: string
format: email
maxLength: 320
description: Email address of the applicant
age:
type: integer
minimum: 13
description: Age of the applicant, must be 13 or older
discordUsername:
type: string
maxLength: 32
description: Discord username of the applicant
meetsRequirements:
type: boolean
description: Confirmation that the applicant meets all requirements
pronouns:
type: string
maxLength: 32
description: Preferred pronouns of the applicant
joinDate:
type: string
maxLength: 256
format: date
description: Date when the applicant joined the service
weeklyPlaytime:
type: integer
minimum: 1
description: Average weekly playtime in hours
availableDays:
type: array
items:
type: string
maxLength: 256
description: Days of the week when the applicant is available
availableTimes:
type: string
maxLength: 1000
description: Time ranges when the applicant is available
previousExperience:
type: string
minLength: 10
maxLength: 4000
description: Description of previous relevant experience
pluginExperience:
type: string
minLength: 10
maxLength: 4000
description: Description of experience with plugins
moderatorExpectations:
type: string
minLength: 10
maxLength: 4000
description: Applicant's expectations and understanding of moderator responsibilities
additionalInfo:
type: string
maxLength: 4000
description: Any additional information the applicant wishes to provide

View File

@ -0,0 +1,18 @@
components:
schemas:
FormResponse:
type: object
required:
- id
- message
- verified_mail
properties:
id:
type: string
description: Unique identifier for the submitted form for referring to it later
message:
type: string
description: Confirmation message
verified_mail:
type: boolean
description: If this user has verified their mail already