Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bfcdb6ccc | ||
|
|
64ea68ab39 | ||
|
|
f117cb2477 | ||
|
|
d84d0c7fef | ||
|
|
00bf7caec2 | ||
|
|
41dab473b0 | ||
|
|
b71ea7da8b | ||
|
|
a55806e5dd | ||
|
|
7e25cc583c | ||
|
|
894dfac0c6 | ||
|
|
300d33da7d | ||
|
|
6f6801c728 | ||
|
|
f8157e997a | ||
|
|
74e8697fef | ||
|
|
8ad87da47e | ||
|
|
29a28e712e | ||
|
|
6ad3b5221a | ||
|
|
1b697fcaa3 | ||
|
|
ed9d41cdc6 | ||
|
|
5eaeb3552a | ||
|
|
8e9e267fb0 | ||
|
|
6d8f73201f | ||
|
|
91e5a2a9a0 | ||
|
|
0005b3b6d4 | ||
|
|
b3999b3389 |
@@ -9,6 +9,7 @@ import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
@@ -17,16 +18,22 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
@@ -47,13 +54,14 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/login/userLogin/**").permitAll()
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.oauth2ResourceServer(
|
||||
oauth2 -> oauth2
|
||||
.jwt(Customizer.withDefaults())
|
||||
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
|
||||
.authenticationEntryPoint(securityAuthFailureHandler)
|
||||
.accessDeniedHandler(securityAuthFailureHandler)
|
||||
)
|
||||
@@ -84,4 +92,46 @@ public class SecurityConfig {
|
||||
KeyPair keyPair = keyPairService.getJwtSigningKeyPair();
|
||||
return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Bean
|
||||
public JwtAuthenticationConverter jwtAuthenticationConverter() {
|
||||
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
||||
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
|
||||
Map<String, Object> claims = jwt.getClaims();
|
||||
|
||||
Object authoritiesClaim = claims.get("authorities");
|
||||
if (authoritiesClaim instanceof List<?> authorities) {
|
||||
Collection<GrantedAuthority> authorityList = authorities.stream()
|
||||
.map(Object::toString)
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.collect(Collectors.toList());
|
||||
log.debug("Authorities found in authorities: {}", authorityList);
|
||||
return authorityList;
|
||||
}
|
||||
|
||||
Object scopeClaim = claims.get("scope");
|
||||
if (scopeClaim instanceof String scopeString) {
|
||||
Collection<GrantedAuthority> authorityList = Arrays.stream(scopeString.split(" "))
|
||||
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
|
||||
.collect(Collectors.toList());
|
||||
log.debug("Authorities found in authorities scope string: {}", authorityList);
|
||||
return authorityList;
|
||||
}
|
||||
|
||||
if (scopeClaim instanceof List<?> scopeList) {
|
||||
Collection<GrantedAuthority> authorityList = scopeList.stream()
|
||||
.map(Object::toString)
|
||||
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
|
||||
.collect(Collectors.toList());
|
||||
log.debug("Authorities found in authorities scope list: {}", authorityList);
|
||||
return authorityList;
|
||||
}
|
||||
|
||||
log.debug("No granted authorities found");
|
||||
return Collections.emptyList();
|
||||
});
|
||||
return converter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public class CorsConfig implements WebMvcConfigurer {
|
||||
log.info("Registering CORS mappings for {}", String.join(", ", allowedOrigins));
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
|
||||
+10
-1
@@ -1,24 +1,33 @@
|
||||
package com.alttd.altitudeweb.controllers.data_from_auth;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class AuthenticatedUuid {
|
||||
@Value("${UNSECURED:#{false}}")
|
||||
private boolean unsecured;
|
||||
|
||||
/**
|
||||
* Extracts and validates the authenticated user's UUID from the JWT token.
|
||||
*
|
||||
* @return The UUID of the authenticated user
|
||||
* @throws ResponseStatusException with 401 status if authentication is invalid
|
||||
*/
|
||||
public static UUID getAuthenticatedUserUuid() {
|
||||
public UUID getAuthenticatedUserUuid() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
|
||||
if (unsecured) {
|
||||
return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f");
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
|
||||
|
||||
+41
-3
@@ -3,6 +3,7 @@ package com.alttd.altitudeweb.controllers.forms;
|
||||
import com.alttd.altitudeweb.api.ApplicationsApi;
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
|
||||
@@ -16,9 +17,11 @@ import com.alttd.altitudeweb.services.mail.StaffApplicationMail;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -30,13 +33,30 @@ import java.util.concurrent.TimeUnit;
|
||||
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
|
||||
public class ApplicationController implements ApplicationsApi {
|
||||
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
private final StaffApplicationDataMapper staffApplicationDataMapper;
|
||||
private final StaffApplicationMail staffApplicationMail;
|
||||
private final StaffApplicationDiscord staffApplicationDiscord;
|
||||
|
||||
private final Instant open = Instant.parse("2025-10-18T00:00:00Z");
|
||||
private final Instant close = Instant.parse("2025-10-26T00:00:00Z");
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Boolean> getStaffApplicationsIsOpen() {
|
||||
return ResponseEntity.ok(isOpen());
|
||||
}
|
||||
|
||||
private boolean isOpen() {
|
||||
Instant now = Instant.now();
|
||||
return !now.isBefore(open) && !now.isAfter(close);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<FormResponseDto> submitStaffApplication(StaffApplicationDto staffApplicationDto) {
|
||||
UUID userUuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||
if (!isOpen()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
UUID userUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
|
||||
String email = staffApplicationDto.getEmail() == null ? null : staffApplicationDto.getEmail().toLowerCase();
|
||||
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, email);
|
||||
@@ -49,8 +69,10 @@ public class ApplicationController implements ApplicationsApi {
|
||||
StaffApplication application = staffApplicationDataMapper.map(userUuid, staffApplicationDto);
|
||||
saveApplication(application);
|
||||
|
||||
String username = getUsername(userUuid);
|
||||
|
||||
try {
|
||||
if (!staffApplicationMail.sendApplicationEmail(application)) {
|
||||
if (!staffApplicationMail.sendApplicationEmail(username, application)) {
|
||||
log.warn("Failed to send staff application email for {}", application.id());
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
@@ -60,7 +82,7 @@ public class ApplicationController implements ApplicationsApi {
|
||||
}
|
||||
|
||||
try {
|
||||
staffApplicationDiscord.sendApplicationToDiscord(application);
|
||||
staffApplicationDiscord.sendApplicationToDiscord(username, application);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send staff application {} to Discord", application.id(), e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
@@ -114,4 +136,20 @@ public class ApplicationController implements ApplicationsApi {
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private String getUsername(UUID uuid) {
|
||||
CompletableFuture<String> usernameFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.LUCK_PERMS)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Loading username for uuid {}", uuid);
|
||||
try {
|
||||
String username = sqlSession.getMapper(UUIDUsernameMapper.class).getUsernameFromUUID(uuid.toString());
|
||||
usernameFuture.complete(username);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load username for uuid {}", uuid, e);
|
||||
usernameFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return usernameFuture.join();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +28,12 @@ import java.util.concurrent.TimeUnit;
|
||||
public class MailController implements MailApi {
|
||||
|
||||
private final MailVerificationService mailVerificationService;
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
|
||||
@Override
|
||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit")
|
||||
public ResponseEntity<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) {
|
||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream()
|
||||
.filter(EmailVerification::verified)
|
||||
.map(EmailVerification::email)
|
||||
@@ -52,7 +53,7 @@ public class MailController implements MailApi {
|
||||
@Override
|
||||
@RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify")
|
||||
public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
|
||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode());
|
||||
if (optionalEmailVerification.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code");
|
||||
@@ -68,7 +69,7 @@ public class MailController implements MailApi {
|
||||
@Override
|
||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend")
|
||||
public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) {
|
||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
EmailVerification updated = mailVerificationService.resend(uuid, submitEmailDto.getEmail());
|
||||
if (updated == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
|
||||
@@ -83,7 +84,7 @@ public class MailController implements MailApi {
|
||||
@Override
|
||||
@RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete")
|
||||
public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) {
|
||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail());
|
||||
if (!deleted) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
|
||||
@@ -93,7 +94,7 @@ public class MailController implements MailApi {
|
||||
|
||||
@Override
|
||||
public ResponseEntity<List<EmailEntryDto>> getUserEmails() {
|
||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
List<EmailVerification> emails = mailVerificationService.listAll(uuid);
|
||||
List<EmailEntryDto> result = emails.stream()
|
||||
.map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified()))
|
||||
|
||||
+111
-1
@@ -1,6 +1,7 @@
|
||||
package com.alttd.altitudeweb.controllers.history;
|
||||
|
||||
import com.alttd.altitudeweb.api.HistoryApi;
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import com.alttd.altitudeweb.model.HistoryCountDto;
|
||||
import com.alttd.altitudeweb.model.PunishmentHistoryListDto;
|
||||
@@ -8,9 +9,11 @@ import com.alttd.altitudeweb.setup.Connection;
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.litebans.*;
|
||||
import com.alttd.altitudeweb.model.PunishmentHistoryDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -20,8 +23,11 @@ import java.util.concurrent.TimeUnit;
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RateLimit(limit = 30, timeValue = 10, timeUnit = TimeUnit.SECONDS)
|
||||
@RequiredArgsConstructor
|
||||
public class HistoryApiController implements HistoryApi {
|
||||
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
|
||||
@Override
|
||||
public ResponseEntity<PunishmentHistoryListDto> getHistoryForAll(String userType, String type, Integer page) {
|
||||
return getHistoryForUsers(userType, type, "", page);
|
||||
@@ -229,4 +235,108 @@ public class HistoryApiController implements HistoryApi {
|
||||
.type(type)
|
||||
.id(historyRecord.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<PunishmentHistoryDto> updatePunishmentReason(String type, Integer id, String reason) {
|
||||
log.debug("Updating reason for {} id {} to {}", type, id, reason);
|
||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||
try {
|
||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
|
||||
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||
if (before == null) {
|
||||
result.complete(null);
|
||||
return;
|
||||
}
|
||||
int changed = editMapper.setReason(historyTypeEnum, id, reason);
|
||||
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
|
||||
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
|
||||
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to update reason for {} id {}", type, id, e);
|
||||
result.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
PunishmentHistoryDto body = result.join();
|
||||
if (body == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<PunishmentHistoryDto> updatePunishmentUntil(String type, Integer id, Long until) {
|
||||
log.debug("Updating until for {} id {} to {}", type, id, until);
|
||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||
try {
|
||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
|
||||
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||
if (before == null) {
|
||||
result.complete(null);
|
||||
return;
|
||||
}
|
||||
int changed = editMapper.setUntil(historyTypeEnum, id, until);
|
||||
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
|
||||
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
|
||||
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid until edit for type {} id {}: {}", type, id, e.getMessage());
|
||||
result.complete(null);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to update until for {} id {}", type, id, e);
|
||||
result.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
PunishmentHistoryDto body = result.join();
|
||||
if (body == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> removePunishment(String type, Integer id) {
|
||||
log.debug("Removing punishment for {} id {}", type, id);
|
||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||
try {
|
||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
|
||||
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||
if (before == null) {
|
||||
result.complete(false);
|
||||
return;
|
||||
}
|
||||
UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
|
||||
int changed = editMapper.remove(historyTypeEnum, id);
|
||||
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",
|
||||
actorName, actorUuid, historyTypeEnum, id,
|
||||
before.getRemovedByName() == null ? 1 : 0, before.getRemovedByName(), before.getRemovedByReason(),
|
||||
changed);
|
||||
result.complete(changed > 0);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to remove punishment for {} id {}", type, id, e);
|
||||
result.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
Boolean ok = result.join();
|
||||
if (ok == null || !ok) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
+12
-11
@@ -2,26 +2,21 @@ package com.alttd.altitudeweb.controllers.login;
|
||||
|
||||
import com.alttd.altitudeweb.api.LoginApi;
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
||||
import com.alttd.altitudeweb.database.litebans.RecentNamesMapper;
|
||||
import com.alttd.altitudeweb.database.litebans.UUIDHistoryMapper;
|
||||
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.litebans.RecentNamesMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.PrivilegedUser;
|
||||
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
||||
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
||||
import com.alttd.altitudeweb.model.UsernameDto;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
@@ -37,10 +32,12 @@ import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@EnableScheduling
|
||||
@RestController
|
||||
public class LoginController implements LoginApi {
|
||||
|
||||
private final JwtEncoder jwtEncoder;
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
|
||||
@Value("${login.secret:#{null}}")
|
||||
private String loginSecret;
|
||||
@@ -54,7 +51,7 @@ public class LoginController implements LoginApi {
|
||||
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||
|
||||
@Scheduled(fixedRate = 300000) // 5 minutes in milliseconds
|
||||
private void clearExpiredCacheEntries() {
|
||||
protected void clearExpiredCacheEntries() {
|
||||
Instant now = Instant.now();
|
||||
int initialCacheSize = cache.size();
|
||||
cache.entrySet().removeIf(entry -> entry.getValue().expiry().isBefore(now));
|
||||
@@ -71,6 +68,8 @@ public class LoginController implements LoginApi {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
log.info("{} is requesting a login code", uuid);
|
||||
|
||||
if (authorization == null || !authorization.startsWith("SECRET ")) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
@@ -86,10 +85,12 @@ public class LoginController implements LoginApi {
|
||||
.findFirst();
|
||||
|
||||
if (key.isPresent()) {
|
||||
log.info("{} got cached key: {}", uuid, key.get());
|
||||
return ResponseEntity.ok(key.get());
|
||||
}
|
||||
|
||||
String loginCode = generateLoginCode(uuidFromString);
|
||||
log.info("{} received login code: {}", uuid, loginCode);
|
||||
return ResponseEntity.ok(loginCode);
|
||||
}
|
||||
|
||||
@@ -99,7 +100,7 @@ public class LoginController implements LoginApi {
|
||||
|
||||
try {
|
||||
// Get authenticated UUID using the utility method
|
||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
log.debug("Loaded username for logged in user {}", uuid);
|
||||
|
||||
// Create response with username
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.alttd.altitudeweb.controllers.site;
|
||||
|
||||
import com.alttd.altitudeweb.api.SiteApi;
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.model.VoteDataDto;
|
||||
import com.alttd.altitudeweb.model.VoteStatsDto;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import com.alttd.altitudeweb.services.site.VoteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RateLimit(limit = 2, timeValue = 10, timeUnit = TimeUnit.SECONDS)
|
||||
public class SiteController implements SiteApi {
|
||||
|
||||
private final VoteService voteService;
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
|
||||
@Override
|
||||
public ResponseEntity<VoteDataDto> getVoteStats() {
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid);
|
||||
if (optionalVoteDataDto.isEmpty()) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
VoteDataDto voteDataDto = optionalVoteDataDto.get();
|
||||
return ResponseEntity.ok(voteDataDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.alttd.altitudeweb.mappers;
|
||||
|
||||
import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow;
|
||||
import com.alttd.altitudeweb.model.VoteDataDto;
|
||||
import com.alttd.altitudeweb.model.VoteInfoDto;
|
||||
import com.alttd.altitudeweb.model.VoteStatsDto;
|
||||
import com.alttd.altitudeweb.model.VoteStreakDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class VotingStatsRowToVoteDataDto {
|
||||
|
||||
public VoteDataDto map(VotingStatsRow votingStatsRow) {
|
||||
VoteDataDto voteDataDto = new VoteDataDto();
|
||||
voteDataDto.setVoteStats(getVoteStats(votingStatsRow));
|
||||
voteDataDto.setVoteStreak(getVoteStreak(votingStatsRow));
|
||||
voteDataDto.setBestVoteStreak(getBestVoteStreak(votingStatsRow));
|
||||
voteDataDto.setAllVoteInfo(getVoteInfo(votingStatsRow.lastVotes()));
|
||||
return voteDataDto;
|
||||
}
|
||||
|
||||
private VoteStreakDto getVoteStreak(VotingStatsRow votingStatsRow) {
|
||||
VoteStreakDto voteStreakDto = new VoteStreakDto();
|
||||
voteStreakDto.setDailyStreak(votingStatsRow.dailyStreak());
|
||||
voteStreakDto.setWeeklyStreak(votingStatsRow.weeklyStreak());
|
||||
voteStreakDto.setMonthlyStreak(votingStatsRow.monthlyStreak());
|
||||
return voteStreakDto;
|
||||
}
|
||||
|
||||
private VoteStreakDto getBestVoteStreak(VotingStatsRow votingStatsRow) {
|
||||
VoteStreakDto voteStreakDto = new VoteStreakDto();
|
||||
voteStreakDto.setDailyStreak(votingStatsRow.bestDailyStreak());
|
||||
voteStreakDto.setWeeklyStreak(votingStatsRow.bestWeeklyStreak());
|
||||
voteStreakDto.setMonthlyStreak(votingStatsRow.bestMonthlyStreak());
|
||||
return voteStreakDto;
|
||||
}
|
||||
|
||||
|
||||
private VoteStatsDto getVoteStats(VotingStatsRow votingStatsRow) {
|
||||
VoteStatsDto voteStatsDto = new VoteStatsDto();
|
||||
voteStatsDto.setDaily(votingStatsRow.totalVotesToday());
|
||||
voteStatsDto.setWeekly(votingStatsRow.totalVotesThisWeek());
|
||||
voteStatsDto.setMonthly(votingStatsRow.totalVotesThisMonth());
|
||||
voteStatsDto.setTotal(votingStatsRow.totalVotesAllTime());
|
||||
return voteStatsDto;
|
||||
}
|
||||
|
||||
private List<VoteInfoDto> getVoteInfo(String lastVotes) {
|
||||
return Arrays.stream(lastVotes.split("%line%"))
|
||||
.map(voteInfo -> {
|
||||
String[] siteAndTimestamp = voteInfo.split("//");
|
||||
VoteInfoDto voteInfoDto = new VoteInfoDto();
|
||||
voteInfoDto.setSiteName(siteAndTimestamp[0]);
|
||||
voteInfoDto.setLastVoteTimestamp(Long.parseLong(siteAndTimestamp[1]));
|
||||
return voteInfoDto;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -22,7 +22,7 @@ public class StaffApplicationDiscord {
|
||||
|
||||
private static final String OUTPUT_TYPE = "STAFF_APPLICATION";
|
||||
|
||||
public void sendApplicationToDiscord(StaffApplication application) {
|
||||
public void sendApplicationToDiscord(String username, StaffApplication application) {
|
||||
// Fetch channels for staff applications
|
||||
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
|
||||
@@ -46,7 +46,7 @@ public class StaffApplicationDiscord {
|
||||
List<DiscordSender.EmbedField> fields = new ArrayList<>();
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"Applicant",
|
||||
"UUID: " + safe(String.valueOf(application.uuid())) + "\n" +
|
||||
"Username: " + safe(username) + "\n" +
|
||||
"Discord: " + safe(application.discordUsername()) + "\n" +
|
||||
"Email: " + safe(application.email()) + "\n" +
|
||||
"Age: " + safe(String.valueOf(application.age())) + "\n" +
|
||||
|
||||
@@ -32,9 +32,9 @@ public class StaffApplicationMail {
|
||||
* Sends an email with the staff application details to the staff applications team mailbox.
|
||||
* Returns true if the email was sent successfully.
|
||||
*/
|
||||
public boolean sendApplicationEmail(StaffApplication application) {
|
||||
public boolean sendApplicationEmail(String username, StaffApplication application) {
|
||||
try {
|
||||
doSend(application);
|
||||
doSend(username, application);
|
||||
log.info("Staff application email sent successfully for application ID: {}", application.id());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
@@ -43,7 +43,7 @@ public class StaffApplicationMail {
|
||||
}
|
||||
}
|
||||
|
||||
private void doSend(StaffApplication application) throws MessagingException {
|
||||
private void doSend(String username, StaffApplication application) throws MessagingException {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true);
|
||||
|
||||
@@ -59,6 +59,7 @@ public class StaffApplicationMail {
|
||||
Context context = new Context();
|
||||
context.setVariable("application", application);
|
||||
context.setVariable("createdAt", createdAt);
|
||||
context.setVariable("username", username);
|
||||
|
||||
String content = templateEngine.process("staff-application-email", context);
|
||||
helper.setText(content, true);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.alttd.altitudeweb.services.site;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper;
|
||||
import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow;
|
||||
import com.alttd.altitudeweb.mappers.VotingStatsRowToVoteDataDto;
|
||||
import com.alttd.altitudeweb.model.VoteDataDto;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VoteService {
|
||||
|
||||
private final VotingStatsRowToVoteDataDto votingStatsRowToVoteDataDto;
|
||||
|
||||
public Optional<VoteDataDto> getVoteStats(UUID uuid) {
|
||||
CompletableFuture<Optional<VoteDataDto>> voteDataDtoFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.VOTING_PLUGIN).runQuery(sqlSession -> {
|
||||
try {
|
||||
VotingPluginUsersMapper votingPluginUsersMapper = sqlSession.getMapper(VotingPluginUsersMapper.class);
|
||||
Optional<VotingStatsRow> optionalVotingStatsRow = votingPluginUsersMapper.getStatsByUuid(uuid);
|
||||
if (optionalVotingStatsRow.isEmpty()) {
|
||||
log.debug("No voting stats found for {}", uuid);
|
||||
voteDataDtoFuture.complete(Optional.empty());
|
||||
return;
|
||||
}
|
||||
|
||||
VotingStatsRow votingStatsRow = optionalVotingStatsRow.get();
|
||||
|
||||
VoteDataDto voteDataDto = votingStatsRowToVoteDataDto.map(votingStatsRow);
|
||||
voteDataDtoFuture.complete(Optional.of(voteDataDto));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get vote data for {}", uuid, e);
|
||||
voteDataDtoFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return voteDataDtoFuture.join();//TODO handle exception
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080}
|
||||
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080,http://localhost:80}
|
||||
my-server.address=${SERVER_ADDRESS:http://localhost:8080}
|
||||
logging.level.com.alttd.altitudeweb=DEBUG
|
||||
logging.level.org.springframework.security=DEBUG
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
<div>
|
||||
<h2>Applicant</h2>
|
||||
<ul>
|
||||
<li><strong>UUID:</strong> <span th:text="${application.uuid}">uuid</span></li>
|
||||
<li><strong>Username:</strong> <span th:text="${username}">uuid</span></li>
|
||||
<li><strong>Email:</strong> <span th:text="${application.email}">email</span></li>
|
||||
<li><strong>Discord:</strong> <span th:text="${application.discordUsername}">discord</span></li>
|
||||
<li><strong>Age:</strong> <span th:text="${application.age}">age</span></li>
|
||||
|
||||
@@ -7,7 +7,8 @@ public enum Databases {
|
||||
DEFAULT("web_db"),
|
||||
LUCK_PERMS("luckperms"),
|
||||
LITE_BANS("litebans"),
|
||||
DISCORD("discordLink");
|
||||
DISCORD("discordLink"),
|
||||
VOTING_PLUGIN("votingplugin");
|
||||
|
||||
private final String internalName;
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.alttd.altitudeweb.database.litebans;
|
||||
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public interface EditHistoryMapper {
|
||||
|
||||
@Update("""
|
||||
UPDATE ${table_name}
|
||||
SET reason = #{reason}
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
int updateReason(@Param("table_name") String tableName,
|
||||
@Param("id") int id,
|
||||
@Param("reason") String reason);
|
||||
|
||||
@Update("""
|
||||
UPDATE ${table_name}
|
||||
SET until = #{until}
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
int updateUntil(@Param("table_name") String tableName,
|
||||
@Param("id") int id,
|
||||
@Param("until") Long until);
|
||||
|
||||
@Delete("""
|
||||
DELETE FROM ${table_name}
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
int deletePunishment(@Param("table_name") String tableName,
|
||||
@Param("id") int id);
|
||||
|
||||
default int setReason(@NotNull HistoryType type, int id, String reason) {
|
||||
return switch (type) {
|
||||
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
|
||||
case BAN -> updateReason("litebans_bans", id, reason);
|
||||
case MUTE -> updateReason("litebans_mutes", id, reason);
|
||||
case KICK -> updateReason("litebans_kicks", id, reason);
|
||||
case WARN -> updateReason("litebans_warnings", id, reason);
|
||||
};
|
||||
}
|
||||
|
||||
default int setUntil(@NotNull HistoryType type, int id, Long until) {
|
||||
return switch (type) {
|
||||
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
|
||||
case BAN -> updateUntil("litebans_bans", id, until);
|
||||
case MUTE -> updateUntil("litebans_mutes", id, until);
|
||||
case KICK -> throw new IllegalArgumentException("KICK has no until");
|
||||
case WARN -> throw new IllegalArgumentException("WARN has no until");
|
||||
};
|
||||
}
|
||||
|
||||
default int remove(@NotNull HistoryType type, int id) {
|
||||
return switch (type) {
|
||||
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
|
||||
case BAN -> deletePunishment("litebans_bans", id);
|
||||
case MUTE -> deletePunishment("litebans_mutes", id);
|
||||
case KICK -> deletePunishment("litebans_kicks", id);
|
||||
case WARN -> deletePunishment("litebans_warnings", id);
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -12,7 +12,8 @@ public interface RecentNamesMapper {
|
||||
SELECT DISTINCT name AS username
|
||||
FROM litebans_history
|
||||
WHERE uuid = #{uuid}
|
||||
ORDER BY date DESC;
|
||||
ORDER BY date DESC
|
||||
LIMIT 1;
|
||||
""")
|
||||
String getUsername(@Param("uuid") String uuid);
|
||||
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.alttd.altitudeweb.database.luckperms;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
public interface UUIDUsernameMapper {
|
||||
@Select("""
|
||||
SELECT username
|
||||
FROM luckperms_players
|
||||
WHERE uuid = #{uuid}
|
||||
""")
|
||||
String getUsernameFromUUID(@Param("uuid") String uuid);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.alttd.altitudeweb.database.votingplugin;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface VotingPluginUsersMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT
|
||||
LastVotes as lastVotes,
|
||||
BestDayVoteStreak as bestDailyStreak,
|
||||
BestWeekVoteStreak as bestWeeklyStreak,
|
||||
BestMonthVoteStreak as bestMonthlyStreak,
|
||||
DayVoteStreak as dailyStreak,
|
||||
WeekVoteStreak as weeklyStreak,
|
||||
MonthVoteStreak as monthlyStreak,
|
||||
DailyTotal as totalVotesToday,
|
||||
WeeklyTotal as totalVotesThisWeek,
|
||||
MonthTotal as totalVotesThisMonth,
|
||||
AllTimeTotal as totalVotesAllTime
|
||||
FROM votingplugin.votingplugin_users
|
||||
WHERE uuid = #{uuid}
|
||||
""")
|
||||
Optional<VotingStatsRow> getStatsByUuid(@Param("uuid") UUID uuid);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.alttd.altitudeweb.database.votingplugin;
|
||||
|
||||
public record VotingStatsRow(
|
||||
String lastVotes,
|
||||
Integer bestDailyStreak,
|
||||
Integer bestWeeklyStreak,
|
||||
Integer bestMonthlyStreak,
|
||||
Integer dailyStreak,
|
||||
Integer weeklyStreak,
|
||||
Integer monthlyStreak,
|
||||
Integer totalVotesToday,
|
||||
Integer totalVotesThisWeek,
|
||||
Integer totalVotesThisMonth,
|
||||
Integer totalVotesAllTime
|
||||
) { }
|
||||
@@ -38,6 +38,7 @@ public class Connection {
|
||||
InitializeLiteBans.init();
|
||||
InitializeLuckPerms.init();
|
||||
InitializeDiscord.init();
|
||||
InitializeVotingPlugin.init();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
@@ -163,10 +164,24 @@ public class Connection {
|
||||
private static @NotNull Configuration getConfiguration(DatabaseSettings settings) {
|
||||
PooledDataSource dataSource = new PooledDataSource();
|
||||
dataSource.setDriver("com.mysql.cj.jdbc.Driver");
|
||||
dataSource.setUrl(String.format("jdbc:mysql://%s:%d/%s", settings.host(),
|
||||
settings.port(), settings.name()));
|
||||
|
||||
String url = String.format(
|
||||
"jdbc:mysql://%s:%d/%s?useSSL=true&tcpKeepAlive=true&socketTimeout=60000&connectTimeout=10000&autoReconnect=false&useUnicode=true&characterEncoding=utf8",
|
||||
settings.host(),
|
||||
settings.port(),
|
||||
settings.name()
|
||||
);
|
||||
dataSource.setUrl(url);
|
||||
dataSource.setUsername(settings.username());
|
||||
dataSource.setPassword(settings.password());
|
||||
|
||||
dataSource.setPoolMaximumActiveConnections(10);
|
||||
dataSource.setPoolMaximumIdleConnections(5);
|
||||
dataSource.setPoolTimeToWait(20000);
|
||||
dataSource.setPoolPingEnabled(true);
|
||||
dataSource.setPoolPingQuery("SELECT 1");
|
||||
dataSource.setPoolPingConnectionsNotUsedFor(300000); // 5 min
|
||||
|
||||
Environment environment = new Environment("production", new JdbcTransactionFactory(), dataSource);
|
||||
return new Configuration(environment);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public class InitializeLiteBans {
|
||||
configuration.addMapper(UUIDHistoryMapper.class);
|
||||
configuration.addMapper(HistoryCountMapper.class);
|
||||
configuration.addMapper(IdHistoryMapper.class);
|
||||
configuration.addMapper(EditHistoryMapper.class);
|
||||
}).join()
|
||||
.runQuery(sqlSession -> {
|
||||
createAllPunishmentsView(sqlSession);
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.alttd.altitudeweb.setup;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
|
||||
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@@ -11,6 +12,7 @@ public class InitializeLuckPerms {
|
||||
log.info("Initializing LuckPerms");
|
||||
Connection.getConnection(Databases.LUCK_PERMS, (configuration) -> {
|
||||
configuration.addMapper(TeamMemberMapper.class);
|
||||
configuration.addMapper(UUIDUsernameMapper.class);
|
||||
}).join();
|
||||
log.debug("Initialized LuckPerms");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.alttd.altitudeweb.setup;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class InitializeVotingPlugin {
|
||||
|
||||
protected static void init() {
|
||||
log.info("Initializing VotingPlugin");
|
||||
Connection.getConnection(Databases.VOTING_PLUGIN, (configuration) -> {
|
||||
configuration.addMapper(VotingPluginUsersMapper.class);
|
||||
}).join();
|
||||
log.debug("Initialized VotingPlugin");
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,11 @@ export const routes: Routes = [
|
||||
path: 'forms',
|
||||
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
|
||||
},
|
||||
{
|
||||
path: 'appeal',
|
||||
redirectTo: 'forms/appeal',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'forms/appeal',
|
||||
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
||||
@@ -126,6 +131,11 @@ export const routes: Routes = [
|
||||
requiredAuthorizations: ['SCOPE_user']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'apply',
|
||||
redirectTo: 'forms/staff-application',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'forms/staff-application',
|
||||
loadComponent: () => import('./pages/forms/staff-application/staff-application.component').then(m => m.StaffApplicationComponent),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
|
||||
import {Observable} from 'rxjs';
|
||||
import {map, Observable} from 'rxjs';
|
||||
import {AuthService} from '@services/auth.service';
|
||||
import {MatDialog} from '@angular/material/dialog';
|
||||
import {LoginDialogComponent} from '@shared-components/login/login.component';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -10,7 +12,8 @@ export class AuthGuard implements CanActivate {
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private router: Router
|
||||
private router: Router,
|
||||
private dialog: MatDialog
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -18,8 +21,19 @@ export class AuthGuard implements CanActivate {
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
||||
if (!this.authService.checkAuthStatus()) {
|
||||
return this.router.createUrlTree(['/']);
|
||||
if (!this.authService.isAuthenticated$()) {
|
||||
this.router.createUrlTree(['/']);
|
||||
const dialogRef = this.dialog.open(LoginDialogComponent, {
|
||||
width: '400px',
|
||||
})
|
||||
return dialogRef.afterClosed().pipe(
|
||||
map(result => {
|
||||
if (result) {
|
||||
return this.router.createUrlTree([state.url]);
|
||||
}
|
||||
return this.router.createUrlTree(['/']);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const requiredAuthorizations = route.data['requiredAuthorizations'] as string[];
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<h1>Dynamic Server Maps</h1>
|
||||
<h2>Which server would you like to see?</h2>
|
||||
<div>
|
||||
<a href="https://gmap.alttd.com">
|
||||
<a href="https://bmap.alttd.com">
|
||||
<div style="background-color: #F547B2;" class="button-outer map-button">
|
||||
<span class="button-inner">Grove</span>
|
||||
<span class="button-inner">Bayou</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="https://cmap.alttd.com">
|
||||
|
||||
@@ -6,313 +6,329 @@
|
||||
</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) {
|
||||
@if (staffApplicationsIsOpen()) {
|
||||
<section class="darkmodeSection staff-application-container">
|
||||
<div class="form-container">
|
||||
<div class="pages">
|
||||
<!-- Welcome Page -->
|
||||
@if (currentPageIndex === 0) {
|
||||
<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.</span>
|
||||
</ng-container>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="invalid-email">
|
||||
<ng-container matSuffix>
|
||||
<mat-icon>close</mat-icon>
|
||||
<span>You have not used this email address before. Before going to the next page you will be asked to verify it.</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
|
||||
<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>
|
||||
}
|
||||
|
||||
<!-- Experience Page -->
|
||||
@if (currentPageIndex === 3) {
|
||||
<!-- Confirmation Page -->
|
||||
@if (currentPageIndex === 1) {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<h2>Experience & Availability</h2>
|
||||
<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>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
<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.</span>
|
||||
</ng-container>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="invalid-email">
|
||||
<ng-container matSuffix>
|
||||
<mat-icon>close</mat-icon>
|
||||
<span>You have not used this email address before. Before going to the next page you will be asked to verify it.</span>
|
||||
</ng-container>
|
||||
</div>
|
||||
}
|
||||
</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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
@if (form.controls.availableDays.invalid && form.controls.availableDays.touched) {
|
||||
<mat-error>
|
||||
Please select at least one day
|
||||
</mat-error>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
}
|
||||
<!-- Experience Page -->
|
||||
@if (currentPageIndex === 3) {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<h2>Experience & Availability</h2>
|
||||
|
||||
<!-- Qualifications Page -->
|
||||
@if (currentPageIndex === 4) {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<h2>Qualifications & Expectations</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>
|
||||
|
||||
<!-- 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)
|
||||
<!-- 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
|
||||
} @else if (form.controls.weeklyPlaytime.errors?.['pattern']) {
|
||||
Please enter a whole number of hours (e.g., 20)
|
||||
}
|
||||
</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>
|
||||
}
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@if (form.controls.availableDays.invalid && form.controls.availableDays.touched) {
|
||||
<mat-error>
|
||||
Please select at least one day
|
||||
</mat-error>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
<!-- Qualifications Page -->
|
||||
@if (currentPageIndex === 4) {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<h2>Qualifications & Expectations</h2>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
||||
@for (i of totalPages; track i) {
|
||||
<div
|
||||
class="nav-dot"
|
||||
[class.active]="i === currentPageIndex"
|
||||
(click)="goToPage(i)">
|
||||
</div>
|
||||
}
|
||||
<!-- 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>
|
||||
|
||||
<button mat-icon-button class="nav-button" (click)="nextPage()" [disabled]="isLastPage()">
|
||||
<mat-icon>navigate_next</mat-icon>
|
||||
</button>
|
||||
<!-- 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]="isFormInvalid() || isSubmitting()">
|
||||
Submit Application
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 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>
|
||||
} @else {
|
||||
<section class="darkmodeSection staff-application-container">
|
||||
<div>
|
||||
<h1>Staff applications closed</h1>
|
||||
<p class="center">Staff applications are closed at this time. Please keep an eye on our announcement channel
|
||||
in Discord to
|
||||
see
|
||||
when it opens!</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import {MatDatepickerModule} from '@angular/material/datepicker';
|
||||
import {MatNativeDateModule} from '@angular/material/core';
|
||||
import {MatChipsModule} from '@angular/material/chips';
|
||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
|
||||
@Component({
|
||||
selector: 'app-staff-application',
|
||||
@@ -51,10 +52,12 @@ import {MatChipsModule} from '@angular/material/chips';
|
||||
export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
|
||||
private mailService = inject(MailService);
|
||||
private matSnackBar = inject(MatSnackBar);
|
||||
public authService = inject(AuthService);
|
||||
public staffApplicationService = inject(ApplicationsService)
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private boundHandleResize: any;
|
||||
protected isSubmitting = signal<boolean>(false);
|
||||
|
||||
protected form: FormGroup<StaffApplicationForm>;
|
||||
private emails = signal<EmailEntry[]>([]);
|
||||
@@ -66,6 +69,7 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
protected availableDays: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
protected selectedDays: string[] = [];
|
||||
protected userTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
protected staffApplicationsIsOpen = signal<boolean>(false)
|
||||
|
||||
|
||||
constructor(
|
||||
@@ -99,7 +103,7 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}),
|
||||
weeklyPlaytime: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.min(1)]
|
||||
validators: [Validators.required, Validators.min(1), Validators.pattern('^[0-9]*$')]
|
||||
}),
|
||||
availableDays: new FormControl([], {
|
||||
nonNullable: true,
|
||||
@@ -149,6 +153,9 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.staffApplicationService.getStaffApplicationsIsOpen().subscribe(isOpen => {
|
||||
this.staffApplicationsIsOpen.set(isOpen)
|
||||
})
|
||||
const uuid = this.authService.getUuid();
|
||||
if (uuid === null) {
|
||||
alert('Error retrieving token, please relog on the website and try again')
|
||||
@@ -213,8 +220,11 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
|
||||
public onSubmit() {
|
||||
this.isSubmitting.set(true);
|
||||
if (this.form === undefined) {
|
||||
console.error('Form is undefined');
|
||||
this.matSnackBar.open('An error occurred, please try again later')
|
||||
this.isSubmitting.set(false);
|
||||
return
|
||||
}
|
||||
if (this.form.valid) {
|
||||
@@ -225,22 +235,42 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
const control = this.form.get(field);
|
||||
control?.markAsTouched();
|
||||
});
|
||||
this.matSnackBar.open('Please fill out all required fields')
|
||||
this.isSubmitting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
isFormInvalid() {
|
||||
return !this.form.valid
|
||||
}
|
||||
|
||||
private router = inject(Router)
|
||||
|
||||
private sendForm() {
|
||||
const staffApplication: StaffApplication = this.mapToStaffApplication(this.form.getRawValue());
|
||||
|
||||
this.staffApplicationService.submitStaffApplication(staffApplication).subscribe(result => {
|
||||
if (!result.verified_mail) {
|
||||
throw new Error('Submitted a form with an e-mail that was not verified.');
|
||||
this.staffApplicationService.submitStaffApplication(staffApplication).subscribe({
|
||||
next: result => {
|
||||
if (!result.verified_mail) {
|
||||
this.isSubmitting.set(false);
|
||||
this.matSnackBar.open('Your email has not been verified. Please verify your email before submitting.', 'Close', {
|
||||
duration: 5000
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.router.navigate(['/forms/sent'], {
|
||||
state: {message: result.message}
|
||||
}).then();
|
||||
},
|
||||
error: (error) => {
|
||||
this.isSubmitting.set(false);
|
||||
console.error('Error submitting application:', error);
|
||||
this.matSnackBar.open('An error occurred while submitting your application. Please try again later.', 'Close', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
this.router.navigate(['/forms/sent'], {
|
||||
state: {message: result.message}
|
||||
}).then();
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
public currentPageIndex: number = 0;
|
||||
@@ -322,7 +352,7 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
joinDate: joinDateString,
|
||||
weeklyPlaytime: Number(formData.weeklyPlaytime),
|
||||
availableDays: formData.availableDays,
|
||||
availableTimes: `Timezone: ${this.userTimezone}\nAvailable Times:${formData.availableTimes}`,
|
||||
availableTimes: `Timezone: ${this.userTimezone}\nAvailable Times: ${formData.availableTimes}`,
|
||||
previousExperience: formData.previousExperience,
|
||||
pluginExperience: formData.pluginExperience,
|
||||
moderatorExpectations: formData.moderatorExpectations,
|
||||
|
||||
@@ -142,7 +142,9 @@
|
||||
<a [id]="getCurrentPageId(['particles'])"
|
||||
class="nav_link fake_link" [ngClass]="active">Special</a>
|
||||
<ul class="dropdown">
|
||||
<li class="nav_li"><a class="nav_link2" [routerLink]="['/particles']">Particles</a></li>
|
||||
@if (hasAccess([PermissionClaim.HEAD_MOD])) {
|
||||
<li class="nav_li"><a class="nav_link2" [routerLink]="['/particles']">Particles</a></li>
|
||||
}
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {AuthService} from '@services/auth.service';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {LoginDialogComponent} from '@shared-components/login/login.component';
|
||||
import {MatDialog} from '@angular/material/dialog';
|
||||
import {PermissionClaim} from '@api';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -90,4 +91,6 @@ export class HeaderComponent implements OnDestroy {
|
||||
protected logout() {
|
||||
this.authService.logout()
|
||||
}
|
||||
|
||||
protected readonly PermissionClaim = PermissionClaim;
|
||||
}
|
||||
|
||||
@@ -1,157 +1,160 @@
|
||||
<ng-container>
|
||||
<app-header [current_page]="'home'" height="100vh"
|
||||
[background_image]="'/public/img/backgrounds/120spawn-min.png'">
|
||||
[background_image]="'/public/img/backgrounds/120spawn-min.png'">
|
||||
<div class="title" header-content>
|
||||
<h1 style="display: none;">Altitude</h1>
|
||||
<img id="header-img" ngSrc="/public/img/logos/logo.png" alt="The Altitude Minecraft Server" height="319"
|
||||
width="550">
|
||||
<h2 style="font-size: 2.5em;" id="homeh2">Altitude now on {{ ALTITUDE_VERSION }}!</h2>
|
||||
<a id="scroll-button" (click)="scrollToSection()">
|
||||
<span></span>
|
||||
<p style="display: none;">Scroll Down</p>
|
||||
</a>
|
||||
</div>
|
||||
</app-header>
|
||||
<main>
|
||||
<section id="scrollingpoint" style="background: #202020; text-align: center; padding: 80px 0;">
|
||||
<!-- TODO load player count from old api or backend?-->
|
||||
width="550">
|
||||
<h2 style="font-size: 2.5em;" id="homeh2">Altitude now on {{ ALTITUDE_VERSION }}!</h2>
|
||||
<a id="scroll-button" (click)="scrollToSection()">
|
||||
<span></span>
|
||||
<p style="display: none;">Scroll Down</p>
|
||||
</a>
|
||||
</div>
|
||||
</app-header>
|
||||
<main>
|
||||
<section id="scrollingpoint" style="background: #202020; text-align: center; padding: 80px 0;">
|
||||
@if (this.playerCount() === null) {
|
||||
<h2 style="color: white;"><span class="player-count">Loading...</span></h2>
|
||||
<h2 style="color: white;">Server IP: play.alttd.com</h2>
|
||||
<div style="padding-top: 35px;">
|
||||
<app-copy-ip></app-copy-ip>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
<div class="container">
|
||||
<div class="paragraph">
|
||||
<h2>Adventure Begins</h2>
|
||||
<p>You awake in a strange town, where are you? There are residents running about trading with each other and
|
||||
stories of distant realms with more towns. It's time to write your story. Welcome to Altitude, the laid-back
|
||||
} @else {
|
||||
<h2 style="color: white;"><span class="player-count">Current players online: {{ playerCount() }}</span></h2>
|
||||
}
|
||||
<h2 style="color: white;">Server IP: play.alttd.com</h2>
|
||||
<div style="padding-top: 35px;">
|
||||
<app-copy-ip></app-copy-ip>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
<div class="container">
|
||||
<div class="paragraph">
|
||||
<h2>Adventure Begins</h2>
|
||||
<p>You awake in a strange town, where are you? There are residents running about trading with each other and
|
||||
stories of distant realms with more towns. It's time to write your story. Welcome to Altitude, the laid-back
|
||||
community-oriented server that hosts your home for Minecraft.</p>
|
||||
</div>
|
||||
<img ngSrc="/public/img/items/bookquill.png" style="width: 150px; align-self: center; margin: 0 auto;"
|
||||
alt="Alternative Altitude Server Logo"
|
||||
height="150" width="150">
|
||||
</div>
|
||||
</section>
|
||||
<!--
|
||||
<section id="section1">
|
||||
<div class="container" id="video">
|
||||
<h2 style="display: none;">YouTube Trailer</h2>
|
||||
<div style="border-radius:5px;overflow:hidden;position:relative;width:100%">
|
||||
<img style="width: 100%" src="https://img.youtube.com/vi/Nzbj9Dbv5Wk/maxresdefault.jpg" alt="Altitude YouTube Trailer">
|
||||
<div id="youtube">
|
||||
<div class="play" onclick="playVideo()"></div>
|
||||
</div>
|
||||
<img ngSrc="/public/img/items/bookquill.png" style="width: 150px; align-self: center; margin: 0 auto;"
|
||||
alt="Alternative Altitude Server Logo"
|
||||
height="150" width="150">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
-->
|
||||
<section class="darkmodeSection">
|
||||
<div class="container" style="padding: 10px 0 80px 0">
|
||||
<iframe id="discord-widget" src="https://discordapp.com/widget?id=141644560005595136&theme=dark"></iframe>
|
||||
<div class="paragraph" id="discord-p">
|
||||
<h2>Meet the Community</h2>
|
||||
<p>Altitude is home to players young and old from all around the globe, and here, everyone is family. Altitude
|
||||
is your place to get together with friends and relax - and maybe enjoy some survival too. Altitude is
|
||||
intended for older players, but all are welcome!</p>
|
||||
<p>Don't have Discord? Keep up with news and announcements at the <a href="alttd.com/blog">blog</a>.</p>
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<a target="_blank" rel="noopener" href="https://discordapp.com/invite/TGqpzCJ">
|
||||
<div style="margin-top: 30px;" class="button-outer">
|
||||
<span class="button-inner">Join Our Discord</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<!--
|
||||
<section id="section1">
|
||||
<div class="container" id="video">
|
||||
<h2 style="display: none;">YouTube Trailer</h2>
|
||||
<div style="border-radius:5px;overflow:hidden;position:relative;width:100%">
|
||||
<img style="width: 100%" src="https://img.youtube.com/vi/Nzbj9Dbv5Wk/maxresdefault.jpg" alt="Altitude YouTube Trailer">
|
||||
<div id="youtube">
|
||||
<div class="play" onclick="playVideo()"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="section2">
|
||||
<div class="customContainer">
|
||||
<h2 style="color: white; padding-bottom: 35px; font-size: 2.8em;">Survival Shaped by You</h2>
|
||||
<p style="color: white; padding-bottom: 35px; font-size: 1.1em; margin: auto;">Altitude is built by the
|
||||
community, for the community. We've added features requested by our members and several custom plugins to
|
||||
create our "perfect" survival experience.</p>
|
||||
<div class="survivalShapedContainerPlugins">
|
||||
<div class="pluginColumn">
|
||||
<h2>McMMO & MyPet</h2>
|
||||
<p>Two of the most requested plugins on Altitude, level up yourself and your pet with these MMO-based
|
||||
plugins!</p>
|
||||
<a [routerLink]="['/mypet']">
|
||||
<div class="button-outer">
|
||||
<span class="button-inner">MyPet Info</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
-->
|
||||
<section class="darkmodeSection">
|
||||
<div class="container" style="padding: 10px 0 80px 0">
|
||||
<iframe id="discord-widget" src="https://discordapp.com/widget?id=141644560005595136&theme=dark"></iframe>
|
||||
<div class="paragraph" id="discord-p">
|
||||
<h2>Meet the Community</h2>
|
||||
<p>Altitude is home to players young and old from all around the globe, and here, everyone is family. Altitude
|
||||
is your place to get together with friends and relax - and maybe enjoy some survival too. Altitude is
|
||||
intended for older players, but all are welcome!</p>
|
||||
<p>Don't have Discord? Keep up with news and announcements at the <a href="alttd.com/blog">blog</a>.</p>
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<a target="_blank" rel="noopener" href="https://discordapp.com/invite/TGqpzCJ">
|
||||
<div style="margin-top: 30px;" class="button-outer">
|
||||
<span class="button-inner">Join Our Discord</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="pluginColumn">
|
||||
<h2>Dynamic map</h2>
|
||||
<p>See the world and the players around it in real-time! The map shows the entire survival world with claims
|
||||
and warps.</p>
|
||||
<a [routerLink]="['/map']">
|
||||
<div class="button-outer">
|
||||
<span class="button-inner">Visit Map</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section id="section2">
|
||||
<div class="customContainer">
|
||||
<h2 style="color: white; padding-bottom: 35px; font-size: 2.8em;">Survival Shaped by You</h2>
|
||||
<p style="color: white; padding-bottom: 35px; font-size: 1.1em; margin: auto;">Altitude is built by the
|
||||
community, for the community. We've added features requested by our members and several custom plugins to
|
||||
create our "perfect" survival experience.</p>
|
||||
<div class="survivalShapedContainerPlugins">
|
||||
<div class="pluginColumn">
|
||||
<h2>McMMO & MyPet</h2>
|
||||
<p>Two of the most requested plugins on Altitude, level up yourself and your pet with these MMO-based
|
||||
plugins!</p>
|
||||
<a [routerLink]="['/mypet']">
|
||||
<div class="button-outer">
|
||||
<span class="button-inner">MyPet Info</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="pluginColumn">
|
||||
<h2>Player Shops</h2>
|
||||
<p>Our economy is built on player shops, encouraging player interaction and putting the control in your
|
||||
hands.</p>
|
||||
<a [routerLink]="['/economy']">
|
||||
<div class="button-outer">
|
||||
<span class="button-inner">Shop Guide</span>
|
||||
</div>
|
||||
<div class="pluginColumn">
|
||||
<h2>Dynamic map</h2>
|
||||
<p>See the world and the players around it in real-time! The map shows the entire survival world with claims
|
||||
and warps.</p>
|
||||
<a [routerLink]="['/map']">
|
||||
<div class="button-outer">
|
||||
<span class="button-inner">Visit Map</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="pluginColumn">
|
||||
<h2>Player Shops</h2>
|
||||
<p>Our economy is built on player shops, encouraging player interaction and putting the control in your
|
||||
hands.</p>
|
||||
<a [routerLink]="['/economy']">
|
||||
<div class="button-outer">
|
||||
<span class="button-inner">Shop Guide</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="remove-mobile darkmodeSection">
|
||||
<div class="customContainer">
|
||||
<h2 style="padding-bottom: 35px; font-size: 2.8em;">Community Builds</h2>
|
||||
<p style="padding-bottom: 35px; font-size: 1.1em; margin: auto;">Thank you to our brilliant players for sharing
|
||||
their impressive builds. Take a look at some of them here!<br>
|
||||
If you know of any builds that should be featured, please let us know!</p>
|
||||
<div class="sliderWrapper">
|
||||
<div class="sliderContent">
|
||||
<div class="indexSlider">
|
||||
<div id="go-left" (click)="previousSlide()" class="circleBehind goLeft"></div>
|
||||
<div id="go-right" (click)="nextSlide()" class="circleBehind goRight"></div>
|
||||
<div class="display">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="remove-mobile darkmodeSection">
|
||||
<div class="customContainer">
|
||||
<h2 style="padding-bottom: 35px; font-size: 2.8em;">Community Builds</h2>
|
||||
<p style="padding-bottom: 35px; font-size: 1.1em; margin: auto;">Thank you to our brilliant players for sharing
|
||||
their impressive builds. Take a look at some of them here!<br>
|
||||
If you know of any builds that should be featured, please let us know!</p>
|
||||
<div class="sliderWrapper">
|
||||
<div class="sliderContent">
|
||||
<div class="indexSlider">
|
||||
<div id="go-left" (click)="previousSlide()" class="circleBehind goLeft"></div>
|
||||
<div id="go-right" (click)="nextSlide()" class="circleBehind goRight"></div>
|
||||
<div class="display">
|
||||
<span class="slide"
|
||||
[style.background-image]="'url(' + slide + ')'"
|
||||
[style.opacity]="carouselOpacity"
|
||||
[style.display]="'block'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dots">
|
||||
@for (slide of getSlideIndices(); track slide) {
|
||||
<span class="dot" (click)="setSlide(slide)" [ngClass]="getDotClass(slide)"></span>
|
||||
}
|
||||
</div>
|
||||
[style.background-image]="'url(' + slide + ')'"
|
||||
[style.opacity]="carouselOpacity"
|
||||
[style.display]="'block'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section style="background: #202020;">
|
||||
<div class="customContainer">
|
||||
<h2 id="quote" style="color: white; font-family: 'minecraft-text',sans-serif; line-height: 1.3em;">"Great
|
||||
community, great people, great server all round . . . If it can bring back my love for minecraft, it could
|
||||
work wonders for you."</h2>
|
||||
<p style="color: white; margin-top:30px;">- /u/seanhanley1993</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
<div class="customContainer">
|
||||
<img ngSrc="/public/img/logos/log.png" alt="Alternative Altitude Server Logo" height="129"
|
||||
width="120">
|
||||
<h2 style="margin: 20px 0; font-size: 1.8em; padding-bottom: 0 !important;">play.alttd.com</h2>
|
||||
<app-copy-ip></app-copy-ip>
|
||||
<div class="dots">
|
||||
@for (slide of getSlideIndices(); track slide) {
|
||||
<span class="dot" (click)="setSlide(slide)" [ngClass]="getDotClass(slide)"></span>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<a (click)="this.scrollService.scrollToTop()" class="scroll-up-button, active">
|
||||
<span></span>
|
||||
<p style="display: none;">Scroll Down</p>
|
||||
</a>
|
||||
</main>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section style="background: #202020;">
|
||||
<div class="customContainer">
|
||||
<h2 id="quote" style="color: white; font-family: 'minecraft-text',sans-serif; line-height: 1.3em;">"Great
|
||||
community, great people, great server all round . . . If it can bring back my love for minecraft, it could
|
||||
work wonders for you."</h2>
|
||||
<p style="color: white; margin-top:30px;">- /u/seanhanley1993</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
<div class="customContainer">
|
||||
<img ngSrc="/public/img/logos/log.png" alt="Alternative Altitude Server Logo" height="129"
|
||||
width="120">
|
||||
<h2 style="margin: 20px 0; font-size: 1.8em; padding-bottom: 0 !important;">play.alttd.com</h2>
|
||||
<app-copy-ip></app-copy-ip>
|
||||
</div>
|
||||
</section>
|
||||
<a (click)="this.scrollService.scrollToTop()" class="scroll-up-button, active">
|
||||
<span></span>
|
||||
<p style="display: none;">Scroll Down</p>
|
||||
</a>
|
||||
</main>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
|
||||
import {Title} from '@angular/platform-browser';
|
||||
import {ALTITUDE_VERSION} from '@custom-types/constant';
|
||||
import {ScrollService} from '@services/scroll.service';
|
||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||
import {CommonModule, NgOptimizedImage} from '@angular/common';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
import {CopyIpComponent} from '@shared-components/copy-ip/copy-ip.component';
|
||||
import {RouterLink} from '@angular/router';
|
||||
import {JwtHelperService} from '@auth0/angular-jwt';
|
||||
import {interval, map} from 'rxjs';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {startWith} from 'rxjs/operators';
|
||||
|
||||
interface MinecraftServerStatus {
|
||||
online?: boolean;
|
||||
players?: {
|
||||
online?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -24,9 +34,16 @@ import {JwtHelperService} from '@auth0/angular-jwt';
|
||||
templateUrl: './home.component.html',
|
||||
styleUrl: './home.component.scss'
|
||||
})
|
||||
export class HomeComponent implements OnInit {
|
||||
constructor(private titleService: Title, public scrollService: ScrollService) {
|
||||
}
|
||||
export class HomeComponent implements OnInit, OnDestroy {
|
||||
private httpClient = inject(HttpClient);
|
||||
private titleService = inject(Title);
|
||||
public scrollService = inject(ScrollService);
|
||||
public playerCount = signal<null | number>(null);
|
||||
private playerCountUpdateInterval = interval(60000).pipe(
|
||||
startWith(() => 0),
|
||||
).subscribe(() => {
|
||||
this.updatePlayerCount();
|
||||
});
|
||||
|
||||
private slides: string[] = ["/public/img/backgrounds/caruselimage2.png",
|
||||
"/public/img/backgrounds/caruselimage4.png",
|
||||
@@ -44,6 +61,10 @@ export class HomeComponent implements OnInit {
|
||||
this.randomizeSlides()
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.playerCountUpdateInterval.unsubscribe();
|
||||
}
|
||||
|
||||
private randomizeSlides(): void {
|
||||
const array = [...this.slides];
|
||||
|
||||
@@ -108,5 +129,15 @@ export class HomeComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private updatePlayerCount() {
|
||||
this.httpClient.get<MinecraftServerStatus>('https://api.mcsrvstat.us/2/play.alttd.com').pipe(
|
||||
map(response => {
|
||||
if (response.online && response.players && response.players.online) {
|
||||
this.playerCount.set(response.players.online);
|
||||
}
|
||||
})
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
protected readonly ALTITUDE_VERSION = ALTITUDE_VERSION;
|
||||
}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<h2 mat-dialog-title>Edit {{ data.punishment.type }} #{{ data.punishment.id }}</h2>
|
||||
<div mat-dialog-content>
|
||||
<div class="dialog-content">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Reason</mat-label>
|
||||
<input matInput type="text" [(ngModel)]="reason" placeholder="Enter reason"/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox [(ngModel)]="isPermanent"><span style="color: black">Permanent</span></mat-checkbox>
|
||||
|
||||
@if (!isPermanent) {
|
||||
<div class="datetime-container">
|
||||
<mat-form-field appearance="fill" class="date-field">
|
||||
<mat-label>Expiry Date</mat-label>
|
||||
<input matInput [matDatepicker]="dateTimePicker" [(ngModel)]="expiryDate" [disabled]="isPermanent">
|
||||
<mat-hint>MM/DD/YYYY</mat-hint>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="dateTimePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dateTimePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="fill" class="time-field">
|
||||
<mat-label>Expiry Time</mat-label>
|
||||
<input matInput type="time" [(ngModel)]="expiryTime" [disabled]="isPermanent">
|
||||
<mat-hint>HH:MM (24-hour)</mat-hint>
|
||||
</mat-form-field>
|
||||
<p>Time in timezone: {{ getTimezone() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (errorMessage()) {
|
||||
<div class="error">{{ errorMessage() }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div mat-dialog-actions align="end">
|
||||
<button mat-button color="warn" (click)="onRemove()" [disabled]="isBusy()">Remove</button>
|
||||
<button mat-raised-button color="primary" (click)="onUpdate()" [disabled]="isBusy()">Update</button>
|
||||
<button mat-button (click)="onCancel()" [disabled]="isBusy()">Cancel</button>
|
||||
</div>
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
.dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.datetime-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
|
||||
.date-field {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.time-field {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #beb8b8;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
margin-top: 16px;
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import {Component, Inject, inject, signal} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||
import {MatInput, MatLabel} from '@angular/material/input';
|
||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
MatDialogTitle
|
||||
} from '@angular/material/dialog';
|
||||
import {HistoryService, PunishmentHistory} from '@api';
|
||||
import {firstValueFrom} from 'rxjs';
|
||||
import {MatDatepickerModule} from '@angular/material/datepicker';
|
||||
|
||||
interface EditPunishmentData {
|
||||
punishment: PunishmentHistory;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-punishment-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInput,
|
||||
MatLabel,
|
||||
MatCheckboxModule,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDatepickerModule,
|
||||
],
|
||||
templateUrl: './edit-punishment-dialog.component.html',
|
||||
styleUrl: './edit-punishment-dialog.component.scss'
|
||||
})
|
||||
export class EditPunishmentDialogComponent {
|
||||
// Form model
|
||||
protected reason: string = '';
|
||||
protected isPermanent: boolean = false;
|
||||
protected expiresAt: Date;
|
||||
protected expiryDate: Date | null = null;
|
||||
protected expiryTime: string = '';
|
||||
|
||||
// UI state
|
||||
protected isBusy = signal<boolean>(false);
|
||||
protected errorMessage = signal<string | null>(null);
|
||||
|
||||
private historyApi = inject(HistoryService);
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<EditPunishmentDialogComponent, PunishmentHistory | { removed: true } | null>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: EditPunishmentData
|
||||
) {
|
||||
const punishment = data.punishment;
|
||||
this.reason = punishment.reason ?? '';
|
||||
this.isPermanent = punishment.expiryTime <= 0;
|
||||
this.expiresAt = new Date(punishment.expiryTime);
|
||||
|
||||
if (this.expiresAt && !isNaN(this.expiresAt.getTime())) {
|
||||
this.expiryDate = new Date(this.expiresAt);
|
||||
|
||||
const hours = this.expiresAt.getHours().toString().padStart(2, '0');
|
||||
const minutes = this.expiresAt.getMinutes().toString().padStart(2, '0');
|
||||
this.expiryTime = `${hours}:${minutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
if (this.isBusy()) {
|
||||
return;
|
||||
}
|
||||
this.dialogRef.close(null);
|
||||
}
|
||||
|
||||
private computeUntilMs(): number {
|
||||
if (this.isPermanent) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!this.expiryDate) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const combinedDateTime = new Date(this.expiryDate);
|
||||
|
||||
if (this.expiryTime) {
|
||||
const [hours, minutes] = this.expiryTime.split(':').map(Number);
|
||||
combinedDateTime.setHours(hours, minutes, 0, 0);
|
||||
}
|
||||
|
||||
this.expiresAt = combinedDateTime;
|
||||
|
||||
const ms = combinedDateTime.getTime();
|
||||
return isNaN(ms) ? -1 : ms;
|
||||
}
|
||||
|
||||
onUpdate(): void {
|
||||
const punishment = this.data.punishment;
|
||||
if (!window.confirm('Are you sure you want to update this punishment?')) {
|
||||
return;
|
||||
}
|
||||
this.isBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
const updates: Array<Promise<PunishmentHistory>> = [] as any;
|
||||
|
||||
if ((this.reason ?? '') !== (punishment.reason ?? '')) {
|
||||
console.log('Changing reason to ', this.reason, ' from ', punishment.reason, '')
|
||||
updates.push(firstValueFrom(this.historyApi.updatePunishmentReason(punishment.type, punishment.id, this.reason)));
|
||||
}
|
||||
|
||||
const newUntil = this.computeUntilMs();
|
||||
if (newUntil !== punishment.expiryTime) {
|
||||
console.log('Changing until to ', newUntil, ' from ', punishment.expiryTime, '')
|
||||
updates.push(firstValueFrom(this.historyApi.updatePunishmentUntil(punishment.type, punishment.id, newUntil)));
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
this.isBusy.set(false);
|
||||
this.dialogRef.close(null);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all(updates)
|
||||
.then(results => {
|
||||
const updated = results[results.length - 1];
|
||||
this.isBusy.set(false);
|
||||
this.dialogRef.close(updated);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
this.errorMessage.set('Failed to update punishment');
|
||||
this.isBusy.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
onRemove(): void {
|
||||
const punishment = this.data.punishment;
|
||||
if (!window.confirm('Are you sure you want to remove this punishment?')) {
|
||||
return;
|
||||
}
|
||||
this.isBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
this.historyApi.removePunishment(punishment.type as any, punishment.id).subscribe({
|
||||
next: () => {
|
||||
this.isBusy.set(false);
|
||||
this.dialogRef.close({removed: true});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(err);
|
||||
this.errorMessage.set('Failed to remove punishment');
|
||||
this.isBusy.set(false);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getTimezone() {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Unknown';
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export class HistoryFormatService {
|
||||
|
||||
public getAvatarUrl(entry: string, size: string = '25'): string {
|
||||
let uuid = entry.replace('-', '');
|
||||
if (uuid === 'C') {
|
||||
if (uuid === 'C' || uuid === 'Console' || uuid === '[Console]') {
|
||||
uuid = "f78a4d8dd51b4b3998a3230f2de0c670"
|
||||
}
|
||||
return `https://crafatar.com/avatars/${uuid}?size=${size}&overlay`;
|
||||
|
||||
@@ -6,14 +6,17 @@
|
||||
<table [cellSpacing]="0">
|
||||
<div class="historyTableHead">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="historyType">Type</th>
|
||||
<th class="historyPlayer">Player</th>
|
||||
<th class="historyPlayer">Banned By</th>
|
||||
<th class="historyReason">Reason</th>
|
||||
<th class="historyDate">Date</th>
|
||||
<th class="historyDate">Expires</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="historyType">Type</th>
|
||||
<th class="historyPlayer">Player</th>
|
||||
<th class="historyPlayer">Banned By</th>
|
||||
<th class="historyReason">Reason</th>
|
||||
<th class="historyDate">Date</th>
|
||||
<th class="historyDate">Expires</th>
|
||||
@if (canEdit()) {
|
||||
<th class="historyActions"></th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
</div>
|
||||
<div>
|
||||
@@ -26,30 +29,36 @@
|
||||
<td class="historyPlayer" (click)="setSearch(entry.username, 'player')">
|
||||
<div class="playerContainer">
|
||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.uuid)" width="25" height="25"
|
||||
alt="{{entry.username}}'s Minecraft skin">
|
||||
<span class="username">{{ entry.username }}</span>
|
||||
</div>
|
||||
alt="{{entry.username}}'s Minecraft skin">
|
||||
<span class="username">{{ entry.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
|
||||
<div class="playerContainer">
|
||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25"
|
||||
height="25"
|
||||
alt="{{entry.punishedBy}}'s Minecraft skin">
|
||||
<span>{{ entry.punishedBy }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="historyReason" (click)="showDetailedPunishment(entry)">
|
||||
{{ entry.reason | removeTrailingPeriod }}
|
||||
</td>
|
||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||
{{ this.historyFormat.getPunishmentTime(entry) }}
|
||||
</td>
|
||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||
{{ this.historyFormat.getExpiredTime(entry) }}
|
||||
</td>
|
||||
@if (canEdit()) {
|
||||
<td class="historyActions" (click)="openEdit(entry)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</td>
|
||||
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
|
||||
<div class="playerContainer">
|
||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25" height="25"
|
||||
alt="{{entry.punishedBy}}'s Minecraft skin">
|
||||
<span>{{ entry.punishedBy }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="historyReason" (click)="showDetailedPunishment(entry)">
|
||||
{{ entry.reason | removeTrailingPeriod }}
|
||||
</td>
|
||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||
{{ this.historyFormat.getPunishmentTime(entry) }}
|
||||
</td>
|
||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||
{{ this.historyFormat.getExpiredTime(entry) }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</div>
|
||||
</table>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</div>
|
||||
</table>
|
||||
}
|
||||
|
||||
|
||||
@@ -111,3 +111,8 @@ img {
|
||||
.historyDate {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.historyActions {
|
||||
width: 100px;
|
||||
padding: 0 10px 0 10px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Component, EventEmitter, Input, OnChanges, OnInit, Output} from '@angular/core';
|
||||
import {Component, EventEmitter, inject, Input, OnChanges, OnInit, Output} from '@angular/core';
|
||||
import {HistoryService, PunishmentHistory} from '@api';
|
||||
import {catchError, map, Observable, shareReplay} from 'rxjs';
|
||||
import {NgOptimizedImage} from '@angular/common';
|
||||
@@ -8,12 +8,17 @@ import {HttpErrorResponse} from '@angular/common/http';
|
||||
import {HistoryFormatService} from '../history-format.service';
|
||||
import {SearchParams} from '../search-terms';
|
||||
import {Router} from '@angular/router';
|
||||
import {AuthService} from '@services/auth.service';
|
||||
import {MatDialog} from '@angular/material/dialog';
|
||||
import {EditPunishmentDialogComponent} from '../edit-punishment-dialog/edit-punishment-dialog.component';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-history',
|
||||
imports: [
|
||||
NgOptimizedImage,
|
||||
RemoveTrailingPeriodPipe
|
||||
RemoveTrailingPeriodPipe,
|
||||
MatIconModule,
|
||||
],
|
||||
templateUrl: './history.component.html',
|
||||
styleUrl: './history.component.scss',
|
||||
@@ -35,8 +40,11 @@ export class HistoryComponent implements OnInit, OnChanges {
|
||||
|
||||
public history: PunishmentHistory[] = []
|
||||
|
||||
constructor(private historyApi: HistoryService, public historyFormat: HistoryFormatService, private router: Router) {
|
||||
}
|
||||
private historyApi: HistoryService = inject(HistoryService)
|
||||
public historyFormat: HistoryFormatService = inject(HistoryFormatService)
|
||||
private router: Router = inject(Router)
|
||||
private authService: AuthService = inject(AuthService)
|
||||
private dialog: MatDialog = inject(MatDialog)
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.reloadHistory();
|
||||
@@ -101,4 +109,21 @@ export class HistoryComponent implements OnInit, OnChanges {
|
||||
public showDetailedPunishment(entry: PunishmentHistory) {
|
||||
this.router.navigate([`bans/${entry.type}/${entry.id}`]).then();
|
||||
}
|
||||
|
||||
public canEdit(): boolean {
|
||||
return this.authService.hasAccess(['SCOPE_head_mod']);
|
||||
}
|
||||
|
||||
public openEdit(punishment: PunishmentHistory) {
|
||||
if (!this.canEdit()) return;
|
||||
const ref = this.dialog.open(EditPunishmentDialogComponent, {
|
||||
data: {punishment},
|
||||
width: '500px',
|
||||
});
|
||||
ref.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.reloadHistory();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,78 +41,27 @@
|
||||
</section>
|
||||
</section>
|
||||
<section class="voteSection">
|
||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
||||
<div class="vote">
|
||||
<h2>MinecraftServers</h2>
|
||||
<div>
|
||||
<a onclick="clickVote('vote1');" oncontextmenu="clickVote('vote1');" target="_blank" rel="noopener"
|
||||
href="https://minecraftservers.org/vote/284208">
|
||||
<div class="button-outer">
|
||||
<span id="vote1" class="button-inner">Vote!</span>
|
||||
<div class="container voteContainer">
|
||||
@for (voteSite of Object.keys(voteSites); track voteSite) {
|
||||
<div class="vote">
|
||||
<h2>{{ voteSite }}</h2>
|
||||
<div>
|
||||
<a (click)="clickVote(voteSite)" (contextmenu)="clickVote(voteSite); $event.preventDefault()"
|
||||
target="_blank" rel="noopener"
|
||||
[href]="voteSites[voteSite]">
|
||||
<div class=button-outer [class.not-available-button-outer]="!canVote(voteSite)"
|
||||
[class.available-button-outer]="canVote(voteSite)">
|
||||
<span class="button-inner">{{ getVoteText(voteSite) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@if (voteStats) {
|
||||
<div class="voteStats">
|
||||
<p>Last voted: {{ getLastVoted(voteSite) | TimeAgo: true }}</p>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vote">
|
||||
<h2>TopMinecraftServers</h2>
|
||||
<div>
|
||||
<a (click)="clickVote('vote2')" oncontextmenu="clickVote('vote2'); return false;" target="_blank"
|
||||
rel="noopener"
|
||||
href="https://topminecraftservers.org/vote/4906">
|
||||
<div class="button-outer">
|
||||
<span id="vote2" class="button-inner">Vote!</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vote">
|
||||
<h2>MCSL</h2>
|
||||
<div>
|
||||
<a (click)="clickVote('vote3')" oncontextmenu="clickVote('vote3'); return false;" target="_blank"
|
||||
rel="noopener"
|
||||
href="https://minecraft-server-list.com/server/298238/vote/">
|
||||
<div class="button-outer">
|
||||
<span id="vote3" class="button-inner">Vote!</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vote">
|
||||
<h2>Minecraft-Server</h2>
|
||||
<div>
|
||||
<a (click)="clickVote('vote4')" oncontextmenu="clickVote('vote4'); return false;" target="_blank"
|
||||
rel="noopener"
|
||||
href="https://minecraft-server.net/vote/Altitude/">
|
||||
<div class="button-outer">
|
||||
<span id="vote4" class="button-inner">Vote!</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vote">
|
||||
<h2>PlanetMinecraft</h2>
|
||||
<div>
|
||||
<a (click)="clickVote('vote5')" oncontextmenu="clickVote('vote5'); return false;" target="_blank"
|
||||
rel="noopener"
|
||||
href="https://www.planetminecraft.com/server/alttd/vote/">
|
||||
<div class="button-outer">
|
||||
<span id="vote5" class="button-inner">Vote!</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vote">
|
||||
<h2>Minecraft-MP</h2>
|
||||
<div>
|
||||
<a (click)="clickVote('vote6')" oncontextmenu="clickVote('vote6'); return false;" target="_blank"
|
||||
rel="noopener"
|
||||
href="https://minecraft-mp.com/server/98955/vote/">
|
||||
<div class="button-outer">
|
||||
<span id="vote6" class="button-inner">Vote!</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.voteContainer {
|
||||
padding: 50px 0 0 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.voteSection {
|
||||
background-color: var(--link-color);
|
||||
transition: 0.5s ease;
|
||||
@@ -36,3 +41,11 @@
|
||||
color: black;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.available-button-outer {
|
||||
background-color: #4caf50 !important;
|
||||
}
|
||||
|
||||
.not-available-button-outer {
|
||||
background-color: var(--white) !important;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,102 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {Component, effect, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {ScrollService} from '@services/scroll.service';
|
||||
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
import {SiteService, VoteData} from '@api';
|
||||
import {AuthService} from '@services/auth.service';
|
||||
import {interval, Subscription} from 'rxjs';
|
||||
import {TimeAgoPipe} from '@pipes/TimeAgoPipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-vote',
|
||||
standalone: true,
|
||||
imports: [
|
||||
HeaderComponent
|
||||
],
|
||||
HeaderComponent,
|
||||
TimeAgoPipe
|
||||
],
|
||||
templateUrl: './vote.component.html',
|
||||
styleUrl: './vote.component.scss'
|
||||
})
|
||||
export class VoteComponent {
|
||||
constructor(public scrollService: ScrollService) {
|
||||
export class VoteComponent implements OnInit, OnDestroy {
|
||||
private readonly defaultVoteMessage = 'Vote!';
|
||||
private readonly clickedVoteMessage = 'Clicked!';
|
||||
|
||||
private voteMessages: { [key: string]: string } = {}
|
||||
private refreshSubscription: Subscription | null = null;
|
||||
|
||||
protected readonly voteSites: { [key: string]: string } = {
|
||||
'PlanetMinecraft': 'https://www.planetminecraft.com/server/alttd/vote/',
|
||||
'TopMinecraftServers': 'https://topminecraftservers.org/vote/4906',
|
||||
'Minecraft-Server': 'https://minecraft-server.net/vote/Altitude/',
|
||||
'MinecraftServers': 'https://minecraftservers.org/vote/284208',
|
||||
'MCSL': 'https://minecraft-server-list.com/server/298238/vote/',
|
||||
'Minecraft-MP': 'https://minecraft-mp.com/server/98955/vote/',
|
||||
}
|
||||
|
||||
voteMessage: string = '';
|
||||
protected scrollService: ScrollService = inject(ScrollService);
|
||||
protected siteService = inject(SiteService)
|
||||
protected authService = inject(AuthService)
|
||||
|
||||
protected voteStats: VoteData | null = null
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.authService.isAuthenticated$()) {
|
||||
this.loadVoteStats();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.refreshSubscription = interval(300000).subscribe(() => {
|
||||
this.loadVoteStats();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.refreshSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
clickVote(id: string) {
|
||||
this.voteMessage = 'Clicked!';
|
||||
this.voteMessages[id] = this.clickedVoteMessage;
|
||||
}
|
||||
|
||||
getVoteText(id: string) {
|
||||
return this.voteMessages[id] || this.defaultVoteMessage;
|
||||
}
|
||||
|
||||
private loadVoteStats(): void {
|
||||
if (!this.authService.isAuthenticated$()) {
|
||||
return
|
||||
}
|
||||
this.siteService.getVoteStats().subscribe(voteStats => {
|
||||
this.voteStats = voteStats;
|
||||
});
|
||||
}
|
||||
|
||||
protected getLastVoted(id: string): Date | null {
|
||||
if (!this.voteStats) {
|
||||
return null;
|
||||
}
|
||||
const filteredVoteInfo = this.voteStats.allVoteInfo
|
||||
.filter(voteInfo => voteInfo.siteName === id);
|
||||
if (filteredVoteInfo.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(filteredVoteInfo[0].lastVoteTimestamp);
|
||||
}
|
||||
|
||||
protected readonly Object = Object;
|
||||
|
||||
canVote(voteSite: string) {
|
||||
if (!this.voteStats) {
|
||||
return false;
|
||||
}
|
||||
const now: Date = new Date();
|
||||
return (
|
||||
this.voteStats.allVoteInfo.some(voteInfo => voteInfo.siteName === voteSite
|
||||
&& voteInfo.lastVoteTimestamp - now.getTime() < 86400000)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'TimeAgo',
|
||||
standalone: true
|
||||
})
|
||||
export class TimeAgoPipe implements PipeTransform {
|
||||
transform(value: Date | string | number | null, short?: boolean): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
const now = new Date();
|
||||
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (seconds < 60) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
let returnText = 'ago';
|
||||
|
||||
const allMinutes = Math.floor(seconds / 60);
|
||||
const minutes = allMinutes % 60;
|
||||
if (short) {
|
||||
returnText = `${minutes}m ${returnText}`
|
||||
} else {
|
||||
returnText = `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ${returnText}`
|
||||
}
|
||||
if (allMinutes < 60) {
|
||||
return returnText
|
||||
}
|
||||
|
||||
|
||||
const allHours = Math.floor(allMinutes / 60);
|
||||
const hours = allHours % 24;
|
||||
if (short) {
|
||||
returnText = `${hours}h ${returnText}`
|
||||
} else {
|
||||
returnText = `${hours} ${hours === 1 ? 'hour' : 'hours'} ${returnText}`
|
||||
}
|
||||
if (allHours < 24) {
|
||||
return returnText
|
||||
}
|
||||
|
||||
const days = Math.floor(allHours / 24);
|
||||
if (short) {
|
||||
returnText = `${days}d ${returnText}`
|
||||
} else {
|
||||
returnText = `${days} ${days === 1 ? 'day' : 'days'} ${returnText}`
|
||||
}
|
||||
return returnText
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export class AuthService {
|
||||
/**
|
||||
* Check if the user is authenticated
|
||||
*/
|
||||
public checkAuthStatus(): boolean {
|
||||
private checkAuthStatus(): boolean {
|
||||
const jwt = this.getJwt();
|
||||
if (!jwt) {
|
||||
console.log("No JWT found");
|
||||
@@ -82,7 +82,6 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const claims = this.extractJwtClaims(jwt);
|
||||
console.log("User claims: ", claims);
|
||||
this.userClaimsSubject.set(claims);
|
||||
this.isAuthenticatedSubject.set(true);
|
||||
if (this.username() == null) {
|
||||
@@ -109,7 +108,6 @@ export class AuthService {
|
||||
localStorage.setItem('jwt', jwt);
|
||||
|
||||
const claims = this.extractJwtClaims(jwt);
|
||||
console.log("Saving user claims: ", claims);
|
||||
this.userClaimsSubject.set(claims);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {provideRouter} from '@angular/router';
|
||||
import {routes} from './app/app.routes';
|
||||
import {provideHttpClient, withInterceptors} from '@angular/common/http';
|
||||
import {authInterceptor} from '@services/AuthInterceptor';
|
||||
import {provideNativeDateAdapter} from '@angular/material/core';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
@@ -12,6 +13,7 @@ bootstrapApplication(AppComponent, {
|
||||
provideHttpClient(
|
||||
withInterceptors([authInterceptor])
|
||||
),
|
||||
provideNativeDateAdapter()
|
||||
]
|
||||
}).catch(err => console.error(err));
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ tags:
|
||||
description: All action related to appeals
|
||||
- name: mail
|
||||
description: All actions related to user email verification
|
||||
- name: site
|
||||
description: Actions related to small features on the site such as displaying vote stats or pt/rank stats
|
||||
paths:
|
||||
/api/team/{team}:
|
||||
$ref: './schemas/team/team.yml#/getTeam'
|
||||
@@ -49,6 +51,12 @@ paths:
|
||||
$ref: './schemas/bans/bans.yml#/getAllHistoryForUUID'
|
||||
/api/history/total:
|
||||
$ref: './schemas/bans/bans.yml#/getTotalPunishments'
|
||||
/api/history/admin/{type}/{id}/reason:
|
||||
$ref: './schemas/bans/bans.yml#/updatePunishmentReason'
|
||||
/api/history/admin/{type}/{id}/until:
|
||||
$ref: './schemas/bans/bans.yml#/updatePunishmentUntil'
|
||||
/api/history/admin/{type}/{id}:
|
||||
$ref: './schemas/bans/bans.yml#/removePunishment'
|
||||
/api/appeal/update-mail:
|
||||
$ref: './schemas/forms/appeal/appeal.yml#/UpdateMail'
|
||||
/api/appeal/minecraft-appeal:
|
||||
@@ -57,6 +65,8 @@ paths:
|
||||
$ref: './schemas/forms/appeal/appeal.yml#/DiscordAppeal'
|
||||
/api/apply/staff-application:
|
||||
$ref: './schemas/forms/staff_apply/staff_apply.yml#/StaffApply'
|
||||
/api/apply/staff-application-is-open:
|
||||
$ref: './schemas/forms/staff_apply/staff_apply.yml#/StaffApplicationsIsOpen'
|
||||
/api/login/requestNewUserLogin/{uuid}:
|
||||
$ref: './schemas/login/login.yml#/RequestNewUserLogin'
|
||||
/api/login/userLogin/{code}:
|
||||
@@ -81,3 +91,5 @@ paths:
|
||||
$ref: './schemas/forms/mail/mail.yml#/DeleteEmail'
|
||||
/api/mail/list:
|
||||
$ref: './schemas/forms/mail/mail.yml#/GetEmails'
|
||||
/api/site/vote:
|
||||
$ref: './schemas/site/vote.yml#/VoteStats'
|
||||
|
||||
@@ -211,6 +211,73 @@ getAllHistoryForUUID:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PunishmentHistoryList'
|
||||
updatePunishmentReason:
|
||||
patch:
|
||||
tags:
|
||||
- history
|
||||
summary: Update punishment reason
|
||||
description: Updates the reason for a specific punishment history entry
|
||||
operationId: updatePunishmentReason
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/HistoryType'
|
||||
- $ref: '#/components/parameters/Id'
|
||||
- $ref: '#/components/parameters/NewPunishmentReason'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated punishment
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PunishmentHistory'
|
||||
default:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||
updatePunishmentUntil:
|
||||
patch:
|
||||
tags:
|
||||
- history
|
||||
summary: Update punishment expiry time
|
||||
description: Updates the expiry time (until) for a specific punishment history entry (only for ban and mute)
|
||||
operationId: updatePunishmentUntil
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/HistoryType'
|
||||
- $ref: '#/components/parameters/Id'
|
||||
- $ref: '#/components/parameters/NewUntil'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated punishment
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PunishmentHistory'
|
||||
default:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||
removePunishment:
|
||||
delete:
|
||||
tags:
|
||||
- history
|
||||
summary: Remove punishment
|
||||
description: Removes a punishment from history. For bans and mutes, the punishment is deactivated; for kicks and warnings, the row is deleted.
|
||||
operationId: removePunishment
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/HistoryType'
|
||||
- $ref: '#/components/parameters/Id'
|
||||
responses:
|
||||
'204':
|
||||
description: Punishment removed
|
||||
default:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||
components:
|
||||
parameters:
|
||||
HistoryType:
|
||||
@@ -250,6 +317,21 @@ components:
|
||||
schema:
|
||||
type: integer
|
||||
description: The id of the punishment that should be retrieved
|
||||
NewPunishmentReason:
|
||||
name: reason
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The new reason to set for the punishment
|
||||
NewUntil:
|
||||
name: until
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
description: The new expiry time (epoch millis)
|
||||
schemas:
|
||||
SearchResults:
|
||||
type: integer
|
||||
|
||||
@@ -24,6 +24,28 @@ StaffApply:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
|
||||
StaffApplicationsIsOpen:
|
||||
get:
|
||||
tags:
|
||||
- applications
|
||||
summary: Get if staff applications are open
|
||||
description: Get if staff applications are open
|
||||
operationId: getStaffApplicationsIsOpen
|
||||
responses:
|
||||
'200':
|
||||
description: If staff applications are open
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: boolean
|
||||
'403':
|
||||
description: If staff applications are not open
|
||||
default:
|
||||
description: Unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
|
||||
components:
|
||||
schemas:
|
||||
StaffApplication:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
VoteStats:
|
||||
get:
|
||||
tags:
|
||||
- site
|
||||
summary: Get vote stats
|
||||
description: Get vote stats for current user
|
||||
operationId: getVoteStats
|
||||
responses:
|
||||
'200':
|
||||
description: Vote stats retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VoteData'
|
||||
components:
|
||||
schemas:
|
||||
VoteData:
|
||||
type: object
|
||||
required:
|
||||
- allVoteInfo
|
||||
- voteStats
|
||||
- voteStreak
|
||||
- bestVoteStreak
|
||||
properties:
|
||||
allVoteInfo:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/VoteInfo'
|
||||
voteStats:
|
||||
$ref: '#/components/schemas/VoteStats'
|
||||
voteStreak:
|
||||
$ref: '#/components/schemas/VoteStreak'
|
||||
bestVoteStreak:
|
||||
$ref: '#/components/schemas/VoteStreak'
|
||||
VoteInfo:
|
||||
type: object
|
||||
required:
|
||||
- siteName
|
||||
- lastVoteTimestamp
|
||||
properties:
|
||||
siteName:
|
||||
type: string
|
||||
lastVoteTimestamp:
|
||||
type: integer
|
||||
format: int64
|
||||
VoteStats:
|
||||
type: object
|
||||
required:
|
||||
- total
|
||||
- monthly
|
||||
- weekly
|
||||
- daily
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
format: int32
|
||||
monthly:
|
||||
type: integer
|
||||
format: int32
|
||||
weekly:
|
||||
type: integer
|
||||
format: int32
|
||||
daily:
|
||||
type: integer
|
||||
format: int32
|
||||
VoteStreak:
|
||||
type: object
|
||||
required:
|
||||
- dailyStreak
|
||||
- weeklyStreak
|
||||
- monthlyStreak
|
||||
properties:
|
||||
dailyStreak:
|
||||
type: integer
|
||||
format: int32
|
||||
weeklyStreak:
|
||||
type: integer
|
||||
format: int32
|
||||
monthlyStreak:
|
||||
type: integer
|
||||
format: int32
|
||||
Reference in New Issue
Block a user